diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 381ac469..f7365186 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -23,7 +23,6 @@ examine_globs = [ "crates/labcolors-core/src/numerical_plan.rs", "crates/labcolors-core/src/numerics.rs", "crates/labcolors-core/src/observation.rs", - "crates/labcolors-core/src/pair.rs", "crates/labcolors-core/src/recheck.rs", "crates/labcolors-core/src/srgb8.rs", "crates/labcolors-core/src/wcag22.rs", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4c09683..32f170f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,6 +161,10 @@ jobs: # reference from merging silently (main was green with ~81 broken links # because cargo doc was never gated — #26). run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --locked + - name: staged Program is absent from the resolved public API + run: | + python3 scripts/test_program_public_surface.py + python3 scripts/verify_program_public_surface.py target/doc/labcolors_core - name: package labcolors-core and run extracted package doctests run: | set -euo pipefail diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8d6188f9..9a1c5264 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -674,7 +674,7 @@ jobs: JSON.stringify(point.sourceBinding.exclusions) !== JSON.stringify(pointProof.source_binding_exclusions) || point.sourceBinding.closureSha256 !== pointProof.source_closure_sha256 || - pointProof.source_negative_controls !== 33 || + pointProof.source_negative_controls !== 43 || pointAlgebra?.method !== "exact-sparse-integer-polynomial-identities-plus-positive-denominator-order-lemma-v1" || pointAlgebra?.wolfram_language_cross_check?.query_sha256 !== @@ -790,7 +790,6 @@ jobs: "stable-cam16-glow-target-or-maximum-selection", "renderer-or-output-pipeline-equivalence", "spatial-glow-field", - "display-p3", ]; if ( JSON.stringify(manifest.supported) !== JSON.stringify(expectedSupported) || diff --git a/bindings/swift/README.md b/bindings/swift/README.md index 59d9279f..f240d726 100644 --- a/bindings/swift/README.md +++ b/bindings/swift/README.md @@ -53,7 +53,7 @@ Swift/UniFFI evidence сейчас ограничено описанным вы runtime. Solve-hex — квантование трансцендентного резолва, ±1 LSB на канал. Неуспешный solve возвращает `ColorError.Failure(category, code)`: category — закрытый enum `FailureCategory`, а не произвольная строка. Он -отделяет доказанную `unreachable` от `unresolved`, `rejected` и `unsupported`, +отделяет доказанную `unreachable` от `unresolved` и `rejected`, а code задаёт конкретную машинную причину. Оба поля приходят из одного core-owned descriptor и проверяются conformance-паком. diff --git a/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift b/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift index e812e566..10ba110b 100644 --- a/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift +++ b/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift @@ -53,7 +53,6 @@ final class ConformanceTests: XCTestCase { case "unreachable": return .unreachable case "unresolved": return .unresolved case "rejected": return .rejected - case "unsupported": return .unsupported default: fatalError("неизвестная failure category в pack: \(key)") } } diff --git a/conformance/README.md b/conformance/README.md index 5fba329a..74a50287 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -76,7 +76,7 @@ adjacent bytes или нормативного отношения пересчи - `(category, code)` — атомарная core-owned классификация, общая для всех биндингов: `unreachable/exceeds_range`, `unreachable/floor_unreachable`, `unresolved/bounded_search_exhausted`, `rejected/invalid_input`, - `unreachable/below_contrast_floor` и `unsupported/gamut_unsupported`. Только + `unreachable/below_contrast_floor`. Только `unreachable` доказывает отсутствие решения в объявленном полном domain; `unresolved` не делает утверждения о непроверенных кандидатах. diff --git a/crates/labcolors-conformance/src/lib.rs b/crates/labcolors-conformance/src/lib.rs index 19690e9f..fe3be626 100644 --- a/crates/labcolors-conformance/src/lib.rs +++ b/crates/labcolors-conformance/src/lib.rs @@ -296,7 +296,7 @@ pub enum SolveOutcome { floor_override: bool, }, /// Resolver не вернул цвет; category отделяет доказанную недостижимость от - /// unresolved, rejected и unsupported исходов. + /// unresolved и rejected исходов. Failure { /// Стабильная семантическая категория core failure. category: String, @@ -883,7 +883,6 @@ mod tests { "unreachable", "floor_unreachable", ), - (F::GamutUnsupported, "unsupported", "gamut_unsupported"), ( F::InvalidInput("fixture".into()), "rejected", diff --git a/crates/labcolors-core/contracts/point-support-reference-surplus-q55-bps-proof-v1.json b/crates/labcolors-core/contracts/point-support-reference-surplus-q55-bps-proof-v1.json index 8462366a..28b469c3 100644 --- a/crates/labcolors-core/contracts/point-support-reference-surplus-q55-bps-proof-v1.json +++ b/crates/labcolors-core/contracts/point-support-reference-surplus-q55-bps-proof-v1.json @@ -1 +1 @@ -{"artifact_id":"wcag22-srgb8-luminance-q55-v1","basis_point_proof":{"checks":30,"drop_all_semantics":"zero required surplus; current must still meet the anchor","drop_domain_inclusive":[0,10000],"nonpositive_baseline_semantics":"zero required surplus; current must meet the anchor"},"bound_id":"point-support-reference-surplus-q55-bps-v1","certified_claim":"for every successfully evaluated enabled stability cell, decision is Retained iff current_lower_surplus >= (10000-drop_bps)/10000 * max(baseline_lower_surplus,0); the declared anchor remains a separate hard floor","comparator_proof":{"algorithm":"euclidean-continued-fraction-ordering-v1","dense_denominator_inclusive":[1,31],"dense_numerator_inclusive":[0,31],"dense_small_cases":984064,"invariant":"equal integer parts; reciprocal proper fractions reverse order","largest_fibonacci_index":186,"oracle":"unbounded-integer-cross-product","random_cases":250000,"random_corpus_sha256":"97c4af7b452b31a4ab92645f70c17acb38bf57ca55484e32ad9d7d79d97a333d","random_seed":210583930,"termination":"each nonterminal denominator becomes a strictly smaller remainder","u128_adversarial_cases":190},"declared_operation_law":"q55-lower-reference-distance-explicit-anchor-bps-retention-v1","excluded_claim":"does not certify retention against the unknown exact baseline surplus, renderer equivalence outside encoded-sRGB8 source-over, or a successful result when evaluation fails","integer_replay_envelope":{"assumption":"every Q55 luminance upper <= scale + 3","i128_max":170141183460469231731687303715884105727,"offset_cleared_denominator_max":756604737398243388,"positive_baseline_numerator_max":1188950301625811064,"rational_denominator_max":1513209474796486776,"required_denominator_max":15132094747964867760000,"required_numerator_max":11889503016258110640000,"signed_anchor_abs_coarse_max":5296233161787703716,"u128_max":340282366920938463463374607431768211455,"u64_max":18446744073709551615},"profile_id":"srgb8-q55-retained-reference-surplus-bps-v1","proof_id":"point-support-reference-surplus-integer-v1","proof_payload_sha256":"76cc2f9916efb337fdc7cb20c444f619c289dc3a3d9a877fdc4087fefe33a12a","q55_dependency":{"artifact_id":"wcag22-srgb8-luminance-q55-v1","artifact_sha256":"7ff239d9052b346f3c50da01ca65ca2330892ed1a3ff30e190797fcef6f03604","maximum_luminance_upper":36028797018963971,"outward_interval_width_bound":3,"proof_id":"wcag22-srgb8-full-domain-q55-v1","proof_payload_sha256":"3c639a7c875046c46b56b51ecdd67d5ecaf14a1134490c88a222e7037b63c0f2","proof_sha256":"ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd","q55_scale":36028797018963968},"reference_and_anchor_proof":{"anchor_identity_checks":75,"orientation_law":"distance-magnitude-symmetric-orientation-reported-separately","overlap_lower_distance":"0/1","separated_endpoint_checks":504},"schema_version":2,"site_id":"point-support-retained-reference-surplus-v1","source_binding_exclusions":["whole-crate compilation or compiler/toolchain attestation","binary, package, FFI, renderer, or browser transport attestation","unrelated Lab Colors modules outside the declared point-support semantic cone"],"source_binding_law":"point-support-rust-whole-file-semantic-cone-v2","source_binding_schema_version":2,"source_binding_scope":"exact bytes of the private point-support Rust semantic cone and its two WCAG include_str inputs; comments and cfg(test) text are intentionally significant","source_closure_sha256":"c2825216354b796924560d98e01ae5cebedf324c47e7b26332119c61aded783e","source_files":[{"kind":"compile-time-input","path":"crates/labcolors-core/contracts/wcag22-srgb8-q55-proof-v1.json","sha256":"ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd"},{"kind":"compile-time-input","path":"crates/labcolors-core/contracts/wcag22-srgb8-v1.json","sha256":"b4bb7e5f17a99f2c911fdbe3da23a48b049277b796291094950f14680cc3cc7b"},{"kind":"rust-source","path":"crates/labcolors-core/src/appearance.rs","sha256":"5df64a220c86378c66d25f0e0abe2507b636b0ec6cd8217a6235e17809f00f48"},{"kind":"rust-source","path":"crates/labcolors-core/src/composition.rs","sha256":"5218a845b85a27571a710c7c967b2937b94cf4622a3073487a808936ac85468a"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/exact.rs","sha256":"45bd09cd8f6f74860a0a3ffeb410ee2c3cf91b4868877abbc949e724b61e1ccd"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/mod.rs","sha256":"ad721c625b33a7432de44e5f63459a62c1cbc8ec2492855f783b5440f04978f9"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/wcag22.rs","sha256":"985e21abccd1fcb4a3f9624d5947ff3d1af7a1bf26b6b170ea8bbef77d6f1798"},{"kind":"rust-source","path":"crates/labcolors-core/src/hash.rs","sha256":"f97a0fd7d6ad3162f0f1dfb326fccfb7ed40da9a8fa67a5b8a239a1ae2ae49c3"},{"kind":"rust-source","path":"crates/labcolors-core/src/lib.rs","sha256":"95113d8a25fd1577823b8c6342c06272b12ea8711ee5e5a62d034948177c13db"},{"kind":"rust-source","path":"crates/labcolors-core/src/numerics.rs","sha256":"e73a12136494f2ef9aca4e943ab38302c1439f054cecab36a552d35252c164f9"},{"kind":"rust-source","path":"crates/labcolors-core/src/observation.rs","sha256":"208066b255bc406303b18dc2eb6f1720c8203ea6eb37d225f9d8110c2449edc4"},{"kind":"rust-source","path":"crates/labcolors-core/src/point_support.rs","sha256":"56c06b49f4687b3def69c247978da1b0cb45b42c09515a001d196cb1457c93a6"},{"kind":"rust-source","path":"crates/labcolors-core/src/session.rs","sha256":"d4b06651e07cf4f93eccf9866f6e63c35ea72fa7125bdb41e222b19b70dd2f32"},{"kind":"rust-source","path":"crates/labcolors-core/src/srgb8.rs","sha256":"6c95324eb05476f35f75375a9af0b2b4a41b8b2978c46e67d2ce1aea5adde342"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22.rs","sha256":"7ba7864eb7e73789bad6c63c64a4dc2dcc08c2da6921375fb9564fca230c2780"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22/kernel.rs","sha256":"c97980c1ca2c7ea9cabff9c8d2fb7282773cca180ae15948391c29c9d6196040"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22/q55_data.rs","sha256":"af4d23d6b70c45ce6efa839e7dda4bb0a61f6aae43cb805af6fa9b29e6c3bae2"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22_evidence.rs","sha256":"3c5a75b07254c6071a64700af208a64987d0f0ea9698eadc54a9e74585ce1f72"}],"source_negative_controls":33,"universal_algebraic_certificate":{"basis_point_scale_instantiation":10000,"domain":"integers; Q55 scale Q>0; anchor L>=D>=0; lighter monotonicity L2>=L1>D>=0; darker monotonicity L>D2>=D1>=0; current/baseline denominators b,q>0; basis-point scale B>0 instantiated as 10000; p>0; a>=0; 0<=drop_bps<=B","identities":["three explicit anchor-surplus formulas after denominator clearing","reference distance is monotone increasing in lighter L","reference distance is monotone decreasing in darker D","positive-baseline retained threshold is p*(B-drop)/(q*B)","a/b >= p*(B-drop)/(q*B) iff a*q*B >= p*(B-drop)*b"],"method":"exact-sparse-integer-polynomial-identities-plus-positive-denominator-order-lemma-v1","nonpositive_baseline_case":"max(baseline,0)=0; retained threshold is exactly zero","symbolic_mutation_controls":{"anchor_coefficients_and_denominator":6,"retained_cross_product":5},"wolfram_language_cross_check":{"query":"FullSimplify[{20 g/d - 0 == 20 g/d, 20 g/d - 2 == (20 g - 2 d)/d, 20 g/d - 7/2 == (40 g - 7 d)/(2 d), Equivalent[a/b >= p (s-x)/(q s), a q s >= p (s-x) b], Max[p/q, 0] (s-x)/s == Piecewise[{{0, p <= 0}}, p (s-x)/(q s)]}, Assumptions -> Element[{a,b,p,q,s,x,g,d}, Integers] && a >= 0 && b > 0 && q > 0 && s > 0 && 0 <= x <= s && d > 0 && g >= 0]","query_sha256":"8cdbb9964583030c8b92498961896cb2a98613f1cb31eb7c54acdf8e16beff10","result":"{True, True, True, True, True}","result_sha256":"13a8f2ee8d0fde335a638e46d7cc8a8427b9a1437c77d22cfcf925bb87fa6303"}},"verifier_sha256":"bc9ceb056a9bd4e93f5c5c5fd575779384027f00985b0075356169a8a11aabf4"} +{"artifact_id":"wcag22-srgb8-luminance-q55-v1","basis_point_proof":{"checks":30,"drop_all_semantics":"zero required surplus; current must still meet the anchor","drop_domain_inclusive":[0,10000],"nonpositive_baseline_semantics":"zero required surplus; current must meet the anchor"},"bound_id":"point-support-reference-surplus-q55-bps-v1","certified_claim":"for every successfully evaluated enabled stability cell, decision is Retained iff current_lower_surplus >= (10000-drop_bps)/10000 * max(baseline_lower_surplus,0); the declared anchor remains a separate hard floor","comparator_proof":{"algorithm":"euclidean-continued-fraction-ordering-v1","dense_denominator_inclusive":[1,31],"dense_numerator_inclusive":[0,31],"dense_small_cases":984064,"invariant":"equal integer parts; reciprocal proper fractions reverse order","largest_fibonacci_index":186,"oracle":"unbounded-integer-cross-product","random_cases":250000,"random_corpus_sha256":"97c4af7b452b31a4ab92645f70c17acb38bf57ca55484e32ad9d7d79d97a333d","random_seed":210583930,"termination":"each nonterminal denominator becomes a strictly smaller remainder","u128_adversarial_cases":190},"declared_operation_law":"q55-lower-reference-distance-explicit-anchor-bps-retention-v1","excluded_claim":"does not certify retention against the unknown exact baseline surplus, renderer equivalence outside encoded-sRGB8 source-over, or a successful result when evaluation fails","integer_replay_envelope":{"assumption":"every Q55 luminance upper <= scale + 3","i128_max":170141183460469231731687303715884105727,"offset_cleared_denominator_max":756604737398243388,"positive_baseline_numerator_max":1188950301625811064,"rational_denominator_max":1513209474796486776,"required_denominator_max":15132094747964867760000,"required_numerator_max":11889503016258110640000,"signed_anchor_abs_coarse_max":5296233161787703716,"u128_max":340282366920938463463374607431768211455,"u64_max":18446744073709551615},"profile_id":"srgb8-q55-retained-reference-surplus-bps-v1","proof_id":"point-support-reference-surplus-integer-v1","proof_payload_sha256":"1477d4fa428c54bab14ccdcc336e34c94aa68d70f768cfa26df289e103daf7f7","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":"fe841df4f7a63adc94423660b2ef4daefad539a3d3748bc9dafff08491be0ddd","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":"09be54900efe29ffdac8705efd0d6d613055c90d634446ca4b228f51a63997d0"},{"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":"51d7835a52b9eb4ab4888aef401ab145b1764eb2054ff7e856828fefc770a132"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/wcag22.rs","sha256":"856093c91159d8b3faab001f2d6524d33d7b16458a5a4e98ea65f8c62ab2694c"},{"kind":"rust-source","path":"crates/labcolors-core/src/hash.rs","sha256":"f97a0fd7d6ad3162f0f1dfb326fccfb7ed40da9a8fa67a5b8a239a1ae2ae49c3"},{"kind":"rust-source","path":"crates/labcolors-core/src/lcs_occurrence.rs","sha256":"6f202ad7425a235b9d18caba0c817fc33a2b8e042050a34f5ddff3fd09efc53d"},{"kind":"rust-source","path":"crates/labcolors-core/src/lib.rs","sha256":"51ab4da1ab650b063e57bf158b554f3fdc561f3e86d3ad60a660310991c7a7d1"},{"kind":"rust-source","path":"crates/labcolors-core/src/numerics.rs","sha256":"e73a12136494f2ef9aca4e943ab38302c1439f054cecab36a552d35252c164f9"},{"kind":"rust-source","path":"crates/labcolors-core/src/observation.rs","sha256":"8c9838107077775c51d80638ba0b59f9672d14347ca404dfd4c63e2fb62d1c45"},{"kind":"rust-source","path":"crates/labcolors-core/src/point_support.rs","sha256":"6f6a376ff036d3d65960c004e6566e1bca580f19f5bd3cd333a80b0da5b5c242"},{"kind":"rust-source","path":"crates/labcolors-core/src/session.rs","sha256":"4f77643206077c080e5e9b182e896145bfb69bf3db8aa4c1ac7d4c5360ea8504"},{"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":"bf25b5e0704aca428a73aa529f966f0f252be041d3171bca989d9672d3099acf"} diff --git a/crates/labcolors-core/contracts/solve-characterization-v1-linux-x64.json b/crates/labcolors-core/contracts/solve-characterization-v1-linux-x64.json index f778ca88..b9947c14 100644 --- a/crates/labcolors-core/contracts/solve-characterization-v1-linux-x64.json +++ b/crates/labcolors-core/contracts/solve-characterization-v1-linux-x64.json @@ -62,7 +62,6 @@ "bg=#FFFFFF contract=text(150) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4062c00000000000 max_achievable_bits=405a829a490ad002", "bg=#FFFFFF contract=text(3) floor=none hue=0 chroma=neutral": "err below_contrast_floor target_bits=4008000000000000", "bg=#FFFFFF contract=text(30) floor=default hue=264 chroma=neutral": "ok hex=#767676 lc_bits=40510fca61d5b9ee wcag_ratio_bits=40122b3d05125164 floor_override=true jp_bits=404a3678a8f0c874 h_ok_bits=0000000000000000 s_bits=3f92a6b801503a78", - "bg=#FFFFFF contract=text(60) floor=default hue=0 chroma=neutral gamut=display-p3": "err gamut_unsupported", "bg=#FFFFFF contract=text(60) floor=default hue=264 chroma=neutral": "ok hex=#767676 lc_bits=40510fca61d5b9ee wcag_ratio_bits=40122b3d05125164 floor_override=true jp_bits=404a3678a8f0c874 h_ok_bits=0000000000000000 s_bits=3f92a6b801503a78", "bg=#FFFFFF contract=text(7.3) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", "bg=#FFFFFF contract=text(7.35) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", diff --git a/crates/labcolors-core/contracts/solve-characterization-v1-macos-aarch64.json b/crates/labcolors-core/contracts/solve-characterization-v1-macos-aarch64.json index e3d55f3f..115971b1 100644 --- a/crates/labcolors-core/contracts/solve-characterization-v1-macos-aarch64.json +++ b/crates/labcolors-core/contracts/solve-characterization-v1-macos-aarch64.json @@ -62,7 +62,6 @@ "bg=#FFFFFF contract=text(150) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4062c00000000000 max_achievable_bits=405a829a490ad002", "bg=#FFFFFF contract=text(3) floor=none hue=0 chroma=neutral": "err below_contrast_floor target_bits=4008000000000000", "bg=#FFFFFF contract=text(30) floor=default hue=264 chroma=neutral": "ok hex=#767676 lc_bits=40510fca61d5b9ee wcag_ratio_bits=40122b3d05125164 floor_override=true jp_bits=404a3678a8f0c874 h_ok_bits=0000000000000000 s_bits=3f92a6b801503a78", - "bg=#FFFFFF contract=text(60) floor=default hue=0 chroma=neutral gamut=display-p3": "err gamut_unsupported", "bg=#FFFFFF contract=text(60) floor=default hue=264 chroma=neutral": "ok hex=#767676 lc_bits=40510fca61d5b9ee wcag_ratio_bits=40122b3d05125164 floor_override=true jp_bits=404a3678a8f0c874 h_ok_bits=0000000000000000 s_bits=3f92a6b801503a78", "bg=#FFFFFF contract=text(7.3) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", "bg=#FFFFFF contract=text(7.35) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", diff --git a/crates/labcolors-core/src/agnostic_gates.rs b/crates/labcolors-core/src/agnostic_gates.rs index 7130def7..147eb137 100644 --- a/crates/labcolors-core/src/agnostic_gates.rs +++ b/crates/labcolors-core/src/agnostic_gates.rs @@ -306,8 +306,8 @@ fn acme_config() -> ThemeConfig { ("night".to_string(), VcPreset::Dim), ], }, - // A small but real role set: a text ladder, a neutral fill, a brand fill, - // a hued brand label, a badge label, a brand focus ring, a brand glow. + // A small but real role set: a text ladder, a neutral fill, a hued brand + // label, a brand focus ring and a brand glow. roles: vec![ ("text-strong".to_string(), text(0.968, Floor::AaText)), ("text-weak".to_string(), text(0.461, Floor::AaUi)), @@ -319,12 +319,6 @@ fn acme_config() -> ThemeConfig { floor: None, }, ), - ( - "brand-fill".to_string(), - RoleRecipe::PairFill { - source: LadderSource::Brand, - }, - ), ( "brand-label".to_string(), RoleRecipe::TextAnchor { @@ -333,16 +327,6 @@ fn acme_config() -> ThemeConfig { hue: Some(LadderSource::Brand), }, ), - // Лейбл пары: любой клиент получает жёсткий контраст против - // фактически emitted PairFill Surface через общий joint engine. - ( - "badge-label".to_string(), - RoleRecipe::PairLabel { - source: LadderSource::Brand, - fraction: 0.461, - floor: Floor::AaUi, - }, - ), ("focus".to_string(), brand_ladder(LadderPosition::FocusRing)), // Свечение бренда (#292): numerical-decision профиль обязателен // ЯВНО и у чужого клиента — implicit legacy непредставим; выбор @@ -369,8 +353,8 @@ fn a_second_company_config_compiles_and_emits_a_valid_system() { .expect("a well-formed foreign config must compile"); assert_eq!( table.entries().len(), - 8, - "acme declared eight roles; the table carries exactly them" + 6, + "acme declared six roles; the table carries exactly them" ); // Численный план (#292) второго клиента — derived-проекция той же таблицы: @@ -394,7 +378,7 @@ fn a_second_company_config_compiles_and_emits_a_valid_system() { let bg = BgInput::solid(bg_hex).unwrap(); let set = resolve_named_set(&bg, &table, &vc) .expect("валидный второй клиент обязан резолвиться атомарно"); - assert_eq!(set.len(), 8, "every declared role resolves to an outcome"); + assert_eq!(set.len(), 6, "every declared role resolves to an outcome"); // The text ladder is real: strong is a solved colour that clears its AA // text floor and reads stronger than the weak rung. @@ -419,26 +403,6 @@ fn a_second_company_config_compiles_and_emits_a_valid_system() { matches!(brand_label.1, Resolved::Color { .. }), "hued brand-label must resolve to a solved colour on {bg_hex}" ); - - // PairLabel — агностичный жёсткий контраст: решается цветом и держит - // UI-пол (3:1) против фактически emitted PairFill Surface, а не - // страницы или скрытой синтетической подложки. - let badge_label = set.iter().find(|(n, _)| n == "badge-label").unwrap(); - let Resolved::Color { solved, .. } = &badge_label.1 else { - panic!("badge-label must resolve to a solved colour on {bg_hex}"); - }; - let brand_fill = set.iter().find(|(n, _)| n == "brand-fill").unwrap(); - let surface_hex = brand_fill - .1 - .translucent() - .expect("brand-fill is the emitted PairFill surface") - .composite_hex(); - let enc = |h: &str| crate::spaces::srgb::srgb_encoded_from_hex(h).unwrap(); - let ratio = crate::wcag::contrast_ratio(enc(solved.hex()), enc(surface_hex)); - assert!( - ratio >= 3.0 - 1e-9, - "badge-label must clear 3:1 against emitted PairFill on {bg_hex}: got {ratio:.2}:1" - ); } } diff --git a/crates/labcolors-core/src/appearance.rs b/crates/labcolors-core/src/appearance.rs index 32c2c0b7..321a3709 100644 --- a/crates/labcolors-core/src/appearance.rs +++ b/crates/labcolors-core/src/appearance.rs @@ -13,10 +13,18 @@ //! //! Code-owned adapter представлен sealed borrowed IR и исполняется тем же //! evaluator-ом, что результат декларативной компиляции. Структурное равенство -//! статического IR результату compiler-а закреплено proof-тестом; production- -//! артефакт не содержит admission/topology compiler. +//! статического IR результату compiler-а закреплено proof-тестом. Compiler входит +//! в production Core: любой внутренний lowerer собирает тот же нейтральный +//! Paint/Surface/Occurrence DAG, не добавляя в физику словарь клиента. + +#![cfg_attr( + not(test), + expect( + dead_code, + reason = "production physical-graph compiler lands before its Core lowerer consumer" + ) +)] -#[cfg(test)] use std::collections::BTreeSet; use crate::Srgb8; @@ -46,7 +54,6 @@ impl SurfaceInputPortId { } /// Exact transport value. It has identity semantics only. - #[cfg(test)] pub(crate) const fn value(self) -> u32 { self.0 } @@ -60,6 +67,10 @@ impl OpacityInputId { pub(crate) const fn new(raw: u32) -> Self { Self(raw) } + + pub(crate) const fn value(self) -> u32 { + self.0 + } } /// Непрозрачный handle Paint-программы. @@ -70,6 +81,10 @@ impl PaintId { pub(crate) const fn new(raw: u32) -> Self { Self(raw) } + + pub(crate) const fn value(self) -> u32 { + self.0 + } } /// Непрозрачный handle наблюдаемой поверхности. @@ -80,6 +95,10 @@ impl SurfaceId { pub(crate) const fn new(raw: u32) -> Self { Self(raw) } + + pub(crate) const fn value(self) -> u32 { + self.0 + } } /// Непрозрачный handle применения Paint к Surface. @@ -93,7 +112,6 @@ impl OccurrenceId { } /// Exact transport value. It has identity semantics only. - #[cfg(test)] pub(crate) const fn value(self) -> u32 { self.0 } @@ -116,24 +134,20 @@ pub(crate) struct ProgramOccurrenceBindingV1 { } impl ProgramOccurrenceBindingV1 { - #[cfg(test)] pub(crate) const fn occurrence(self) -> OccurrenceId { self.occurrence } - #[cfg(test)] pub(crate) const fn subject(self) -> PaintId { self.subject } - #[cfg(test)] pub(crate) const fn backdrop_surface(self) -> SurfaceId { self.backdrop_surface } } /// Paint-конструкторы point-домена. Ни один вариант не знает Surface. -#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PaintSpec { /// Непрозрачный encoded-sRGB8 Paint из цветового входа. @@ -151,7 +165,6 @@ pub(crate) enum PaintSpec { }, } -#[cfg(test)] impl PaintSpec { fn id(&self) -> PaintId { match self { @@ -162,7 +175,6 @@ impl PaintSpec { /// Surface либо приходит извне как point-вход, либо является видимым /// результатом объявленного occurrence. -#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SurfaceSpec { Input { @@ -175,7 +187,6 @@ pub(crate) enum SurfaceSpec { }, } -#[cfg(test)] impl SurfaceSpec { fn id(&self) -> SurfaceId { match self { @@ -185,7 +196,6 @@ impl SurfaceSpec { } /// Единственная canonical application Paint к backdrop Surface. -#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct OccurrenceSpec { pub(crate) id: OccurrenceId, @@ -194,9 +204,7 @@ pub(crate) struct OccurrenceSpec { pub(crate) profile: CompositionProfileV1, } -/// Ошибки AOT-компиляции декларации. Compiler принадлежит proof-поверхности и -/// не входит в production-артефакт. -#[cfg(test)] +/// Ошибки атомарной AOT-компиляции физической декларации. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum CompileError { DuplicateColorInput { @@ -258,7 +266,6 @@ pub(crate) enum CompileError { /// Ошибки admission runtime bindings. Исполнение начинается только после /// полной проверки, поэтому частичного результата нет. -#[cfg(test)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum BindingError { DuplicateColorBinding { @@ -290,8 +297,16 @@ pub(crate) enum BindingError { }, OpacityOutOfDomain { input: OpacityInputId, - message: String, + reason: crate::composition::OpacityAdmissionErrorV1, }, + /// Bindings were admitted against a different exact typed input schema. + IncompatibleAdmittedBindings, + /// Scratch belongs to a different physical graph shape. Reusing storage is + /// allowed only when every typed output domain has the same cardinality. + IncompatibleWorkspace, + /// A fallible allocation needed to prepare bindings, scratch or an owned + /// result could not be satisfied. No numeric policy limit is implied. + ResourceExhausted, } /// Единственный отказ sealed point-adapter-а: невалидная authored alpha. @@ -309,7 +324,6 @@ impl PointOpacityError { } /// Плоские декларации до атомарной компиляции. Порядок списков смысла не несёт. -#[cfg(test)] #[derive(Debug, Clone, PartialEq)] pub(crate) struct AppearanceGraphSpec { color_inputs: Vec, @@ -320,7 +334,6 @@ pub(crate) struct AppearanceGraphSpec { occurrences: Vec, } -#[cfg(test)] impl AppearanceGraphSpec { pub(crate) fn new( color_inputs: Vec, @@ -598,7 +611,6 @@ impl AppearanceGraphSpec { } } -#[cfg(test)] fn adjacent_duplicate(sorted: &[T]) -> Option { sorted .windows(2) @@ -609,7 +621,6 @@ fn adjacent_duplicate(sorted: &[T]) -> Option { /// Topo для functional dependency graph: каждый узел имеет не более одной /// зависимости. При цикле возвращает только его реальные узлы, а не весь /// заблокированный Kahn-остаток. -#[cfg(test)] fn canonical_functional_topology( keys: &[K], dependencies: &[Option], @@ -649,7 +660,6 @@ fn canonical_functional_topology( /// Итеративный functional-cycle detector: O(V), без риска переполнить стек на /// большом входе и без ложного включения деревьев, ведущих в цикл. -#[cfg(test)] fn functional_cycle_members(dependencies: &[Option]) -> Vec { const UNSEEN: u8 = 0; const ACTIVE: u8 = 1; @@ -694,7 +704,6 @@ fn functional_cycle_members(dependencies: &[Option]) -> Vec { } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -#[cfg(test)] enum RenderKey { Surface(SurfaceId), Occurrence(OccurrenceId), @@ -719,6 +728,14 @@ enum CompiledPaintSpec { }, } +impl CompiledPaintSpec { + const fn id(&self) -> PaintId { + match self { + Self::Solid { id, .. } | Self::Opacity { id, .. } => *id, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum CompiledSurfaceSpec { Input { @@ -731,7 +748,6 @@ enum CompiledSurfaceSpec { }, } -#[cfg(test)] impl CompiledSurfaceSpec { fn id(&self) -> SurfaceId { match self { @@ -750,9 +766,38 @@ struct CompiledOccurrenceSpec { profile: CompositionProfileV1, } +/// Cold-bound canonical Paint position for allocation-free repeated lookup. +/// +/// Both fields are private to this module: callers can obtain a slot only from +/// a compiled graph and cannot forge a raw ordinal. The retained nominal ID is +/// checked again by every evaluation view before the ordinal is dereferenced. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CompiledPaintSlotV1 { + index: usize, + id: PaintId, +} + +/// Cold-bound canonical colour-input position used by finite Program targets. +/// The nominal ID is retained and rechecked on every overwrite. This rejects +/// stale or mismatched index/ID pairs, but does not claim graph-instance +/// identity when two graphs have the same canonical input at that position. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CompiledColorInputSlotV1 { + index: usize, + id: ColorInputId, +} + +/// Cold-bound canonical Occurrence position for allocation-free repeated +/// lookup. As with [`CompiledPaintSlotV1`], construction remains sealed inside +/// the compiled appearance graph and every use revalidates the exact ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CompiledOccurrenceSlotV1 { + index: usize, + id: OccurrenceId, +} + /// Канонический compiled IR с индексными ссылками: после проверки bindings /// исполнение самих Paint/Surface/Occurrence узлов линейно по их числу. -#[cfg(test)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CompiledAppearanceGraph { color_inputs: Vec, @@ -766,8 +811,8 @@ pub(crate) struct CompiledAppearanceGraph { } /// Borrowed runtime-представление уже проверенного compiled IR. Оно отделяет -/// исполнение от compiler-а и позволяет статическим внутренним adapter-ам не -/// тащить admission/topology machinery в конечный binary. +/// исполнение от compiler-а, а статическим внутренним adapter-ам — исполнять +/// заранее доказанную топологию без повторной компиляции. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct CompiledInputSchema<'a> { color_inputs: &'a [ColorInputId], @@ -993,7 +1038,6 @@ pub(crate) fn point_program_matches(compiled: &CompiledAppearanceGraph) -> bool } /// Runtime bindings одного атомарного evaluate. -#[cfg(test)] #[derive(Debug, Clone, PartialEq)] pub(crate) struct AppearanceBindings { colors: Vec<(ColorInputId, Srgb8)>, @@ -1001,7 +1045,6 @@ pub(crate) struct AppearanceBindings { opacities: Vec<(OpacityInputId, f64)>, } -#[cfg(test)] impl AppearanceBindings { pub(crate) fn new( mut colors: Vec<(ColorInputId, Srgb8)>, @@ -1019,6 +1062,121 @@ impl AppearanceBindings { } } +/// Один раз полностью проверенные runtime bindings в typed physical domain. +/// +/// IDs остаются рядом со значениями: это позволяет fail-closed отвергнуть +/// случайное применение bindings к другому compiled input schema. Значения +/// alpha уже представлены [`crate::composition::AdmittedOpacityV1`], поэтому +/// steady-state evaluate не повторяет numeric admission. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct AdmittedAppearanceBindings { + colors: Vec<(ColorInputId, Srgb8)>, + surfaces: Vec<(SurfaceInputPortId, Srgb8)>, + opacities: Vec<(OpacityInputId, crate::composition::AdmittedOpacityV1)>, +} + +impl AdmittedAppearanceBindings { + /// Fallibly duplicate one fully admitted value for an independent Session. + /// + /// An ordinary [`Clone`] can abort the process on allocation failure. The + /// runtime attachment boundary uses this method so resource exhaustion is + /// returned before a partially prepared Session can escape. + pub(crate) fn try_clone_v1(&self) -> Result { + fn copy_vec(source: &[T]) -> Result, BindingError> { + let mut copied = Vec::new(); + copied + .try_reserve_exact(source.len()) + .map_err(|_| BindingError::ResourceExhausted)?; + copied.extend_from_slice(source); + Ok(copied) + } + + Ok(Self { + colors: copy_vec(&self.colors)?, + surfaces: copy_vec(&self.surfaces)?, + opacities: copy_vec(&self.opacities)?, + }) + } + + /// Overwrite the complete canonical Surface-input slice from one borrowed + /// value source. + /// + /// The exact typed schema is checked in full before `value_at` can run or + /// any admitted value can change. After that O(N) preflight, `value_at` is + /// called exactly once per canonical input and every destination value is + /// overwritten exactly once. Neither pass needs lookup or allocation. + pub(crate) fn overwrite_surface_inputs_canonical( + &mut self, + expected_inputs: impl IntoIterator, + mut value_at: impl FnMut(usize) -> Srgb8, + ) -> Result<(), BindingError> { + if !expected_inputs + .into_iter() + .eq(self.surfaces.iter().map(|(input, _)| *input)) + { + return Err(BindingError::IncompatibleAdmittedBindings); + } + + for (index, (_, value)) in self.surfaces.iter_mut().enumerate() { + *value = value_at(index); + } + Ok(()) + } + + /// Overwrite one prebound finite-target input without lookup or allocation. + /// The canonical index and nominal ID must both match before mutation. + pub(crate) fn overwrite_color_at( + &mut self, + slot: CompiledColorInputSlotV1, + value: Srgb8, + ) -> Result<(), BindingError> { + let Some((bound, destination)) = self.colors.get_mut(slot.index) else { + return Err(BindingError::IncompatibleAdmittedBindings); + }; + if *bound != slot.id { + return Err(BindingError::IncompatibleAdmittedBindings); + } + *destination = value; + Ok(()) + } + + /// Borrow the admitted Surface-input slice in its exact canonical order. + pub(crate) fn surface_inputs_canonical( + &self, + ) -> impl ExactSizeIterator + '_ { + self.surfaces.iter().copied() + } + + #[cfg(test)] + pub(crate) fn opacity_bits(&self, input: OpacityInputId) -> Option { + self.opacities + .binary_search_by_key(&input, |(bound, _)| *bound) + .ok() + .map(|index| self.opacities[index].1.bits()) + } + + fn matches_schema(&self, schema: CompiledInputSchema<'_>) -> bool { + schema.color_inputs.len() == self.colors.len() + && schema.surface_input_ports.len() == self.surfaces.len() + && schema.opacity_inputs.len() == self.opacities.len() + && schema + .color_inputs + .iter() + .zip(&self.colors) + .all(|(declared, (bound, _))| declared == bound) + && schema + .surface_input_ports + .iter() + .zip(&self.surfaces) + .all(|(declared, (bound, _))| declared == bound) + && schema + .opacity_inputs + .iter() + .zip(&self.opacities) + .all(|(declared, (bound, _))| declared == bound) + } +} + /// Материализованный encoded point Paint вне зависимости от стадии владения. /// /// Graph materialization и downstream recheck разделяют это одно физическое @@ -1056,7 +1214,6 @@ impl EncodedPointPaintV1 { self.opacity } - #[cfg(test)] pub(crate) const fn opacity_bits(self) -> u64 { self.opacity.bits() } @@ -1081,7 +1238,6 @@ impl SourceOverCertificateV1 { .composite(self.subject_rgb, self.subject_opacity, self.backdrop_rgb) } - #[cfg(test)] pub(crate) const fn profile(&self) -> CompositionProfileV1 { self.profile } @@ -1196,11 +1352,11 @@ pub(crate) struct VisiblePointBindingV1 { } impl VisiblePointBindingV1 { - pub(crate) fn program_occurrence(self) -> ProgramOccurrenceBindingV1 { + pub(crate) const fn program_occurrence(self) -> ProgramOccurrenceBindingV1 { self.program_occurrence } - pub(crate) fn occurrence(self) -> SourceOverCertificateV1 { + pub(crate) const fn occurrence(self) -> SourceOverCertificateV1 { self.occurrence } @@ -1210,7 +1366,6 @@ impl VisiblePointBindingV1 { } /// Полный атомарный результат evaluate в каноническом typed-ID порядке. -#[cfg(test)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AppearanceEvaluation { paints: Vec, @@ -1218,7 +1373,6 @@ pub(crate) struct AppearanceEvaluation { occurrences: Vec, } -#[cfg(test)] impl AppearanceEvaluation { pub(crate) fn paint(&self, id: PaintId) -> Option<&EncodedPointPaintV1> { self.paints @@ -1242,7 +1396,195 @@ impl AppearanceEvaluation { } } -#[cfg(test)] +/// Reusable scratch для одного compiled physical graph. +/// +/// После первого fallible sizing повторные evaluate того же shape только +/// очищают slots; capacity и backing allocations остаются неизменными. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AppearanceWorkspaceShape { + paints: usize, + surfaces: usize, + occurrences: usize, +} + +impl AppearanceWorkspaceShape { + const fn of(program: CompiledAppearanceProgram<'_>) -> Self { + Self { + paints: program.paints.len(), + surfaces: program.surfaces.len(), + occurrences: program.occurrences.len(), + } + } +} + +#[derive(Debug)] +pub(crate) struct AppearanceWorkspace { + shape: AppearanceWorkspaceShape, + paints: Vec>, + surfaces: Vec>, + occurrences: Vec>, +} + +impl AppearanceWorkspace { + fn for_program(program: CompiledAppearanceProgram<'_>) -> Result { + let shape = AppearanceWorkspaceShape::of(program); + let mut workspace = Self { + shape, + paints: Vec::new(), + surfaces: Vec::new(), + occurrences: Vec::new(), + }; + initialise_workspace_slots(&mut workspace.paints, shape.paints)?; + initialise_workspace_slots(&mut workspace.surfaces, shape.surfaces)?; + initialise_workspace_slots(&mut workspace.occurrences, shape.occurrences)?; + Ok(workspace) + } + + fn prepare(&mut self, program: CompiledAppearanceProgram<'_>) -> Result<(), BindingError> { + if self.shape != AppearanceWorkspaceShape::of(program) { + return Err(BindingError::IncompatibleWorkspace); + } + self.paints.fill(None); + self.surfaces.fill(None); + self.occurrences.fill(None); + Ok(()) + } + + #[cfg(test)] + pub(crate) fn storage_signature(&self) -> [(usize, usize); 3] { + [ + (self.paints.as_ptr() as usize, self.paints.capacity()), + (self.surfaces.as_ptr() as usize, self.surfaces.capacity()), + ( + self.occurrences.as_ptr() as usize, + self.occurrences.capacity(), + ), + ] + } +} + +fn initialise_workspace_slots( + slots: &mut Vec>, + required_len: usize, +) -> Result<(), BindingError> { + slots + .try_reserve_exact(required_len) + .map_err(|_| BindingError::ResourceExhausted)?; + slots.resize(required_len, None); + Ok(()) +} + +/// Borrowed allocation-free result of one workspace evaluation. +/// +/// The mutable workspace borrow prevents another evaluate from invalidating +/// these values while a consumer is still reading them. +#[derive(Debug)] +pub(crate) struct AppearanceEvaluationView<'program, 'workspace> { + program: CompiledAppearanceProgram<'program>, + workspace: &'workspace AppearanceWorkspace, +} + +impl AppearanceEvaluationView<'_, '_> { + pub(crate) fn paint(&self, id: PaintId) -> Option<&EncodedPointPaintV1> { + let index = self + .program + .paints + .binary_search_by_key(&id, CompiledPaintSpec::id) + .ok()?; + self.workspace.paints[index].as_ref() + } + + /// Resolve a compiler-minted Paint slot in constant time. + /// + /// A slot from a graph whose canonical ordinal names another Paint is + /// rejected before returning workspace data. No fallback ID lookup occurs. + pub(crate) fn paint_at(&self, slot: CompiledPaintSlotV1) -> Option<&EncodedPointPaintV1> { + let spec = self.program.paints.get(slot.index)?; + if spec.id() != slot.id { + return None; + } + let paint = self.workspace.paints.get(slot.index)?.as_ref()?; + (paint.id == slot.id).then_some(paint) + } + + pub(crate) fn surface_rgb(&self, id: SurfaceId) -> Option<[u8; 3]> { + let index = self + .program + .surfaces + .binary_search_by_key(&id, CompiledSurfaceSpec::id) + .ok()?; + self.workspace.surfaces[index].map(Srgb8::bytes) + } + + pub(crate) fn occurrence(&self, id: OccurrenceId) -> Option<&ResolvedOccurrence> { + let index = self + .program + .occurrences + .binary_search_by_key(&id, |occurrence| occurrence.id) + .ok()?; + self.workspace.occurrences[index].as_ref() + } + + /// Resolve a compiler-minted Occurrence slot in constant time, retaining + /// exact nominal identity as the fail-closed cross-graph check. + pub(crate) fn occurrence_at( + &self, + slot: CompiledOccurrenceSlotV1, + ) -> Option<&ResolvedOccurrence> { + let spec = self.program.occurrences.get(slot.index)?; + if spec.id != slot.id { + return None; + } + let occurrence = self.workspace.occurrences.get(slot.index)?.as_ref()?; + (occurrence.id == slot.id).then_some(occurrence) + } + + pub(crate) fn occurrences(&self) -> impl ExactSizeIterator + '_ { + self.workspace.occurrences.iter().map(|occurrence| { + occurrence + .as_ref() + .unwrap_or_else(|| unreachable!("render topo covers every Occurrence")) + }) + } + + fn try_to_owned(&self) -> Result { + let mut paints = Vec::new(); + paints + .try_reserve_exact(self.workspace.paints.len()) + .map_err(|_| BindingError::ResourceExhausted)?; + for paint in &self.workspace.paints { + paints.push(paint.unwrap_or_else(|| unreachable!("Paint topo covers every node"))); + } + + let mut surfaces = Vec::new(); + surfaces + .try_reserve_exact(self.workspace.surfaces.len()) + .map_err(|_| BindingError::ResourceExhausted)?; + for (spec, value) in self.program.surfaces.iter().zip(&self.workspace.surfaces) { + surfaces.push(( + spec.id(), + value.unwrap_or_else(|| unreachable!("render topo covers every Surface")), + )); + } + + let mut occurrences = Vec::new(); + occurrences + .try_reserve_exact(self.workspace.occurrences.len()) + .map_err(|_| BindingError::ResourceExhausted)?; + for occurrence in &self.workspace.occurrences { + occurrences.push( + occurrence.unwrap_or_else(|| unreachable!("render topo covers every Occurrence")), + ); + } + + Ok(AppearanceEvaluation { + paints, + surfaces, + occurrences, + }) + } +} + impl CompiledAppearanceGraph { fn program(&self) -> CompiledAppearanceProgram<'_> { CompiledAppearanceProgram::from_validated_parts( @@ -1259,26 +1601,101 @@ impl CompiledAppearanceGraph { ) } + #[cfg(test)] fn matches_program(&self, program: CompiledAppearanceProgram<'_>) -> bool { self.program() == program } + /// Bind one colour input to its canonical compiled ordinal. Runtime target + /// selection consumes the sealed slot instead of repeating an ID search. + pub(crate) fn bind_color_input(&self, id: ColorInputId) -> Option { + let index = self.color_inputs.binary_search(&id).ok()?; + Some(CompiledColorInputSlotV1 { index, id }) + } + + /// Bind one Paint identity to its canonical compiled ordinal. + /// + /// This is the only cold lookup. Repeated evaluations consume the sealed + /// slot through [`AppearanceEvaluationView::paint_at`] without searching. + pub(crate) fn bind_paint(&self, id: PaintId) -> Option { + let index = self + .paints + .binary_search_by_key(&id, CompiledPaintSpec::id) + .ok()?; + Some(CompiledPaintSlotV1 { index, id }) + } + + /// Bind one Occurrence identity to its canonical compiled ordinal. + pub(crate) fn bind_occurrence(&self, id: OccurrenceId) -> Option { + let index = self + .occurrences + .binary_search_by_key(&id, |occurrence| occurrence.id) + .ok()?; + Some(CompiledOccurrenceSlotV1 { index, id }) + } + + /// Canonical client-owned occurrence identities emitted by this program. + pub(crate) fn occurrence_ids(&self) -> impl ExactSizeIterator + '_ { + self.occurrences.iter().map(|occurrence| occurrence.id) + } + + /// Canonical physical Surface-input schema accepted by this program. + pub(crate) fn surface_input_ports( + &self, + ) -> impl ExactSizeIterator + '_ { + self.surface_input_ports.iter().copied() + } + + /// Проверить полный typed schema и один раз понизить authored alpha в + /// admitted physical values. Результат можно клонировать для независимых + /// runtime callers без повторной numeric admission. + pub(crate) fn admit_bindings( + &self, + bindings: &AppearanceBindings, + ) -> Result { + self.program().admit_bindings(bindings) + } + + /// Fallible one-time allocation of scratch for this exact physical shape. + pub(crate) fn new_workspace(&self) -> Result { + AppearanceWorkspace::for_program(self.program()) + } + + /// Allocation-free steady-state execution over already admitted bindings. + pub(crate) fn evaluate_admitted_into<'workspace>( + &self, + bindings: &AdmittedAppearanceBindings, + workspace: &'workspace mut AppearanceWorkspace, + ) -> Result, BindingError> { + self.program().evaluate_admitted_into(bindings, workspace) + } + + /// Cold convenience внутри Core для callers, которым нужен owned result. + /// Hot Session обязан хранить admitted bindings и workspace между вызовами. pub(crate) fn evaluate( &self, bindings: &AppearanceBindings, ) -> Result { - self.program().evaluate(bindings) + let admitted = self.admit_bindings(bindings)?; + let mut workspace = self.new_workspace()?; + self.evaluate_admitted_into(&admitted, &mut workspace)? + .try_to_owned() } } -impl CompiledAppearanceProgram<'_> { - /// Проверить bindings, материализовать Paint DAG один раз и исполнить - /// Surface/Occurrence DAG один раз. Частичный результат не возвращается. - #[cfg(test)] - pub(crate) fn evaluate( +impl<'program> CompiledAppearanceProgram<'program> { + const fn input_schema(self) -> CompiledInputSchema<'program> { + CompiledInputSchema::new( + self.color_inputs, + self.surface_input_ports, + self.opacity_inputs, + ) + } + + fn admit_bindings( &self, bindings: &AppearanceBindings, - ) -> Result { + ) -> Result { let colors = &bindings.colors; if let Some(window) = colors.windows(2).find(|window| window[0].0 == window[1].0) { return Err(BindingError::DuplicateColorBinding { input: window[0].0 }); @@ -1337,14 +1754,53 @@ impl CompiledAppearanceProgram<'_> { return Err(BindingError::UnexpectedSurfaceInputBinding { input: *bound }); } } + + let mut admitted_colors = Vec::new(); + admitted_colors + .try_reserve_exact(colors.len()) + .map_err(|_| BindingError::ResourceExhausted)?; + admitted_colors.extend(colors.iter().copied()); + + let mut admitted_surfaces = Vec::new(); + admitted_surfaces + .try_reserve_exact(surfaces.len()) + .map_err(|_| BindingError::ResourceExhausted)?; + admitted_surfaces.extend(surfaces.iter().copied()); + + let mut admitted_opacities = Vec::new(); + admitted_opacities + .try_reserve_exact(opacities.len()) + .map_err(|_| BindingError::ResourceExhausted)?; for (input, alpha) in opacities { - if let Err(message) = crate::composition::validate_alpha(*alpha) { - return Err(BindingError::OpacityOutOfDomain { + let value = crate::composition::AdmittedOpacityV1::new(*alpha).map_err(|reason| { + BindingError::OpacityOutOfDomain { input: *input, - message, - }); - } + reason, + } + })?; + admitted_opacities.push((*input, value)); + } + + Ok(AdmittedAppearanceBindings { + colors: admitted_colors, + surfaces: admitted_surfaces, + opacities: admitted_opacities, + }) + } + + fn evaluate_admitted_into<'workspace>( + self, + bindings: &AdmittedAppearanceBindings, + workspace: &'workspace mut AppearanceWorkspace, + ) -> Result, BindingError> { + if !bindings.matches_schema(self.input_schema()) { + return Err(BindingError::IncompatibleAdmittedBindings); } + workspace.prepare(self)?; + + let colors = &bindings.colors; + let surfaces = &bindings.surfaces; + let opacities = &bindings.opacities; let color_value = |id: ColorInputId| -> Srgb8 { let index = colors @@ -1362,55 +1818,27 @@ impl CompiledAppearanceProgram<'_> { let index = opacities .binary_search_by_key(&id, |(bound, _)| *bound) .unwrap_or_else(|_| unreachable!("bindings were matched before evaluation")); - crate::composition::AdmittedOpacityV1::new(opacities[index].1) - .unwrap_or_else(|_| unreachable!("opacity bindings were admitted before execution")) + opacities[index].1 }; - let mut resolved_paints: Vec> = vec![None; self.paints.len()]; - let mut resolved_surfaces: Vec> = vec![None; self.surfaces.len()]; - let mut resolved_occurrences: Vec> = - vec![None; self.occurrences.len()]; self.execute_into( color_value, surface_value, opacity_value, - &mut resolved_paints, - &mut resolved_surfaces, - &mut resolved_occurrences, + &mut workspace.paints, + &mut workspace.surfaces, + &mut workspace.occurrences, ); - let paints = resolved_paints - .into_iter() - .map(|paint| paint.unwrap_or_else(|| unreachable!("Paint topo covers every node"))) - .collect(); - let surfaces = self - .surfaces - .iter() - .zip(resolved_surfaces) - .map(|(surface, value)| { - ( - surface.id(), - value.unwrap_or_else(|| unreachable!("render topo covers every Surface")), - ) - }) - .collect(); - let occurrences = resolved_occurrences - .into_iter() - .map(|occurrence| { - occurrence.unwrap_or_else(|| unreachable!("render topo covers every Occurrence")) - }) - .collect(); - - Ok(AppearanceEvaluation { - paints, - surfaces, - occurrences, + Ok(AppearanceEvaluationView { + program: self, + workspace, }) } /// Единственное исполнение compiled IR. Scratch принадлежит caller-у: - /// static adapter использует stack arrays, test-only generic admission — - /// динамические buffers. Алгоритм и сертификат при этом общие. + /// static adapter использует stack arrays, generic admission — + /// владеющие buffers. Алгоритм и сертификат при этом общие. fn execute_into( &self, color_value: C, diff --git a/crates/labcolors-core/src/appearance_graph_tests.rs b/crates/labcolors-core/src/appearance_graph_tests.rs index 9e25144f..109e8f4c 100644 --- a/crates/labcolors-core/src/appearance_graph_tests.rs +++ b/crates/labcolors-core/src/appearance_graph_tests.rs @@ -3,15 +3,17 @@ //! Граф владеет только физической топологией: Paint материализуется независимо //! от подложки, Occurrence является его единственным применением к Surface, а //! `surfaceFrom` лишь даёт видимому результату повторно используемую identity. -//! Словарь Pair/role и perception-утверждения сюда не входят. +//! Клиентский словарь и perception-утверждения сюда не входят. + +use std::cell::Cell; use proptest::prelude::*; use crate::Srgb8; use crate::appearance::{ - AppearanceBindings, AppearanceGraphSpec, BindingError, ColorInputId, CompileError, - CompositionProfileV1, EncodedPointPaintV1, OccurrenceId, OccurrenceSpec, OpacityInputId, - PaintId, PaintSpec, SurfaceId, SurfaceInputPortId, SurfaceSpec, + AdmittedAppearanceBindings, AppearanceBindings, AppearanceGraphSpec, BindingError, + ColorInputId, CompileError, CompositionProfileV1, EncodedPointPaintV1, OccurrenceId, + OccurrenceSpec, OpacityInputId, PaintId, PaintSpec, SurfaceId, SurfaceInputPortId, SurfaceSpec, }; use crate::constraints::Evaluator; @@ -27,6 +29,7 @@ const OTHER_PAINT: PaintId = PaintId::new(41); const CONTEXT_SURFACE: SurfaceId = SurfaceId::new(90); const DERIVED_SURFACE: SurfaceId = SurfaceId::new(2); const FILL_OCCURRENCE: OccurrenceId = OccurrenceId::new(800); +const OTHER_OCCURRENCE: OccurrenceId = OccurrenceId::new(400); fn point_component(reverse_paints: bool, reverse_surfaces: bool) -> AppearanceGraphSpec { let mut paints = vec![ @@ -80,6 +83,71 @@ fn bindings(source: [u8; 3], opacity: f64, context: [u8; 3]) -> AppearanceBindin ) } +fn slot_component(reverse_declarations: bool) -> AppearanceGraphSpec { + let mut paints = vec![ + PaintSpec::Solid { + id: SOLID_PAINT, + color: SOURCE, + }, + PaintSpec::Solid { + id: FILL_PAINT, + color: OTHER_SOURCE, + }, + ]; + let mut occurrences = vec![ + OccurrenceSpec { + id: FILL_OCCURRENCE, + subject: FILL_PAINT, + against: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + OccurrenceSpec { + id: OTHER_OCCURRENCE, + subject: SOLID_PAINT, + against: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + ]; + if reverse_declarations { + paints.reverse(); + occurrences.reverse(); + } + + AppearanceGraphSpec::new( + vec![SOURCE, OTHER_SOURCE], + vec![CONTEXT], + vec![], + paints, + vec![SurfaceSpec::Input { + id: CONTEXT_SURFACE, + port: CONTEXT, + }], + occurrences, + ) +} + +fn admitted_surface_triplet() -> AdmittedAppearanceBindings { + let inputs = [ + SurfaceInputPortId::new(30), + SurfaceInputPortId::new(10), + SurfaceInputPortId::new(20), + ]; + let graph = AppearanceGraphSpec::new(vec![], inputs.to_vec(), vec![], vec![], vec![], vec![]) + .compile() + .unwrap(); + graph + .admit_bindings(&AppearanceBindings::new( + vec![], + vec![ + (inputs[0], Srgb8::new([30; 3])), + (inputs[1], Srgb8::new([10; 3])), + (inputs[2], Srgb8::new([20; 3])), + ], + vec![], + )) + .unwrap() +} + #[test] fn static_exact_program_is_declarative_topology_plus_typed_constraint() { let compiled = crate::appearance::point_opacity_over_surface_declarative_spec() @@ -91,7 +159,9 @@ fn static_exact_program_is_declarative_topology_plus_typed_constraint() { crate::appearance::PhysicalProgramIdentityV1::SolidOpacityOverSurfaceEncodedSrgb8V1 ); assert_eq!( - crate::constraints::ExactSrgb8IdentityV1.identity(), + >::identity(&crate::constraints::ExactSrgb8IdentityV1), crate::constraints::ExactConstraintIdentityV1::FinalSrgb8IdentityV1 ); } @@ -148,6 +218,127 @@ fn compile_and_evaluate_ignore_declaration_order() { assert_eq!(expected, both_reversed.evaluate(&values).unwrap()); } +#[test] +fn compiled_slots_bind_found_ids_and_reject_missing_ids() { + let graph = point_component(false, false).compile().unwrap(); + + assert!(graph.bind_paint(SOLID_PAINT).is_some()); + assert!(graph.bind_paint(FILL_PAINT).is_some()); + assert!(graph.bind_paint(PaintId::new(999)).is_none()); + assert!(graph.bind_occurrence(FILL_OCCURRENCE).is_some()); + assert!(graph.bind_occurrence(OccurrenceId::new(999)).is_none()); +} + +#[test] +fn compiled_slots_have_canonical_ordinals_across_declaration_permutations() { + let canonical = slot_component(false).compile().unwrap(); + let reversed = slot_component(true).compile().unwrap(); + + for paint in [FILL_PAINT, SOLID_PAINT] { + assert_eq!(canonical.bind_paint(paint), reversed.bind_paint(paint)); + } + for occurrence in [OTHER_OCCURRENCE, FILL_OCCURRENCE] { + assert_eq!( + canonical.bind_occurrence(occurrence), + reversed.bind_occurrence(occurrence) + ); + } +} + +#[test] +fn evaluation_view_rejects_same_ordinal_slots_with_different_nominal_ids() { + let compile_single = |paint, occurrence| { + AppearanceGraphSpec::new( + vec![SOURCE], + vec![CONTEXT], + vec![], + vec![PaintSpec::Solid { + id: paint, + color: SOURCE, + }], + vec![SurfaceSpec::Input { + id: CONTEXT_SURFACE, + port: CONTEXT, + }], + vec![OccurrenceSpec { + id: occurrence, + subject: paint, + against: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }], + ) + .compile() + .unwrap() + }; + let graph = compile_single(FILL_PAINT, FILL_OCCURRENCE); + let incompatible = compile_single(OTHER_PAINT, OTHER_OCCURRENCE); + let incompatible_paint = incompatible.bind_paint(OTHER_PAINT).unwrap(); + let incompatible_occurrence = incompatible.bind_occurrence(OTHER_OCCURRENCE).unwrap(); + let admitted = graph + .admit_bindings(&AppearanceBindings::new( + vec![(SOURCE, Srgb8::new([10, 20, 30]))], + vec![(CONTEXT, Srgb8::new([40, 50, 60]))], + vec![], + )) + .unwrap(); + let mut workspace = graph.new_workspace().unwrap(); + let evaluated = graph + .evaluate_admitted_into(&admitted, &mut workspace) + .unwrap(); + + assert!(evaluated.paint_at(incompatible_paint).is_none()); + assert!(evaluated.occurrence_at(incompatible_occurrence).is_none()); +} + +#[test] +fn prebound_view_lookup_returns_exact_values_and_allocates_nothing() { + let graph = point_component(false, false).compile().unwrap(); + let admitted = graph + .admit_bindings(&bindings([0xFF, 0xA1, 0x00], 0.122, [0xFF; 3])) + .unwrap(); + let mut workspace = graph.new_workspace().unwrap(); + let evaluated = graph + .evaluate_admitted_into(&admitted, &mut workspace) + .unwrap(); + let paint_slot = graph.bind_paint(FILL_PAINT).unwrap(); + let occurrence_slot = graph.bind_occurrence(FILL_OCCURRENCE).unwrap(); + + let (resolved, allocations) = crate::test_support::measured_allocations(|| { + let paint = evaluated.paint_at(paint_slot); + let occurrence = evaluated.occurrence_at(occurrence_slot); + ( + paint.map(|paint| (paint.id(), paint.source(), paint.opacity_bits())), + occurrence.map(|occurrence| { + ( + occurrence.id(), + occurrence.subject(), + occurrence.against(), + occurrence.backdrop(), + occurrence.visible(), + *occurrence.certificate(), + ) + }), + ) + }); + + assert_eq!(allocations, 0); + assert_eq!( + resolved.0, + Some(( + FILL_PAINT, + Srgb8::new([0xFF, 0xA1, 0x00]), + 0.122f64.to_bits(), + )) + ); + let occurrence = resolved.1.unwrap(); + assert_eq!(occurrence.0, FILL_OCCURRENCE); + assert_eq!(occurrence.1, FILL_PAINT); + assert_eq!(occurrence.2, CONTEXT_SURFACE); + assert_eq!(occurrence.3, [0xFF; 3]); + assert_eq!(occurrence.4, [0xFF, 0xF4, 0xE0]); + assert_eq!(occurrence.5.replay(), [0xFF, 0xF4, 0xE0]); +} + #[test] fn complete_typed_id_renaming_does_not_change_physics() { let source = ColorInputId::new(700); @@ -239,6 +430,96 @@ fn complete_typed_id_renaming_does_not_change_physics() { ); } +#[test] +fn equal_transport_numbers_remain_opaque_in_a_nested_occurrence_graph() { + let first_color = ColorInputId::new(41); + let second_color = ColorInputId::new(7); + let surface_port = SurfaceInputPortId::new(41); + let first_opacity = OpacityInputId::new(41); + let second_opacity = OpacityInputId::new(7); + let first_solid = PaintId::new(41); + let first_modulated = PaintId::new(42); + let second_solid = PaintId::new(7); + let second_modulated = PaintId::new(8); + let backdrop = SurfaceId::new(41); + let derived = SurfaceId::new(7); + let first_occurrence = OccurrenceId::new(41); + let second_occurrence = OccurrenceId::new(7); + + let graph = AppearanceGraphSpec::new( + vec![first_color, second_color], + vec![surface_port], + vec![first_opacity, second_opacity], + vec![ + PaintSpec::Opacity { + id: first_modulated, + source: first_solid, + opacity: first_opacity, + }, + PaintSpec::Solid { + id: second_solid, + color: second_color, + }, + PaintSpec::Opacity { + id: second_modulated, + source: second_solid, + opacity: second_opacity, + }, + PaintSpec::Solid { + id: first_solid, + color: first_color, + }, + ], + vec![ + SurfaceSpec::FromOccurrence { + id: derived, + occurrence: first_occurrence, + }, + SurfaceSpec::Input { + id: backdrop, + port: surface_port, + }, + ], + vec![ + OccurrenceSpec { + id: second_occurrence, + subject: second_modulated, + against: derived, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + OccurrenceSpec { + id: first_occurrence, + subject: first_modulated, + against: backdrop, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + ], + ) + .compile() + .unwrap(); + let evaluated = graph + .evaluate(&AppearanceBindings::new( + vec![ + (first_color, Srgb8::new([200, 80, 40])), + (second_color, Srgb8::new([30, 160, 230])), + ], + vec![(surface_port, Srgb8::new([20, 40, 60]))], + vec![(first_opacity, 0.5), (second_opacity, 0.25)], + )) + .unwrap(); + + let first = evaluated.occurrence(first_occurrence).unwrap(); + let second = evaluated.occurrence(second_occurrence).unwrap(); + assert_eq!(first.subject(), first_modulated); + assert_eq!(first.against(), backdrop); + assert_eq!(first.visible(), [110, 60, 50]); + assert_eq!(evaluated.surface_rgb(derived), Some([110, 60, 50])); + assert_eq!(second.subject(), second_modulated); + assert_eq!(second.against(), derived); + assert_eq!(second.backdrop(), first.visible()); + assert_eq!(second.visible(), [90, 85, 95]); +} + #[test] fn occurrence_uses_the_declared_paint_not_an_unrelated_color_input() { let graph = AppearanceGraphSpec::new( @@ -852,7 +1133,8 @@ proptest! { } else { (invalid, 0.5, OPACITY) }; - let expected_message = crate::composition::validate_alpha(invalid).unwrap_err(); + let expected_reason = + crate::composition::AdmittedOpacityV1::new(invalid).unwrap_err(); prop_assert_eq!( graph.evaluate(&AppearanceBindings::new( vec![(SOURCE, Srgb8::new([1, 2, 3]))], @@ -861,7 +1143,7 @@ proptest! { )), Err(BindingError::OpacityOutOfDomain { input: expected_input, - message: expected_message, + reason: expected_reason, }) ); } @@ -1333,16 +1615,53 @@ fn evaluate_rejects_duplicate_missing_and_unexpected_bindings() { } #[test] -fn evaluate_rejects_invalid_alpha_with_the_ssot_domain_text() { +fn binding_admission_is_atomic_before_any_occurrence_is_evaluated() { + let graph = point_component(false, false).compile().unwrap(); + + crate::composition::reset_source_over_evaluation_count(); + let duplicate_surface = graph.evaluate(&AppearanceBindings::new( + vec![(SOURCE, Srgb8::new([1, 2, 3]))], + vec![ + (CONTEXT, Srgb8::new([4, 5, 6])), + (CONTEXT, Srgb8::new([7, 8, 9])), + ], + vec![(OPACITY, 0.5)], + )); + assert_eq!( + duplicate_surface, + Err(BindingError::DuplicateSurfaceInputBinding { input: CONTEXT }) + ); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); + + crate::composition::reset_source_over_evaluation_count(); + let invalid_opacity = graph.evaluate(&bindings([1, 2, 3], f64::NAN, [4, 5, 6])); + assert_eq!( + invalid_opacity, + Err(BindingError::OpacityOutOfDomain { + input: OPACITY, + reason: crate::composition::OpacityAdmissionErrorV1::NonFinite, + }) + ); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); + + let evaluated = graph + .evaluate(&bindings([1, 2, 3], 0.5, [4, 5, 6])) + .unwrap(); + assert_eq!(crate::composition::source_over_evaluation_count(), 1); + assert!(evaluated.occurrence(FILL_OCCURRENCE).is_some()); +} + +#[test] +fn evaluate_rejects_invalid_alpha_with_the_typed_admission_reason() { let graph = point_component(false, false).compile().unwrap(); for bad_alpha in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.1, 1.5] { - let expected = crate::alpha::composite_over_srgb8([1, 2, 3], bad_alpha, [4, 5, 6]) - .expect_err("композитор обязан отвергать тот же домен alpha"); + let expected = crate::composition::AdmittedOpacityV1::new(bad_alpha) + .expect_err("общий admission обязан отвергать alpha"); assert_eq!( graph.evaluate(&bindings([1, 2, 3], bad_alpha, [4, 5, 6])), Err(BindingError::OpacityOutOfDomain { input: OPACITY, - message: expected, + reason: expected, }), "alpha={bad_alpha}" ); @@ -1364,3 +1683,251 @@ fn signed_zero_opacity_has_one_canonical_state() { 0.0f64.to_bits() ); } + +#[test] +fn canonical_surface_overwrite_rejects_every_schema_drift_before_read_or_mutation() { + let mut admitted = admitted_surface_triplet(); + let first = SurfaceInputPortId::new(10); + let second = SurfaceInputPortId::new(20); + let third = SurfaceInputPortId::new(30); + let original = [ + (first, Srgb8::new([10; 3])), + (second, Srgb8::new([20; 3])), + (third, Srgb8::new([30; 3])), + ]; + let reordered = [second, first, third]; + let relabelled = [first, SurfaceInputPortId::new(21), third]; + let truncated = [first, second]; + let extended = [first, second, third, SurfaceInputPortId::new(40)]; + + for expected in [ + reordered.as_slice(), + relabelled.as_slice(), + truncated.as_slice(), + extended.as_slice(), + ] { + let reads = Cell::new(0); + let (result, allocations) = crate::test_support::measured_allocations(|| { + admitted.overwrite_surface_inputs_canonical(expected.iter().copied(), |_| { + reads.set(reads.get() + 1); + Srgb8::new([0; 3]) + }) + }); + + assert_eq!(result, Err(BindingError::IncompatibleAdmittedBindings)); + assert_eq!(reads.get(), 0); + assert_eq!(allocations, 0); + assert!(admitted.surface_inputs_canonical().eq(original)); + } +} + +#[test] +fn canonical_surface_overwrite_reads_once_and_exposes_all_values_without_allocation() { + let mut admitted = admitted_surface_triplet(); + let expected_inputs = [ + SurfaceInputPortId::new(10), + SurfaceInputPortId::new(20), + SurfaceInputPortId::new(30), + ]; + + admitted + .overwrite_surface_inputs_canonical(expected_inputs, |index| Srgb8::new([index as u8; 3])) + .unwrap(); + + let values = [ + Srgb8::new([101, 102, 103]), + Srgb8::new([111, 112, 113]), + Srgb8::new([121, 122, 123]), + ]; + let reads = Cell::new([0_usize; 3]); + let (result, allocations) = crate::test_support::measured_allocations(|| { + admitted.overwrite_surface_inputs_canonical(expected_inputs, |index| { + let mut counts = reads.get(); + counts[index] += 1; + reads.set(counts); + values[index] + })?; + Ok::<_, BindingError>( + admitted + .surface_inputs_canonical() + .eq(expected_inputs.into_iter().zip(values)), + ) + }); + + assert_eq!(result, Ok(true)); + assert_eq!(reads.get(), [1, 1, 1]); + assert_eq!(allocations, 0); +} + +#[test] +fn admitted_surface_runtime_exposes_only_canonical_bulk_seams() { + let source = include_str!("appearance.rs"); + let forbidden = concat!("set_surface_", "input"); + assert!( + !source.contains(forbidden), + "per-port Surface mutation must not return after the canonical bulk cut" + ); + for required in [ + "overwrite_surface_inputs_canonical", + "surface_inputs_canonical", + ] { + assert!( + source.contains(required), + "canonical runtime seam `{required}` must remain" + ); + } +} + +#[test] +fn admitted_bindings_and_workspace_are_reused_without_storage_churn() { + let graph = point_component(false, false).compile().unwrap(); + let mut admitted = graph + .admit_bindings(&bindings([200, 80, 40], 0.5, [20, 40, 60])) + .unwrap(); + let mut independent = admitted.try_clone_v1().unwrap(); + assert_eq!(independent, admitted); + independent + .overwrite_surface_inputs_canonical([CONTEXT], |_| Srgb8::new([1, 2, 3])) + .unwrap(); + assert_ne!(independent, admitted); + assert_eq!(admitted.opacity_bits(OPACITY), Some(0.5f64.to_bits())); + assert_eq!( + graph.occurrence_ids().collect::>(), + vec![FILL_OCCURRENCE] + ); + assert_eq!( + graph.surface_input_ports().collect::>(), + vec![CONTEXT] + ); + + let mut workspace = graph.new_workspace().unwrap(); + let storage = workspace.storage_signature(); + for (backdrop, expected) in [ + ([20, 40, 60], [110, 60, 50]), + ([100, 100, 100], [150, 90, 70]), + ] { + admitted + .overwrite_surface_inputs_canonical([CONTEXT], |_| Srgb8::new(backdrop)) + .unwrap(); + { + let evaluated = graph + .evaluate_admitted_into(&admitted, &mut workspace) + .unwrap(); + assert_eq!( + evaluated.paint(FILL_PAINT).unwrap().opacity_bits(), + 0.5f64.to_bits() + ); + assert_eq!(evaluated.surface_rgb(CONTEXT_SURFACE), Some(backdrop)); + assert_eq!( + evaluated.occurrence(FILL_OCCURRENCE).unwrap().visible(), + expected + ); + assert_eq!( + evaluated + .occurrences() + .map(|occurrence| occurrence.id()) + .collect::>(), + vec![FILL_OCCURRENCE] + ); + } + assert_eq!(workspace.storage_signature(), storage); + } +} + +#[test] +fn admitted_schema_and_workspace_shape_mismatches_fail_before_composition() { + let graph = point_component(false, false).compile().unwrap(); + let admitted = graph + .admit_bindings(&bindings([1, 2, 3], 0.5, [4, 5, 6])) + .unwrap(); + let empty = AppearanceGraphSpec::new(vec![], vec![], vec![], vec![], vec![], vec![]) + .compile() + .unwrap(); + let mut wrong_workspace = empty.new_workspace().unwrap(); + let wrong_storage = wrong_workspace.storage_signature(); + + crate::composition::reset_source_over_evaluation_count(); + assert_eq!( + graph + .evaluate_admitted_into(&admitted, &mut wrong_workspace) + .unwrap_err(), + BindingError::IncompatibleWorkspace + ); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); + assert_eq!(wrong_workspace.storage_signature(), wrong_storage); + + let other_source = ColorInputId::new(100); + let other_context = SurfaceInputPortId::new(101); + let other_opacity = OpacityInputId::new(102); + let other_solid = PaintId::new(103); + let other_paint = PaintId::new(104); + let other_surface = SurfaceId::new(105); + let other_derived = SurfaceId::new(106); + let other_occurrence = OccurrenceId::new(107); + let other = AppearanceGraphSpec::new( + vec![other_source], + vec![other_context], + vec![other_opacity], + vec![ + PaintSpec::Solid { + id: other_solid, + color: other_source, + }, + PaintSpec::Opacity { + id: other_paint, + source: other_solid, + opacity: other_opacity, + }, + ], + vec![ + SurfaceSpec::Input { + id: other_surface, + port: other_context, + }, + SurfaceSpec::FromOccurrence { + id: other_derived, + occurrence: other_occurrence, + }, + ], + vec![OccurrenceSpec { + id: other_occurrence, + subject: other_paint, + against: other_surface, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }], + ) + .compile() + .unwrap(); + let other_admitted = other + .admit_bindings(&AppearanceBindings::new( + vec![(other_source, Srgb8::new([1, 2, 3]))], + vec![(other_context, Srgb8::new([4, 5, 6]))], + vec![(other_opacity, 0.5)], + )) + .unwrap(); + let mut workspace = graph.new_workspace().unwrap(); + let storage = workspace.storage_signature(); + + assert_eq!( + graph + .evaluate_admitted_into(&other_admitted, &mut workspace) + .unwrap_err(), + BindingError::IncompatibleAdmittedBindings + ); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); + assert_eq!(workspace.storage_signature(), storage); +} + +#[test] +fn invalid_opacity_is_rejected_during_admission_before_any_composition() { + let graph = point_component(false, false).compile().unwrap(); + crate::composition::reset_source_over_evaluation_count(); + assert_eq!( + graph.admit_bindings(&bindings([1, 2, 3], f64::NAN, [4, 5, 6])), + Err(BindingError::OpacityOutOfDomain { + input: OPACITY, + reason: crate::composition::OpacityAdmissionErrorV1::NonFinite, + }) + ); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); +} diff --git a/crates/labcolors-core/src/composition.rs b/crates/labcolors-core/src/composition.rs index ffeb7dd0..3f5469dc 100644 --- a/crates/labcolors-core/src/composition.rs +++ b/crates/labcolors-core/src/composition.rs @@ -103,13 +103,6 @@ pub(crate) fn source_over_channel_srgb8(tint: u8, alpha: f64, backdrop: u8) -> u source_over_channel_value(tint, alpha, backdrop).round() as u8 } -#[cfg(test)] -pub(crate) fn validate_alpha(alpha: f64) -> Result<(), String> { - AdmittedOpacityV1::new(alpha) - .map(|_| ()) - .map_err(|_| format!("alpha вне конечного [0,1]: {alpha}")) -} - pub(crate) fn source_over_srgb8( tint: [u8; 3], alpha: f64, diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index 31d5e8d7..ca8ac53b 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -435,26 +435,6 @@ pub enum RoleRecipe { /// Обязательный numerical-decision profile; implicit legacy запрещён. decision_profile: crate::glow::GlowDecisionProfileV1, }, - /// Frozen PairFill frontend до C7c. Источник эмитится opaque Paint через - /// общий point occurrence; отдельной Pair-эвристики и скрытой роли нет. - PairFill { - /// Источник якоря: бренд, семейство или нейтраль. - source: LadderSource, - }, - /// Frozen PairLabel frontend до C7c. Label-кандидаты проверяются против - /// фактически emitted opaque [`PairFill`](Self::PairFill) Surface общим - /// joint hard-report и fresh recheck. - PairLabel { - /// Источник физической цветовой идентичности: бренд, семейство или нейтраль. - source: LadderSource, - /// Доля максимума контраста PairFill Surface `(0, 1]` (как у - /// [`TextAnchor`](Self::TextAnchor)): низкая доля оставляет больше места - /// для хромы источника у пола, высокая тянет к контрастному пределу. - /// Точный серый source при любой доле остаётся нейтральным. - fraction: f64, - /// WCAG-пол против emitted PairFill Surface, не страницы. - floor: Floor, - }, /// Альфа-аналог solid-источника через точечную композит-инверсию /// ([`crate::alpha`]): `(tint, α)`, чей композит на объявленном фоне равен /// solid-цели `of`. Компилируется в [`RoleSpec::AlphaAnalog`]. @@ -508,8 +488,6 @@ fn reserved_css_suffixes(recipe: &RoleRecipe) -> &'static [&'static str] { | RoleRecipe::DjAnchor { .. } | RoleRecipe::DecorativeLc { .. } | RoleRecipe::Ladder { .. } - | RoleRecipe::PairFill { .. } - | RoleRecipe::PairLabel { .. } | RoleRecipe::AlphaAnalog { .. } | RoleRecipe::Zero => PRIMARY, } @@ -938,19 +916,6 @@ impl ThemeConfig { } // Ступень — закрытый enum, числовой валидации не требует; источник — как у лестницы. RoleRecipe::Glow { source, .. } => self.check_ladder_source(role, source), - RoleRecipe::PairFill { source } => self.check_ladder_source(role, source), - RoleRecipe::PairLabel { - source, fraction, .. - } => { - self.check_ladder_source(role, source)?; - check_in_excl_incl( - &format!("roles.{role}.fraction"), - *fraction, - FRACTION_MIN_EXCLUSIVE, - FRACTION_MAX_INCLUSIVE, - "0 < fraction ≤ 1 (доля максимального контраста PairFill Surface)", - ) - } RoleRecipe::AlphaAnalog { of, alpha } => { self.check_ladder_source(role, of)?; check_in_excl_incl( @@ -1174,26 +1139,6 @@ impl ThemeConfig { floor: *floor, }) } - RoleRecipe::PairFill { source } => Ok(RoleSpec::PairFill { - tint: self.compile_ladder_tint(role, source)?, - }), - RoleRecipe::PairLabel { - source, - fraction, - floor, - } => { - // P1 унифицирует PairFill/PairLabel на единственной поверхности, - // которую публичный PairFill уже эмитил: opaque source Paint. - // Representation не выводится из клиентского имени позиции. - let (surface_alpha_light, surface_alpha_dark) = (1.0, 1.0); - Ok(RoleSpec::PairLabel { - tint: self.compile_ladder_tint(role, source)?, - fraction: *fraction, - floor: *floor, - surface_alpha_light, - surface_alpha_dark, - }) - } } } diff --git a/crates/labcolors-core/src/config/preset.rs b/crates/labcolors-core/src/config/preset.rs index 3a315f7b..f54b3c2e 100644 --- a/crates/labcolors-core/src/config/preset.rs +++ b/crates/labcolors-core/src/config/preset.rs @@ -297,9 +297,7 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { // НЕЙТРАЛЬНЫЙ (стаб: fill-neutral солид-литерал; fill-neutral-tinted и // border-neutral алиасят нейтральные core-роли fill-primary/border-base). // - // Словарный канон labui#92: `fill-accent`/`fill-danger` — НЕ роли, а - // deprecation-алиасы на `badge-fill-brand`/`badge-fill-danger` (закон пары; - // labui_preset_aliases). `-tinted` остаётся РОЛЬЮ: ЗАЛИВКА при низкой альфе + // `-tinted` остаётся РОЛЬЮ: ЗАЛИВКА при низкой альфе // (тинт×альфа напрямую), то есть Ladder FillPrimary — тинт = якорь источника, // α = @12 (солид над белым дал бы α_min≈1 и «-tinted» перестал быть // полупрозрачным, поэтому Ladder, а не инверсия). @@ -341,31 +339,6 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { brand_pos(LadderPosition::FocusRing), )); - // Frozen Pair frontend: PairFill эмитит exact source как opaque occurrence; - // PairLabel строит конечный candidate domain на фактически emitted Surface - // и проверяет его общим joint evaluator/recheck. Статики проходят тот же путь. - let pair = |source| RoleRecipe::PairFill { source }; - roles.push(("badge-fill-brand".to_string(), pair(LadderSource::Brand))); - for (client_name, family_key) in [ - ("danger", "red"), - ("warning", "orange"), - ("success", "green"), - ("info", "blue"), - ] { - roles.push(( - format!("badge-fill-{client_name}"), - pair(LadderSource::Family(family_key.to_string())), - )); - } - roles.push(( - "badge-fill-static-dark".to_string(), - pair(LadderSource::Neutral(NeutralPick::Dark)), - )); - roles.push(( - "badge-fill-static-light".to_string(), - pair(LadderSource::Neutral(NeutralPick::Light)), - )); - roles } @@ -375,8 +348,7 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { /// Нейтральные компонент-роли, которые стаб алиасит через `var()` на /// нейтральные core-роли (одна истина, ноль дублирования значений): /// fill-neutral-tinted = var(--lab-fill-primary); border-neutral = -/// var(--lab-border-base). Плюс deprecation-алиасы канона #92: fill-accent/ -/// fill-danger → badge-fill-brand/danger (закон пары), icon → label-tertiary +/// var(--lab-border-base). Плюс алиасы канона #92: icon → label-tertiary /// (глиф красится Labels), border-ghost → border-none (честный ноль). Пресет /// наполняет роли И алиасы как единое целое. pub fn labui_preset_aliases() -> Vec<(String, String)> { @@ -391,10 +363,7 @@ pub fn labui_preset_aliases() -> Vec<(String, String)> { "fill-quaternary".to_string(), ), // Словарный канон labui#92 (порядок = passport.aliases labui; отпечаток - // тонкий==полный чувствителен к порядку). Акцент/данжер-заливки — закон - // пары; icon — глиф (label-tertiary); border-ghost — честный ноль. - ("fill-accent".to_string(), "badge-fill-brand".to_string()), - ("fill-danger".to_string(), "badge-fill-danger".to_string()), + // тонкий==полный чувствителен к порядку). ("icon".to_string(), "label-tertiary".to_string()), ("border-ghost".to_string(), "border-none".to_string()), ] diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index 3270bcca..bad8b291 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -647,9 +647,7 @@ const LABUI_CONSUMED_ROLES: &[&str] = &[ "fx-shadow-penumbra", "fx-shadow-major", // Component. - "fill-accent", "fill-neutral", - "fill-danger", "fill-accent-tinted", "fill-neutral-tinted", "fill-danger-tinted", @@ -659,14 +657,6 @@ const LABUI_CONSUMED_ROLES: &[&str] = &[ "border-neutral", "border-danger", "border-focus", - // Пары бейджа: exact opaque fill и joint-verified label на emitted Surface. - "badge-fill-brand", - "badge-fill-danger", - "badge-fill-warning", - "badge-fill-success", - "badge-fill-info", - "badge-fill-static-dark", - "badge-fill-static-light", // Прочие эмитируемые нейтральные (none — core; icon снят с контракта каноном // #92 — глиф красится label-tertiary; separator НЕ токен: бордер и сепаратор // едины, компонент применяет бордер-токен). @@ -716,14 +706,11 @@ const COLLAPSED_ROLES: &[(&str, &str)] = &[ "bg-overlay-*", "оверлеи → alpha.rs-роли (вне поглощаемого GAP)", ), - // Компонентные алиасы — конфиг-алиасы, не рецепты. Бейдж сузился законом - // пары: badge-fill-* — первоклассная эмиссия RoleRecipe::PairFill; - // label frontend использует фактически emitted fill Surface без зависимости - // по имени токена. - ( - "badge-label-*", - "лейбл бейджа — joint-verified foreground на emitted PairFill Surface", - ), + // Компонентная композиция принадлежит клиентскому Program, а не закрытому + // ролевому меню Core. + ("badge-*", "client-owned Program composition"), + ("fill-accent", "client-owned alias"), + ("fill-danger", "client-owned alias"), ("control-bg", "компонентный алиас, не рецепт эмиссии"), ]; @@ -785,8 +772,6 @@ fn consumed_roles_diff_is_empty_against_labui_contract() { }; assert!(hits("label-on-accent") && hits("bg-material-thick") && hits("tint-static-dark-4")); assert!(!hits("fx-glow-inverted") && !hits("label-danger-primary")); - // COLLAPSED_ROLES не пуст — декларация причин присутствует. - assert!(!COLLAPSED_ROLES.is_empty()); } /// Glob-сопоставление паттернов [`COLLAPSED_ROLES`] (`*` — любая подстрока): @@ -898,8 +883,6 @@ fn renaming_family_id_and_references_does_not_change_the_compiled_graph() { } RoleRecipe::Ladder { source, .. } | RoleRecipe::Glow { source, .. } - | RoleRecipe::PairFill { source } - | RoleRecipe::PairLabel { source, .. } | RoleRecipe::Material { source, .. } => { rename_source(source, "red", "client-family-42"); } @@ -1312,42 +1295,6 @@ fn value_test_bites_on_alpha_mutation() { ); } -/// P1 не выводит representation из client-owned имени позиции и не двигает -/// authored source скрытой Pair-эвристикой. Во всех VC PairFill эмитит точный -/// выбранный source как opaque Paint; смена темы меняет только authored anchor. -#[test] -fn pair_fill_is_exact_opaque_source_across_viewing_conditions() { - let table = labui_reference().compile_named_role_table().unwrap(); - let cases = [ - (ViewingConditions::srgb(), "#FFFFFF", "#007AFF"), - (ViewingConditions::dim_surround(), "#101012", "#4A8FFF"), - ( - ViewingConditions::srgb_high_contrast(), - "#FFFFFF", - "#0040DD", - ), - ( - ViewingConditions::dim_surround_high_contrast(), - "#101012", - "#409CFF", - ), - ]; - - for (vc, background, expected_source) in cases { - let set = resolve_named_set(&BgInput::solid(background).unwrap(), &table, &vc) - .expect("валидная PairFill fixture обязана резолвиться"); - let (_, resolved) = set - .iter() - .find(|(name, _)| name == "badge-fill-brand") - .expect("паспорт несёт badge-fill-brand"); - let fill = resolved - .translucent() - .expect("PairFill эмитится общей rgba-формой"); - assert_eq!(fill.tint_hex(), expected_source); - assert_eq!(fill.alpha().to_bits(), 1.0_f64.to_bits()); - } -} - /// Дубликаты ключей всех словарей отвергаются (повтор имени = неоднозначный /// lookup), включая алиас, затеняющий роль. #[test] @@ -2018,10 +1965,10 @@ fn every_hue_consuming_path_preserves_achromatic_source_identity() { for (name, recipe) in &mut config.roles { match name.as_str() { "label-brand-secondary" => { - *recipe = RoleRecipe::PairLabel { - source: LadderSource::Brand, + *recipe = RoleRecipe::TextAnchor { fraction: 0.5, floor: Floor::AaUi, + hue: Some(LadderSource::Brand), }; } "fill-brand-secondary" => { @@ -2082,10 +2029,10 @@ fn nearest_chromatic_source_survives_every_current_source_consuming_path() { for (name, recipe) in &mut config.roles { match name.as_str() { "label-brand-secondary" => { - *recipe = RoleRecipe::PairLabel { - source: LadderSource::Brand, + *recipe = RoleRecipe::TextAnchor { fraction: 0.5, floor: Floor::AaUi, + hue: Some(LadderSource::Brand), }; } "fill-brand-secondary" => { @@ -2127,15 +2074,6 @@ fn nearest_chromatic_source_survives_every_current_source_consuming_path() { panic!("fill-brand-secondary must resolve to Material"); }; assert_chromatic_hex(material.base_hex(), "Material"); - - let (_, Resolved::Translucent(pair_fill)) = set - .iter() - .find(|(name, _)| name == "badge-fill-brand") - .expect("pair fill exists") - else { - panic!("badge-fill-brand must resolve to Translucent"); - }; - assert_chromatic_hex(pair_fill.tint_hex(), "PairFill"); } #[test] diff --git a/crates/labcolors-core/src/constraints/exact.rs b/crates/labcolors-core/src/constraints/exact.rs index c3e06d59..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, 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 несовместимыми, но не @@ -78,6 +85,50 @@ impl Evaluator for ExactSrgb8IdentityV1 { } } +impl Evaluator for ExactSrgb8IdentityV1 { + type Invocation = Srgb8; + type Identity = ExactConstraintIdentityV1; + type Release = ExactIdentityReleaseV1; + type Capability = ExactIdentityCapabilityV1; + type Measurement = Srgb8; + type Error = Infallible; + + fn identity(&self) -> Self::Identity { + Self::IDENTITY + } + + fn release(&self) -> Self::Release { + ExactIdentityReleaseV1::V1 + } + + fn capability(&self) -> Self::Capability { + ExactIdentityCapabilityV1::FinalOccurrenceSrgb8IdentityV1 + } + + fn evaluate( + &self, + occurrence: &ProgramPointTargetV1, + invocation: &Self::Invocation, + ) -> Result { + >::evaluate( + self, + &occurrence.encoded(), + invocation, + ) + } +} + +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 fdcaf610..6ae5a1c3 100644 --- a/crates/labcolors-core/src/constraints/mod.rs +++ b/crates/labcolors-core/src/constraints/mod.rs @@ -7,6 +7,8 @@ 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::{ @@ -19,14 +21,64 @@ pub(crate) use exact::ExactIdentityPassV1; mod wcag22; -pub(crate) use wcag22::Wcag22Srgb8V1; - -#[cfg(test)] pub(crate) use wcag22::{ - ApplicableWcag22EvaluationErrorV1, ApplicableWcag22MeasurementV1, Wcag22PassV1, - Wcag22ViolationV1, + ApplicableWcag22EvaluationErrorV1, Wcag22Srgb8CapabilityV1, Wcag22Srgb8EvaluatorIdentityV1, + Wcag22Srgb8V1, }; +#[cfg(test)] +pub(crate) use wcag22::{ApplicableWcag22MeasurementV1, Wcag22PassV1, Wcag22ViolationV1}; + +/// Test-only probe around the production Program WCAG evaluator. It records +/// each physical visible signal without changing measurement or +/// classification, allowing execution-count assertions at the Program +/// certification boundary. +#[cfg(test)] +#[derive(Debug, Clone, Default)] +pub(crate) struct CountingProgramWcag22Srgb8V1 { + calls: std::rc::Rc>>, +} + +#[cfg(test)] +impl CountingProgramWcag22Srgb8V1 { + pub(crate) fn calls(&self) -> Vec { + self.calls.borrow().clone() + } +} + +#[cfg(test)] +#[derive(Debug, Default)] +struct FinalRecheckMutantControlV1 { + armed: std::cell::Cell, + calls_after_arm: std::cell::Cell, + force_current_violation: std::cell::Cell, +} + +/// Test-only exact evaluator which can be armed to pass the next search call +/// and fail the immediately following final-recheck call. +#[cfg(test)] +#[derive(Debug, Clone, Default)] +pub(crate) struct FinalRecheckMutantProgramEvaluatorV1 { + control: std::rc::Rc, +} + +#[cfg(test)] +impl FinalRecheckMutantProgramEvaluatorV1 { + pub(crate) fn arm(&self) { + self.control.calls_after_arm.set(0); + self.control.force_current_violation.set(false); + self.control.armed.set(true); + } +} + +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct MutantExactPassV1; + +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct MutantExactViolationV1; + /// Seals недоступны внешним crate-ам: новые evaluator/classifier families /// добавляются только вместе с code-owned physical adapter-ом. mod private { @@ -34,6 +86,18 @@ mod private { pub trait HardClassifierSealed {} } +#[cfg(test)] +impl private::EvaluatorSealed for CountingProgramWcag22Srgb8V1 {} + +#[cfg(test)] +impl private::HardClassifierSealed for CountingProgramWcag22Srgb8V1 {} + +#[cfg(test)] +impl private::EvaluatorSealed for FinalRecheckMutantProgramEvaluatorV1 {} + +#[cfg(test)] +impl private::HardClassifierSealed for FinalRecheckMutantProgramEvaluatorV1 {} + pub(crate) trait Evaluator: private::EvaluatorSealed { type Invocation; type Identity; @@ -91,7 +155,6 @@ impl &self.invocation } - #[cfg(test)] pub(crate) fn measurement(&self) -> &Measurement { &self.measurement } @@ -120,8 +183,7 @@ impl ClassifiedMeasurement { Pass(Pass), @@ -154,6 +216,363 @@ pub(crate) type PointViolation = , >>::Violation; +/// Program-only target: the exact encoded point used by physical evaluators +/// and the context-bound LCS occurrence derived from that same visible signal. +/// Neither half is optional, and construction remains inside the sole Program +/// execution path. +#[derive(Debug, Clone, Copy)] +pub(crate) struct ProgramPointTargetV1 { + encoded: ModeledSrgb8PointOccurrence, + modeled_lcs: ModeledLcsOccurrenceV1, +} + +impl ProgramPointTargetV1 { + pub(crate) const fn new( + encoded: ModeledSrgb8PointOccurrence, + modeled_lcs: ModeledLcsOccurrenceV1, + ) -> Self { + Self { + encoded, + modeled_lcs, + } + } + + pub(crate) const fn encoded(self) -> ModeledSrgb8PointOccurrence { + self.encoded + } + + pub(crate) const fn modeled_lcs(self) -> ModeledLcsOccurrenceV1 { + self.modeled_lcs + } +} + +pub(crate) type ProgramPointInvocation = + >::Invocation; +pub(crate) type ProgramPointMeasurement = + >::Measurement; +pub(crate) type ProgramPointPass = , + ProgramPointMeasurement, +>>::Pass; +pub(crate) type ProgramPointViolation = , + ProgramPointMeasurement, +>>::Violation; + +/// Sealed, statically dispatched evaluator family for context-bound Program +/// occurrences. Fixed point-support intentionally keeps its narrower encoded +/// target and therefore cannot silently satisfy an LCS-aware invocation. +pub(crate) trait ProgramPointEvaluatorV1: + Sized + + Evaluator + + HardClassifier, ProgramPointMeasurement> + + ProgramPointEvaluatorContentV1 +{ +} + +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; + type Identity = >::Identity; + type Release = >::Release; + type Capability = >::Capability; + type Measurement = >::Measurement; + type Error = >::Error; + + fn identity(&self) -> Self::Identity { + >::identity(&Wcag22Srgb8V1) + } + + fn release(&self) -> Self::Release { + >::release(&Wcag22Srgb8V1) + } + + fn capability(&self) -> Self::Capability { + >::capability(&Wcag22Srgb8V1) + } + + fn evaluate( + &self, + target: &ProgramPointTargetV1, + invocation: &Self::Invocation, + ) -> Result { + self.calls + .borrow_mut() + .push(Srgb8::new(target.encoded().visible())); + >::evaluate( + &Wcag22Srgb8V1, + target, + invocation, + ) + } +} + +#[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< + >::Invocation, + >::Measurement, + > for CountingProgramWcag22Srgb8V1 +{ + type Pass = >::Invocation, + >::Measurement, + >>::Pass; + type Violation = >::Invocation, + >::Measurement, + >>::Violation; + + fn classify( + &self, + invocation: &>::Invocation, + measurement: &>::Measurement, + ) -> HardDecision { + >::classify(&Wcag22Srgb8V1, invocation, measurement) + } +} + +#[cfg(test)] +impl Evaluator for FinalRecheckMutantProgramEvaluatorV1 { + type Invocation = Srgb8; + type Identity = (); + type Release = (); + type Capability = (); + type Measurement = Srgb8; + type Error = core::convert::Infallible; + + fn identity(&self) -> Self::Identity {} + + fn release(&self) -> Self::Release {} + + fn capability(&self) -> Self::Capability {} + + fn evaluate( + &self, + target: &ProgramPointTargetV1, + _invocation: &Self::Invocation, + ) -> Result { + let force_violation = if self.control.armed.get() { + let call = self.control.calls_after_arm.get(); + self.control.calls_after_arm.set(call + 1); + if call == 1 { + self.control.armed.set(false); + true + } else { + false + } + } else { + false + }; + self.control.force_current_violation.set(force_violation); + Ok(Srgb8::new(target.encoded().visible())) + } +} + +#[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; + type Violation = MutantExactViolationV1; + + fn classify( + &self, + invocation: &Srgb8, + measurement: &Srgb8, + ) -> HardDecision { + if self.control.force_current_violation.replace(false) || invocation != measurement { + HardDecision::Violation(MutantExactViolationV1) + } else { + HardDecision::Pass(MutantExactPassV1) + } + } +} + +/// Program evidence binds the physical source-over certificate and the exact +/// modeled LCS provenance/context used by the evaluator in one non-forgeable +/// value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ProgramVisiblePointBindingV1 { + physical: VisiblePointBindingV1, + modeled_lcs: ModeledLcsOccurrenceV1, +} + +impl ProgramVisiblePointBindingV1 { + pub(crate) const fn physical(self) -> VisiblePointBindingV1 { + self.physical + } + + pub(crate) const fn modeled_lcs(self) -> ModeledLcsOccurrenceV1 { + self.modeled_lcs + } +} + +type BoundProgramPointMeasurement = BoundEvidence< + ProgramVisiblePointBindingV1, + >::Identity, + >::Release, + >::Capability, + >::Invocation, + Measurement, +>; + +pub(crate) type ProgramVisiblePointPassEvidence = BoundProgramPointMeasurement< + Evaluation, + ClassifiedMeasurement, ProgramPointPass>, +>; +pub(crate) type ProgramVisiblePointViolationEvidence = BoundProgramPointMeasurement< + Evaluation, + ClassifiedMeasurement, ProgramPointViolation>, +>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ProgramPointBindingMismatchV1 { + physical: Srgb8, + modeled: Srgb8, +} + +impl ProgramPointBindingMismatchV1 { + pub(crate) const fn physical(self) -> Srgb8 { + self.physical + } + + pub(crate) const fn modeled(self) -> Srgb8 { + self.modeled + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ProgramPointAssessmentErrorV1 { + Binding(ProgramPointBindingMismatchV1), + Evaluator(EvaluatorError), +} + +pub(crate) type ProgramPointAssessmentResultV1 = Result< + HardDecision< + ProgramVisiblePointPassEvidence, + ProgramVisiblePointViolationEvidence, + >, + ProgramPointAssessmentErrorV1<>::Error>, +>; + +pub(crate) fn assess_program_point_hard( + source: &ResolvedOccurrence, + modeled_lcs: ModeledLcsOccurrenceV1, + evaluator: &Evaluation, + invocation: ProgramPointInvocation, +) -> ProgramPointAssessmentResultV1 +where + Evaluation: Evaluator + + HardClassifier, ProgramPointMeasurement>, +{ + let physical = Srgb8::new(source.visible()); + let modeled = modeled_lcs.signal().srgb8(); + if physical != modeled { + return Err(ProgramPointAssessmentErrorV1::Binding( + ProgramPointBindingMismatchV1 { physical, modeled }, + )); + } + let target = ProgramPointTargetV1::new(source.modeled_srgb8_point(), modeled_lcs); + let binding = ProgramVisiblePointBindingV1 { + physical: source.visible_point_binding(), + modeled_lcs: target.modeled_lcs(), + }; + let measurement = + >::evaluate(evaluator, &target, &invocation) + .map_err(ProgramPointAssessmentErrorV1::Evaluator)?; + let classification = evaluator.classify(&invocation, &measurement); + let identity = >::identity(evaluator); + let release = >::release(evaluator); + let capability = >::capability(evaluator); + + Ok(match classification { + HardDecision::Pass(payload) => HardDecision::Pass(BoundEvidence { + binding, + identity, + release, + capability, + invocation, + measurement: ClassifiedMeasurement::new(measurement, payload), + }), + HardDecision::Violation(payload) => HardDecision::Violation(BoundEvidence { + binding, + identity, + release, + capability, + invocation, + measurement: ClassifiedMeasurement::new(measurement, payload), + }), + }) +} + type BoundVisiblePointMeasurement = BoundEvidence< VisiblePointBindingV1, >::Identity, diff --git a/crates/labcolors-core/src/constraints/wcag22.rs b/crates/labcolors-core/src/constraints/wcag22.rs index 347014ce..cc9f3a78 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, private}; +use crate::constraints::{ + Evaluator, HardClassifier, HardDecision, ProgramConstraintContentV1, + ProgramPointEvaluatorContentV1, ProgramPointTargetV1, private, +}; use crate::numerics::NumericalDecisionEvidenceV1; use crate::wcag22::{ Wcag22ApplicableDecisionV1, Wcag22AssessmentV1, Wcag22ClientDeclaredNotApplicableV1, @@ -27,7 +30,6 @@ pub(crate) struct ApplicableWcag22MeasurementV1 { evidence: NumericalDecisionEvidenceV1, } -#[cfg(test)] impl ApplicableWcag22MeasurementV1 { pub(crate) const fn profile_id(&self) -> Wcag22ProfileIdV1 { self.profile_id @@ -41,6 +43,7 @@ impl ApplicableWcag22MeasurementV1 { &self.measurement } + #[cfg(test)] pub(crate) const fn decision(&self) -> Wcag22ApplicableDecisionV1 { self.decision } @@ -142,6 +145,53 @@ impl Evaluator for Wcag22Srgb8V1 { } } +impl Evaluator for Wcag22Srgb8V1 { + type Invocation = Wcag22CriterionV1; + type Identity = Wcag22Srgb8EvaluatorIdentityV1; + type Release = Wcag22ProfileIdV1; + type Capability = Wcag22Srgb8CapabilityV1; + type Measurement = ApplicableWcag22MeasurementV1; + type Error = ApplicableWcag22EvaluationErrorV1; + + fn identity(&self) -> Self::Identity { + Wcag22Srgb8EvaluatorIdentityV1 + } + + fn release(&self) -> Self::Release { + wcag22_profile_v1().profile_id + } + + fn capability(&self) -> Self::Capability { + Wcag22Srgb8CapabilityV1 + } + + fn evaluate( + &self, + target: &ProgramPointTargetV1, + invocation: &Self::Invocation, + ) -> Result { + >::evaluate( + self, + &target.encoded(), + invocation, + ) + } +} + +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 new file mode 100644 index 00000000..e40cfc45 --- /dev/null +++ b/crates/labcolors-core/src/generic_boundary_tests.rs @@ -0,0 +1,1298 @@ +use std::ffi::OsStr; +use std::path::PathBuf; + +const APPEARANCE_SOURCE: &str = include_str!("appearance.rs"); +const CONSTRAINTS_SOURCE: &str = include_str!("constraints/mod.rs"); +const EXACT_CONSTRAINT_SOURCE: &str = include_str!("constraints/exact.rs"); +const JOINT_SOURCE: &str = include_str!("joint.rs"); +const LIB_SOURCE: &str = include_str!("lib.rs"); +const LCS_OCCURRENCE_SOURCE: &str = include_str!("lcs_occurrence.rs"); +const OBSERVATION_SOURCE: &str = include_str!("observation.rs"); +const OUTPUT_PROJECTION_SOURCE: &str = include_str!("output_projection.rs"); +const PROGRAM_SOURCE: &str = include_str!("program.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); 4] = [ + ("appearance.rs", APPEARANCE_SOURCE), + ("lcs_occurrence.rs", LCS_OCCURRENCE_SOURCE), + ("program_identity.rs", PROGRAM_IDENTITY_SOURCE), + ("program_session.rs", PROGRAM_SESSION_SOURCE), +]; + +const CLIENT_OR_LEGACY_VOCABULARY: [&str; 13] = [ + "Lab UI", + "ThemeConfig", + "RoleRecipe", + "RoleSpec", + "NamedRoleTable", + "Glow", + "Material", + "Ladder", + "AlphaAnalog", + "themeHandle", + "resolveTheme", + "Primary", + "Danger", +]; + +fn source_scope<'a>(source: &'a str, start: &str, end: &str) -> &'a str { + let start = source + .find(start) + .unwrap_or_else(|| panic!("missing source boundary start `{start}`")); + let tail = &source[start..]; + let end = tail + .find(end) + .unwrap_or_else(|| panic!("missing source boundary end `{end}` after `{start}`")); + &tail[..end] +} + +fn normalized_source_scope(source: &str, start: &str, end: &str) -> String { + source_scope(source, start, end) + .split_whitespace() + .collect::>() + .join(" ") +} + +fn contains_rust_identifier(source: &str, identifier: &str) -> bool { + fn is_continue(character: char) -> bool { + character == '_' || character.is_ascii_alphanumeric() + } + + source.match_indices(identifier).any(|(start, _)| { + let before = source[..start].chars().next_back(); + let after = source[start + identifier.len()..].chars().next(); + !before.is_some_and(is_continue) && !after.is_some_and(is_continue) + }) +} + +fn assert_only_in_compile_fail(source: &str, needle: &str) { + let mut in_compile_fail = false; + let mut occurrences = 0; + + for (line_index, line) in source.lines().enumerate() { + let doc = line.trim_start().strip_prefix("///").map(str::trim_start); + match doc { + Some("```compile_fail") => { + assert!( + !in_compile_fail, + "nested compile_fail fence before line {}", + line_index + 1, + ); + in_compile_fail = true; + continue; + } + Some("```") if in_compile_fail => { + in_compile_fail = false; + continue; + } + _ => {} + } + + assert!( + !in_compile_fail || doc.is_some() || line.trim().is_empty(), + "compile_fail sentinel was interrupted by live code at line {}", + line_index + 1, + ); + let line_occurrences = line.matches(needle).count(); + if line_occurrences == 0 { + continue; + } + assert!( + in_compile_fail && doc.is_some(), + "`{needle}` escaped its negative compile_fail sentinel at line {}", + line_index + 1, + ); + occurrences += line_occurrences; + } + + assert!(!in_compile_fail, "unclosed compile_fail fence"); + assert!( + occurrences > 0, + "`{needle}` must remain covered by a negative API sentinel", + ); +} + +#[test] +fn compile_fail_scanner_rejects_live_code_between_document_fences() { + let escaped = "/// ```compile_fail\npub type PackageProgram = u8;\n/// ```"; + assert!( + std::panic::catch_unwind(|| assert_only_in_compile_fail(escaped, "PackageProgram")) + .is_err(), + "a live declaration must never inherit compile_fail state from adjacent documentation", + ); +} + +fn production_rust_sources() -> Vec<(String, String)> { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut pending = vec![root.clone()]; + let mut sources = Vec::new(); + while let Some(directory) = pending.pop() { + for entry in std::fs::read_dir(&directory).expect("Core source directory must be readable") + { + let path = entry.expect("Core source entry must be readable").path(); + if path.is_dir() { + pending.push(path); + continue; + } + let is_production_rust = path.extension() == Some(OsStr::new("rs")) + && !path + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| name.ends_with("_tests.rs")); + if !is_production_rust { + continue; + } + let relative = path + .strip_prefix(&root) + .expect("Core source must remain below its manifest root") + .to_string_lossy() + .into_owned(); + let source = + std::fs::read_to_string(&path).expect("Core Rust source must be valid UTF-8"); + sources.push((relative, source)); + } + } + sources.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + sources +} + +#[test] +fn generic_physical_and_transport_modules_contain_no_client_or_legacy_vocabulary() { + for (path, source) in GENERIC_SOURCES { + for forbidden in CLIENT_OR_LEGACY_VOCABULARY { + assert!( + !source.contains(forbidden), + "{path} must remain client-semantic agnostic; found `{forbidden}`" + ); + } + } +} + +#[test] +fn staged_program_module_is_private_module_qualified_and_transport_neutral() { + let normalized_lib = LIB_SOURCE.split_whitespace().collect::>().join(" "); + assert_eq!( + LIB_SOURCE + .lines() + .filter(|line| line.trim() == "pub(crate) mod program;") + .count(), + 1, + "the crate root must retain exactly one crate-private file-backed Program module", + ); + assert!( + normalized_lib.contains("#[deny(missing_docs)] pub(crate) mod program;"), + "the staged Program candidate must stay documented but crate-private", + ); + assert!( + !LIB_SOURCE + .lines() + .any(|line| line.trim() == "pub mod program;"), + "the incomplete Program candidate must not become externally reachable", + ); + assert!( + PROGRAM_SOURCE + .lines() + .any(|line| line.trim() == "#![forbid(unreachable_pub)]"), + "rustc must reject accidentally over-visible items inside the staged module", + ); + + let source_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); + assert!( + source_root.join("program.rs").is_file(), + "the staged Program implementation must remain file-backed", + ); + assert!( + !source_root.join("package_bridge.rs").exists(), + "the superseded transport-named module must not return", + ); + assert!( + !PROGRAM_SOURCE.contains("PackageProgram") + && !contains_rust_identifier(PROGRAM_SOURCE, "package_bridge"), + "the staged Program source must not retain transport-era vocabulary", + ); + + for (path, source) in production_rust_sources() { + if path == "lib.rs" { + continue; + } + assert!( + !source.contains("PackageProgram") + && !contains_rust_identifier(&source, "package_bridge"), + "{path} must not retain the superseded public path or prefix", + ); + } + assert_only_in_compile_fail(LIB_SOURCE, "PackageProgram"); + assert_only_in_compile_fail(LIB_SOURCE, "package_bridge"); + assert_only_in_compile_fail(LIB_SOURCE, "use labcolors_core::program;"); +} + +#[test] +fn staged_program_draft_wraps_the_single_canonical_core_graph() { + assert_eq!( + normalized_source_scope( + PROGRAM_SOURCE, + "pub(crate) struct DraftV1 {", + "/// Ошибка изменения Draft до компиляции.", + ), + "pub(crate) struct DraftV1 { inner: CoreProgramDraftV1, }", + "the staged seam must forward actual IR nodes into the sole Core draft", + ); + assert_eq!( + normalized_source_scope( + PROGRAM_SESSION_SOURCE, + "pub(crate) struct CoreProgramDraftV1 {", + "#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(crate) enum CoreProgramDraftErrorV1", + ), + "pub(crate) struct CoreProgramDraftV1 { program: CoreProgramV1, }", + "the mutable Core seam must own the actual concrete Program, not mirror its fields", + ); + for forbidden in [ + "PairFill", + "PairLabel", + "Glow", + "Material", + "Ladder", + "AlphaAnalog", + "ThemeConfig", + "RoleRecipe", + "serde", + "serde_json", + "String", + "HashMap", + "dyn Program", + "OutputProfileId", + "ObservationGroupIdV1", + "OpacityIdV1", + "SurfaceInputIdV1", + "push_opacity(", + "push_surface_input(", + "surface_input_slots(", + ] { + assert!( + !PROGRAM_SOURCE.contains(forbidden), + "the staged concrete lowerer must not acquire `{forbidden}`", + ); + } +} + +#[test] +fn staged_session_keeps_evidence_but_owner_alone_grants_updates_and_operations() { + assert_eq!( + normalized_source_scope( + PROGRAM_SOURCE, + "pub(crate) struct SessionV1 {", + "impl SessionV1", + ), + concat!( + "pub(crate) struct SessionV1 { ", + "scenario_order_scratch: Vec, ", + "session: CoreProgramSessionV1, ", + "}", + ), + "the staged Session must not duplicate owner schema, outputs, stream, or lifecycle state", + ); + + let session_api = source_scope( + PROGRAM_SOURCE, + "impl SessionV1 {", + "struct ScenarioSourceV1", + ); + assert_eq!( + session_api.matches("pub(crate) fn evidence(").count(), + 1, + "historical evidence is the Session's sole boundary projection", + ); + assert_eq!( + session_api.matches("pub(crate) ").count(), + 1, + "Session must not expose a second authority by changing function qualifiers", + ); + for forbidden in [ + "fn state(", + "fn update(", + "fn surface_input_port_count(", + "fn surface_input_ports(", + "fn output_slots(", + ] { + assert!( + !session_api.contains(forbidden), + "Session must not regain owner authority through `{forbidden}`", + ); + } + + let evidence_api = source_scope( + PROGRAM_SOURCE, + "impl<'a> EvidenceViewV1<'a> {", + "struct BorrowScopeV1<'owner, 'session>", + ); + for forbidden in [ + "fn revision(", + "const fn revision(", + "fn stream(", + "const fn stream(", + ] { + assert!( + !evidence_api.contains(forbidden), + "evidence must not flatten atomic raw-head provenance through `{forbidden}`", + ); + } + + let owner_api = source_scope( + PROGRAM_SOURCE, + "impl OwnerV1 {", + "pub(crate) struct ScenarioV1<'a> {", + ); + for required in [ + "pub(crate) fn project<'owner, 'session>(", + "pub(crate) fn update<'owner, 'session>(", + ".owns_session(&session.session)", + ] { + assert!( + owner_api.contains(required), + "the exact owner must remain the only operation authority; missing `{required}`", + ); + } + let update = source_scope( + owner_api, + "pub(crate) fn update<'owner, 'session>(", + "pub(crate) fn instantiate(", + ); + assert!( + update + .find(".owns_session(&session.session)") + .expect("owner update must preflight exact membership") + < update + .find("session.apply_update(update)?") + .expect("owner update must delegate one atomic Session update"), + "owner mismatch must be rejected before admission, allocation, or evaluation", + ); + + let staged_access_errors = source_scope( + PROGRAM_SOURCE, + "pub(crate) enum AccessErrorV1 {", + "impl OwnerV1", + ); + assert!( + staged_access_errors.contains("OwnerMismatch,") + && !staged_access_errors.contains("OwnerExpired"), + "operation projection must distinguish foreign ownership, not expose internal expiry", + ); + let staged_update_errors = source_scope( + PROGRAM_SOURCE, + "pub(crate) enum UpdateErrorKindV1 {", + "pub(crate) enum UpdateErrorV1 {", + ); + assert!( + staged_update_errors.contains("OwnerMismatch,") + && !staged_update_errors.contains("OwnerExpired"), + "owner expiry is an internal invariant after a matching owner borrow", + ); + assert!( + PROGRAM_SOURCE.contains("pub(crate) enum UpdateErrorV1 {") + && !PROGRAM_SOURCE.contains("pub(crate) struct UpdateErrorV1 {") + && PROGRAM_SOURCE + .contains("fn map_observation_error(error: ObservationError) -> UpdateErrorV1",) + && PROGRAM_SOURCE + .contains("fn map_plan_error(error: CoreProgramPlanErrorV1) -> UpdateErrorV1",), + "update errors must retain payloads in the authoritative enum before kind projection", + ); + + for (payload, end) in [ + ( + "pub(crate) struct SetV1<'owner, 'session> {", + "impl<'session> SetV1<'_, 'session>", + ), + ( + "pub(crate) struct RemoveV1<'owner, 'session> {", + "impl RemoveV1<'_, '_>", + ), + ] { + assert!( + source_scope(PROGRAM_SOURCE, payload, end) + .contains("_scope: BorrowScopeV1<'owner, 'session>,"), + "{payload} must retain both owner and immutable Session borrows", + ); + } + assert!( + !PROGRAM_SOURCE.contains("HoldV1") && !PROGRAM_SOURCE.contains("OperationV1::Hold"), + "past evidence must not become a current emission authority", + ); +} + +#[test] +fn shared_observation_ssot_has_one_backing_without_lifecycle_or_adapter_facades() { + assert_eq!( + normalized_source_scope( + OBSERVATION_SOURCE, + "struct ObservedScenarioSet {", + "impl ObservedScenarioSet", + ), + concat!( + "struct ObservedScenarioSet { ", + "cases: Box<[PhysicalScenario]>, ", + "values: Box<[ColorSignal]>, ", + "provenance: Box<[ScenarioId]>, ", + "}", + ), + "the canonical scenario set must keep one flat physical backing", + ); + assert_eq!( + normalized_source_scope( + OBSERVATION_SOURCE, + "struct ObservationBackingV1 {", + "/// Sealed observation admitted", + ), + concat!( + "struct ObservationBackingV1 { ", + "schema: CanonicalObservationSchemaV1, ", + "set: ObservedScenarioSet, ", + "}", + ), + "the shared backing must own only canonical schema and scenario data", + ); + assert_eq!( + normalized_source_scope( + OBSERVATION_SOURCE, + "pub(crate) struct RevisionBoundObservationV1 {", + "impl RevisionBoundObservationV1", + ), + concat!( + "pub(crate) struct RevisionBoundObservationV1 { ", + "stream: ObservationStreamId, ", + "revision: Revision, ", + "backing: Rc, ", + "}", + ), + "revision identity must wrap exactly one shared immutable backing", + ); + assert_eq!( + OBSERVATION_SOURCE + .matches("pub(crate) struct RevisionBoundObservationV1") + .count(), + 1, + "production must define exactly one revision-bound observation value", + ); + assert!( + OBSERVATION_SOURCE.contains("use std::rc::Rc;"), + "single-threaded Core observations must use Rc", + ); + assert!( + OBSERVATION_SOURCE + .contains("pub(crate) struct CanonicalObservationSchemaV1(Rc<[SurfaceInputPortId]>);"), + "compiled schema and observations must share the same Rc-backed schema", + ); + assert!( + OBSERVATION_SOURCE.contains( + "#[derive(Debug, PartialEq, Eq)]\n#[cfg_attr(test, derive(Clone))]\npub(crate) struct CanonicalObservationSchemaV1", + ), + "production schema ownership must not expose a general Clone capability", + ); + assert_eq!( + OBSERVATION_SOURCE + .matches("schema.share_for_observation()") + .count(), + 2, + "only keyed and schema-ordered admission may share a schema handle", + ); + assert!( + !OBSERVATION_SOURCE.contains("schema: schema.clone()"), + "admission must use the private schema-sharing capability", + ); + for forbidden in ["std::sync::Arc", "Arc<", "RefCell<", "Mutex<", "RwLock<"] { + assert!( + !OBSERVATION_SOURCE.contains(forbidden), + "immutable single-threaded observation backing must not acquire `{forbidden}`", + ); + } + + assert_eq!( + normalized_source_scope( + SESSION_SOURCE, + "pub(crate) enum SessionState {", + "impl SessionState", + ), + concat!( + "pub(crate) enum SessionState { ", + "Waiting, ", + "Ready { current: Verified, }, ", + "Stale { previous: Verified, }, ", + "Failed { cause: Violation, previous: Option, }, ", + "}", + ), + "lifecycle state must not duplicate the current raw observation", + ); + assert_eq!( + normalized_source_scope( + SESSION_SOURCE, + "enum SessionObservationHeadV1 {", + "impl ObservationOwnerV1 for SessionObservationHeadV1", + ), + concat!( + "enum SessionObservationHeadV1 { ", + "Empty, ", + "Unknown(RevisionBoundUnknownV1), ", + "Observed(RevisionBoundObservationV1), ", + "}", + ), + "raw Empty/Unknown/Observed must remain separate from lifecycle state", + ); + let session_owner = source_scope( + SESSION_SOURCE, + "pub(crate) struct Session {", + "impl Session", + ); + for required in [ + "raw_head: SessionObservationHeadV1,", + "state: SessionState,", + ] { + assert_eq!( + session_owner.matches(required).count(), + 1, + "Session must own exactly one `{required}` field", + ); + } + assert!( + !session_owner.contains("schema: CanonicalObservationSchemaV1,"), + "the concrete plan is the sole Session-local owner of its canonical schema", + ); + for forbidden in [ + "current_unknown", + "observation: RevisionBoundObservationV1", + "unknown: RevisionBoundUnknownV1", + ] { + assert!( + !session_owner.contains(forbidden), + "Session owner must not duplicate raw storage through `{forbidden}`", + ); + } + assert_eq!( + SESSION_SOURCE.matches("pub(crate) struct Session<").count(), + 1, + "production must have exactly one generic revision-bound Session owner", + ); + for required in [ + "type OwnerLease;", + "type Verified: SessionEvidenceV1;", + "type Violation: SessionEvidenceV1;", + "fn try_acquire_owner(&self) -> Option;", + "owner: &'a Self::OwnerLease,", + "SessionUpdateError::OwnerExpired", + ".is_same_binding_as(expected_observation)", + "SessionUpdateError::EvidenceBindingInvariant", + ] { + assert!( + SESSION_SOURCE.contains(required), + "Session must reject detached evaluator evidence; missing `{required}`", + ); + } + let session_plan_implementors = production_rust_sources() + .into_iter() + .filter_map(|(path, source)| { + let count = source.matches("SessionPlanV1 for").count(); + (count != 0).then_some((path, count)) + }) + .collect::>(); + assert_eq!( + session_plan_implementors, + vec![ + ("point_support.rs".to_owned(), 1), + ("program_session.rs".to_owned(), 1), + ], + "only the audited point-support and Program plans may inhabit Session", + ); + for (path, source) in [ + ("session.rs", SESSION_SOURCE), + ("point_support.rs", POINT_SUPPORT_SOURCE), + ("program_session.rs", PROGRAM_SESSION_SOURCE), + ] { + for forbidden in [ + "PointSupportSessionV1", + "PointSupportSessionStateV1", + "BoundPointSupportRecheckV1", + "into_session_recheck", + "ObservationStreamBinding", + "ProgramExpired", + ] { + assert!( + !source.contains(forbidden), + "{path} must not restore a second owner or adapter `{forbidden}`", + ); + } + } + for (path, source) in [ + ("session.rs", SESSION_SOURCE), + ("point_support.rs", POINT_SUPPORT_SOURCE), + ] { + assert!( + !source.contains("Weak<"), + "{path} must not create another weak ownership boundary", + ); + } + + for (name, update, prepare) in [ + ( + "keyed", + source_scope( + SESSION_SOURCE, + "pub(crate) fn update(", + "/// Stream-affine `Unknown` admission", + ), + "prepare_observation(", + ), + ( + "schema-ordered", + source_scope( + SESSION_SOURCE, + "pub(crate) fn update_schema_ordered", + "fn apply_prepared_update", + ), + "prepare_schema_ordered_observation(", + ), + ] { + let owner_preflight = update + .find(".try_acquire_owner()") + .unwrap_or_else(|| panic!("{name} update must acquire the exact owner generation")); + let schema = update + .find("let schema = self.plan.observation_schema(&owner);") + .unwrap_or_else(|| panic!("{name} update must derive schema from that owner")); + let admission = update + .find(prepare) + .unwrap_or_else(|| panic!("{name} update must perform canonical admission")); + assert!( + owner_preflight < schema && schema < admission, + "{name} update must pin owner, derive its schema, then admit", + ); + assert_eq!( + update + .matches("let schema = self.plan.observation_schema(&owner);") + .count(), + 1, + "{name} update must borrow exactly one schema", + ); + assert!( + !update.contains("observation_schema(&owner).clone()"), + "{name} admission must not create a transient schema owner", + ); + } + + let consuming_entry = source_scope( + POINT_SUPPORT_SOURCE, + "impl SessionPlanV1 for CompiledPointSupportRecheckV1 {", + "pub(crate) enum PointSupportEvaluationErrorV1", + ); + for required in [ + "observation: RevisionBoundObservationV1,", + "evaluate_bound_point_support(self, &observation)?", + "assessment.bind(observation)", + ] { + assert!( + consuming_entry.contains(required), + "point support must consume the shared observation directly; missing `{required}`", + ); + } + for forbidden in [ + ".clone()", + ".to_vec()", + "ObservationAdapter", + "adapt_observation", + ] { + assert!( + !consuming_entry.contains(forbidden), + "point-support entry must not introduce observation façade `{forbidden}`", + ); + } + + let evaluator = source_scope( + POINT_SUPPORT_SOURCE, + "fn evaluate_bound_point_support(", + "fn reference_distance(", + ); + assert!( + evaluator.contains("observation: &RevisionBoundObservationV1"), + "the evaluator must borrow the shared revision-bound observation", + ); + assert_eq!( + evaluator.matches(".physical_values(case_index)").count(), + 1, + "point support must read each canonical physical case through the observation API", + ); + let physical_values = evaluator + .find(".physical_values(case_index)") + .expect("physical-values route must exist"); + let indexed_surface = evaluator[physical_values..] + .find("values.get(*surface_index)") + .expect("the prebound surface index must read from physical values"); + assert!( + indexed_surface > 0, + "surface lookup must follow the canonical physical-values projection", + ); + for forbidden in [ + "physical_bindings", + "SurfaceInputBinding", + ".clone()", + ".to_vec()", + "ObservationAdapter", + "adapt_observation", + ] { + assert!( + !evaluator.contains(forbidden), + "point evaluator must not reconstruct or adapt observations through `{forbidden}`", + ); + } + + let report = source_scope( + POINT_SUPPORT_SOURCE, + "pub(crate) struct RevisionBoundPointSupportReportV1 {", + "impl RevisionBoundPointSupportReportV1", + ); + assert_eq!( + report + .matches("observation: RevisionBoundObservationV1,") + .count(), + 1, + "a report must own the same revision-bound observation without a parallel snapshot", + ); + + for (path, source) in [ + ("observation.rs", OBSERVATION_SOURCE), + ("session.rs", SESSION_SOURCE), + ("point_support.rs", POINT_SUPPORT_SOURCE), + ] { + for forbidden in [ + "FrozenObservationV1", + "PriorObservation", + "Availability", + "ObservationSnapshot", + "WaitingRecheckV1", + "StaleRecheckV1", + "PresentationHoldV1", + "HoldErrorV1", + "reuse_for", + "ReuseErrorV1", + "FinalRecheckOutcomeV1", + "ObservationAdapter", + "adapt_observation", + ] { + assert!( + !source.contains(forbidden), + "{path} must not restore compatibility or adapter API `{forbidden}`", + ); + } + } +} + +#[test] +fn program_session_owns_context_bound_lcs_evidence_and_one_session_scratch_cache() { + for required in [ + "ProgramPointTargetV1", + "ModeledLcsOccurrenceV1", + "AppearanceContextId", + "ProgramVisiblePointPassEvidence", + "ProgramVisiblePointViolationEvidence", + "ModeledLcsOccurrenceV1::from_signal_in_context(", + "source.visible() != source.certificate().output_rgb()", + "binding.context,", + "assess_program_point_hard(", + ] { + assert!( + PROGRAM_SESSION_SOURCE.contains(required), + "Program must retain physical-source and context-bound LCS evidence; missing `{required}`", + ); + } + + let program_evidence_binding = normalized_source_scope( + CONSTRAINTS_SOURCE, + "pub(crate) struct ProgramVisiblePointBindingV1 {", + "impl ProgramVisiblePointBindingV1", + ); + for required in [ + "physical: VisiblePointBindingV1,", + "modeled_lcs: ModeledLcsOccurrenceV1,", + ] { + assert!( + program_evidence_binding.contains(required), + "Program evidence must bind source-over and LCS context in one value; missing `{required}`", + ); + } + + let result = source_scope( + PROGRAM_SESSION_SOURCE, + "impl ProgramConstraintResultV1", + "/// One canonical `physical case × constraint` report cell.", + ); + for required in [ + "fn binding(&self) -> ProgramVisiblePointBindingV1", + "fn modeled_lcs_occurrence(&self) -> ModeledLcsOccurrenceV1", + "self.binding().modeled_lcs()", + ] { + assert!( + result.contains(required), + "Program result must project modeled LCS from its evidence SSOT; missing `{required}`", + ); + } + let cell = source_scope( + PROGRAM_SESSION_SOURCE, + "pub struct ProgramConstraintCellV1", + "impl ProgramConstraintCellV1", + ); + assert!( + cell.contains("result: ProgramConstraintResultV1,"), + "each Program cell must own the typed evidence result", + ); + assert!( + !cell.contains("modeled_lcs_occurrence: ModeledLcsOccurrenceV1,"), + "a Program cell must not duplicate the modeled occurrence already owned by evidence", + ); + + let plan = source_scope( + PROGRAM_SESSION_SOURCE, + "pub(crate) struct ProgramSessionPlan", + "impl session_private::PlanSealed for ProgramSessionPlan", + ); + assert_eq!( + plan.matches("owner_generation: Weak>,") + .count(), + 1, + "a Program Session must hold exactly one weak compiled-generation binding", + ); + assert!( + !plan.contains("epoch: Rc>,"), + "a Program Session must not prolong its CompiledProgram owner", + ); + assert!( + !plan.contains("schema: CanonicalObservationSchemaV1,"), + "a Program Session must derive schema from its pinned owner generation", + ); + let instantiate = source_scope( + PROGRAM_SESSION_SOURCE, + "pub(crate) fn instantiate(", + "/// Failure while preparing mutable storage", + ); + assert!( + !instantiate.contains("observation_group.schema.clone()"), + "empty Program Sessions must not add persistent schema handles", + ); + let compiled = source_scope( + PROGRAM_SESSION_SOURCE, + "pub struct CompiledProgram", + "impl CompiledProgram", + ); + assert_eq!( + compiled + .matches("owner_generation: Rc>,") + .count(), + 1, + "CompiledProgram must be the one strong owner of its generation", + ); + assert_eq!( + plan.matches("modeled_occurrences: Vec>,") + .count(), + 1, + "each Program Session must own exactly one reusable modeled-occurrence scratch cache", + ); + assert_eq!( + PROGRAM_SESSION_SOURCE + .matches("modeled_occurrences: Vec>,") + .count(), + 1, + "the modeled-occurrence scratch cache must not be duplicated outside the Session plan", + ); + + let preparation = normalized_source_scope( + PROGRAM_SESSION_SOURCE, + "let all_occurrence_contexts = compile_occurrence_contexts(", + "let outputs = compile_outputs(", + ); + for required in [ + "compile_constraints::(&graph, &all_occurrence_contexts, &program.constraints)?", + "compact_constraint_contexts(&all_occurrence_contexts, &mut constraints)?", + ] { + assert!( + preparation.contains(required), + "Program compilation must compact full occurrence metadata to constrained targets; missing `{required}`", + ); + } + + let compaction = normalized_source_scope( + PROGRAM_SESSION_SOURCE, + "fn compact_constraint_contexts(", + "fn compile_outputs(", + ); + for required in [ + "targets.sort_unstable(); targets.dedup();", + ".binary_search_by_key(&constraint.target_id, |binding| binding.occurrence)", + "constraint.modeled_occurrence_index = index;", + ] { + assert!( + compaction.contains(required), + "cold compilation must deduplicate targets and remap every constraint; missing `{required}`", + ); + } + + let hot_evaluation = source_scope( + PROGRAM_SESSION_SOURCE, + "fn evaluate_program_session(", + "fn map_program_execution_binding_error(", + ); + assert!( + !hot_evaluation.contains("binary_search"), + "hot Program evaluation must consume compile-time direct indices without searching", + ); + for required in [ + "plan.modeled_occurrences.fill(None);", + ".get(constraint.modeled_occurrence_index)", + ".get_mut(constraint.modeled_occurrence_index)", + ] { + assert!( + hot_evaluation.contains(required), + "hot Program evaluation must reuse the compact direct-index cache; missing `{required}`", + ); + } +} + +#[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 [ + "LcsColor", + "ViewingConditions", + "from_hex", + "to_hex", + "is_dark_theme", + "CAM16-UCS", + "ucs_", + "ProjectionSourceV1", + "default_context", + "default context", + "DefaultContext", + ] { + assert!( + !PROGRAM_SESSION_SOURCE.contains(forbidden), + "program_session.rs must not bypass explicit source/context admission through `{forbidden}`", + ); + } + + for (path, source) in [ + ("point_support.rs", POINT_SUPPORT_SOURCE), + ("joint.rs", JOINT_SOURCE), + ] { + for forbidden in [ + "ProgramPointTargetV1", + "ProgramPointEvaluatorV1", + "ProgramPointInvocation", + "ProgramVisiblePointBindingV1", + "ProgramVisiblePointPassEvidence", + "ProgramVisiblePointViolationEvidence", + "ModeledLcsOccurrenceV1", + "AppearanceContextId", + "LcsOccurrence", + ] { + assert!( + !contains_rust_identifier(source, forbidden), + "{path} must not absorb Program-only contextual LCS type `{forbidden}`", + ); + } + } + + for forbidden in [ + "PointEvaluatorV1", + "PointInvocation", + "VisiblePointBindingV1", + "VisiblePointPassEvidence", + "VisiblePointViolationEvidence", + "assess_visible_point_hard", + ] { + assert!( + !contains_rust_identifier(PROGRAM_SESSION_SOURCE, forbidden), + "program_session.rs must not route Program through legacy point evidence `{forbidden}`", + ); + } + + for forbidden in [ + "AppearanceState", + "Cam16ViewV1", + "Cam16SurroundV1", + "ViewingConditions", + "derive_cam16_view_v1", + "forward_correlates_v1", + ] { + assert!( + !contains_rust_identifier(PROGRAM_SESSION_SOURCE, forbidden), + "program_session.rs hot lowering must not derive CAM16 through `{forbidden}`", + ); + } + assert!( + !PROGRAM_SESSION_SOURCE.contains(".cam16("), + "program_session.rs hot lowering must not request a CAM16 view", + ); + + assert!( + !OUTPUT_PROJECTION_SOURCE.contains("ProjectionSourceV1"), + "output_projection.rs must consume ModeledLcsOccurrenceV1 directly, not define ProjectionSourceV1", + ); +} + +#[test] +fn existing_encoded_evaluators_delegate_program_targets_without_parallel_formulae() { + for (path, source, evaluator, next_impl) in [ + ( + "constraints/exact.rs", + EXACT_CONSTRAINT_SOURCE, + "ExactSrgb8IdentityV1", + "impl HardClassifier for ExactSrgb8IdentityV1", + ), + ( + "constraints/wcag22.rs", + WCAG22_CONSTRAINT_SOURCE, + "Wcag22Srgb8V1", + "impl HardClassifier for Wcag22Srgb8V1", + ), + ] { + let start = format!("impl Evaluator for {evaluator} {{"); + let implementation = source_scope(source, &start, next_impl); + assert_eq!( + implementation + .matches(">::evaluate(") + .count(), + 1, + "{path} Program evaluator must delegate exactly once to its encoded-target SSOT", + ); + assert_eq!( + implementation.matches(".encoded(),").count(), + 1, + "{path} Program evaluator must pass only the typed encoded target to its SSOT", + ); + assert!( + !implementation.contains(".modeled_lcs()"), + "{path} encoded evaluator must not silently grow a contextual LCS formula", + ); + } +} + +#[test] +fn staged_program_and_lcs_occurrence_modules_remain_private() { + for required in [ + "pub(crate) mod lcs_occurrence;", + "pub(crate) mod program;", + "pub(crate) mod program_session;", + ] { + assert!( + LIB_SOURCE.contains(required), + "the pre-hard-cut boundary must remain crate-private; missing `{required}`", + ); + } + for forbidden in [ + "pub mod lcs_occurrence;", + "pub mod program;", + "pub mod program_session;", + ] { + assert!( + !LIB_SOURCE.contains(forbidden), + "lib.rs must not publish a staged private module `{forbidden}`", + ); + } +} + +#[test] +fn program_compiler_cannot_regrow_the_superseded_runtime_facade() { + for forbidden in [ + "PointRenderOwner", + "PointRenderAttachError", + "ObservationStreamBinding", + "SurfaceUpdate", + "OutputValueV1", + "ExecutionFrame", + "SessionState", + ] { + assert!( + !PROGRAM_SESSION_SOURCE.contains(forbidden), + "program_session.rs must remain compiler/lowering-only; found superseded `{forbidden}`" + ); + } +} + +#[test] +fn current_og0_program_epoch_has_one_explicit_group_without_scenario_scope_creep() { + fn declaration<'a>(source: &'a str, start: &str, end: &str) -> &'a str { + let start = source + .find(start) + .unwrap_or_else(|| panic!("missing OG0 declaration start `{start}`")); + let end = source[start..] + .find(end) + .map(|offset| start + offset) + .unwrap_or_else(|| panic!("missing OG0 declaration end `{end}`")); + &source[start..end] + } + + // This pins only the current private OG0 Program/epoch shape. It does not + // prescribe how a future explicit join or independent component API works. + let program = declaration( + PROGRAM_SESSION_SOURCE, + "pub struct Program<", + "impl Program", + ); + let epoch = declaration( + PROGRAM_SESSION_SOURCE, + "struct ProgramEpochV1<", + "/// Fully validated immutable Program", + ); + assert_eq!( + program + .matches("observation_group: ObservationGroup,") + .count(), + 1, + "the current OG0 authored Program owns one explicit atomic group" + ); + assert_eq!( + epoch + .matches("observation_group: CompiledObservationGroupV1,") + .count(), + 1, + "the current OG0 epoch must retain that one compiled group" + ); + for (name, scope) in [("Program", program), ("ProgramEpochV1", epoch)] { + for forbidden in ["Vec", "Scenario"] { + assert!( + !scope.contains(forbidden), + "current OG0 {name} declaration must not grow `{forbidden}` scope" + ); + } + } + + for forbidden in [ + "Vec", + "ScenarioId", + "ScenarioSet", + "ObservedScenarioSet", + "GraphTemplate", + "Cartesian", + "wasm_bindgen", + "serde", + "Dto", + "DTO", + ] { + assert!( + !PROGRAM_SESSION_SOURCE.contains(forbidden), + "current OG0 transport module must not acquire `{forbidden}` scope" + ); + } +} + +#[test] +fn f0_signal_transform_has_no_renderer_alias_or_raw_xyz_production_constructor() { + for forbidden in [ + "RenderProfileId", + "AppearanceContextReleaseId", + "enum ObserveError", + "fn observe(", + "pub(crate) fn new(\n xyz", + ] { + assert!( + !LCS_OCCURRENCE_SOURCE.contains(forbidden), + "lcs_occurrence.rs must keep the F0 identity boundary sealed; found `{forbidden}`", + ); + } + + for required in [ + "ColorimetricTransformReleaseId", + "AppearanceContextSchemaReleaseId", + "fn admitted_binding(", + "match output_profile", + "fn derive_sample_with_binding(", + "binding.transform_release()", + "xyz_d65_from_srgb8_v1", + "ModeledTristimulusProvenanceV1", + "ModeledTristimulusDerivationV1", + ] { + assert!( + LCS_OCCURRENCE_SOURCE.contains(required), + "lcs_occurrence.rs must retain the sealed F0 route; missing `{required}`", + ); + } + + let raw_xyz_declaration = LCS_OCCURRENCE_SOURCE + .lines() + .find(|line| line.contains("fn try_from_registered_xyz(")) + .expect("registered raw-XYZ admission must remain visible to this source gate"); + assert_eq!( + raw_xyz_declaration.trim(), + "fn try_from_registered_xyz(", + "registered raw-XYZ admission must remain module-private", + ); + + let raw_frame_declaration = LCS_OCCURRENCE_SOURCE + .lines() + .find(|line| line.contains("const fn registered(")) + .expect("registered frame constructor must remain visible to this source gate"); + assert_eq!( + raw_frame_declaration.trim(), + "const fn registered(", + "registered frame construction must remain module-private", + ); + + let dispatch_start = LCS_OCCURRENCE_SOURCE + .find("fn admitted_binding(") + .expect("binding dispatch must remain present"); + let dispatch_end = LCS_OCCURRENCE_SOURCE[dispatch_start..] + .find("/// Derive one modeled tristimulus") + .map(|offset| dispatch_start + offset) + .expect("modeled derivation docs must delimit the dispatch source gate"); + let dispatch_source = &LCS_OCCURRENCE_SOURCE[dispatch_start..dispatch_end]; + assert!( + !dispatch_source.contains("_ =>"), + "closed F0 profile/transform dispatch must not silently fall back", + ); +} + +#[test] +fn f0_modeled_derivation_mints_no_observation_or_bounded_evidence() { + for forbidden in [ + "BoundEvidence", + "NumericalDecisionEvidenceV1", + "CanonicalFiniteBoundedEvidenceV1", + "SourceOverCertificateV1", + "GlowCompositeCertificateV1", + "RendererCapability", + "RenderObservation", + "crate::constraints", + "crate::wcag22_evidence", + ] { + assert!( + !LCS_OCCURRENCE_SOURCE.contains(forbidden), + "deterministic signal lowering cannot mint `{forbidden}`", + ); + } +} diff --git a/crates/labcolors-core/src/joint.rs b/crates/labcolors-core/src/joint.rs index 9cbde260..64689f67 100644 --- a/crates/labcolors-core/src/joint.rs +++ b/crates/labcolors-core/src/joint.rs @@ -19,13 +19,176 @@ use crate::constraints::{ assess_visible_point_hard, }; use crate::observation::{RevisionBoundObservationV1, ScenarioId}; + +/// One canonical candidate ordinal inside a finite target domain. +/// +/// The ordinal is assigned only after the owning Program has sorted the +/// target's opaque candidate IDs. It is therefore an internal compiled index, +/// never client identity or declaration-order policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct FiniteDomainOrdinalV1(usize); + +impl FiniteDomainOrdinalV1 { + pub(crate) const fn new(index: usize) -> Self { + Self(index) + } + + pub(crate) const fn index(self) -> usize { + self.0 + } +} + +/// A fully admitted total order over the product of finite target +/// domains. Each tuple is stored in canonical target order. The tuple order +/// is authored policy; no target ID, candidate value, or declaration position +/// becomes an implicit tie-break. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AdmittedFiniteJointOrderV1 { + first: Box<[FiniteDomainOrdinalV1]>, + rest: Box<[Box<[FiniteDomainOrdinalV1]>]>, +} + +impl AdmittedFiniteJointOrderV1 { + pub(crate) fn tuples(&self) -> impl Iterator + '_ { + std::iter::once(self.first.as_ref()).chain(self.rest.iter().map(Box::as_ref)) + } + + pub(crate) fn state_count(&self) -> usize { + // `first` makes the admitted order structurally non-empty; the + // remaining slice length is bounded by Rust's allocation limit. + self.rest.len() + 1 + } +} + +/// Failure to admit a declared finite joint selection order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FiniteJointOrderErrorV1 { + EmptyDomain { + dimension: usize, + }, + CardinalityOverflow, + EmptyOrder, + TupleArity { + tuple: usize, + expected: usize, + actual: usize, + }, + OrdinalOutOfDomain { + tuple: usize, + dimension: usize, + ordinal: usize, + domain_len: usize, + }, + DuplicateTuple { + first: usize, + duplicate: usize, + }, + IncompleteOrder { + expected: usize, + actual: usize, + }, + ResourceExhausted, +} + +/// Check and seal an explicit total order over a finite product domain. +/// +/// This function deliberately does not synthesize lexicographic policy. A +/// caller must enumerate every tuple exactly once. Checked multiplication and +/// fallible allocation happen before the result can reach runtime. +pub(crate) fn admit_finite_joint_order_v1( + domain_lengths: &[usize], + authored: Vec>, +) -> Result { + let mut expected = 1usize; + for (dimension, &domain_len) in domain_lengths.iter().enumerate() { + if domain_len == 0 { + return Err(FiniteJointOrderErrorV1::EmptyDomain { dimension }); + } + expected = expected + .checked_mul(domain_len) + .ok_or(FiniteJointOrderErrorV1::CardinalityOverflow)?; + } + if authored.is_empty() { + return Err(FiniteJointOrderErrorV1::EmptyOrder); + } + if authored.len() != expected { + return Err(FiniteJointOrderErrorV1::IncompleteOrder { + expected, + actual: authored.len(), + }); + } + + // The mixed-radix ordinal is a bijection over the admitted product. A + // fallibly allocated first-seen table makes duplicate admission O(states × + // dimensions), rather than comparing every tuple with every earlier tuple. + let mut first_seen = Vec::new(); + first_seen + .try_reserve_exact(expected) + .map_err(|_| FiniteJointOrderErrorV1::ResourceExhausted)?; + first_seen.resize(expected, usize::MAX); + + let mut rest = Vec::new(); + rest.try_reserve_exact(authored.len() - 1) + .map_err(|_| FiniteJointOrderErrorV1::ResourceExhausted)?; + let mut first_tuple = None; + for (tuple_index, tuple) in authored.into_iter().enumerate() { + if tuple.len() != domain_lengths.len() { + return Err(FiniteJointOrderErrorV1::TupleArity { + tuple: tuple_index, + expected: domain_lengths.len(), + actual: tuple.len(), + }); + } + let mut mixed_radix_index = 0usize; + for (dimension, (ordinal, &domain_len)) in tuple.iter().zip(domain_lengths).enumerate() { + if ordinal.index() >= domain_len { + return Err(FiniteJointOrderErrorV1::OrdinalOutOfDomain { + tuple: tuple_index, + dimension, + ordinal: ordinal.index(), + domain_len, + }); + } + mixed_radix_index = mixed_radix_index + .checked_mul(domain_len) + .and_then(|index| index.checked_add(ordinal.index())) + .ok_or(FiniteJointOrderErrorV1::CardinalityOverflow)?; + } + let first = first_seen + .get_mut(mixed_radix_index) + .ok_or(FiniteJointOrderErrorV1::CardinalityOverflow)?; + if *first != usize::MAX { + return Err(FiniteJointOrderErrorV1::DuplicateTuple { + first: *first, + duplicate: tuple_index, + }); + } + *first = tuple_index; + let tuple = tuple.into_boxed_slice(); + if first_tuple.is_none() { + first_tuple = Some(tuple); + } else { + rest.push(tuple); + } + } + + Ok(AdmittedFiniteJointOrderV1 { + // Empty input returned above, so the loop always materialises a first + // tuple; keep the typed branch instead of encoding that proof as panic. + first: first_tuple.ok_or(FiniteJointOrderErrorV1::EmptyOrder)?, + rest: rest.into_boxed_slice(), + }) +} use crate::session::SessionObservationBindingPermitV1; /// Sealed evaluator family, которую joint-program вызывает одинаково для /// каждого target occurrence. Конкретные Exact/WCAG/readability payload-и /// остаются в evaluator-модулях и не образуют центральный enum. pub(crate) trait JointPointEvaluatorV1: Clone + Debug + PartialEq { - type Invocation: Clone + Debug + PartialEq; + /// Joint execution repeats one invocation across the complete physical + /// matrix. Requiring a value type here keeps that repetition allocation-free + /// instead of hiding an arbitrary `Clone` behind the engine's preflight. + type Invocation: Copy + Debug + PartialEq; type PassEvidence: Clone + Debug + PartialEq; type ViolationEvidence: Clone + Debug + PartialEq; type Error: Clone + Debug + PartialEq; @@ -44,7 +207,7 @@ where + PartialEq + Evaluator + HardClassifier, PointMeasurement>, - PointInvocation: Clone + Debug + PartialEq, + PointInvocation: Copy + Debug + PartialEq, VisiblePointPassEvidence: Clone + Debug + PartialEq, VisiblePointViolationEvidence: Clone + Debug + PartialEq, >::Error: Clone + Debug + PartialEq, @@ -99,7 +262,7 @@ impl JointCandidateTupleV1 { /// Order-free candidate domain. Policy не участвует в его construction. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct JointCandidateSetV1 { - candidates: Box<[JointCandidateTupleV1]>, + candidates: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -125,20 +288,26 @@ impl JointCandidateSetV1 { return Err(CandidateSetErrorV1::DuplicateOrdinal(pair[0].ordinal)); } } - for (index, first) in candidates.iter().enumerate() { - if let Some(second) = candidates[index + 1..] - .iter() - .find(|second| first.lower == second.lower && first.upper == second.upper) - { - return Err(CandidateSetErrorV1::DuplicatePhysicalTuple { - first: first.ordinal, - second: second.ordinal, - }); - } + // Group equal physical tuples without auxiliary storage. The explicit + // ordinal tie-break makes every group canonical even though the sort is + // unstable. We inspect every duplicate group and retain the same error + // precedence as the former ordinal-order scan: the smallest first + // ordinal, followed by the smallest matching second ordinal. + candidates.sort_unstable_by(|left, right| { + candidate_physical_key(left) + .cmp(&candidate_physical_key(right)) + .then_with(|| left.ordinal.cmp(&right.ordinal)) + }); + let duplicate = candidates + .windows(2) + .filter(|pair| pair[0].lower == pair[1].lower && pair[0].upper == pair[1].upper) + .map(|pair| (pair[0].ordinal, pair[1].ordinal)) + .min(); + if let Some((first, second)) = duplicate { + return Err(CandidateSetErrorV1::DuplicatePhysicalTuple { first, second }); } - Ok(Self { - candidates: candidates.into_boxed_slice(), - }) + candidates.sort_unstable_by_key(|candidate| candidate.ordinal); + Ok(Self { candidates }) } pub(crate) fn candidates(&self) -> &[JointCandidateTupleV1] { @@ -146,6 +315,19 @@ impl JointCandidateSetV1 { } } +fn candidate_physical_key( + candidate: &JointCandidateTupleV1, +) -> (PaintId, Srgb8, u64, PaintId, Srgb8, u64) { + ( + candidate.lower.id(), + candidate.lower.source(), + candidate.lower.opacity().bits(), + candidate.upper.id(), + candidate.upper.source(), + candidate.upper.opacity().bits(), + ) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct JointConstraintIdV1(u32); @@ -191,8 +373,6 @@ where } } -pub(crate) type JointHardConstraintV1 = PointwiseJointHardConstraintV1; - impl PointwiseJointHardConstraintV1 { pub(crate) fn exact( id: JointConstraintIdV1, @@ -218,7 +398,8 @@ mod observation_seal { pub(crate) trait JointObservationV1: observation_seal::Sealed + Debug + PartialEq { fn case_count(&self) -> usize; - fn surface_at(&self, case_index: usize, surface: SurfaceInputPortId) -> Option; + fn bind_surface(&self, surface: SurfaceInputPortId) -> Option; + fn value_at_bound(&self, case_index: usize, bound_index: usize) -> Option; fn provenance(&self, case_index: usize) -> Option<&[ScenarioId]>; } @@ -226,15 +407,18 @@ impl observation_seal::Sealed for RevisionBoundObservationV1 {} impl JointObservationV1 for RevisionBoundObservationV1 { fn case_count(&self) -> usize { - self.set().cases().len() + self.physical_case_count() } - fn surface_at(&self, case_index: usize, surface: SurfaceInputPortId) -> Option { - let bindings = self.physical_bindings(case_index)?; - let index = bindings - .binary_search_by_key(&surface, |binding| binding.port()) - .ok()?; - Some(bindings[index].value()) + fn bind_surface(&self, surface: SurfaceInputPortId) -> Option { + self.schema().binary_search(&surface).ok() + } + + fn value_at_bound(&self, case_index: usize, bound_index: usize) -> Option { + self.physical_values(case_index)? + .get(bound_index) + .copied() + .map(crate::lcs_occurrence::ColorSignal::srgb8) } fn provenance(&self, case_index: usize) -> Option<&[ScenarioId]> { @@ -264,8 +448,12 @@ impl JointObservationV1 for StaticJointObservationV1 { 1 } - fn surface_at(&self, case_index: usize, surface: SurfaceInputPortId) -> Option { - (case_index == 0 && surface == self.root_surface).then_some(self.root) + fn bind_surface(&self, surface: SurfaceInputPortId) -> Option { + (surface == self.root_surface).then_some(0) + } + + fn value_at_bound(&self, case_index: usize, bound_index: usize) -> Option { + (case_index == 0 && bound_index == 0).then_some(self.root) } fn provenance(&self, _case_index: usize) -> Option<&[ScenarioId]> { @@ -283,11 +471,9 @@ where root_surface: SurfaceInputPortId, lower_paint: PaintId, upper_paint: PaintId, - constraints: Box<[PointwiseJointHardConstraintV1]>, + constraints: Vec>, } -pub(crate) type JointPointProgramV1 = PointwiseJointPointProgramV1; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum JointProgramErrorV1 { SamePaintIdentity(PaintId), @@ -299,7 +485,7 @@ impl PointwiseJointPointProgramV1 { root_surface: SurfaceInputPortId, lower_paint: PaintId, upper_paint: PaintId, - constraints: Vec, + constraints: Vec>, ) -> Result { Self::with_evaluator( ExactSrgb8IdentityV1, @@ -336,7 +522,7 @@ where root_surface, lower_paint, upper_paint, - constraints: constraints.into_boxed_slice(), + constraints, }) } @@ -345,7 +531,7 @@ where } pub(crate) fn evaluate_static( - &self, + self, candidates: JointCandidateSetV1, observation: StaticJointObservationV1, ) -> Result< @@ -356,7 +542,7 @@ where } pub(crate) fn evaluate_revision_bound( - &self, + self, candidates: JointCandidateSetV1, observation: RevisionBoundObservationV1, _permit: SessionObservationBindingPermitV1, @@ -368,7 +554,7 @@ where } fn evaluate_owned( - &self, + self, candidates: JointCandidateSetV1, observation: Observation, ) -> Result< @@ -379,46 +565,64 @@ where Observation: JointObservationV1, { self.validate_candidates(&candidates)?; - self.validate_observation(&observation)?; + let root_binding = self.bind_observation_surface(&observation)?; let (execution_count, cell_count) = checked_joint_cardinality_raw( candidates.candidates.len(), observation.case_count(), self.constraints.len(), ) .map_err(|_| PointwiseJointReportErrorV1::ResourceExhausted)?; + let mut feasible_ordinals = Vec::new(); + feasible_ordinals + .try_reserve_exact(candidates.candidates.len()) + .map_err(|_| PointwiseJointReportErrorV1::ResourceExhausted)?; + feasible_ordinals.extend( + candidates + .candidates + .iter() + .map(|candidate| candidate.ordinal), + ); let matrices = self.execute( candidates.candidates(), &observation, + root_binding, execution_count, cell_count, )?; + retain_feasible_ordinals(&mut feasible_ordinals, &matrices.cells); Ok(PointwiseFullHardReportV1 { program_identity: self.identity(), - program: self.clone(), + program: self, candidates, observation, executions: matrices.executions, cells: matrices.cells, + feasible_ordinals, }) } - fn validate_observation( + fn bind_observation_surface( &self, observation: &Observation, - ) -> Result<(), PointwiseJointReportErrorV1> + ) -> Result> where Observation: JointObservationV1, { + let Some(root_binding) = observation.bind_surface(self.root_surface) else { + return Err(PointwiseJointReportErrorV1::MissingRootSurface( + self.root_surface, + )); + }; if (0..observation.case_count()).any(|case_index| { observation - .surface_at(case_index, self.root_surface) + .value_at_bound(case_index, root_binding) .is_none() }) { return Err(PointwiseJointReportErrorV1::MissingRootSurface( self.root_surface, )); } - Ok(()) + Ok(root_binding) } fn validate_candidates( @@ -450,6 +654,7 @@ where &self, candidates: &[JointCandidateTupleV1], observation: &Observation, + root_binding: usize, execution_count: usize, cell_count: usize, ) -> Result< @@ -470,9 +675,9 @@ where for candidate in candidates { for case_index in 0..observation.case_count() { - let root = observation - .surface_at(case_index, self.root_surface) - .unwrap_or_else(|| unreachable!("joint observation passed keyed preflight")); + let root = observation.value_at_bound(case_index, root_binding).ok_or( + PointwiseJointReportErrorV1::MissingRootSurface(self.root_surface), + )?; let lower = PointOpacityOverSurfaceV1::evaluate_admitted( candidate.lower.source().bytes(), candidate.lower.opacity(), @@ -494,7 +699,7 @@ where upper, }); - for constraint in self.constraints.iter().cloned() { + for constraint in &self.constraints { let occurrence = match constraint.target { JointVisibleTargetV1::Lower => &lower, JointVisibleTargetV1::Upper => &upper, @@ -503,7 +708,7 @@ where // поэтому частичная матрица не называется FullHardReport. let decision = match self .evaluator - .assess(occurrence, constraint.invocation.clone()) + .assess(occurrence, constraint.invocation) .map_err(PointwiseJointReportErrorV1::Evaluator)? { HardDecision::Pass(evidence) => { @@ -526,10 +731,7 @@ where debug_assert_eq!(executions.len(), execution_count); debug_assert_eq!(cells.len(), cell_count); - Ok(PointwiseJointEvaluationMatricesV1 { - executions: executions.into_boxed_slice(), - cells: cells.into_boxed_slice(), - }) + Ok(PointwiseJointEvaluationMatricesV1 { executions, cells }) } } @@ -549,8 +751,6 @@ where ResourceExhausted, } -pub(crate) type JointReportErrorV1 = PointwiseJointReportErrorV1; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum JointCapacityErrorV1 { ResourceExhausted, @@ -574,18 +774,41 @@ pub(crate) fn checked_joint_cardinality( candidates: usize, cases: usize, constraints: usize, -) -> Result<(usize, usize), JointReportErrorV1> { +) -> Result<(usize, usize), PointwiseJointReportErrorV1> { checked_joint_cardinality_raw(candidates, cases, constraints) - .map_err(|_| JointReportErrorV1::ResourceExhausted) + .map_err(|_| PointwiseJointReportErrorV1::ResourceExhausted) } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] struct PointwiseJointEvaluationMatricesV1 where Evaluation: JointPointEvaluatorV1, { - executions: Box<[JointExecutionRecordV1]>, - cells: Box<[PointwiseJointConstraintCellV1]>, + executions: Vec, + cells: Vec>, +} + +fn retain_feasible_ordinals( + feasible: &mut Vec, + cells: &[PointwiseJointConstraintCellV1], +) where + Evaluation: JointPointEvaluatorV1, +{ + // Both vectors are candidate-major and ordinal-canonical. One cursor keeps + // classification O(candidates + cells), including the empty-constraint case. + let mut cell_index = 0; + feasible.retain(|ordinal| { + let mut passes = true; + while let Some(cell) = cells + .get(cell_index) + .filter(|cell| cell.ordinal == *ordinal) + { + passes &= cell.decision.is_pass(); + cell_index += 1; + } + passes + }); + debug_assert_eq!(cell_index, cells.len()); } /// Один execution record существует независимо от наличия constraint на lower. @@ -617,14 +840,6 @@ impl JointExecutionRecordV1 { self.upper_paint } - pub(crate) const fn lower_occurrence(&self) -> &ResolvedOccurrence { - &self.lower - } - - pub(crate) const fn upper_occurrence(&self) -> &ResolvedOccurrence { - &self.upper - } - pub(crate) fn lower_visible(&self) -> Srgb8 { Srgb8::new(self.lower.visible()) } @@ -647,9 +862,6 @@ where Violation(Evaluation::ViolationEvidence), } -pub(crate) type JointConstraintDecisionV1 = - PointwiseJointConstraintDecisionV1; - impl PointwiseJointConstraintDecisionV1 where Evaluation: JointPointEvaluatorV1, @@ -714,7 +926,7 @@ where /// Полная матрица candidate x constraint x unique physical case плюс отдельная /// joint execution matrix candidate x case. Report не знает selection policy. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) struct PointwiseFullHardReportV1 where Evaluation: JointPointEvaluatorV1, @@ -724,8 +936,9 @@ where program: PointwiseJointPointProgramV1, candidates: JointCandidateSetV1, observation: Observation, - executions: Box<[JointExecutionRecordV1]>, - cells: Box<[PointwiseJointConstraintCellV1]>, + executions: Vec, + cells: Vec>, + feasible_ordinals: Vec, } impl PointwiseFullHardReportV1 @@ -758,29 +971,17 @@ where } pub(crate) fn classify(self) -> PointwiseHardFeasibilityV1 { - let mut feasible = Vec::new(); - for candidate in self.candidates.candidates() { - if self - .cells - .iter() - .filter(|cell| cell.ordinal == candidate.ordinal) - .all(|cell| cell.decision.is_pass()) - { - feasible.push(candidate.ordinal); - } - } - if feasible.is_empty() { + if self.feasible_ordinals.is_empty() { PointwiseHardFeasibilityV1::Infeasible(self) } else { PointwiseHardFeasibilityV1::NonEmpty(PointwiseNonEmptyFeasibleJointTuplesV1 { report: self, - feasible: feasible.into_boxed_slice(), }) } } } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) enum PointwiseHardFeasibilityV1 where Evaluation: JointPointEvaluatorV1, @@ -790,17 +991,13 @@ where NonEmpty(PointwiseNonEmptyFeasibleJointTuplesV1), } -pub(crate) type HardFeasibilityV1 = - PointwiseHardFeasibilityV1; - -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) struct PointwiseNonEmptyFeasibleJointTuplesV1 where Evaluation: JointPointEvaluatorV1, Observation: JointObservationV1, { report: PointwiseFullHardReportV1, - feasible: Box<[CandidateOrdinalV1]>, } impl PointwiseNonEmptyFeasibleJointTuplesV1 @@ -809,42 +1006,129 @@ where Observation: JointObservationV1, { pub(crate) fn feasible(&self) -> &[CandidateOrdinalV1] { - &self.feasible + &self.report.feasible_ordinals } pub(crate) fn candidate_set(&self) -> &JointCandidateSetV1 { self.report.candidate_set() } + #[expect( + clippy::result_large_err, + reason = "ownership-preserving rejection keeps the expensive report retryable without heap allocation or recomputation" + )] pub(crate) fn select( self, policy: DeclaredTotalOrderV1, - ) -> PointwiseSelectedJointTupleV1 { - let ordinal = policy - .order - .iter() - .copied() - .find(|ordinal| self.feasible.binary_search(ordinal).is_ok()) - .unwrap_or_else(|| unreachable!("validated total order covers nonempty feasible set")); - let candidate = *self + ) -> Result< + PointwiseSelectedJointTupleV1, + PointwiseSelectionFailureV1, + > { + if !policy.is_bound_to(self.report.candidate_set()) { + return Err(PointwiseSelectionFailureV1 { + feasible: self, + policy, + reason: SelectionPolicyErrorV1::CandidateDomainMismatch, + }); + } + // Canonical policy entries and feasible ordinals are both sorted by + // ordinal. One merge scan finds the feasible entry with minimum + // client-declared rank without C×log(F) lookup or auxiliary storage. + let mut feasible_index = 0; + let mut selected: Option<(usize, usize)> = None; + for (candidate_index, entry) in policy.domain.iter().enumerate() { + if self.report.feasible_ordinals.get(feasible_index) == Some(&entry.ordinal) { + if selected.is_none_or(|(rank, _)| entry.rank < rank) { + selected = Some((entry.rank, candidate_index)); + } + feasible_index += 1; + } + } + let Some((_, candidate_index)) = + selected.filter(|_| feasible_index == self.report.feasible_ordinals.len()) + else { + return Err(PointwiseSelectionFailureV1 { + feasible: self, + policy, + reason: SelectionPolicyErrorV1::InternalInvariant, + }); + }; + let Some(candidate) = self .report .candidates .candidates() - .iter() - .find(|candidate| candidate.ordinal == ordinal) - .unwrap_or_else(|| unreachable!("validated ordinal belongs to candidate set")); - PointwiseSelectedJointTupleV1 { + .get(candidate_index) + .copied() + else { + return Err(PointwiseSelectionFailureV1 { + feasible: self, + policy, + reason: SelectionPolicyErrorV1::InternalInvariant, + }); + }; + Ok(PointwiseSelectedJointTupleV1 { report: self.report, policy, candidate, - } + }) + } +} + +/// Recoverable selection rejection. A foreign/malformed policy cannot destroy +/// the expensive full report: the caller can replace only the policy and retry +/// without recomposition or evaluator execution. +#[derive(Debug, PartialEq)] +pub(crate) struct PointwiseSelectionFailureV1 +where + Evaluation: JointPointEvaluatorV1, + Observation: JointObservationV1, +{ + feasible: PointwiseNonEmptyFeasibleJointTuplesV1, + policy: DeclaredTotalOrderV1, + reason: SelectionPolicyErrorV1, +} + +impl PointwiseSelectionFailureV1 +where + Evaluation: JointPointEvaluatorV1, + Observation: JointObservationV1, +{ + pub(crate) const fn feasible( + &self, + ) -> &PointwiseNonEmptyFeasibleJointTuplesV1 { + &self.feasible + } + + pub(crate) const fn policy(&self) -> &DeclaredTotalOrderV1 { + &self.policy + } + + pub(crate) const fn reason(&self) -> SelectionPolicyErrorV1 { + self.reason + } + + pub(crate) fn into_parts( + self, + ) -> ( + PointwiseNonEmptyFeasibleJointTuplesV1, + DeclaredTotalOrderV1, + SelectionPolicyErrorV1, + ) { + (self.feasible, self.policy, self.reason) } } /// Полный client-declared tie-break. Он не участвует в measurement/report. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct DeclaredTotalOrderV1 { - order: Box<[CandidateOrdinalV1]>, + order: Vec, + domain: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct DeclaredPolicyDomainEntryV1 { + ordinal: CandidateOrdinalV1, + rank: usize, } impl DeclaredTotalOrderV1 { @@ -855,33 +1139,60 @@ impl DeclaredTotalOrderV1 { if order.len() != candidates.candidates.len() { return Err(SelectionPolicyErrorV1::NotATotalOrder); } - let mut canonical = order.clone(); - canonical.sort_unstable(); - for pair in canonical.windows(2) { - if pair[0] == pair[1] { - return Err(SelectionPolicyErrorV1::DuplicateOrdinal(pair[0])); - } + let mut domain = Vec::new(); + domain + .try_reserve_exact(candidates.candidates.len()) + .map_err(|_| SelectionPolicyErrorV1::ResourceExhausted)?; + domain.extend( + order + .iter() + .copied() + .enumerate() + .map(|(rank, ordinal)| DeclaredPolicyDomainEntryV1 { ordinal, rank }), + ); + domain.sort_unstable_by_key(|entry| entry.ordinal); + if let Some(pair) = domain + .windows(2) + .find(|pair| pair[0].ordinal == pair[1].ordinal) + { + return Err(SelectionPolicyErrorV1::DuplicateOrdinal(pair[0].ordinal)); } - if canonical.iter().copied().ne(candidates + if domain.iter().map(|entry| entry.ordinal).ne(candidates .candidates .iter() .map(|candidate| candidate.ordinal)) { return Err(SelectionPolicyErrorV1::NotATotalOrder); } - Ok(Self { - order: order.into_boxed_slice(), - }) + Ok(Self { order, domain }) + } + + pub(crate) fn order(&self) -> &[CandidateOrdinalV1] { + &self.order + } + + pub(crate) fn into_order(self) -> Vec { + self.order + } + + fn is_bound_to(&self, candidates: &JointCandidateSetV1) -> bool { + self.domain.iter().map(|entry| entry.ordinal).eq(candidates + .candidates + .iter() + .map(|candidate| candidate.ordinal)) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SelectionPolicyErrorV1 { + ResourceExhausted, DuplicateOrdinal(CandidateOrdinalV1), NotATotalOrder, + CandidateDomainMismatch, + InternalInvariant, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) struct PointwiseSelectedJointTupleV1 where Evaluation: JointPointEvaluatorV1, @@ -910,9 +1221,10 @@ where // A revision-bound report can enter this consuming chain only through // `evaluate_revision_bound` with a Session-minted linear permit. Static // reports have a different concrete observation type. - self.report + let root_binding = self + .report .program - .validate_observation(&self.report.observation) + .bind_observation_surface(&self.report.observation) .map_err(|_| PointwiseSelectedRecheckErrorV1::InvariantDrift)?; let cases = self.report.observation.case_count(); let (execution_count, cell_count) = @@ -924,6 +1236,7 @@ where .execute( core::slice::from_ref(&self.candidate), &self.report.observation, + root_binding, execution_count, cell_count, ) @@ -939,15 +1252,18 @@ where PointwiseSelectedRecheckErrorV1::InvariantDrift } })?; - if let Some(violation) = matrices + if let Some(violation_index) = matrices .cells .iter() - .find(|cell| !cell.decision.is_pass()) - .cloned() + .position(|cell| !cell.decision.is_pass()) { - return Err(PointwiseSelectedRecheckErrorV1::Violation(Box::new( - violation, - ))); + return Err(PointwiseSelectedRecheckErrorV1::Violation { + evidence: PointwiseFreshJointRecheckV1 { + executions: matrices.executions, + cells: matrices.cells, + }, + violation_index, + }); } Ok(PointwiseVerifiedSelectionV1 { selected: self, @@ -959,7 +1275,7 @@ where } } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) enum PointwiseSelectedRecheckErrorV1 where Evaluation: JointPointEvaluatorV1, @@ -967,19 +1283,22 @@ where ResourceExhausted, InvariantDrift, Evaluator(Evaluation::Error), - Violation(Box>), + Violation { + evidence: PointwiseFreshJointRecheckV1, + violation_index: usize, + }, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) struct PointwiseFreshJointRecheckV1 where Evaluation: JointPointEvaluatorV1, { - executions: Box<[JointExecutionRecordV1]>, - cells: Box<[PointwiseJointConstraintCellV1]>, + executions: Vec, + cells: Vec>, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) struct PointwiseVerifiedSelectionV1 where Evaluation: JointPointEvaluatorV1, diff --git a/crates/labcolors-core/src/joint_tests.rs b/crates/labcolors-core/src/joint_tests.rs index 992f61b2..e58051af 100644 --- a/crates/labcolors-core/src/joint_tests.rs +++ b/crates/labcolors-core/src/joint_tests.rs @@ -1,20 +1,21 @@ use crate::Srgb8; use crate::appearance::{EncodedPointPaintV1, PaintId, SurfaceInputPortId}; use crate::composition::AdmittedOpacityV1; -use crate::constraints::{HardDecision, Wcag22Srgb8V1}; +use crate::constraints::{ExactSrgb8IdentityV1, HardDecision, Wcag22Srgb8V1}; use crate::joint::{ - CandidateOrdinalV1, CandidateSetErrorV1, DeclaredTotalOrderV1, HardFeasibilityV1, - JointCandidateSetV1, JointCandidateTupleV1, JointConstraintDecisionV1, JointConstraintIdV1, - JointHardConstraintV1, JointPointEvaluatorV1, JointPointProgramIdentityV1, JointPointProgramV1, - JointProgramErrorV1, JointReportErrorV1, JointVisibleTargetV1, PointwiseHardFeasibilityV1, - PointwiseJointHardConstraintV1, PointwiseJointPointProgramV1, PointwiseJointReportErrorV1, - PointwiseSelectedRecheckErrorV1, SelectionPolicyErrorV1, checked_joint_cardinality, + CandidateOrdinalV1, CandidateSetErrorV1, DeclaredTotalOrderV1, JointCandidateSetV1, + JointCandidateTupleV1, JointConstraintIdV1, JointPointEvaluatorV1, JointPointProgramIdentityV1, + JointProgramErrorV1, JointVisibleTargetV1, PointwiseHardFeasibilityV1, + PointwiseJointConstraintDecisionV1, PointwiseJointHardConstraintV1, + PointwiseJointPointProgramV1, PointwiseJointReportErrorV1, PointwiseSelectedRecheckErrorV1, + SelectionPolicyErrorV1, checked_joint_cardinality, }; +use crate::lcs_occurrence::ColorSignal; use crate::observation::{ ObservationHeadViewV1, ObservationOwnerV1, ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, ObservedScenarioSetInput, PreparedObservationUpdateV1, Revision, RevisionBoundObservationV1, ScenarioId, ScenarioInput, SurfaceInputBinding, - prepare_observation, + canonicalize_observation_schema, prepare_observation, }; use crate::session::SessionObservationBindingPermitV1; use crate::wcag22::Wcag22CriterionV1; @@ -58,20 +59,22 @@ fn candidates(values: Vec) -> JointCandidateSetV1 { JointCandidateSetV1::new(values).unwrap() } -fn program(constraints: Vec) -> JointPointProgramV1 { - JointPointProgramV1::new(ROOT, LOWER, UPPER, constraints).unwrap() +fn program( + constraints: Vec>, +) -> PointwiseJointPointProgramV1 { + PointwiseJointPointProgramV1::new(ROOT, LOWER, UPPER, constraints).unwrap() } -fn exact_upper(id: u32, target: [u8; 3]) -> JointHardConstraintV1 { - JointHardConstraintV1::exact( +fn exact_upper(id: u32, target: [u8; 3]) -> PointwiseJointHardConstraintV1 { + PointwiseJointHardConstraintV1::exact( JointConstraintIdV1::new(id), JointVisibleTargetV1::Upper, Srgb8::new(target), ) } -fn exact_lower(id: u32, target: [u8; 3]) -> JointHardConstraintV1 { - JointHardConstraintV1::exact( +fn exact_lower(id: u32, target: [u8; 3]) -> PointwiseJointHardConstraintV1 { + PointwiseJointHardConstraintV1::exact( JointConstraintIdV1::new(id), JointVisibleTargetV1::Lower, Srgb8::new(target), @@ -80,10 +83,11 @@ fn exact_lower(id: u32, target: [u8; 3]) -> JointHardConstraintV1 { fn observation(revision: u64, cases: Vec<(u32, [u8; 3])>) -> RevisionBoundObservationV1 { let mut owner = EmptyObservationOwner; + let schema = canonicalize_observation_schema(vec![ROOT]).unwrap(); let prepared = prepare_observation( &mut owner, STREAM, - &[ROOT], + &schema, ObservationUpdateInput { stream: STREAM, revision: Revision::new(revision), @@ -94,7 +98,7 @@ fn observation(revision: u64, cases: Vec<(u32, [u8; 3])>) -> RevisionBoundObserv id: ScenarioId::new(id), bindings: vec![SurfaceInputBinding { port: ROOT, - value: Srgb8::new(value), + value: ColorSignal::from_srgb8(Srgb8::new(value)), }], }) .collect(), @@ -109,6 +113,46 @@ fn observation(revision: u64, cases: Vec<(u32, [u8; 3])>) -> RevisionBoundObserv observation } +fn observation_with_unrelated_surface( + revision: u64, + unrelated: [u8; 3], + root: [u8; 3], +) -> RevisionBoundObservationV1 { + let unrelated_surface = SurfaceInputPortId::new(ROOT.value() - 1); + let mut owner = EmptyObservationOwner; + let schema = canonicalize_observation_schema(vec![ROOT, unrelated_surface]).unwrap(); + let prepared = prepare_observation( + &mut owner, + STREAM, + &schema, + ObservationUpdateInput { + stream: STREAM, + revision: Revision::new(revision), + payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { + scenarios: vec![ScenarioInput { + id: ScenarioId::new(17), + bindings: vec![ + SurfaceInputBinding { + port: ROOT, + value: ColorSignal::from_srgb8(Srgb8::new(root)), + }, + SurfaceInputBinding { + port: unrelated_surface, + value: ColorSignal::from_srgb8(Srgb8::new(unrelated)), + }, + ], + }], + }), + }, + ) + .unwrap(); + let PreparedObservationUpdateV1::Observed(prepared) = prepared else { + panic!("fresh observed update must prepare an observation"); + }; + let (_owner, observation) = prepared.into_parts(); + observation +} + #[test] fn linked_candidate_is_selected_only_after_upper_sees_lower_visible_surface() { let observed = observation(1, vec![(1, [0; 3])]); @@ -145,14 +189,14 @@ fn linked_candidate_is_selected_only_after_upper_sees_lower_visible_surface() { assert_eq!(report.cells()[0].decision().target(), Srgb8::new([192; 3])); assert!(matches!( report.cells()[0].decision(), - JointConstraintDecisionV1::Violation(_) + PointwiseJointConstraintDecisionV1::Violation(_) )); assert!(matches!( report.cells()[1].decision(), - JointConstraintDecisionV1::Pass(_) + PointwiseJointConstraintDecisionV1::Pass(_) )); - let HardFeasibilityV1::NonEmpty(feasible) = report.classify() else { + let PointwiseHardFeasibilityV1::NonEmpty(feasible) = report.classify() else { panic!("second joint tuple must be feasible"); }; assert_eq!(feasible.feasible(), &[CandidateOrdinalV1::new(1)]); @@ -161,7 +205,7 @@ fn linked_candidate_is_selected_only_after_upper_sees_lower_visible_surface() { vec![CandidateOrdinalV1::new(0), CandidateOrdinalV1::new(1)], ) .unwrap(); - let selected = feasible.select(policy); + let selected = feasible.select(policy).unwrap(); assert_eq!(selected.ordinal(), CandidateOrdinalV1::new(1)); let verified = selected.recheck().unwrap(); assert_eq!(verified.ordinal(), CandidateOrdinalV1::new(1)); @@ -181,7 +225,7 @@ fn every_unique_physical_case_must_pass_without_worst_or_average_reduction() { assert_eq!(report.cells().len(), 2); assert_eq!(report.cells()[0].decision().actual(), Srgb8::new([128; 3])); assert_eq!(report.cells()[1].decision().actual(), Srgb8::new([192; 3])); - let HardFeasibilityV1::Infeasible(report) = report.classify() else { + let PointwiseHardFeasibilityV1::Infeasible(report) = report.classify() else { panic!("one violated case must exclude the whole tuple"); }; assert_eq!( @@ -275,6 +319,25 @@ fn scenario_declaration_permutation_is_canonical() { assert_eq!(first, second); } +#[test] +fn revision_bound_root_uses_its_schema_ordinal_and_retains_case_provenance() { + let report = program(vec![exact_lower(1, [128; 3])]) + .evaluate_revision_bound( + candidates(vec![candidate(0, ([0; 3], 0.5), ([255; 3], 1.0))]), + observation_with_unrelated_surface(6, [0; 3], [255; 3]), + session_permit(), + ) + .unwrap(); + + assert_eq!(report.executions().len(), 1); + assert_eq!(report.executions()[0].lower_visible(), Srgb8::new([128; 3])); + assert_eq!(report.provenance(0), Some(&[ScenarioId::new(17)][..])); + assert!(matches!( + report.cells()[0].decision(), + PointwiseJointConstraintDecisionV1::Pass(_) + )); +} + #[test] fn static_joint_evaluation_is_explicitly_lifecycle_free() { let report = program(vec![exact_upper(1, [255; 3])]) @@ -301,7 +364,7 @@ fn static_joint_key_mismatch_fails_before_compositing() { assert!(matches!( result, - Err(JointReportErrorV1::MissingRootSurface(ROOT)) + Err(PointwiseJointReportErrorV1::MissingRootSurface(ROOT)) )); assert_eq!(crate::composition::source_over_evaluation_count(), 0); } @@ -340,7 +403,7 @@ fn declared_policy_is_separate_from_report_and_is_the_only_tie_break() { .unwrap() }; - let HardFeasibilityV1::NonEmpty(first) = make_report().classify() else { + let PointwiseHardFeasibilityV1::NonEmpty(first) = make_report().classify() else { panic!("both tuples must pass"); }; let first_policy = DeclaredTotalOrderV1::new( @@ -348,7 +411,7 @@ fn declared_policy_is_separate_from_report_and_is_the_only_tie_break() { vec![CandidateOrdinalV1::new(7), CandidateOrdinalV1::new(4)], ) .unwrap(); - let HardFeasibilityV1::NonEmpty(second) = make_report().classify() else { + let PointwiseHardFeasibilityV1::NonEmpty(second) = make_report().classify() else { panic!("both tuples must pass"); }; let second_policy = DeclaredTotalOrderV1::new( @@ -357,15 +420,168 @@ fn declared_policy_is_separate_from_report_and_is_the_only_tie_break() { ) .unwrap(); assert_eq!( - first.select(first_policy).ordinal(), + first.select(first_policy).unwrap().ordinal(), CandidateOrdinalV1::new(7) ); assert_eq!( - second.select(second_policy).ordinal(), + second.select(second_policy).unwrap().ordinal(), CandidateOrdinalV1::new(4) ); } +#[test] +fn foreign_disjoint_and_partially_overlapping_policy_domains_are_typed_errors() { + let make_actual = || { + let report = program(vec![]) + .evaluate_revision_bound( + candidates(vec![ + candidate(1, ([11; 3], 1.0), ([111; 3], 1.0)), + candidate(2, ([22; 3], 1.0), ([122; 3], 1.0)), + ]), + observation(70, vec![(1, [0; 3])]), + session_permit(), + ) + .unwrap(); + let PointwiseHardFeasibilityV1::NonEmpty(feasible) = report.classify() else { + panic!("an unconstrained nonempty domain must be feasible"); + }; + feasible + }; + + let disjoint_domain = candidates(vec![ + candidate(10, ([10; 3], 1.0), ([210; 3], 1.0)), + candidate(11, ([11; 3], 1.0), ([211; 3], 1.0)), + ]); + let disjoint_policy = DeclaredTotalOrderV1::new( + &disjoint_domain, + vec![CandidateOrdinalV1::new(10), CandidateOrdinalV1::new(11)], + ) + .unwrap(); + let disjoint_feasible = make_actual(); + crate::composition::reset_source_over_evaluation_count(); + let (disjoint_failure, disjoint_allocations) = + crate::test_support::measured_allocations(|| { + match disjoint_feasible.select(disjoint_policy) { + Ok(_) => panic!("a disjoint policy domain must be rejected"), + Err(failure) => failure, + } + }); + assert_eq!( + disjoint_failure.reason(), + SelectionPolicyErrorV1::CandidateDomainMismatch + ); + assert_eq!( + disjoint_failure.feasible().feasible(), + &[CandidateOrdinalV1::new(1), CandidateOrdinalV1::new(2)] + ); + assert_eq!( + disjoint_failure.policy().order(), + &[CandidateOrdinalV1::new(10), CandidateOrdinalV1::new(11)] + ); + assert_eq!(disjoint_allocations, 0); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); + + // Ordinal 1 overlaps and is first in the foreign order. The old selector + // silently accepted it; exact-domain validation must reject the policy. + let partial_domain = candidates(vec![ + candidate(1, ([31; 3], 1.0), ([131; 3], 1.0)), + candidate(3, ([33; 3], 1.0), ([133; 3], 1.0)), + ]); + let partial_policy = DeclaredTotalOrderV1::new( + &partial_domain, + vec![CandidateOrdinalV1::new(1), CandidateOrdinalV1::new(3)], + ) + .unwrap(); + let partial_feasible = make_actual(); + crate::composition::reset_source_over_evaluation_count(); + let (partial_failure, partial_allocations) = crate::test_support::measured_allocations(|| { + match partial_feasible.select(partial_policy) { + Ok(_) => panic!("a partially overlapping policy domain must be rejected"), + Err(failure) => failure, + } + }); + assert_eq!( + partial_failure.reason(), + SelectionPolicyErrorV1::CandidateDomainMismatch + ); + assert_eq!(partial_allocations, 0); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); + + // Recover the expensive report and the caller's original Vec allocation, + // repair only the order, then retry without re-running physical evaluation. + let (recovered_feasible, rejected_policy, reason) = partial_failure.into_parts(); + assert_eq!(reason, SelectionPolicyErrorV1::CandidateDomainMismatch); + let order_backing = rejected_policy.order().as_ptr(); + let mut corrected_order = rejected_policy.into_order(); + corrected_order[1] = CandidateOrdinalV1::new(2); + corrected_order.swap(0, 1); + assert_eq!(corrected_order.as_ptr(), order_backing); + let corrected_policy = + DeclaredTotalOrderV1::new(recovered_feasible.candidate_set(), corrected_order).unwrap(); + assert_eq!(corrected_policy.order().as_ptr(), order_backing); + let selected = recovered_feasible.select(corrected_policy).unwrap(); + assert_eq!(selected.ordinal(), CandidateOrdinalV1::new(2)); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); +} + +#[test] +fn policy_is_reusable_for_the_same_ordinal_domain_without_owning_candidate_physics() { + let make_actual = || { + let report = program(vec![]) + .evaluate_revision_bound( + candidates(vec![ + candidate(1, ([11; 3], 1.0), ([111; 3], 1.0)), + candidate(2, ([22; 3], 1.0), ([222; 3], 1.0)), + ]), + observation(71, vec![(1, [0; 3])]), + session_permit(), + ) + .unwrap(); + let PointwiseHardFeasibilityV1::NonEmpty(feasible) = report.classify() else { + panic!("an unconstrained nonempty domain must be feasible"); + }; + feasible + }; + + let different_physics = candidates(vec![ + candidate(2, ([202; 3], 0.5), ([72; 3], 1.0)), + candidate(1, ([201; 3], 0.5), ([71; 3], 1.0)), + ]); + let reusable_policy = DeclaredTotalOrderV1::new( + &different_physics, + vec![CandidateOrdinalV1::new(2), CandidateOrdinalV1::new(1)], + ) + .unwrap(); + let verified = make_actual() + .select(reusable_policy) + .unwrap() + .recheck() + .unwrap(); + assert_eq!(verified.ordinal(), CandidateOrdinalV1::new(2)); + assert_eq!( + verified.fresh_executions()[0].lower_visible(), + Srgb8::new([22; 3]) + ); + assert_eq!( + verified.fresh_executions()[0].upper_visible(), + Srgb8::new([222; 3]) + ); + + let independently_identical = candidates(vec![ + candidate(2, ([22; 3], 1.0), ([222; 3], 1.0)), + candidate(1, ([11; 3], 1.0), ([111; 3], 1.0)), + ]); + let identical_policy = DeclaredTotalOrderV1::new( + &independently_identical, + vec![CandidateOrdinalV1::new(1), CandidateOrdinalV1::new(2)], + ) + .unwrap(); + assert_eq!( + make_actual().select(identical_policy).unwrap().ordinal(), + CandidateOrdinalV1::new(1) + ); +} + #[test] fn fresh_recheck_executes_the_selected_joint_program_again_on_the_same_revision() { let observed = observation(8, vec![(1, [0; 3]), (2, [255; 3])]); @@ -377,13 +593,13 @@ fn fresh_recheck_executes_the_selected_joint_program_again_on_the_same_revision( ) .unwrap(); crate::composition::reset_source_over_evaluation_count(); - let HardFeasibilityV1::NonEmpty(feasible) = report.classify() else { + let PointwiseHardFeasibilityV1::NonEmpty(feasible) = report.classify() else { panic!("opaque upper must pass on both roots"); }; let policy = DeclaredTotalOrderV1::new(feasible.candidate_set(), vec![CandidateOrdinalV1::new(0)]) .unwrap(); - let selected = feasible.select(policy); + let selected = feasible.select(policy).unwrap(); let verified = selected.recheck().unwrap(); assert_eq!(crate::composition::source_over_evaluation_count(), 4); @@ -403,14 +619,14 @@ fn empty_hard_constraint_set_is_non_vacuously_feasible() { assert_eq!(report.executions().len(), 1); assert!(report.cells().is_empty()); - let HardFeasibilityV1::NonEmpty(feasible) = report.classify() else { + let PointwiseHardFeasibilityV1::NonEmpty(feasible) = report.classify() else { panic!("a tuple with no hard violations must be feasible"); }; assert_eq!(feasible.feasible(), &[CandidateOrdinalV1::new(0)]); let policy = DeclaredTotalOrderV1::new(feasible.candidate_set(), vec![CandidateOrdinalV1::new(0)]) .unwrap(); - let verified = feasible.select(policy).recheck().unwrap(); + let verified = feasible.select(policy).unwrap().recheck().unwrap(); assert_eq!(verified.fresh_executions().len(), 1); assert!(verified.fresh_cells().is_empty()); } @@ -451,7 +667,7 @@ fn generic_wcag_evaluator_can_constrain_the_derived_lower_occurrence() { vec![CandidateOrdinalV1::new(1), CandidateOrdinalV1::new(2)], ) .unwrap(); - let verified = feasible.select(policy).recheck().unwrap(); + let verified = feasible.select(policy).unwrap().recheck().unwrap(); assert_eq!(verified.ordinal(), CandidateOrdinalV1::new(2)); assert_eq!(verified.fresh_cells().len(), 1); assert!(verified.fresh_cells()[0].decision().is_pass()); @@ -535,7 +751,7 @@ fn evaluator_error_invalidates_the_full_report_and_fresh_recheck() { DeclaredTotalOrderV1::new(feasible.candidate_set(), vec![CandidateOrdinalV1::new(0)]) .unwrap(); assert!(matches!( - feasible.select(policy).recheck(), + feasible.select(policy).unwrap().recheck(), Err(PointwiseSelectedRecheckErrorV1::Evaluator( "evaluator-fault" )) @@ -568,7 +784,7 @@ fn invalid_domains_and_policies_fail_before_compositing() { }) ); assert_eq!( - JointPointProgramV1::new(ROOT, LOWER, LOWER, vec![exact_upper(1, [0; 3])]), + PointwiseJointPointProgramV1::new(ROOT, LOWER, LOWER, vec![exact_upper(1, [0; 3])]), Err(JointProgramErrorV1::SamePaintIdentity(LOWER)) ); let observed = observation(9, vec![(1, [0; 3])]); @@ -585,7 +801,7 @@ fn invalid_domains_and_policies_fail_before_compositing() { observed, session_permit() ), - Err(JointReportErrorV1::CandidatePaintMismatch { + Err(PointwiseJointReportErrorV1::CandidatePaintMismatch { stage: JointVisibleTargetV1::Lower, .. }) @@ -606,14 +822,56 @@ fn invalid_domains_and_policies_fail_before_compositing() { ); } +#[test] +fn duplicate_physical_detection_preserves_canonical_ordinal_precedence() { + let first_order = vec![ + candidate(4, ([0; 3], 1.0), ([10; 3], 1.0)), + candidate(9, ([250; 3], 1.0), ([240; 3], 1.0)), + candidate(3, ([0; 3], 1.0), ([10; 3], 1.0)), + candidate(1, ([250; 3], 1.0), ([240; 3], 1.0)), + ]; + let reverse_order = first_order.iter().rev().copied().collect(); + let expected = Err(CandidateSetErrorV1::DuplicatePhysicalTuple { + first: CandidateOrdinalV1::new(1), + second: CandidateOrdinalV1::new(9), + }); + assert_eq!(JointCandidateSetV1::new(first_order), expected); + assert_eq!(JointCandidateSetV1::new(reverse_order), expected); + + assert_eq!( + JointCandidateSetV1::new(vec![ + candidate(7, ([70; 3], 0.5), ([170; 3], 1.0)), + candidate(2, ([70; 3], 0.5), ([170; 3], 1.0)), + candidate(5, ([70; 3], 0.5), ([170; 3], 1.0)), + ]), + Err(CandidateSetErrorV1::DuplicatePhysicalTuple { + first: CandidateOrdinalV1::new(2), + second: CandidateOrdinalV1::new(5), + }) + ); +} + +#[test] +fn large_candidate_domain_remains_ordinal_canonical() { + const COUNT: u32 = 4_096; + let make = |ordinal: u32| { + let bytes = [(ordinal >> 8) as u8, ordinal as u8, 17]; + candidate(ordinal, (bytes, 1.0), ([255, 0, 19], 1.0)) + }; + let input = (0..COUNT).rev().map(make).collect(); + let expected: Vec<_> = (0..COUNT).map(make).collect(); + let domain = JointCandidateSetV1::new(input).unwrap(); + assert_eq!(domain.candidates(), expected); +} + #[test] fn cardinality_overflow_is_rejected_by_preflight() { assert_eq!( checked_joint_cardinality(usize::MAX, 2, 1), - Err(JointReportErrorV1::ResourceExhausted) + Err(PointwiseJointReportErrorV1::ResourceExhausted) ); assert_eq!( checked_joint_cardinality(usize::MAX / 2 + 1, 2, 2), - Err(JointReportErrorV1::ResourceExhausted) + Err(PointwiseJointReportErrorV1::ResourceExhausted) ); } diff --git a/crates/labcolors-core/src/lcs_occurrence.rs b/crates/labcolors-core/src/lcs_occurrence.rs new file mode 100644 index 00000000..fb7f08c5 --- /dev/null +++ b/crates/labcolors-core/src/lcs_occurrence.rs @@ -0,0 +1,1070 @@ +//! Type foundation for a context-bound Labpics Colors Space occurrence. +//! +//! Encoded output, a framed tristimulus, immutable appearance context and +//! separately named derived views are different values. Executable transforms +//! are sealed and versioned: encoded sRGB8 lowers through the existing IEC +//! transfer table and XYZ(D65) matrix, then an occurrence can derive independent +//! rectangular Oklab and contextual CAM16 views. These are deterministic model +//! derivations, not evidence that a host rendered or a person observed the +//! result. In particular, an occurrence has no inverse operation accepting an +//! arbitrary second context. + +use crate::Srgb8; +use crate::spaces::cam16::{forward_correlates_v1, ucs_j, ucs_m}; +use crate::spaces::oklab::xyz_d65_to_oklab_v1; +use crate::spaces::srgb::xyz_d65_from_srgb8_v1; +use crate::spaces::vc::{Cam16SurroundV1, ViewingConditions}; + +/// A registered encoded-output domain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum OutputProfileId { + Iec61966Srgb8D65V1, +} + +/// Exact encoded channels plus the output profile which gives them meaning. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ColorSignal { + srgb8: Srgb8, + output_profile: OutputProfileId, +} + +/// Exhaustive internal decomposition for boundaries that must preserve the +/// signal profile instead of treating encoded bytes as self-describing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ColorSignalViewV1 { + Iec61966Srgb8D65(Srgb8), +} + +impl ColorSignal { + /// Form the only admitted encoded signal without accepting a free-form + /// channel/profile pairing. + pub(crate) const fn from_srgb8(srgb8: Srgb8) -> Self { + Self { + srgb8, + output_profile: OutputProfileId::Iec61966Srgb8D65V1, + } + } + + pub(crate) const fn srgb8(self) -> Srgb8 { + self.srgb8 + } + + pub(crate) const fn output_profile(self) -> OutputProfileId { + self.output_profile + } + + pub(crate) const fn view(self) -> ColorSignalViewV1 { + match self.output_profile { + OutputProfileId::Iec61966Srgb8D65V1 => ColorSignalViewV1::Iec61966Srgb8D65(self.srgb8), + } + } +} + +/// Exact code release for one colorimetric signal-to-tristimulus transform. +/// +/// This is not a composition profile, renderer capability or observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ColorimetricTransformReleaseId { + Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ObserverProfileId { + Cie1931TwoDegreeV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ReferenceWhiteId { + Iec61966D65ChromaticityV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TristimulusScale { + RelativeY1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ColorimetricFrameReleaseId { + XyzV1, + #[cfg(test)] + MutationSentinelV1, +} + +/// Everything required to interpret one XYZ triple. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ColorimetricFrameId { + observer: ObserverProfileId, + reference_white: ReferenceWhiteId, + scale: TristimulusScale, + release: ColorimetricFrameReleaseId, +} + +impl ColorimetricFrameId { + const fn registered( + observer: ObserverProfileId, + reference_white: ReferenceWhiteId, + scale: TristimulusScale, + release: ColorimetricFrameReleaseId, + ) -> Self { + Self { + observer, + reference_white, + scale, + release, + } + } + + pub(crate) const fn observer(self) -> ObserverProfileId { + self.observer + } + + pub(crate) const fn reference_white(self) -> ReferenceWhiteId { + self.reference_white + } + + pub(crate) const fn scale(self) -> TristimulusScale { + self.scale + } + + pub(crate) const fn release(self) -> ColorimetricFrameReleaseId { + self.release + } +} + +/// Canonical result frame of the registered encoded-sRGB8 transform. +pub(crate) const IEC_SRGB_D65_XYZ_FRAME_V1: ColorimetricFrameId = ColorimetricFrameId::registered( + ObserverProfileId::Cie1931TwoDegreeV1, + ReferenceWhiteId::Iec61966D65ChromaticityV1, + TristimulusScale::RelativeY1, + ColorimetricFrameReleaseId::XyzV1, +); + +#[cfg(test)] +pub(crate) const MUTATION_SENTINEL_XYZ_FRAME_V1: ColorimetricFrameId = + ColorimetricFrameId::registered( + ObserverProfileId::Cie1931TwoDegreeV1, + ReferenceWhiteId::Iec61966D65ChromaticityV1, + TristimulusScale::RelativeY1, + ColorimetricFrameReleaseId::MutationSentinelV1, + ); + +/// The one closed, code-owned binding admitted by the current F0 slice. +/// +/// A variant is the tuple: independent profile, transform and frame fields +/// cannot be authored or mixed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AdmittedSrgb8TristimulusBindingV1 { + Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1, +} + +impl AdmittedSrgb8TristimulusBindingV1 { + pub(crate) const fn signal_output_profile(self) -> OutputProfileId { + match self { + Self::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1 => { + OutputProfileId::Iec61966Srgb8D65V1 + } + } + } + + pub(crate) const fn transform_release(self) -> ColorimetricTransformReleaseId { + match self { + Self::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1 => { + ColorimetricTransformReleaseId::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1 + } + } + } + + pub(crate) const fn result_frame(self) -> ColorimetricFrameId { + match self { + Self::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1 => IEC_SRGB_D65_XYZ_FRAME_V1, + } + } +} + +pub(crate) const ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1: AdmittedSrgb8TristimulusBindingV1 = + AdmittedSrgb8TristimulusBindingV1::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NumericDomainError { + NonFinite, + Negative, + NotPositive, + AboveOne, + HueOutOfRange, +} + +/// Finite, non-negative binary64 value with canonical positive zero. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct FiniteNonNegative(u64); + +impl FiniteNonNegative { + fn new(value: f64) -> Result { + if !value.is_finite() { + return Err(NumericDomainError::NonFinite); + } + if value < 0.0 { + return Err(NumericDomainError::Negative); + } + Ok(Self(if value == 0.0 { + 0.0_f64.to_bits() + } else { + value.to_bits() + })) + } + + fn get(self) -> f64 { + f64::from_bits(self.0) + } +} + +/// One finite XYZ point plus the identity of its colorimetric frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TristimulusSample { + xyz: [FiniteNonNegative; 3], + frame: ColorimetricFrameId, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TristimulusComponentV1 { + X, + Y, + Z, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TristimulusDomainErrorV1 { + component: TristimulusComponentV1, + reason: NumericDomainError, +} + +impl TristimulusDomainErrorV1 { + pub(crate) const fn component(self) -> TristimulusComponentV1 { + self.component + } + + pub(crate) const fn reason(self) -> NumericDomainError { + self.reason + } +} + +impl TristimulusSample { + fn try_from_registered_xyz( + xyz: [f64; 3], + frame: ColorimetricFrameId, + ) -> Result { + let admit = |value, component| { + FiniteNonNegative::new(value) + .map_err(|reason| TristimulusDomainErrorV1 { component, reason }) + }; + Ok(Self { + xyz: [ + admit(xyz[0], TristimulusComponentV1::X)?, + admit(xyz[1], TristimulusComponentV1::Y)?, + admit(xyz[2], TristimulusComponentV1::Z)?, + ], + frame, + }) + } + + #[cfg(test)] + pub(crate) fn try_from_xyz_for_test( + xyz: [f64; 3], + frame: ColorimetricFrameId, + ) -> Result { + Self::try_from_registered_xyz(xyz, frame) + } + + pub(crate) fn xyz(self) -> [f64; 3] { + self.xyz.map(FiniteNonNegative::get) + } + + pub(crate) const fn frame(self) -> ColorimetricFrameId { + self.frame + } +} + +/// Content-bound provenance of one deterministic modeled transform. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ModeledTristimulusProvenanceV1 { + source_signal: ColorSignal, + binding: AdmittedSrgb8TristimulusBindingV1, +} + +impl ModeledTristimulusProvenanceV1 { + pub(crate) const fn source_signal(self) -> ColorSignal { + self.source_signal + } + + pub(crate) const fn binding(self) -> AdmittedSrgb8TristimulusBindingV1 { + self.binding + } +} + +/// Replayable ideal colorimetric derivation under one admitted binding. +/// +/// This is not a renderer capability, render observation, human observation or +/// certified field bound. It cannot by itself satisfy such predicates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ModeledTristimulusDerivationV1 { + sample: TristimulusSample, + provenance: ModeledTristimulusProvenanceV1, +} + +impl ModeledTristimulusDerivationV1 { + pub(crate) const fn sample(self) -> TristimulusSample { + self.sample + } + + pub(crate) const fn provenance(self) -> ModeledTristimulusProvenanceV1 { + self.provenance + } + + pub(crate) fn replay(self) -> Result { + derive_sample_with_binding(self.provenance.source_signal, self.provenance.binding) + } +} + +fn admitted_binding(output_profile: OutputProfileId) -> AdmittedSrgb8TristimulusBindingV1 { + match output_profile { + OutputProfileId::Iec61966Srgb8D65V1 => ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, + } +} + +fn derive_sample_with_binding( + signal: ColorSignal, + binding: AdmittedSrgb8TristimulusBindingV1, +) -> Result { + #[cfg(test)] + MODELED_TRISTIMULUS_DERIVATION_CALLS.with(|calls| calls.set(calls.get() + 1)); + let xyz = match ( + signal.output_profile(), + binding.signal_output_profile(), + binding.transform_release(), + ) { + ( + OutputProfileId::Iec61966Srgb8D65V1, + OutputProfileId::Iec61966Srgb8D65V1, + ColorimetricTransformReleaseId::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1, + ) => xyz_d65_from_srgb8_v1(signal.srgb8()), + }; + TristimulusSample::try_from_registered_xyz(xyz, binding.result_frame()) +} + +#[cfg(test)] +thread_local! { + /// Per-thread count of modeled signal-to-tristimulus kernel executions. + /// Counting below both initial derivation and replay keeps a projection + /// from hiding recomputation behind the replay API. + pub(crate) static MODELED_TRISTIMULUS_DERIVATION_CALLS: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} + +/// Derive one modeled tristimulus from an encoded sRGB8 point. +/// +/// Composition provenance remains the responsibility of the upstream +/// render/composition layer that produced the signal; renderer capability and +/// actual observations are deliberately outside this deterministic transform. +pub(crate) fn derive_modeled_tristimulus_v1( + signal: ColorSignal, +) -> Result { + let binding = admitted_binding(signal.output_profile()); + let sample = derive_sample_with_binding(signal, binding)?; + Ok(ModeledTristimulusDerivationV1 { + sample, + provenance: ModeledTristimulusProvenanceV1 { + source_signal: signal, + binding, + }, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AppearanceContextSchemaReleaseId { + Ciecam16ViewingInputsV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SurroundProfileId { + AverageV1, + DimV1, + DarkV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AppearanceContextFieldV1 { + AdaptingLuminanceCdM2, + BackgroundLuminanceRatio, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AppearanceContextDomainErrorV1 { + field: AppearanceContextFieldV1, + reason: NumericDomainError, +} + +impl AppearanceContextDomainErrorV1 { + pub(crate) const fn field(self) -> AppearanceContextFieldV1 { + self.field + } + + pub(crate) const fn reason(self) -> NumericDomainError { + self.reason + } +} + +/// Finite, strictly positive CIECAM16 adapting luminance in cd/m². +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AdaptingLuminanceCdM2(FiniteNonNegative); + +impl AdaptingLuminanceCdM2 { + pub(crate) fn try_new(value: f64) -> Result { + let field = AppearanceContextFieldV1::AdaptingLuminanceCdM2; + let value = FiniteNonNegative::new(value) + .map_err(|reason| AppearanceContextDomainErrorV1 { field, reason })?; + if value.get() == 0.0 { + return Err(AppearanceContextDomainErrorV1 { + field, + reason: NumericDomainError::NotPositive, + }); + } + Ok(Self(value)) + } + + pub(crate) fn get(self) -> f64 { + self.0.get() + } +} + +/// Finite CIECAM16 background ratio `Y_b / Y_w` in `(0, 1]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BackgroundLuminanceRatio(FiniteNonNegative); + +impl BackgroundLuminanceRatio { + pub(crate) fn try_new(value: f64) -> Result { + let field = AppearanceContextFieldV1::BackgroundLuminanceRatio; + let value = FiniteNonNegative::new(value) + .map_err(|reason| AppearanceContextDomainErrorV1 { field, reason })?; + if value.get() == 0.0 { + return Err(AppearanceContextDomainErrorV1 { + field, + reason: NumericDomainError::NotPositive, + }); + } + if value.get() > 1.0 { + return Err(AppearanceContextDomainErrorV1 { + field, + reason: NumericDomainError::AboveOne, + }); + } + Ok(Self(value)) + } + + pub(crate) fn get(self) -> f64 { + self.0.get() + } +} + +/// Content identity of immutable semantic viewing inputs. +/// +/// Derived CAM constants are intentionally absent and must remain a private +/// cache of the appearance-view implementation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AppearanceContextId { + schema_release: AppearanceContextSchemaReleaseId, + frame: ColorimetricFrameId, + adapting_luminance_cd_m2: AdaptingLuminanceCdM2, + background_luminance_ratio: BackgroundLuminanceRatio, + surround: SurroundProfileId, +} + +impl AppearanceContextId { + pub(crate) const fn from_inputs( + schema_release: AppearanceContextSchemaReleaseId, + frame: ColorimetricFrameId, + adapting_luminance_cd_m2: AdaptingLuminanceCdM2, + background_luminance_ratio: BackgroundLuminanceRatio, + surround: SurroundProfileId, + ) -> Self { + Self { + schema_release, + frame, + adapting_luminance_cd_m2, + background_luminance_ratio, + surround, + } + } + + pub(crate) const fn schema_release(self) -> AppearanceContextSchemaReleaseId { + self.schema_release + } + + pub(crate) const fn frame(self) -> ColorimetricFrameId { + self.frame + } + + pub(crate) fn adapting_luminance_cd_m2(self) -> f64 { + self.adapting_luminance_cd_m2.get() + } + + pub(crate) fn background_luminance_ratio(self) -> f64 { + self.background_luminance_ratio.get() + } + + pub(crate) const fn surround_profile(self) -> SurroundProfileId { + self.surround + } +} + +/// A finite angle; absence of hue is represented by [`HueState`], never `0°`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct HueAngle(u64); + +impl HueAngle { + pub(crate) fn new(degrees: f64) -> Result { + if !degrees.is_finite() { + return Err(NumericDomainError::NonFinite); + } + if !(0.0..360.0).contains(°rees) { + return Err(NumericDomainError::HueOutOfRange); + } + Ok(Self(if degrees == 0.0 { + 0.0_f64.to_bits() + } else { + degrees.to_bits() + })) + } + + pub(crate) fn degrees(self) -> f64 { + f64::from_bits(self.0) + } +} + +/// Opaque identity of an admitted powerless-hue rule. +/// +/// It has no constructor until a concrete named-view release is registered. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct HuePowerlessProfileId(u32); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum HueState { + Defined(HueAngle), + UndefinedExact, + PowerlessBy(HuePowerlessProfileId), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OccurrenceFormationError { + FrameMismatch { + stimulus: ColorimetricFrameId, + context: ColorimetricFrameId, + }, +} + +/// LCS identity: one tristimulus sample bound to one immutable context. +/// +/// Whether the sample is modeled, renderer-observed or measured belongs to its +/// external provenance; forming this identity does not upgrade that claim. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LcsOccurrence { + sample: TristimulusSample, + context: AppearanceContextId, +} + +impl LcsOccurrence { + /// Bind one sample to one context with an exactly matching frame. + /// + /// Named appearance views are derived later from this pair. No view + /// coordinate is accepted here, so contradictory cached views cannot become + /// part of occurrence identity. + pub(crate) fn in_context( + sample: TristimulusSample, + context: AppearanceContextId, + ) -> Result { + if sample.frame() != context.frame() { + return Err(OccurrenceFormationError::FrameMismatch { + stimulus: sample.frame(), + context: context.frame(), + }); + } + Ok(Self { sample, context }) + } + + pub(crate) const fn sample(self) -> TristimulusSample { + self.sample + } + + pub(crate) const fn context(self) -> AppearanceContextId { + self.context + } +} + +/// Failure while binding replayable modeled provenance to one context-bound +/// occurrence. Every mismatch is explicit; no convenient encoded signal may be +/// attached to unrelated XYZ/context identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModeledLcsOccurrenceFormationErrorV1 { + Tristimulus(TristimulusDomainErrorV1), + Formation(OccurrenceFormationError), + ProvenanceReplayFailed(TristimulusDomainErrorV1), + RecordedSampleDoesNotReplay { + recorded: TristimulusSample, + replayed: TristimulusSample, + }, + OccurrenceSampleMismatch { + occurrence: TristimulusSample, + modeled: TristimulusSample, + }, +} + +/// One replayable modeled signal derivation bound to exactly one immutable +/// appearance context. +/// +/// The provenance is retained beside the LCS identity: a bare XYZ triple or a +/// derived appearance coordinate can never impersonate the encoded signal +/// which produced this occurrence. This value is still a deterministic model, +/// not evidence that a renderer or observer produced the stimulus. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ModeledLcsOccurrenceV1 { + derivation: ModeledTristimulusDerivationV1, + occurrence: LcsOccurrence, +} + +impl ModeledLcsOccurrenceV1 { + pub(crate) fn from_signal_in_context( + signal: ColorSignal, + context: AppearanceContextId, + ) -> Result { + let derivation = derive_modeled_tristimulus_v1(signal) + .map_err(ModeledLcsOccurrenceFormationErrorV1::Tristimulus)?; + let occurrence = LcsOccurrence::in_context(derivation.sample(), context) + .map_err(ModeledLcsOccurrenceFormationErrorV1::Formation)?; + // Both values are formed in this function from the same admitted + // sample. Replaying the transform here would derive sRGB -> XYZ twice + // on the Program hot path; replay remains mandatory for `bind`, whose + // two pre-existing inputs may be unrelated. + Ok(Self { + derivation, + occurrence, + }) + } + + pub(crate) fn bind( + occurrence: LcsOccurrence, + derivation: ModeledTristimulusDerivationV1, + ) -> Result { + let modeled = Self { + derivation, + occurrence, + }; + modeled.verify()?; + Ok(modeled) + } + + pub(crate) fn verify(self) -> Result<(), ModeledLcsOccurrenceFormationErrorV1> { + let replayed = self + .derivation + .replay() + .map_err(ModeledLcsOccurrenceFormationErrorV1::ProvenanceReplayFailed)?; + let recorded = self.derivation.sample(); + if replayed != recorded { + return Err( + ModeledLcsOccurrenceFormationErrorV1::RecordedSampleDoesNotReplay { + recorded, + replayed, + }, + ); + } + let occurrence = self.occurrence.sample(); + if occurrence != recorded { + return Err( + ModeledLcsOccurrenceFormationErrorV1::OccurrenceSampleMismatch { + occurrence, + modeled: recorded, + }, + ); + } + Ok(()) + } + + pub(crate) const fn derivation(self) -> ModeledTristimulusDerivationV1 { + self.derivation + } + + pub(crate) const fn occurrence(self) -> LcsOccurrence { + self.occurrence + } + + pub(crate) const fn provenance(self) -> ModeledTristimulusProvenanceV1 { + self.derivation.provenance() + } + + pub(crate) const fn signal(self) -> ColorSignal { + self.provenance().source_signal() + } +} + +/// Formula and operation-order release of the rectangular Oklab view. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum OklabViewReleaseId { + Ottosson20210125XyzD65V1, +} + +/// Formula and operation-order release of the context-dependent CAM16 view. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Cam16ViewReleaseId { + LiEtAl2017Cie248ForwardV1, +} + +/// Formula and operation-order release of the rectangular CAM16-UCS view. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Cam16UcsViewReleaseId { + LiEtAl2017Cam16UcsV1, +} + +/// Typed release discriminator used only to qualify derivation errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AppearanceViewReleaseIdV1 { + Oklab(OklabViewReleaseId), + Cam16(Cam16ViewReleaseId), + Cam16Ucs(Cam16UcsViewReleaseId), +} + +pub(crate) const OKLAB_VIEW_RELEASE_V1: OklabViewReleaseId = + OklabViewReleaseId::Ottosson20210125XyzD65V1; +pub(crate) const CAM16_VIEW_RELEASE_V1: Cam16ViewReleaseId = + Cam16ViewReleaseId::LiEtAl2017Cie248ForwardV1; +pub(crate) const CAM16_UCS_VIEW_RELEASE_V1: Cam16UcsViewReleaseId = + Cam16UcsViewReleaseId::LiEtAl2017Cam16UcsV1; + +/// Finite binary64 coordinate with canonical positive zero. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct FiniteCoordinate(u64); + +impl FiniteCoordinate { + fn new(value: f64) -> Result { + if !value.is_finite() { + return Err(NumericDomainError::NonFinite); + } + Ok(Self(if value == 0.0 { + 0.0_f64.to_bits() + } else { + value.to_bits() + })) + } + + fn get(self) -> f64 { + f64::from_bits(self.0) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AppearanceViewFieldV1 { + OklabL, + OklabA, + OklabB, + Cam16J, + Cam16Q, + Cam16C, + Cam16M, + Cam16S, + Cam16Hue, + Cam16UcsJPrime, + Cam16UcsMPrimeIntermediate, + Cam16UcsAPrime, + Cam16UcsBPrime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppearanceStateDerivationErrorV1 { + UnsupportedFrame { + frame: ColorimetricFrameId, + }, + NumericDomain { + release: AppearanceViewReleaseIdV1, + field: AppearanceViewFieldV1, + reason: NumericDomainError, + }, + InconsistentDerivedHueState { + release: Cam16UcsViewReleaseId, + hue: HueState, + m_prime_bits: u64, + }, +} + +/// Rectangular Oklab geometry of one admitted XYZ(D65) stimulus. +/// +/// It deliberately has no hue, context-dependent correlate, inverse or setter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct OklabViewV1 { + release: OklabViewReleaseId, + l: FiniteCoordinate, + a: FiniteCoordinate, + b: FiniteCoordinate, +} + +impl OklabViewV1 { + pub(crate) const fn release(self) -> OklabViewReleaseId { + self.release + } + + pub(crate) fn l(self) -> f64 { + self.l.get() + } + + pub(crate) fn a(self) -> f64 { + self.a.get() + } + + pub(crate) fn b(self) -> f64 { + self.b.get() + } +} + +/// CAM16 appearance correlates of one occurrence under its own context. +/// +/// This view is not CAM16-UCS, a difference calibration or a rendering claim. +/// Its hue is the CAM16 angular correlate only; no Oklab direction enters it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Cam16ViewV1 { + release: Cam16ViewReleaseId, + j: FiniteNonNegative, + q: FiniteNonNegative, + c: FiniteNonNegative, + m: FiniteNonNegative, + s: FiniteNonNegative, + hue: HueState, +} + +impl Cam16ViewV1 { + pub(crate) const fn release(self) -> Cam16ViewReleaseId { + self.release + } + + pub(crate) fn j(self) -> f64 { + self.j.get() + } + + pub(crate) fn q(self) -> f64 { + self.q.get() + } + + pub(crate) fn c(self) -> f64 { + self.c.get() + } + + pub(crate) fn m(self) -> f64 { + self.m.get() + } + + pub(crate) fn s(self) -> f64 { + self.s.get() + } + + pub(crate) const fn hue(self) -> HueState { + self.hue + } +} + +/// Rectangular CAM16-UCS coordinates derived from one admitted CAM16 view. +/// +/// This is a coordinate view of one occurrence, not a pairwise difference +/// calibration, universal perceptual scale, editable colour or inverse route. +/// Its polar magnitude is an intermediate only; the stored coordinates are the +/// published rectangular `(J', a', b')` form. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Cam16UcsViewV1 { + release: Cam16UcsViewReleaseId, + j_prime: FiniteNonNegative, + a_prime: FiniteCoordinate, + b_prime: FiniteCoordinate, +} + +impl Cam16UcsViewV1 { + pub(crate) const fn release(self) -> Cam16UcsViewReleaseId { + self.release + } + + pub(crate) fn j_prime(self) -> f64 { + self.j_prime.get() + } + + pub(crate) fn a_prime(self) -> f64 { + self.a_prime.get() + } + + pub(crate) fn b_prime(self) -> f64 { + self.b_prime.get() + } +} + +/// One-way, derived appearance snapshot of exactly one occurrence. +/// +/// Canonical LCS identity remains [`LcsOccurrence`] (`sample × context`). This +/// type is only a deterministic cache of separately named views and cannot be +/// constructed from, edited through or inverted from view coordinates. +#[derive(Debug, Clone, Copy)] +pub struct AppearanceState { + occurrence: LcsOccurrence, + oklab: OklabViewV1, + cam16: Cam16ViewV1, +} + +impl AppearanceState { + pub(crate) fn derive_v1( + occurrence: LcsOccurrence, + ) -> Result { + if occurrence.sample().frame() != IEC_SRGB_D65_XYZ_FRAME_V1 { + return Err(AppearanceStateDerivationErrorV1::UnsupportedFrame { + frame: occurrence.sample().frame(), + }); + } + + let oklab = derive_oklab_view_v1(occurrence.sample().xyz())?; + let cam16 = derive_cam16_view_v1(occurrence)?; + Ok(Self { + occurrence, + oklab, + cam16, + }) + } + + pub(crate) const fn occurrence(self) -> LcsOccurrence { + self.occurrence + } + + pub(crate) const fn oklab(self) -> OklabViewV1 { + self.oklab + } + + pub(crate) const fn cam16(self) -> Cam16ViewV1 { + self.cam16 + } + + /// Derive the optional-cost rectangular CAM16-UCS view from this state's + /// already admitted CAM16 coordinates. + /// + /// Keeping the rescale lazy avoids an `ln` plus polar-to-rectangular + /// trigonometry for evaluators which request only Oklab or CAM16. + pub(crate) fn cam16_ucs(self) -> Result { + derive_cam16_ucs_view_v1(self.cam16) + } +} + +fn view_numeric_error( + release: AppearanceViewReleaseIdV1, + field: AppearanceViewFieldV1, + reason: NumericDomainError, +) -> AppearanceStateDerivationErrorV1 { + AppearanceStateDerivationErrorV1::NumericDomain { + release, + field, + reason, + } +} + +pub(crate) fn derive_oklab_view_v1( + xyz: [f64; 3], +) -> Result { + let [l, a, b] = xyz_d65_to_oklab_v1(xyz); + let release = AppearanceViewReleaseIdV1::Oklab(OKLAB_VIEW_RELEASE_V1); + Ok(OklabViewV1 { + release: OKLAB_VIEW_RELEASE_V1, + l: FiniteCoordinate::new(l) + .map_err(|reason| view_numeric_error(release, AppearanceViewFieldV1::OklabL, reason))?, + a: FiniteCoordinate::new(a) + .map_err(|reason| view_numeric_error(release, AppearanceViewFieldV1::OklabA, reason))?, + b: FiniteCoordinate::new(b) + .map_err(|reason| view_numeric_error(release, AppearanceViewFieldV1::OklabB, reason))?, + }) +} + +fn derive_cam16_view_v1( + occurrence: LcsOccurrence, +) -> Result { + let context = occurrence.context(); + let surround = match context.surround_profile() { + SurroundProfileId::AverageV1 => Cam16SurroundV1::Average, + SurroundProfileId::DimV1 => Cam16SurroundV1::Dim, + SurroundProfileId::DarkV1 => Cam16SurroundV1::Dark, + }; + let vc = match context.schema_release() { + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1 => { + ViewingConditions::from_semantic_inputs_v1( + context.adapting_luminance_cd_m2(), + context.background_luminance_ratio(), + surround, + ) + } + }; + let coordinates = forward_correlates_v1(occurrence.sample().xyz(), &vc); + let release = AppearanceViewReleaseIdV1::Cam16(CAM16_VIEW_RELEASE_V1); + let admit = |value, field| { + FiniteNonNegative::new(value).map_err(|reason| view_numeric_error(release, field, reason)) + }; + let j = admit(coordinates.j, AppearanceViewFieldV1::Cam16J)?; + let q = admit(coordinates.q, AppearanceViewFieldV1::Cam16Q)?; + let c = admit(coordinates.c, AppearanceViewFieldV1::Cam16C)?; + let m = admit(coordinates.m, AppearanceViewFieldV1::Cam16M)?; + let s = admit(coordinates.s, AppearanceViewFieldV1::Cam16S)?; + let hue = if m.get() == 0.0 { + HueState::UndefinedExact + } else { + HueState::Defined(HueAngle::new(coordinates.h).map_err(|reason| { + view_numeric_error(release, AppearanceViewFieldV1::Cam16Hue, reason) + })?) + }; + Ok(Cam16ViewV1 { + release: CAM16_VIEW_RELEASE_V1, + j, + q, + c, + m, + s, + hue, + }) +} + +fn derive_cam16_ucs_view_v1( + cam16: Cam16ViewV1, +) -> Result { + let release = AppearanceViewReleaseIdV1::Cam16Ucs(CAM16_UCS_VIEW_RELEASE_V1); + let j_prime = FiniteNonNegative::new(ucs_j(cam16.j())).map_err(|reason| { + view_numeric_error(release, AppearanceViewFieldV1::Cam16UcsJPrime, reason) + })?; + let m_prime = FiniteNonNegative::new(ucs_m(cam16.m())).map_err(|reason| { + view_numeric_error( + release, + AppearanceViewFieldV1::Cam16UcsMPrimeIntermediate, + reason, + ) + })?; + + let [a_prime, b_prime] = if m_prime.get() == 0.0 { + // Rectangular zero has no angular dependency. This branch also keeps + // both coordinates canonical positive zero for exact black. + [0.0, 0.0] + } else { + let HueState::Defined(hue) = cam16.hue() else { + return Err( + AppearanceStateDerivationErrorV1::InconsistentDerivedHueState { + release: CAM16_UCS_VIEW_RELEASE_V1, + hue: cam16.hue(), + m_prime_bits: m_prime.get().to_bits(), + }, + ); + }; + let radians = hue.degrees().to_radians(); + [m_prime.get() * radians.cos(), m_prime.get() * radians.sin()] + }; + + Ok(Cam16UcsViewV1 { + release: CAM16_UCS_VIEW_RELEASE_V1, + j_prime, + a_prime: FiniteCoordinate::new(a_prime).map_err(|reason| { + view_numeric_error(release, AppearanceViewFieldV1::Cam16UcsAPrime, reason) + })?, + b_prime: FiniteCoordinate::new(b_prime).map_err(|reason| { + view_numeric_error(release, AppearanceViewFieldV1::Cam16UcsBPrime, reason) + })?, + }) +} diff --git a/crates/labcolors-core/src/lcs_occurrence_tests.rs b/crates/labcolors-core/src/lcs_occurrence_tests.rs new file mode 100644 index 00000000..d51ab4e4 --- /dev/null +++ b/crates/labcolors-core/src/lcs_occurrence_tests.rs @@ -0,0 +1,788 @@ +use proptest::prelude::*; + +use crate::Srgb8; +use crate::lcs_occurrence::{ + ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, AdaptingLuminanceCdM2, AppearanceContextFieldV1, + AppearanceContextId, AppearanceContextSchemaReleaseId, AppearanceState, + AppearanceStateDerivationErrorV1, AppearanceViewFieldV1, AppearanceViewReleaseIdV1, + BackgroundLuminanceRatio, CAM16_UCS_VIEW_RELEASE_V1, CAM16_VIEW_RELEASE_V1, ColorSignal, + ColorimetricFrameId, ColorimetricFrameReleaseId, ColorimetricTransformReleaseId, HueAngle, + HueState, IEC_SRGB_D65_XYZ_FRAME_V1, LcsOccurrence, MUTATION_SENTINEL_XYZ_FRAME_V1, + ModeledTristimulusDerivationV1, NumericDomainError, OKLAB_VIEW_RELEASE_V1, ObserverProfileId, + OccurrenceFormationError, ReferenceWhiteId, SurroundProfileId, TristimulusComponentV1, + TristimulusDomainErrorV1, TristimulusSample, TristimulusScale, derive_modeled_tristimulus_v1, +}; +use crate::spaces::cam16::{ForwardCacheGuard, forward, forward_correlates_v1, ucs_j, ucs_m}; +use crate::spaces::oklab::{srgb_linear_to_oklab, xyz_d65_to_oklab_v1}; +use crate::spaces::srgb::{D65_WHITE, srgb_linear_from_srgb8, srgb_to_xyz}; +use crate::spaces::vc::ViewingConditions; + +fn context(frame: ColorimetricFrameId, la: f64) -> AppearanceContextId { + context_with_surround(frame, la, SurroundProfileId::AverageV1) +} + +fn context_with_surround( + frame: ColorimetricFrameId, + la: f64, + surround: SurroundProfileId, +) -> AppearanceContextId { + AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + frame, + AdaptingLuminanceCdM2::try_new(la).unwrap(), + BackgroundLuminanceRatio::try_new(0.2).unwrap(), + surround, + ) +} + +fn occurrence_from_srgb8(bytes: [u8; 3], context: AppearanceContextId) -> LcsOccurrence { + let sample = derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new(bytes))) + .unwrap() + .sample(); + LcsOccurrence::in_context(sample, context).unwrap() +} + +#[test] +fn xyz_and_context_numeric_admission_is_fail_closed() { + let relative = IEC_SRGB_D65_XYZ_FRAME_V1; + for xyz in [ + [f64::NAN, 0.0, 0.0], + [0.0, f64::INFINITY, 0.0], + [0.0, 0.0, -f64::MIN_POSITIVE], + ] { + assert!(TristimulusSample::try_from_xyz_for_test(xyz, relative).is_err()); + } + + let zero_la = AdaptingLuminanceCdM2::try_new(0.0).unwrap_err(); + assert_eq!( + zero_la.field(), + AppearanceContextFieldV1::AdaptingLuminanceCdM2 + ); + assert_eq!(zero_la.reason(), NumericDomainError::NotPositive); + + let zero_background = BackgroundLuminanceRatio::try_new(0.0).unwrap_err(); + assert_eq!( + zero_background.field(), + AppearanceContextFieldV1::BackgroundLuminanceRatio, + ); + assert_eq!(zero_background.reason(), NumericDomainError::NotPositive); + + let high_background = BackgroundLuminanceRatio::try_new(1.01).unwrap_err(); + assert_eq!( + high_background.field(), + AppearanceContextFieldV1::BackgroundLuminanceRatio, + ); + assert_eq!(high_background.reason(), NumericDomainError::AboveOne); + + for invalid in [f64::NAN, f64::INFINITY, -f64::MIN_POSITIVE, -0.0] { + assert_eq!( + AdaptingLuminanceCdM2::try_new(invalid).unwrap_err().field(), + AppearanceContextFieldV1::AdaptingLuminanceCdM2, + ); + assert_eq!( + BackgroundLuminanceRatio::try_new(invalid) + .unwrap_err() + .field(), + AppearanceContextFieldV1::BackgroundLuminanceRatio, + ); + } +} + +#[test] +fn hue_algebra_cannot_encode_exact_absence_as_zero_degrees() { + assert_ne!( + HueState::UndefinedExact, + HueState::Defined(HueAngle::new(0.0).unwrap()) + ); +} + +#[test] +fn frame_mismatch_cannot_form_an_occurrence() { + let relative = IEC_SRGB_D65_XYZ_FRAME_V1; + let mismatching = MUTATION_SENTINEL_XYZ_FRAME_V1; + let sample = + derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new([0x44, 0x88, 0xCC]))) + .unwrap() + .sample(); + assert_eq!(sample.frame(), relative); + assert_eq!( + LcsOccurrence::in_context(sample, context(mismatching, 64.0)), + Err(OccurrenceFormationError::FrameMismatch { + stimulus: relative, + context: mismatching, + }) + ); +} + +#[test] +fn occurrence_identity_contains_only_sample_and_context() { + let relative = IEC_SRGB_D65_XYZ_FRAME_V1; + let sample = TristimulusSample::try_from_xyz_for_test([0.1, 0.2, 0.3], relative).unwrap(); + let occurrence = LcsOccurrence::in_context(sample, context(relative, 64.0)).unwrap(); + assert_eq!(occurrence.sample(), sample); + assert_eq!(occurrence.context(), context(relative, 64.0)); +} + +#[test] +fn context_identity_changes_with_semantic_input_bits() { + let relative = IEC_SRGB_D65_XYZ_FRAME_V1; + assert_ne!(context(relative, 64.0), context(relative, 32.0)); + + let changed_background = AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + relative, + AdaptingLuminanceCdM2::try_new(64.0).unwrap(), + BackgroundLuminanceRatio::try_new(0.3).unwrap(), + SurroundProfileId::AverageV1, + ); + let changed_surround = AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + relative, + AdaptingLuminanceCdM2::try_new(64.0).unwrap(), + BackgroundLuminanceRatio::try_new(0.2).unwrap(), + SurroundProfileId::DimV1, + ); + assert_ne!(context(relative, 64.0), changed_background); + assert_ne!(context(relative, 64.0), changed_surround); +} + +#[test] +fn hue_numeric_constructor_rejects_nonfinite_and_out_of_domain() { + for invalid in [f64::NAN, f64::INFINITY, -0.01, 360.0] { + assert!(HueAngle::new(invalid).is_err()); + } + assert_eq!(HueAngle::new(-0.0).unwrap(), HueAngle::new(0.0).unwrap()); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn admitted_srgb8_lowering_preserves_existing_decode_and_matrix_bits( + bytes in any::<[u8; 3]>(), + ) { + let signal = ColorSignal::from_srgb8(Srgb8::new(bytes)); + let derived = derive_modeled_tristimulus_v1(signal).unwrap(); + let expected = srgb_to_xyz(srgb_linear_from_srgb8(Srgb8::new(bytes))); + + prop_assert_eq!( + derived.sample().xyz().map(f64::to_bits), + expected.map(f64::to_bits), + ); + prop_assert_eq!(derived.sample().frame(), IEC_SRGB_D65_XYZ_FRAME_V1); + } + + #[test] + fn direct_xyz_oklab_projection_tracks_the_existing_srgb_kernel_without_routing_through_it( + bytes in any::<[u8; 3]>(), + ) { + let linear = srgb_linear_from_srgb8(Srgb8::new(bytes)); + let legacy_srgb_projection = srgb_linear_to_oklab(linear); + let direct_xyz_projection = xyz_d65_to_oklab_v1(srgb_to_xyz(linear)); + + for component in 0..3 { + prop_assert!( + (direct_xyz_projection[component] - legacy_srgb_projection[component]).abs() + <= 2.0e-8, + "component {component}: direct={} existing={}", + direct_xyz_projection[component], + legacy_srgb_projection[component], + ); + } + } +} + +#[test] +fn f0_lowering_signature_accepts_only_one_profiled_signal() { + let lower: fn(ColorSignal) -> Result = + derive_modeled_tristimulus_v1; + assert!(lower(ColorSignal::from_srgb8(Srgb8::new([0; 3]))).is_ok()); +} + +#[test] +fn fixed_corpus_including_eotf_boundary_matches_existing_kernel_bits() { + for bytes in [ + [0x00, 0x00, 0x00], + [0xFF, 0xFF, 0xFF], + [0xFF, 0x00, 0x00], + [0x00, 0xFF, 0x00], + [0x00, 0x00, 0xFF], + [0x0A, 0x0B, 0x80], + [0x44, 0x88, 0xCC], + ] { + let actual = derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new(bytes))) + .unwrap() + .sample() + .xyz(); + let expected = srgb_to_xyz(srgb_linear_from_srgb8(Srgb8::new(bytes))); + assert_eq!(actual.map(f64::to_bits), expected.map(f64::to_bits)); + } +} + +#[test] +fn transform_release_v1_pins_a_pre_f0_binary64_vector() { + // Recorded once from the pre-F0 decode-table + matrix operation order. This + // is a release anti-drift vector, not a claim of measured or bounded + // colorimetric evidence. + let xyz = + derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new([0x0A, 0x0B, 0x80]))) + .unwrap() + .sample() + .xyz(); + assert_eq!( + xyz.map(f64::to_bits), + [ + 0x3FA5_334E_5BB6_D38E, + 0x3F93_11B4_45B1_935D, + 0x3FCA_5268_973F_D7F0, + ], + ); +} + +#[test] +fn every_srgb8_channel_code_has_finite_nonnegative_basis_contribution() { + // The registered EOTF acts independently on each byte and the XYZ matrix is + // a sum of three non-negative basis contributions. Exhausting 3 × 256 basis + // inputs therefore covers the finite/non-negative invariant for every mixed + // triplet without adding a 256³ debug-test loop; the property test above + // separately exercises mixed-sum routing and exact operation order. + for byte in u8::MIN..=u8::MAX { + for channel in 0..3 { + let mut bytes = [0_u8; 3]; + bytes[channel] = byte; + let xyz = derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new(bytes))) + .unwrap() + .sample() + .xyz(); + assert!( + xyz.into_iter() + .all(|component| component.is_finite() && component >= 0.0) + ); + } + } +} + +#[test] +fn admitted_binding_is_one_closed_profile_transform_frame_tuple() { + let binding = ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1; + assert_eq!( + binding.signal_output_profile(), + crate::lcs_occurrence::OutputProfileId::Iec61966Srgb8D65V1, + ); + assert_eq!( + binding.transform_release(), + ColorimetricTransformReleaseId::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1, + ); + assert_eq!(binding.result_frame(), IEC_SRGB_D65_XYZ_FRAME_V1); + assert_eq!( + binding.result_frame().observer(), + ObserverProfileId::Cie1931TwoDegreeV1, + ); + assert_eq!( + binding.result_frame().reference_white(), + ReferenceWhiteId::Iec61966D65ChromaticityV1, + ); + assert_eq!(binding.result_frame().scale(), TristimulusScale::RelativeY1,); + assert_eq!( + binding.result_frame().release(), + ColorimetricFrameReleaseId::XyzV1, + ); +} + +#[test] +fn black_is_positive_zero_and_white_tracks_the_existing_matrix() { + let black = derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new([0; 3]))) + .unwrap() + .sample() + .xyz(); + assert_eq!(black.map(f64::to_bits), [0_u64; 3]); + + let white = derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new([255; 3]))) + .unwrap() + .sample() + .xyz(); + let matrix_white = srgb_to_xyz([1.0; 3]); + assert_eq!(white.map(f64::to_bits), matrix_white.map(f64::to_bits)); + for (actual, reference) in white.into_iter().zip(D65_WHITE) { + assert!((actual - reference).abs() <= f64::EPSILON); + } +} + +#[test] +fn modeled_derivation_is_content_bound_replayable_and_allocation_free() { + let signal = ColorSignal::from_srgb8(Srgb8::new([0x0A, 0x0B, 0x80])); + let (derived, lowering_allocations) = + crate::test_support::measured_allocations(|| derive_modeled_tristimulus_v1(signal)); + let derived = derived.unwrap(); + let (replayed, replay_allocations) = + crate::test_support::measured_allocations(|| derived.replay()); + + assert_eq!(lowering_allocations, 0); + assert_eq!(replay_allocations, 0); + assert_eq!(replayed.unwrap(), derived.sample()); + assert_eq!(derived.provenance().source_signal(), signal); + assert_eq!( + derived.provenance().binding(), + ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, + ); + + let changed_signal = ColorSignal::from_srgb8(Srgb8::new([0x0B, 0x0B, 0x80])); + let changed = derive_modeled_tristimulus_v1(changed_signal).unwrap(); + assert_ne!(derived.provenance(), changed.provenance()); + assert_ne!(derived.sample(), changed.sample()); + assert_eq!(derived.provenance().source_signal(), signal); +} + +#[test] +fn raw_xyz_admission_is_test_only_component_qualified_and_fail_closed() { + let frame = IEC_SRGB_D65_XYZ_FRAME_V1; + for (xyz, component, reason) in [ + ( + [f64::NAN, 0.0, 0.0], + TristimulusComponentV1::X, + NumericDomainError::NonFinite, + ), + ( + [0.0, f64::INFINITY, 0.0], + TristimulusComponentV1::Y, + NumericDomainError::NonFinite, + ), + ( + [0.0, 0.0, -f64::MIN_POSITIVE], + TristimulusComponentV1::Z, + NumericDomainError::Negative, + ), + ] { + let error = TristimulusSample::try_from_xyz_for_test(xyz, frame).unwrap_err(); + assert_eq!(error.component(), component); + assert_eq!(error.reason(), reason); + } + + let admitted = TristimulusSample::try_from_xyz_for_test([-0.0, 0.5, 1.25], frame).unwrap(); + assert_eq!(admitted.xyz()[0].to_bits(), 0.0_f64.to_bits()); + assert_eq!(admitted.xyz()[2], 1.25); +} + +#[test] +fn appearance_context_identity_exposes_only_semantic_inputs() { + let context = context(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0); + assert_eq!( + context.schema_release(), + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + ); + assert_eq!(context.frame(), IEC_SRGB_D65_XYZ_FRAME_V1); + assert_eq!(context.adapting_luminance_cd_m2(), 64.0); + assert_eq!(context.background_luminance_ratio(), 0.2); + assert_eq!(context.surround_profile(), SurroundProfileId::AverageV1); +} + +#[test] +fn appearance_state_is_one_way_views_of_the_same_occurrence_with_separate_releases() { + let occurrence = + occurrence_from_srgb8([0x00, 0x00, 0xFF], context(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0)); + let state = AppearanceState::derive_v1(occurrence).unwrap(); + + assert_eq!(state.occurrence(), occurrence); + assert_eq!(state.oklab().release(), OKLAB_VIEW_RELEASE_V1); + assert_eq!(state.cam16().release(), CAM16_VIEW_RELEASE_V1); + assert_eq!( + state.cam16_ucs().unwrap().release(), + CAM16_UCS_VIEW_RELEASE_V1, + ); + assert_eq!( + state.occurrence().sample().frame().observer(), + ObserverProfileId::Cie1931TwoDegreeV1, + ); + assert_eq!(state.occurrence().context(), occurrence.context()); + + let direct = xyz_d65_to_oklab_v1(occurrence.sample().xyz()); + assert_eq!( + [state.oklab().l(), state.oklab().a(), state.oklab().b()].map(f64::to_bits), + direct.map(f64::to_bits), + ); + + let expected_cam = forward_correlates_v1(occurrence.sample().xyz(), &ViewingConditions::srgb()); + let cam = state.cam16(); + assert_eq!(cam.j().to_bits(), expected_cam.j.to_bits()); + assert_eq!(cam.q().to_bits(), expected_cam.q.to_bits()); + assert_eq!(cam.c().to_bits(), expected_cam.c.to_bits()); + assert_eq!(cam.m().to_bits(), expected_cam.m.to_bits()); + assert_eq!(cam.s().to_bits(), expected_cam.s.to_bits()); + let HueState::Defined(cam_hue) = cam.hue() else { + panic!("chromatic blue must have a CAM16 hue"); + }; + assert_eq!(cam_hue.degrees().to_bits(), expected_cam.h.to_bits()); + + let oklab_hue = state.oklab().b().atan2(state.oklab().a()).to_degrees(); + let oklab_hue = if oklab_hue < 0.0 { + oklab_hue + 360.0 + } else { + oklab_hue + }; + assert!( + (cam_hue.degrees() - oklab_hue).abs() > 10.0, + "CAM16 hue must not be copied from Oklab: CAM16={} Oklab={oklab_hue}", + cam_hue.degrees(), + ); +} + +#[test] +fn context_changes_only_the_contextual_cam16_view() { + let sample = + derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new([0x44, 0x88, 0xCC]))) + .unwrap() + .sample(); + let average = AppearanceState::derive_v1( + LcsOccurrence::in_context( + sample, + context_with_surround( + IEC_SRGB_D65_XYZ_FRAME_V1, + 64.0, + SurroundProfileId::AverageV1, + ), + ) + .unwrap(), + ) + .unwrap(); + let dim = AppearanceState::derive_v1( + LcsOccurrence::in_context( + sample, + context_with_surround(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0, SurroundProfileId::DimV1), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(average.oklab(), dim.oklab()); + assert_ne!(average.cam16().j().to_bits(), dim.cam16().j().to_bits()); + assert_ne!(average.cam16().m().to_bits(), dim.cam16().m().to_bits()); + assert_ne!( + average.cam16_ucs().unwrap().j_prime().to_bits(), + dim.cam16_ucs().unwrap().j_prime().to_bits(), + ); + assert_ne!(average.cam16_ucs().unwrap(), dim.cam16_ucs().unwrap()); + assert_ne!(average.occurrence(), dim.occurrence()); +} + +#[test] +fn every_registered_surround_maps_to_its_exact_cam16_kernel_tuple() { + for (surround, vc) in [ + (SurroundProfileId::AverageV1, ViewingConditions::srgb()), + (SurroundProfileId::DimV1, ViewingConditions::dim_surround()), + ( + SurroundProfileId::DarkV1, + ViewingConditions::dark_surround(), + ), + ] { + let occurrence = occurrence_from_srgb8( + [0x44, 0x88, 0xCC], + context_with_surround(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0, surround), + ); + let actual = AppearanceState::derive_v1(occurrence).unwrap().cam16(); + let expected = forward_correlates_v1(occurrence.sample().xyz(), &vc); + assert_eq!( + [actual.j(), actual.q(), actual.c(), actual.m(), actual.s(),].map(f64::to_bits), + [expected.j, expected.q, expected.c, expected.m, expected.s,].map(f64::to_bits), + "surround {surround:?} must not mix registered tuple fields", + ); + let HueState::Defined(actual_hue) = actual.hue() else { + panic!("chromatic fixture must have hue under {surround:?}"); + }; + assert_eq!(actual_hue.degrees().to_bits(), expected.h.to_bits()); + + let actual_ucs = AppearanceState::derive_v1(occurrence) + .unwrap() + .cam16_ucs() + .unwrap(); + let expected_j_prime = ucs_j(expected.j); + let expected_m_prime = ucs_m(expected.m); + let expected_radians = expected.h.to_radians(); + assert_eq!( + [ + actual_ucs.j_prime(), + actual_ucs.a_prime(), + actual_ucs.b_prime(), + ] + .map(f64::to_bits), + [ + expected_j_prime, + expected_m_prime * expected_radians.cos(), + expected_m_prime * expected_radians.sin(), + ] + .map(f64::to_bits), + "CAM16-UCS must be assembled from the same CAM16 view under {surround:?}", + ); + } +} + +#[test] +fn exact_zero_coordinate_has_no_invented_hue() { + let state = AppearanceState::derive_v1(occurrence_from_srgb8( + [0; 3], + context(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0), + )) + .unwrap(); + + assert_eq!( + [state.oklab().l(), state.oklab().a(), state.oklab().b()].map(f64::to_bits), + [0; 3], + ); + assert_eq!(state.cam16().j().to_bits(), 0); + assert_eq!(state.cam16().q().to_bits(), 0); + assert_eq!(state.cam16().c().to_bits(), 0); + assert_eq!(state.cam16().m().to_bits(), 0); + assert_eq!(state.cam16().s().to_bits(), 0); + assert_eq!(state.cam16().hue(), HueState::UndefinedExact); + let ucs = state.cam16_ucs().unwrap(); + assert_eq!( + [ucs.j_prime(), ucs.a_prime(), ucs.b_prime()].map(f64::to_bits), + [0; 3], + ); +} + +#[test] +fn state_derivation_rejects_an_unregistered_frame_before_any_view_math() { + let frame = MUTATION_SENTINEL_XYZ_FRAME_V1; + let sample = TristimulusSample::try_from_xyz_for_test([0.1, 0.2, 0.3], frame).unwrap(); + let occurrence = LcsOccurrence::in_context(sample, context(frame, 64.0)).unwrap(); + + assert_eq!( + AppearanceState::derive_v1(occurrence).unwrap_err(), + AppearanceStateDerivationErrorV1::UnsupportedFrame { frame }, + ); +} + +#[test] +fn state_derivation_rejects_nonfinite_derived_coordinates_with_release_and_field() { + let frame = IEC_SRGB_D65_XYZ_FRAME_V1; + let sample = TristimulusSample::try_from_xyz_for_test([f64::MAX; 3], frame).unwrap(); + let occurrence = LcsOccurrence::in_context(sample, context(frame, 64.0)).unwrap(); + + assert_eq!( + AppearanceState::derive_v1(occurrence).unwrap_err(), + AppearanceStateDerivationErrorV1::NumericDomain { + release: AppearanceViewReleaseIdV1::Oklab(OKLAB_VIEW_RELEASE_V1), + field: AppearanceViewFieldV1::OklabL, + reason: NumericDomainError::NonFinite, + }, + ); +} + +#[test] +fn direct_oklab_release_pins_an_external_xyz_projection_vector() { + // Fixed vector from the CSS Color 4 direct XYZ(D65) -> Oklab matrices for + // the already-pinned `[0A, 0B, 80]` F0 tristimulus. Tolerance covers only + // cross-libm cbrt ULPs; it is far below the direct-vs-legacy matrix delta. + let state = AppearanceState::derive_v1(occurrence_from_srgb8( + [0x0A, 0x0B, 0x80], + context(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0), + )) + .unwrap(); + let actual = [state.oklab().l(), state.oklab().a(), state.oklab().b()]; + let expected = [ + 0.284_226_036_666_170_47, + -0.009_591_562_466_562_426, + -0.178_614_436_252_897_94, + ]; + for component in 0..3 { + assert!( + (actual[component] - expected[component]).abs() <= 1.0e-14, + "Oklab component {component} drifted: actual={} expected={}", + actual[component], + expected[component], + ); + } +} + +#[test] +fn cam16_release_pins_a_full_external_correlate_vector() { + // colour-science CIECAM16, D65, L_A=64 cd/m², Y_b/Y_w=0.2, average + // surround. Unlike the older J/M/h table this pins the newly exposed + // Q/C/s correlates as well. The tolerances cover only the documented CSS + // XYZ constant delta from colour-science's matrix derivation. + let state = AppearanceState::derive_v1(occurrence_from_srgb8( + [0x00, 0x00, 0xFF], + context(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0), + )) + .unwrap(); + let cam = state.cam16(); + let HueState::Defined(hue) = cam.hue() else { + panic!("reference blue must retain a CAM16 hue"); + }; + + for (name, actual, expected, tolerance) in [ + ("J", cam.j(), 25.271_208_691_856_113, 0.01), + ("Q", cam.q(), 109.108_232_192_767_33, 0.1), + ("C", cam.c(), 86.580_098_936_732_16, 0.1), + ("M", cam.m(), 78.737_310_637_269_06, 0.05), + ("s", cam.s(), 84.949_637_273_879, 0.1), + ("h", hue.degrees(), 282.871_080_928_130_14, 0.15), + ] { + assert!( + (actual - expected).abs() < tolerance, + "CAM16 {name} drifted: actual={actual} expected={expected}", + ); + } + + // The same independent reference vector projected through Li et al. 2017 + // CAM16-UCS. These are coordinates of this occurrence, not a distance claim. + let ucs = state.cam16_ucs().unwrap(); + for (name, actual, expected, tolerance) in [ + ("J'", ucs.j_prime(), 36.503_620_495_334_07, 0.02), + ("a'", ucs.a_prime(), 10.042_750_480_031_993, 0.1), + ("b'", ucs.b_prime(), -43.950_878_225_240_46, 0.1), + ] { + assert!( + (actual - expected).abs() < tolerance, + "CAM16-UCS {name} drifted: actual={actual} expected={expected}", + ); + } +} + +#[test] +fn full_appearance_state_derivation_is_allocation_free() { + let occurrence = + occurrence_from_srgb8([0x44, 0x88, 0xCC], context(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0)); + let (derived, allocations) = + crate::test_support::measured_allocations(|| AppearanceState::derive_v1(occurrence)); + + assert_eq!(allocations, 0); + let derived = derived.unwrap(); + let (ucs, ucs_allocations) = crate::test_support::measured_allocations(|| derived.cam16_ucs()); + assert_eq!(ucs_allocations, 0); + assert!(ucs.is_ok()); +} + +#[test] +fn appearance_state_bypasses_active_xyz_only_cache_for_each_context_without_allocating() { + let frame = IEC_SRGB_D65_XYZ_FRAME_V1; + let sample = + derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new([0x44, 0x88, 0xCC]))) + .unwrap() + .sample(); + let xyz = sample.xyz(); + + // Freeze the per-context cache-free answers before activating the legacy + // XYZ-only cache. These are independent of its ambient guard state. + let expected_dim = forward_correlates_v1(xyz, &ViewingConditions::dim_surround()); + let expected_dark = forward_correlates_v1(xyz, &ViewingConditions::dark_surround()); + + let dim_occurrence = LcsOccurrence::in_context( + sample, + context_with_surround(frame, 64.0, SurroundProfileId::DimV1), + ) + .unwrap(); + let dark_occurrence = LcsOccurrence::in_context( + sample, + context_with_surround(frame, 64.0, SurroundProfileId::DarkV1), + ) + .unwrap(); + + let _guard = ForwardCacheGuard::activate(); + let cached_average = forward(xyz, &ViewingConditions::srgb()); + assert_ne!(cached_average.0.to_bits(), expected_dim.j.to_bits()); + assert_ne!(cached_average.0.to_bits(), expected_dark.j.to_bits()); + + let (dim, dim_allocations) = + crate::test_support::measured_allocations(|| AppearanceState::derive_v1(dim_occurrence)); + let (dark, dark_allocations) = + crate::test_support::measured_allocations(|| AppearanceState::derive_v1(dark_occurrence)); + let dim = dim.unwrap().cam16(); + let dark = dark.unwrap().cam16(); + + assert_eq!(dim_allocations, 0); + assert_eq!(dark_allocations, 0); + assert_eq!( + [dim.j(), dim.q(), dim.c(), dim.m(), dim.s()].map(f64::to_bits), + [ + expected_dim.j, + expected_dim.q, + expected_dim.c, + expected_dim.m, + expected_dim.s, + ] + .map(f64::to_bits), + ); + assert_eq!( + [dark.j(), dark.q(), dark.c(), dark.m(), dark.s()].map(f64::to_bits), + [ + expected_dark.j, + expected_dark.q, + expected_dark.c, + expected_dark.m, + expected_dark.s, + ] + .map(f64::to_bits), + ); + + let HueState::Defined(dim_hue) = dim.hue() else { + panic!("chromatic dim fixture must have CAM16 hue"); + }; + let HueState::Defined(dark_hue) = dark.hue() else { + panic!("chromatic dark fixture must have CAM16 hue"); + }; + assert_eq!(dim_hue.degrees().to_bits(), expected_dim.h.to_bits()); + assert_eq!(dark_hue.degrees().to_bits(), expected_dark.h.to_bits()); + + let expected_ucs = |expected: crate::spaces::cam16::Cam16CorrelatesV1| { + let m_prime = ucs_m(expected.m); + let radians = expected.h.to_radians(); + [ + ucs_j(expected.j), + m_prime * radians.cos(), + m_prime * radians.sin(), + ] + .map(f64::to_bits) + }; + let actual_ucs = |view: crate::lcs_occurrence::Cam16UcsViewV1| { + [view.j_prime(), view.a_prime(), view.b_prime()].map(f64::to_bits) + }; + assert_eq!( + actual_ucs( + AppearanceState::derive_v1(dim_occurrence) + .unwrap() + .cam16_ucs() + .unwrap(), + ), + expected_ucs(expected_dim), + ); + assert_eq!( + actual_ucs( + AppearanceState::derive_v1(dark_occurrence) + .unwrap() + .cam16_ucs() + .unwrap(), + ), + expected_ucs(expected_dark), + ); +} + +#[test] +fn occurrence_views_have_no_ambient_preset_or_client_semantic_route() { + let source = include_str!("lcs_occurrence.rs"); + for forbidden in [ + "ViewingConditions::srgb()", + "ViewingConditions::dim_surround()", + "ForwardCacheGuard", + "PairFill", + "Glow", + "LabUI", + "Compatibility", + "Legacy", + ] { + assert!( + !source.contains(forbidden), + "occurrence/view foundation must not contain `{forbidden}`", + ); + } + for required in [ + "ViewingConditions::from_semantic_inputs_v1", + "forward_correlates_v1(occurrence.sample().xyz(), &vc)", + "derive_cam16_ucs_view_v1(self.cam16)", + ] { + assert!( + source.contains(required), + "explicit derived-view route must retain `{required}`", + ); + } +} diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index bd470c2f..dd914c57 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -22,21 +22,53 @@ pub mod glow; pub mod hash; pub mod ladder; pub mod lcs; +#[expect( + dead_code, + reason = "F0 colour-identity internals are exposed only through typed Program evidence" +)] +pub(crate) mod lcs_occurrence; pub(crate) mod lpc; pub mod material; pub mod neutral; pub mod numerical_plan; -pub(crate) mod pair; +#[expect( + dead_code, + reason = "the output-profile firewall is intentionally internal to registered profiles" +)] +pub(crate) mod output_projection; #[cfg_attr( not(test), expect( dead_code, - reason = "private C8d full-support recheck is production-compiled before its package bridge exists" + reason = "the full-support recheck remains a private verified engine" ) )] pub(crate) mod point_support; +#[expect( + dead_code, + reason = "the complete Program candidate remains private until terminal C7c" +)] +#[deny(missing_docs)] +pub(crate) mod program; +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "generic Program machinery is used only through the staged concrete module" + ) +)] +pub(crate) mod program_session; +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "release-registry internals are projected only through typed Program evidence" + ) +)] +pub(crate) mod release_registry; pub mod scale; pub mod semantic; +pub(crate) mod sha256; pub mod solve; pub(crate) mod wcag; @@ -57,11 +89,44 @@ mod agnostic_gates; #[cfg(test)] mod appearance_graph_tests; +#[cfg(test)] +mod lcs_occurrence_tests; + +#[cfg(test)] +mod output_projection_tests; + +#[cfg(test)] +mod program_session_tests; + +#[cfg(test)] +mod program_lcs_integration_tests; + +#[cfg(test)] +mod program_joint_integration_tests; + +#[cfg(test)] +mod program_mixed_evaluator_tests; + +#[cfg(test)] +mod program_identity_tests; + +#[cfg(test)] +mod program_boundary_tests; + +#[cfg(test)] +mod program_api_tests; + +#[cfg(test)] +mod release_registry_tests; + +#[cfg(test)] +mod generic_boundary_tests; + #[cfg_attr( not(test), expect( dead_code, - reason = "private F2 raw admission is production-compiled before its package bridge exists" + reason = "raw observation ownership is used only through the staged Program session" ) )] pub(crate) mod observation; @@ -76,7 +141,7 @@ mod point_support_tests; not(test), expect( dead_code, - reason = "private F2 Session is production-compiled before C8c package integration exists" + reason = "the generic Session engine is used only through the staged Program owner" ) )] pub(crate) mod session; @@ -88,7 +153,7 @@ mod session_tests; not(test), expect( dead_code, - reason = "private V2a joint selection is production-compiled before Pair lowering or a public Program exists" + reason = "joint-selection internals are used only through the staged Program contract" ) )] pub(crate) mod joint; @@ -118,9 +183,6 @@ mod continuity_tests; #[cfg(test)] mod dim_tinted_tests; -#[cfg(test)] -mod pair_label_tests; - #[cfg(test)] mod r3_byte_identity_tests; @@ -194,7 +256,6 @@ pub use solve::{ SolveFailureCategory, SolveJob, Solved, solve, solve_many, }; pub use spaces::oklch::{css_alpha_value, oklch_css_from_hex, oklch_from_hex}; -pub use spaces::p3::{p3_css_from_hex, p3_from_hex}; pub use spaces::srgb::srgb_encoded_from_hex; pub use spaces::vc::ViewingConditions; @@ -308,17 +369,33 @@ pub struct NoHybridLpcSurfaceMetric; #[cfg(doctest)] pub struct NoPrematureScalarLpcApi; -/// Frozen Pair frontend не публикует собственную физику или solver extension -/// point; Pair lowering остаётся private до общего Program cutover. +/// Кандидат Program остаётся внутренним до завершения terminal C7c: неполную +/// emission/attachment/transaction поверхность нельзя случайно опубликовать. +/// +/// ```compile_fail +/// use labcolors_core::program; +/// ``` +/// +/// ```compile_fail +/// use labcolors_core::package_bridge; +/// ``` +/// +/// ```compile_fail +/// use labcolors_core::package_bridge::PackageProgramDraftV1; +/// ``` +/// +/// ```compile_fail +/// use labcolors_core::program::PackageProgramDraftV1; +/// ``` /// /// ```compile_fail -/// use labcolors_core::pair::pair_side; +/// use labcolors_core::DraftV1; /// ``` #[cfg(doctest)] -pub struct NoPublicPairRecipeApi; +pub struct NoPrematureProgramApi; -/// C8d recheck and F2 observation remain one private Session-owned protocol; -/// they do not create a second public authoring root before C7c. +/// C8d recheck и F2 observation остаются деталями одной приватной Session; +/// они не могут стать дополнительными public authoring/runtime roots. /// /// ```compile_fail /// use labcolors_core::point_support::CompiledPointSupportRecheckV1; @@ -329,7 +406,7 @@ pub struct NoPublicPairRecipeApi; /// ``` /// /// ```compile_fail -/// use labcolors_core::session::PointSupportSessionV1; +/// use labcolors_core::session::Session; /// ``` #[cfg(doctest)] pub struct NoPrematurePointSupportApi; diff --git a/crates/labcolors-core/src/numerical_plan.rs b/crates/labcolors-core/src/numerical_plan.rs index bd05d4f5..ab674f6d 100644 --- a/crates/labcolors-core/src/numerical_plan.rs +++ b/crates/labcolors-core/src/numerical_plan.rs @@ -271,6 +271,8 @@ pub fn compile_numerical_plan_v1<'a>( #[cfg(test)] mod tests { + use std::fmt::Write as _; + use super::*; const SITE: NumericalSiteIdV1 = NumericalSiteIdV1::GlowTargetOrMaximumV1; @@ -386,11 +388,11 @@ mod tests { (b"z".as_slice(), SITE, compatibility()), ]) .unwrap(); - let hex: String = plan - .canonical_checksum_preimage() - .iter() - .map(|b| format!("{b:02x}")) - .collect(); + let preimage = plan.canonical_checksum_preimage(); + let mut hex = String::with_capacity(preimage.len() * 2); + for byte in preimage { + write!(&mut hex, "{byte:02x}").expect("writing to String cannot fail"); + } let frozen = "1b0000006c6162636f6c6f72732e6e756d65726963616c2d706c616e2e763101000000020000004f000000210000006c6162636f6c6f72732e6e756d65726963616c2d696e766f636174696f6e2e763101000000010000006119000000676c6f772d7461726765742d6f722d6d6178696d756d2d76310000000019000000676c6f772d7461726765742d6f722d6d6178696d756d2d76310b000000737461626c652d6f6e6c79000000004f000000210000006c6162636f6c6f72732e6e756d65726963616c2d696e766f636174696f6e2e763101000000010000007a19000000676c6f772d7461726765742d6f722d6d6178696d756d2d76310000000019000000676c6f772d7461726765742d6f722d6d6178696d756d2d7631160000006578706c696369742d636f6d7061746962696c69747926000000676c6f772d63616d31362d7563732d6a7072696d652d7461726765742d6f722d6d61782d7631"; assert_eq!(hex, frozen, "canonical plan encoding v1 заморожен"); assert_eq!(plan.checksum.hex(), "49e5b6b7"); diff --git a/crates/labcolors-core/src/observation.rs b/crates/labcolors-core/src/observation.rs index a67d813a..686d4c5f 100644 --- a/crates/labcolors-core/src/observation.rs +++ b/crates/labcolors-core/src/observation.rs @@ -4,10 +4,23 @@ //! The F2 Session is the sole production owner of the current payload and the //! only code allowed to bind an admitted observation to evaluator evidence. +use core::cmp::Ordering; use core::ops::Range; +use std::rc::Rc; use crate::Srgb8; use crate::appearance::SurfaceInputPortId; +use crate::lcs_occurrence::ColorSignal; + +/// Stable compile-time identity of one atomic observation boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct ObservationGroupId(u32); + +impl ObservationGroupId { + pub(crate) const fn new(raw: u32) -> Self { + Self(raw) + } +} /// Runtime instance/epoch of one atomic observation stream. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -17,6 +30,10 @@ impl ObservationStreamId { pub(crate) const fn new(raw: u32) -> Self { Self(raw) } + + pub(crate) const fn value(self) -> u32 { + self.0 + } } /// Monotonic revision inside one [`ObservationStreamId`]. @@ -27,6 +44,10 @@ impl Revision { pub(crate) const fn new(raw: u64) -> Self { Self(raw) } + + pub(crate) const fn value(self) -> u64 { + self.0 + } } /// Opaque provenance of one simultaneously observed tuple. @@ -37,6 +58,10 @@ impl ScenarioId { pub(crate) const fn new(raw: u32) -> Self { Self(raw) } + + pub(crate) const fn value(self) -> u32 { + self.0 + } } /// Opaque reason why the current observation is unavailable. @@ -47,27 +72,23 @@ impl UnknownReasonId { pub(crate) const fn new(raw: u32) -> Self { Self(raw) } + + pub(crate) const fn value(self) -> u32 { + self.0 + } } /// One raw surface-input binding inside a correlated scenario. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct SurfaceInputBinding { pub(crate) port: SurfaceInputPortId, - pub(crate) value: Srgb8, + pub(crate) value: ColorSignal, } impl SurfaceInputBinding { - pub(crate) const fn new(port: SurfaceInputPortId, value: Srgb8) -> Self { + pub(crate) const fn new(port: SurfaceInputPortId, value: ColorSignal) -> Self { Self { port, value } } - - pub(crate) const fn port(self) -> SurfaceInputPortId { - self.port - } - - pub(crate) const fn value(self) -> Srgb8 { - self.value - } } /// Raw tuple: every binding was observed simultaneously. @@ -98,20 +119,26 @@ pub(crate) struct ObservationUpdateInput { pub(crate) payload: ObservationPayloadInput, } -/// One unique physical tuple as canonical keyed bindings. Port identity travels -/// with every value, so an observation cannot be reinterpreted through a -/// different positional schema. -#[derive(Debug, PartialEq, Eq)] -#[cfg_attr(test, derive(Clone))] -pub(crate) struct PhysicalScenario { - bindings: Vec, - provenance: Range, +/// Borrowed schema-ordered point-sRGB8 source for the package hot path. +/// +/// The trait is crate-private and statically dispatched. Callers provide one +/// value per compiled schema ordinal; no port IDs, transport words, or +/// intermediate keyed binding collections enter Core admission. +pub(crate) trait SchemaOrderedScenarioSourceV1 { + fn scenario_count(&self) -> usize; + fn scenario_id(&self, scenario_index: usize) -> ScenarioId; + fn value_count(&self, scenario_index: usize) -> usize; + fn value(&self, scenario_index: usize, binding_index: usize) -> Srgb8; } -impl PhysicalScenario { - pub(crate) fn bindings(&self) -> &[SurfaceInputBinding] { - &self.bindings - } +/// One unique physical tuple inside the shared canonical backing. +/// +/// Values are stored once in schema order. The schema remains attached to the +/// backing itself instead of being repeated beside every value. +#[derive(Debug, PartialEq, Eq)] +struct PhysicalScenario { + values: Range, + provenance: Range, } /// First canonical ordinal where an observation's intrinsic keyed schema and a @@ -138,58 +165,91 @@ impl ObservationSchemaMismatchV1 { actual, } } + + pub(crate) const fn into_parts( + self, + ) -> ( + usize, + usize, + Option, + Option, + ) { + ( + self.case_index, + self.binding_index, + self.expected, + self.actual, + ) + } } -/// Canonical nonempty set. Provenance is one flat allocation shared by ranges, -/// not one allocation per physical case. +/// Canonical nonempty correlated set. Values and provenance each use one flat +/// allocation; a physical case owns only ranges into those arrays. #[derive(Debug, PartialEq, Eq)] -#[cfg_attr(test, derive(Clone))] -pub(crate) struct ObservedScenarioSet { - cases: Vec, - provenance: Vec, +struct ObservedScenarioSet { + cases: Box<[PhysicalScenario]>, + values: Box<[ColorSignal]>, + provenance: Box<[ScenarioId]>, } impl ObservedScenarioSet { - pub(crate) fn cases(&self) -> &[PhysicalScenario] { - &self.cases + fn values(&self, case_index: usize) -> Option<&[ColorSignal]> { + let values = &self.cases.get(case_index)?.values; + self.values.get(values.start..values.end) } - pub(crate) fn provenance(&self, case_index: usize) -> Option<&[ScenarioId]> { + fn provenance(&self, case_index: usize) -> Option<&[ScenarioId]> { let provenance = &self.cases.get(case_index)?.provenance; - let range = provenance.start..provenance.end; - self.provenance.get(range) + self.provenance.get(provenance.start..provenance.end) } +} - fn validate_surface_schema( - &self, - expected: &[SurfaceInputPortId], - ) -> Result<(), ObservationSchemaMismatchV1> { - for (case_index, case) in self.cases.iter().enumerate() { - let schema_len = expected.len().max(case.bindings.len()); - for binding_index in 0..schema_len { - let expected_input = expected.get(binding_index).copied(); - let actual_input = case.bindings.get(binding_index).map(|binding| binding.port); - if expected_input != actual_input { - return Err(ObservationSchemaMismatchV1::new( - case_index, - binding_index, - expected_input, - actual_input, - )); - } - } - } - Ok(()) +/// Canonical immutable schema shared by the compiled recheck and every +/// admitted observation backing created for it. +#[derive(Debug, PartialEq, Eq)] +#[cfg_attr(test, derive(Clone))] +pub(crate) struct CanonicalObservationSchemaV1(Rc<[SurfaceInputPortId]>); + +impl CanonicalObservationSchemaV1 { + pub(crate) fn as_slice(&self) -> &[SurfaceInputPortId] { + &self.0 + } + + fn shares_backing_with(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.0, &other.0) + } + + /// Admission is the sole production boundary allowed to share the compiled + /// schema handle: the immutable observation backing must prove the exact + /// schema against which it was admitted. + fn share_for_observation(&self) -> Self { + Self(Rc::clone(&self.0)) + } + + #[cfg(test)] + pub(crate) fn backing_ptr_for_test(&self) -> *const SurfaceInputPortId { + self.0.as_ptr() + } + + #[cfg(test)] + pub(crate) fn strong_count_for_test(&self) -> usize { + Rc::strong_count(&self.0) } } -/// Sealed observation admitted against the Session-owned compiled schema. #[derive(Debug, PartialEq, Eq)] -#[cfg_attr(test, derive(Clone))] +struct ObservationBackingV1 { + schema: CanonicalObservationSchemaV1, + set: ObservedScenarioSet, +} + +/// Sealed observation admitted against the exact schema owned by its sealed +/// Session plan. +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RevisionBoundObservationV1 { stream: ObservationStreamId, revision: Revision, - set: ObservedScenarioSet, + backing: Rc, } impl RevisionBoundObservationV1 { @@ -201,30 +261,87 @@ impl RevisionBoundObservationV1 { self.revision } - pub(crate) const fn set(&self) -> &ObservedScenarioSet { - &self.set + pub(crate) fn schema(&self) -> &[SurfaceInputPortId] { + self.backing.schema.as_slice() } pub(crate) fn physical_case_count(&self) -> usize { - self.set.cases().len() + self.backing.set.cases.len() } - pub(crate) fn physical_bindings(&self, case_index: usize) -> Option<&[SurfaceInputBinding]> { - self.set - .cases() - .get(case_index) - .map(PhysicalScenario::bindings) + pub(crate) fn physical_values(&self, case_index: usize) -> Option<&[ColorSignal]> { + self.backing.set.values(case_index) } pub(crate) fn provenance(&self, case_index: usize) -> Option<&[ScenarioId]> { - self.set.provenance(case_index) + self.backing.set.provenance(case_index) } pub(crate) fn validate_surface_schema( &self, expected: &[SurfaceInputPortId], ) -> Result<(), ObservationSchemaMismatchV1> { - self.set.validate_surface_schema(expected) + let actual = self.schema(); + let schema_len = expected.len().max(actual.len()); + for binding_index in 0..schema_len { + let expected_input = expected.get(binding_index).copied(); + let actual_input = actual.get(binding_index).copied(); + if expected_input != actual_input { + return Err(ObservationSchemaMismatchV1::new( + 0, + binding_index, + expected_input, + actual_input, + )); + } + } + Ok(()) + } + + pub(crate) fn shares_schema_backing_with( + &self, + expected: &CanonicalObservationSchemaV1, + ) -> bool { + self.backing.schema.shares_backing_with(expected) + } + + pub(crate) fn is_same_binding_as(&self, other: &Self) -> bool { + self.stream == other.stream + && self.revision == other.revision + && Rc::ptr_eq(&self.backing, &other.backing) + } + + fn has_canonical_input( + &self, + schema: &CanonicalObservationSchemaV1, + scenarios: &[ScenarioInput], + ) -> bool { + &self.backing.schema == schema && canonical_input_matches_set(&self.backing.set, scenarios) + } + + fn has_schema_ordered_input( + &self, + schema: &CanonicalObservationSchemaV1, + source: &Source, + order: &[usize], + ) -> bool { + &self.backing.schema == schema + && schema_ordered_input_matches_set( + &self.backing.set, + source, + order, + schema.as_slice().len(), + ) + } + + #[cfg(test)] + pub(crate) fn backing_ptr_for_test(&self) -> *const () { + Rc::as_ptr(&self.backing).cast() + } + + #[cfg(test)] + pub(crate) fn schema_ptr_for_test(&self) -> *const SurfaceInputPortId { + self.backing.schema.backing_ptr_for_test() } } @@ -289,6 +406,11 @@ pub(crate) enum ObservationError { DuplicateScenarioId { scenario: ScenarioId, }, + SchemaOrderedValueCountMismatch { + scenario: ScenarioId, + expected: usize, + actual: usize, + }, DuplicateSurfaceInputBinding { scenario: ScenarioId, input: SurfaceInputPortId, @@ -365,11 +487,15 @@ pub(crate) enum PreparedObservationUpdateV1<'owner, Owner> { Observed(PreparedObservedV1<'owner, Owner>), } -/// Canonicalize and validate a compiled surface-input schema without any new -/// allocation. The supplied Vec remains the unique schema owner. +/// Canonicalize and validate a compiled surface-input schema. +/// +/// Sorting and duplicate detection reuse the supplied `Vec`. Converting its +/// storage into the immutable `Rc`-backed schema can still allocate through the +/// global allocator; this function does not claim allocator-wide recoverable +/// OOM semantics. pub(crate) fn canonicalize_observation_schema( mut ports: Vec, -) -> Result, ObservationError> { +) -> Result { if ports.is_empty() { return Err(ObservationError::EmptyCompiledSurfaceInputSchema); } @@ -379,16 +505,20 @@ pub(crate) fn canonicalize_observation_schema( input: duplicate[0], }); } - Ok(ports) + Ok(CanonicalObservationSchemaV1(Rc::from( + ports.into_boxed_slice(), + ))) } -/// Prepare without mutation. Cheap stream/lower-revision/payload-kind failures -/// precede scenario canonicalization; exact same-revision observed replay still -/// canonicalizes so equality is content-complete. +/// Prepare without mutation. Stream identity always precedes payload handling. +/// `Scenarios` are fully admitted before revision comparison so malformed +/// correlated input is never masked by an otherwise valid revision error. +/// `Unknown` has no payload structure to admit and takes the cheap revision +/// path directly. pub(crate) fn prepare_observation<'owner, Owner: ObservationOwnerV1>( owner: &'owner mut Owner, stream: ObservationStreamId, - schema: &[SurfaceInputPortId], + schema: &CanonicalObservationSchemaV1, update: ObservationUpdateInput, ) -> Result, ObservationError> { if update.stream != stream { @@ -398,18 +528,17 @@ pub(crate) fn prepare_observation<'owner, Owner: ObservationOwnerV1>( }); } - let current_revision = owner.observation_head().revision(); - if let Some(current) = current_revision { - if update.revision < current { - return Err(ObservationError::RevisionOutOfOrder { - current, - incoming: update.revision, - }); - } - } - match update.payload { ObservationPayloadInput::Unknown(reason) => { + let current_revision = owner.observation_head().revision(); + if let Some(current) = current_revision { + if update.revision < current { + return Err(ObservationError::RevisionOutOfOrder { + current, + incoming: update.revision, + }); + } + } if current_revision == Some(update.revision) { let exact = matches!( owner.observation_head(), @@ -435,6 +564,16 @@ pub(crate) fn prepare_observation<'owner, Owner: ObservationOwnerV1>( })) } ObservationPayloadInput::Scenarios(raw) => { + let scenarios = canonicalize_scenarios_input(schema.as_slice(), raw)?; + let current_revision = owner.observation_head().revision(); + if let Some(current) = current_revision { + if update.revision < current { + return Err(ObservationError::RevisionOutOfOrder { + current, + incoming: update.revision, + }); + } + } if current_revision == Some(update.revision) && !matches!(owner.observation_head(), ObservationHeadViewV1::Observed(_)) { @@ -443,11 +582,10 @@ pub(crate) fn prepare_observation<'owner, Owner: ObservationOwnerV1>( }); } - let set = admit_scenarios(schema, raw)?; if current_revision == Some(update.revision) { let exact = matches!( - owner.observation_head(), - ObservationHeadViewV1::Observed(current) if current.set == set + owner.observation_head(), ObservationHeadViewV1::Observed(current) + if current.has_canonical_input(schema, &scenarios) ); return if exact { Ok(PreparedObservationUpdateV1::Idempotent( @@ -460,22 +598,154 @@ pub(crate) fn prepare_observation<'owner, Owner: ObservationOwnerV1>( }; } + let set = materialize_scenarios(schema.as_slice(), scenarios)?; Ok(PreparedObservationUpdateV1::Observed(PreparedObservedV1 { owner, observation: RevisionBoundObservationV1 { stream, revision: update.revision, - set, + backing: Rc::new(ObservationBackingV1 { + schema: schema.share_for_observation(), + set, + }), }, })) } } } -fn admit_scenarios( +/// Prepare one borrowed schema-ordered observation without constructing keyed +/// port bindings. One caller-owned scratch vector is reused first as an +/// open-addressed scenario-ID set and then as the canonical scenario order. +/// Consequently admission needs one sort, performs no per-scenario +/// allocation, and exact replay can be allocation-free after scratch growth. +/// A higher revision materializes the canonical backing exactly once; exact +/// replay compares against the existing backing without rebuilding it. +pub(crate) fn prepare_schema_ordered_observation< + 'owner, + Owner: ObservationOwnerV1, + Source: SchemaOrderedScenarioSourceV1, +>( + owner: &'owner mut Owner, + stream: ObservationStreamId, + schema: &CanonicalObservationSchemaV1, + revision: Revision, + source: &Source, + order_scratch: &mut Vec, +) -> Result, ObservationError> { + let scenario_count = source.scenario_count(); + if scenario_count == 0 { + return Err(ObservationError::EmptyScenarioSet); + } + + let id_table_len = scenario_count + .checked_mul(2) + .and_then(usize::checked_next_power_of_two) + .ok_or(ObservationError::ResourceExhausted)?; + order_scratch.clear(); + order_scratch + .try_reserve_exact(id_table_len) + .map_err(|_| ObservationError::ResourceExhausted)?; + order_scratch.resize(id_table_len, usize::MAX); + + for scenario_index in 0..scenario_count { + let actual = source.value_count(scenario_index); + if actual != schema.as_slice().len() { + return Err(ObservationError::SchemaOrderedValueCountMismatch { + scenario: source.scenario_id(scenario_index), + expected: schema.as_slice().len(), + actual, + }); + } + + let scenario_id = source.scenario_id(scenario_index); + let mut table_index = + (scenario_id.0 as usize).wrapping_mul(0x9e37_79b1) & (id_table_len - 1); + loop { + let existing_index = order_scratch[table_index]; + if existing_index == usize::MAX { + order_scratch[table_index] = scenario_index; + break; + } + if source.scenario_id(existing_index) == scenario_id { + return Err(ObservationError::DuplicateScenarioId { + scenario: scenario_id, + }); + } + table_index = (table_index + 1) & (id_table_len - 1); + } + } + + order_scratch.clear(); + order_scratch.extend(0..scenario_count); + order_scratch.sort_unstable_by(|&left, &right| { + compare_schema_ordered_scenarios(source, left, right, schema.as_slice().len()) + }); + + let current_revision = owner.observation_head().revision(); + if let Some(current) = current_revision { + if revision < current { + return Err(ObservationError::RevisionOutOfOrder { + current, + incoming: revision, + }); + } + } + if current_revision == Some(revision) + && !matches!(owner.observation_head(), ObservationHeadViewV1::Observed(_)) + { + return Err(ObservationError::RevisionConflict { revision }); + } + if current_revision == Some(revision) { + let exact = matches!( + owner.observation_head(), + ObservationHeadViewV1::Observed(current) + if current.has_schema_ordered_input(schema, source, order_scratch) + ); + return if exact { + Ok(PreparedObservationUpdateV1::Idempotent( + PreparedIdempotentV1 { owner }, + )) + } else { + Err(ObservationError::RevisionConflict { revision }) + }; + } + + let set = materialize_schema_ordered_scenarios(schema.as_slice(), source, order_scratch)?; + Ok(PreparedObservationUpdateV1::Observed(PreparedObservedV1 { + owner, + observation: RevisionBoundObservationV1 { + stream, + revision, + backing: Rc::new(ObservationBackingV1 { + schema: schema.share_for_observation(), + set, + }), + }, + })) +} + +fn compare_schema_ordered_scenarios( + source: &Source, + left: usize, + right: usize, + binding_count: usize, +) -> Ordering { + for binding_index in 0..binding_count { + let ordering = source + .value(left, binding_index) + .cmp(&source.value(right, binding_index)); + if ordering != Ordering::Equal { + return ordering; + } + } + source.scenario_id(left).cmp(&source.scenario_id(right)) +} + +fn canonicalize_scenarios_input( schema: &[SurfaceInputPortId], raw: ObservedScenarioSetInput, -) -> Result { +) -> Result, ObservationError> { if raw.scenarios.is_empty() { return Err(ObservationError::EmptyScenarioSet); } @@ -491,11 +761,7 @@ fn admit_scenarios( }); } - let mut tuples = Vec::new(); - tuples - .try_reserve_exact(scenarios.len()) - .map_err(|_| ObservationError::ResourceExhausted)?; - for mut scenario in scenarios { + for scenario in &mut scenarios { scenario .bindings .sort_unstable_by_key(|binding| binding.port); @@ -531,42 +797,246 @@ fn admit_scenarios( input: unexpected.port, }); } + } + + // Reuse the caller-owned outer Vec and every bindings Vec. This complete + // canonical input is enough for lower/same-revision decisions without any + // new allocation; only an applied higher revision materializes backing. + scenarios.sort_unstable_by(|left, right| { + left.bindings + .cmp(&right.bindings) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(scenarios) +} + +fn canonical_input_matches_set(set: &ObservedScenarioSet, scenarios: &[ScenarioInput]) -> bool { + let mut case_index = 0; + let mut scenario_index = 0; + while scenario_index < scenarios.len() { + let bindings = &scenarios[scenario_index].bindings; + let Some(values) = set.values(case_index) else { + return false; + }; + if bindings.len() != values.len() + || bindings + .iter() + .zip(values) + .any(|(binding, value)| binding.value != *value) + { + return false; + } - // Move the already allocated, sorted keyed tuple directly. Keeping the - // port beside the value makes schema identity part of observation - // equality and removes any later positional reinterpretation seam. - tuples.push((scenario.bindings, scenario.id)); + let first = scenario_index; + scenario_index += 1; + while scenario_index < scenarios.len() + && scenarios[scenario_index].bindings.as_slice() == bindings.as_slice() + { + scenario_index += 1; + } + let Some(provenance) = set.provenance(case_index) else { + return false; + }; + if provenance.len() != scenario_index - first + || scenarios[first..scenario_index] + .iter() + .zip(provenance) + .any(|(scenario, provenance)| scenario.id != *provenance) + { + return false; + } + case_index += 1; } + case_index == set.cases.len() +} - tuples.sort_unstable_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); +fn schema_ordered_input_matches_set( + set: &ObservedScenarioSet, + source: &Source, + order: &[usize], + binding_count: usize, +) -> bool { + let mut case_index = 0; + let mut scenario_ordinal = 0; + while scenario_ordinal < order.len() { + let scenario_index = order[scenario_ordinal]; + let Some(values) = set.values(case_index) else { + return false; + }; + if values.len() != binding_count + || values.iter().enumerate().any(|(binding_index, value)| { + *value != ColorSignal::from_srgb8(source.value(scenario_index, binding_index)) + }) + { + return false; + } + + let first = scenario_ordinal; + scenario_ordinal += 1; + while scenario_ordinal < order.len() + && schema_ordered_scenarios_equal( + source, + order[first], + order[scenario_ordinal], + binding_count, + ) + { + scenario_ordinal += 1; + } + let Some(provenance) = set.provenance(case_index) else { + return false; + }; + if provenance.len() != scenario_ordinal - first + || order[first..scenario_ordinal] + .iter() + .zip(provenance) + .any(|(&source_index, expected)| source.scenario_id(source_index) != *expected) + { + return false; + } + case_index += 1; + } + case_index == set.cases.len() +} + +fn schema_ordered_scenarios_equal( + source: &Source, + left: usize, + right: usize, + binding_count: usize, +) -> bool { + (0..binding_count).all(|binding_index| { + source.value(left, binding_index) == source.value(right, binding_index) + }) +} + +fn materialize_schema_ordered_scenarios( + schema: &[SurfaceInputPortId], + source: &Source, + order: &[usize], +) -> Result { + debug_assert!(!order.is_empty()); + + let unique_case_count = 1 + order + .windows(2) + .filter(|pair| !schema_ordered_scenarios_equal(source, pair[0], pair[1], schema.len())) + .count(); + let value_count = unique_case_count + .checked_mul(schema.len()) + .ok_or(ObservationError::ResourceExhausted)?; - // Both output allocations are reserved before grouping. Moving tuples and - // pushing into these capacities cannot allocate afterward. let mut cases = Vec::new(); cases - .try_reserve_exact(tuples.len()) + .try_reserve_exact(unique_case_count) + .map_err(|_| ObservationError::ResourceExhausted)?; + let mut values = Vec::new(); + values + .try_reserve_exact(value_count) .map_err(|_| ObservationError::ResourceExhausted)?; let mut provenance = Vec::new(); provenance - .try_reserve_exact(tuples.len()) + .try_reserve_exact(order.len()) .map_err(|_| ObservationError::ResourceExhausted)?; - let mut tuples = tuples.into_iter().peekable(); - while let Some((bindings, first_id)) = tuples.next() { - let start = provenance.len(); + let mut scenario_ordinal = 0; + while scenario_ordinal < order.len() { + let first_source_index = order[scenario_ordinal]; + let values_start = values.len(); + values.extend((0..schema.len()).map(|binding_index| { + ColorSignal::from_srgb8(source.value(first_source_index, binding_index)) + })); + let values_end = values.len(); + let provenance_start = provenance.len(); + provenance.push(source.scenario_id(first_source_index)); + scenario_ordinal += 1; + while scenario_ordinal < order.len() + && schema_ordered_scenarios_equal( + source, + first_source_index, + order[scenario_ordinal], + schema.len(), + ) + { + provenance.push(source.scenario_id(order[scenario_ordinal])); + scenario_ordinal += 1; + } + let provenance_end = provenance.len(); + cases.push(PhysicalScenario { + values: values_start..values_end, + provenance: provenance_start..provenance_end, + }); + } + + debug_assert_eq!(cases.len(), unique_case_count); + debug_assert_eq!(values.len(), value_count); + Ok(ObservedScenarioSet { + cases: cases.into_boxed_slice(), + values: values.into_boxed_slice(), + provenance: provenance.into_boxed_slice(), + }) +} + +fn materialize_scenarios( + schema: &[SurfaceInputPortId], + scenarios: Vec, +) -> Result { + debug_assert!(!scenarios.is_empty()); + + let unique_case_count = 1 + scenarios + .windows(2) + .filter(|window| window[0].bindings.as_slice() != window[1].bindings.as_slice()) + .count(); + let value_count = unique_case_count + .checked_mul(schema.len()) + .ok_or(ObservationError::ResourceExhausted)?; + + // Capacity for every variable-sized Vec used by grouping is fallibly + // reserved before the first push/extend, so grouping cannot grow one of + // those Vecs. The final boxed representation and its later Rc owner still + // follow the global allocator's OOM behavior. + let mut cases = Vec::new(); + cases + .try_reserve_exact(unique_case_count) + .map_err(|_| ObservationError::ResourceExhausted)?; + let mut values = Vec::new(); + values + .try_reserve_exact(value_count) + .map_err(|_| ObservationError::ResourceExhausted)?; + let mut provenance = Vec::new(); + provenance + .try_reserve_exact(scenarios.len()) + .map_err(|_| ObservationError::ResourceExhausted)?; + + let mut scenarios = scenarios.into_iter().peekable(); + while let Some(ScenarioInput { + id: first_id, + bindings, + }) = scenarios.next() + { + let values_start = values.len(); + values.extend(bindings.iter().map(|binding| binding.value)); + let values_end = values.len(); + let provenance_start = provenance.len(); provenance.push(first_id); - while matches!(tuples.peek(), Some((candidate, _)) if candidate == &bindings) { - let (_, id) = tuples + while matches!(scenarios.peek(), Some(candidate) if candidate.bindings.as_slice() == bindings.as_slice()) + { + let ScenarioInput { id, .. } = scenarios .next() - .unwrap_or_else(|| unreachable!("peek observed the next tuple")); + .unwrap_or_else(|| unreachable!("peek observed the next scenario")); provenance.push(id); } - let end = provenance.len(); + let provenance_end = provenance.len(); cases.push(PhysicalScenario { - bindings, - provenance: start..end, + values: values_start..values_end, + provenance: provenance_start..provenance_end, }); } - Ok(ObservedScenarioSet { cases, provenance }) + debug_assert_eq!(cases.len(), unique_case_count); + debug_assert_eq!(values.len(), value_count); + Ok(ObservedScenarioSet { + cases: cases.into_boxed_slice(), + values: values.into_boxed_slice(), + provenance: provenance.into_boxed_slice(), + }) } diff --git a/crates/labcolors-core/src/observation_tests.rs b/crates/labcolors-core/src/observation_tests.rs index 33b0f9ed..579af3a2 100644 --- a/crates/labcolors-core/src/observation_tests.rs +++ b/crates/labcolors-core/src/observation_tests.rs @@ -4,11 +4,12 @@ use crate::appearance::{ OccurrenceId, OccurrenceSpec, PaintId, PaintSpec, PointOpacityError, PointOpacityOverSurfaceV1, ResolvedOccurrence, SurfaceId, SurfaceInputPortId, SurfaceSpec, }; +use crate::lcs_occurrence::ColorSignal; use crate::observation::{ - ObservationError, ObservationHeadViewV1, ObservationOwnerV1, ObservationPayloadInput, - ObservationSchemaMismatchV1, ObservationStreamId, ObservationUpdateInput, ObservedScenarioSet, - ObservedScenarioSetInput, PreparedObservationUpdateV1, Revision, RevisionBoundObservationV1, - RevisionBoundUnknownV1, ScenarioId, ScenarioInput, SurfaceInputBinding, UnknownReasonId, + CanonicalObservationSchemaV1, ObservationError, ObservationHeadViewV1, ObservationOwnerV1, + ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, ObservedScenarioSetInput, + PreparedObservationUpdateV1, Revision, RevisionBoundObservationV1, RevisionBoundUnknownV1, + ScenarioId, ScenarioInput, SurfaceInputBinding, UnknownReasonId, canonicalize_observation_schema, prepare_observation, }; @@ -42,7 +43,7 @@ impl ObservationOwnerV1 for TestOwner { #[derive(Debug, Clone, PartialEq, Eq)] struct TestState { stream: ObservationStreamId, - schema: Vec, + schema: CanonicalObservationSchemaV1, owner: TestOwner, } @@ -107,7 +108,7 @@ impl TestState { } fn binding(port: SurfaceInputPortId, bytes: [u8; 3]) -> SurfaceInputBinding { - SurfaceInputBinding::new(port, Srgb8::new(bytes)) + SurfaceInputBinding::new(port, ColorSignal::from_srgb8(Srgb8::new(bytes))) } fn scenario(id: u32, bindings: impl IntoIterator) -> ScenarioInput { @@ -154,13 +155,6 @@ fn paired_set(first: ([u8; 3], [u8; 3]), second: ([u8; 3], [u8; 3])) -> Observed ]) } -fn observed_set(state: &TestState) -> &ObservedScenarioSet { - state - .current_observation() - .expect("expected admitted observation") - .set() -} - fn revision_bound(state: &TestState) -> &RevisionBoundObservationV1 { state .current_observation() @@ -232,14 +226,17 @@ fn admission_preserves_correlated_tuples_without_cartesian_product() { )) .unwrap(); - let cases: Vec> = observed_set(&state) - .cases() - .iter() - .map(|case| { - case.bindings() + let observation = revision_bound(&state); + assert_eq!(observation.schema(), &[PORT_A, PORT_B]); + let cases: Vec> = (0..observation.physical_case_count()) + .map(|case_index| { + observation + .physical_values(case_index) + .unwrap() .iter() .copied() - .map(|binding| binding.value().bytes()) + .map(ColorSignal::srgb8) + .map(Srgb8::bytes) .collect() }) .collect(); @@ -269,27 +266,134 @@ fn canonicalization_ignores_declaration_order_and_groups_duplicate_physics() { right.apply(observed_update(STREAM, 1, second)).unwrap(); assert_eq!(left, right); - let set = observed_set(&left); - assert_eq!(set.cases().len(), 2); + let observation = revision_bound(&left); + assert_eq!(observation.schema(), &[PORT_A, PORT_B]); + assert_eq!(observation.physical_case_count(), 2); assert_eq!( - set.provenance(0).unwrap(), + observation.provenance(0).unwrap(), &[ScenarioId::new(3), ScenarioId::new(9)] ); - assert_eq!(set.provenance(1).unwrap(), &[ScenarioId::new(4)]); - let physical_bindings: Vec> = set - .cases() - .iter() - .map(|case| case.bindings().to_vec()) + assert_eq!(observation.provenance(1).unwrap(), &[ScenarioId::new(4)]); + let physical_values: Vec> = (0..observation.physical_case_count()) + .map(|case_index| observation.physical_values(case_index).unwrap().to_vec()) .collect(); assert_eq!( - physical_bindings, + physical_values, vec![ - vec![binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])], - vec![binding(PORT_A, [9, 8, 7]), binding(PORT_B, [6, 5, 4])], + vec![ + ColorSignal::from_srgb8(Srgb8::new([1, 2, 3])), + ColorSignal::from_srgb8(Srgb8::new([4, 5, 6])), + ], + vec![ + ColorSignal::from_srgb8(Srgb8::new([9, 8, 7])), + ColorSignal::from_srgb8(Srgb8::new([6, 5, 4])), + ], ] ); } +#[test] +fn revision_bound_clone_is_allocation_free_and_shares_all_canonical_backing() { + let mut state = TestState::new(STREAM, vec![PORT_B, PORT_A]).unwrap(); + state + .apply(observed_update( + STREAM, + 1, + scenarios([ + scenario(9, [binding(PORT_B, [4, 5, 6]), binding(PORT_A, [1, 2, 3])]), + scenario(3, [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])]), + ]), + )) + .unwrap(); + + let observation = revision_bound(&state); + let backing_ptr = observation.backing_ptr_for_test(); + let schema_ptr = observation.schema_ptr_for_test(); + let (cloned, allocations) = crate::test_support::measured_allocations(|| observation.clone()); + + assert_eq!(allocations, 0); + assert_eq!(&cloned, observation); + assert_eq!(cloned.backing_ptr_for_test(), backing_ptr); + assert_eq!(cloned.schema_ptr_for_test(), schema_ptr); + assert_eq!(cloned.schema(), observation.schema()); + assert_eq!(cloned.physical_values(0), observation.physical_values(0)); + assert_eq!(cloned.provenance(0), observation.provenance(0)); +} + +#[test] +fn independent_equal_admissions_do_not_alias_observation_or_schema_backing() { + let first = scenarios([ + scenario(9, [binding(PORT_B, [4, 5, 6]), binding(PORT_A, [1, 2, 3])]), + scenario(3, [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])]), + ]); + let second = scenarios([ + scenario(3, [binding(PORT_B, [4, 5, 6]), binding(PORT_A, [1, 2, 3])]), + scenario(9, [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])]), + ]); + let mut left = TestState::new(STREAM, vec![PORT_B, PORT_A]).unwrap(); + let mut right = TestState::new(STREAM, vec![PORT_A, PORT_B]).unwrap(); + left.apply(observed_update(STREAM, 1, first)).unwrap(); + right.apply(observed_update(STREAM, 1, second)).unwrap(); + + let left_observation = revision_bound(&left); + let right_observation = revision_bound(&right); + assert_eq!(left_observation, right_observation); + assert!( + !left_observation.shares_schema_backing_with(&right.schema), + "equal schema values from another owner must not inherit authority", + ); + assert_ne!( + left_observation.backing_ptr_for_test(), + right_observation.backing_ptr_for_test() + ); + assert_ne!( + left_observation.schema_ptr_for_test(), + right_observation.schema_ptr_for_test() + ); +} + +#[test] +fn schema_and_values_are_aligned_once_while_provenance_remains_complete() { + let mut state = TestState::new(STREAM, vec![PORT_B, PORT_A]).unwrap(); + state + .apply(observed_update( + STREAM, + 1, + scenarios([ + scenario(9, [binding(PORT_B, [4, 5, 6]), binding(PORT_A, [1, 2, 3])]), + scenario(4, [binding(PORT_A, [9, 8, 7]), binding(PORT_B, [6, 5, 4])]), + scenario(3, [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])]), + ]), + )) + .unwrap(); + + let observation = revision_bound(&state); + let first_values: &[ColorSignal] = observation.physical_values(0).unwrap(); + let second_values: &[ColorSignal] = observation.physical_values(1).unwrap(); + assert_eq!(observation.schema(), &[PORT_A, PORT_B]); + assert_eq!( + first_values, + &[ + ColorSignal::from_srgb8(Srgb8::new([1, 2, 3])), + ColorSignal::from_srgb8(Srgb8::new([4, 5, 6])), + ] + ); + assert_eq!( + second_values, + &[ + ColorSignal::from_srgb8(Srgb8::new([9, 8, 7])), + ColorSignal::from_srgb8(Srgb8::new([6, 5, 4])), + ] + ); + assert_eq!( + observation.provenance(0), + Some(&[ScenarioId::new(3), ScenarioId::new(9)][..]) + ); + assert_eq!(observation.provenance(1), Some(&[ScenarioId::new(4)][..])); + assert_eq!(observation.physical_values(2), None); + assert_eq!(observation.provenance(2), None); +} + #[test] fn keyed_schema_is_intrinsic_to_revision_bound_observation_identity() { let mut left = TestState::new(STREAM, vec![PORT_A]).unwrap(); @@ -309,26 +413,24 @@ fn keyed_schema_is_intrinsic_to_revision_bound_observation_identity() { .unwrap(); assert_ne!(revision_bound(&left), revision_bound(&right)); + assert_eq!(revision_bound(&left).schema(), &[PORT_A]); + assert_eq!(revision_bound(&right).schema(), &[PORT_B]); assert_eq!( - revision_bound(&left).validate_surface_schema(&[PORT_A, PORT_B]), - Err(ObservationSchemaMismatchV1::new(0, 1, Some(PORT_B), None,)) + revision_bound(&left).physical_values(0), + Some(&[ColorSignal::from_srgb8(Srgb8::new([7, 8, 9]))][..]) ); assert_eq!( - revision_bound(&left).validate_surface_schema(&[PORT_B]), - Err(ObservationSchemaMismatchV1::new( - 0, - 0, - Some(PORT_B), - Some(PORT_A), - )) + revision_bound(&right).physical_values(0), + Some(&[ColorSignal::from_srgb8(Srgb8::new([7, 8, 9]))][..]) ); + let alternate_schema = canonicalize_observation_schema(vec![PORT_B]).unwrap(); let before = left.clone(); assert!(matches!( prepare_observation( &mut left.owner, STREAM, - &[PORT_B], + &alternate_schema, observed_update( STREAM, 1, @@ -342,21 +444,27 @@ fn keyed_schema_is_intrinsic_to_revision_bound_observation_identity() { } #[test] -fn lower_revision_is_rejected_before_malformed_payload_scan_and_never_moves_head() { +fn stream_precedes_full_scenario_admission_which_precedes_revision_checks() { let mut state = TestState::new(STREAM, vec![PORT_A, PORT_B]).unwrap(); state.apply(unknown_update(STREAM, 4, 1)).unwrap(); let before = state.clone(); - let malformed = scenarios([scenario(1, [binding(PORT_A, [1; 3])])]); + let malformed = || scenarios([scenario(1, [binding(PORT_A, [1; 3])])]); + assert_eq!( - state.apply(observed_update(STREAM, 2, malformed.clone())), - Err(ObservationError::RevisionOutOfOrder { - current: Revision::new(4), - incoming: Revision::new(2), + state.apply(observed_update( + ObservationStreamId::new(99), + 2, + malformed(), + )), + Err(ObservationError::StreamMismatch { + expected: STREAM, + actual: ObservationStreamId::new(99), }) ); assert_eq!(state, before); + assert_eq!( - state.apply(observed_update(STREAM, 5, malformed)), + state.apply(observed_update(STREAM, 2, malformed())), Err(ObservationError::MissingSurfaceInputBinding { scenario: ScenarioId::new(1), input: PORT_B, @@ -364,12 +472,32 @@ fn lower_revision_is_rejected_before_malformed_payload_scan_and_never_moves_head ); assert_eq!(state, before); - let corrected = scenarios([scenario( - 1, - [binding(PORT_A, [1; 3]), binding(PORT_B, [2; 3])], - )]); assert_eq!( - state.apply(observed_update(STREAM, 5, corrected)), + state.apply(observed_update(STREAM, 4, malformed())), + Err(ObservationError::MissingSurfaceInputBinding { + scenario: ScenarioId::new(1), + input: PORT_B, + }) + ); + assert_eq!(state, before); + + let valid = || { + scenarios([scenario( + 1, + [binding(PORT_A, [1; 3]), binding(PORT_B, [2; 3])], + )]) + }; + assert_eq!( + state.apply(observed_update(STREAM, 2, valid())), + Err(ObservationError::RevisionOutOfOrder { + current: Revision::new(4), + incoming: Revision::new(2), + }) + ); + assert_eq!(state, before); + + assert_eq!( + state.apply(observed_update(STREAM, 5, valid())), Ok(UpdateDisposition::Applied) ); assert!(state.current_observation().is_some()); @@ -427,13 +555,18 @@ fn observed_to_unknown_replaces_raw_payload_instead_of_duplicating_it() { )) .unwrap(); let old_revision = revision_bound(&state).revision(); - let old_bindings = revision_bound(&state).set().cases()[0].bindings().to_vec(); + let old_schema = revision_bound(&state).schema().to_vec(); + let old_values = revision_bound(&state).physical_values(0).unwrap().to_vec(); state.apply(unknown_update(STREAM, 2, 9)).unwrap(); assert!(state.current_observation().is_none()); assert!(matches!(state.head(), ObservationHeadViewV1::Unknown(_))); assert_eq!(old_revision, Revision::new(1)); - assert_eq!(old_bindings, vec![binding(PORT_A, [3, 4, 5])]); + assert_eq!(old_schema, vec![PORT_A]); + assert_eq!( + old_values, + vec![ColorSignal::from_srgb8(Srgb8::new([3, 4, 5]))] + ); } #[test] @@ -459,6 +592,36 @@ fn same_revision_exact_payload_is_idempotent_and_conflict_is_rejected() { assert_eq!(state, before); } +#[test] +fn same_revision_permuted_replay_is_idempotent_without_replacing_backing() { + let first = scenarios([ + scenario(9, [binding(PORT_B, [4, 5, 6]), binding(PORT_A, [1, 2, 3])]), + scenario(4, [binding(PORT_A, [9, 8, 7]), binding(PORT_B, [6, 5, 4])]), + scenario(3, [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])]), + ]); + let replay = scenarios([ + scenario(3, [binding(PORT_B, [4, 5, 6]), binding(PORT_A, [1, 2, 3])]), + scenario(9, [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])]), + scenario(4, [binding(PORT_B, [6, 5, 4]), binding(PORT_A, [9, 8, 7])]), + ]); + let mut state = TestState::new(STREAM, vec![PORT_B, PORT_A]).unwrap(); + assert_eq!( + state.apply(observed_update(STREAM, 7, first)), + Ok(UpdateDisposition::Applied) + ); + let before = state.clone(); + let backing_ptr = revision_bound(&state).backing_ptr_for_test(); + let schema_ptr = revision_bound(&state).schema_ptr_for_test(); + + assert_eq!( + state.apply(observed_update(STREAM, 7, replay)), + Ok(UpdateDisposition::Idempotent) + ); + assert_eq!(state, before); + assert_eq!(revision_bound(&state).backing_ptr_for_test(), backing_ptr); + assert_eq!(revision_bound(&state).schema_ptr_for_test(), schema_ptr); +} + #[test] fn lower_revision_and_foreign_stream_are_atomic_rejections() { let mut state = TestState::new(STREAM, vec![PORT_A]).unwrap(); @@ -532,7 +695,18 @@ fn two_streams_have_independent_watermarks_and_same_physics() { left.apply(observed_update(STREAM, 5, set.clone())).unwrap(); right.apply(observed_update(other, 1, set)).unwrap(); - assert_eq!(revision_bound(&left).set(), revision_bound(&right).set()); + assert_eq!( + revision_bound(&left).schema(), + revision_bound(&right).schema() + ); + assert_eq!( + revision_bound(&left).physical_values(0), + revision_bound(&right).physical_values(0) + ); + assert_eq!( + revision_bound(&left).provenance(0), + revision_bound(&right).provenance(0) + ); assert_ne!( revision_bound(&left).stream(), revision_bound(&right).stream() diff --git a/crates/labcolors-core/src/output_projection.rs b/crates/labcolors-core/src/output_projection.rs new file mode 100644 index 00000000..e0bf949f --- /dev/null +++ b/crates/labcolors-core/src/output_projection.rs @@ -0,0 +1,466 @@ +//! Private, release-bound output projection from one modeled LCS occurrence. +//! +//! This module deliberately admits one narrow edge only: +//! +//! ```text +//! modeled IEC sRGB8 signal +//! -> replayed tristimulus +//! -> the same context-bound LCS occurrence +//! -> solid CSS Color 4 `oklch(...)` + replayable certificate +//! ``` +//! +//! An output projection is neither an appearance view nor a pairwise +//! difference calibration. The nominal release identifiers below therefore +//! cannot be substituted for one another. No alpha/composition, inverse, +//! output-gamut transform, P3 path or perceptual metric is admitted by this +//! slice. + +use crate::lcs_occurrence::{ + AppearanceStateDerivationErrorV1, ColorSignal, HueAngle, HueState, LcsOccurrence, + ModeledLcsOccurrenceFormationErrorV1, ModeledLcsOccurrenceV1, ModeledTristimulusProvenanceV1, + NumericDomainError, OKLAB_VIEW_RELEASE_V1, OklabViewReleaseId, derive_oklab_view_v1, +}; + +/// Formula, policy and operation-order identity of an output projection. +/// +/// The source qualifier is intentional: this release can re-express only an +/// occurrence whose exact modeled IEC sRGB8 provenance is supplied and +/// replayed. It does not claim an inverse rendering solution for arbitrary +/// XYZ occurrences. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum OutputProjectionReleaseIdV1 { + CssColor4OklchD65FromModeledIec61966Srgb8SolidV1, +} + +impl OutputProjectionReleaseIdV1 { + pub(crate) const fn key(self) -> &'static str { + match self { + Self::CssColor4OklchD65FromModeledIec61966Srgb8SolidV1 => { + "css-color-4-oklch-d65-from-modeled-iec61966-srgb8-solid-v1" + } + } + } +} + +pub(crate) const CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1: OutputProjectionReleaseIdV1 = + OutputProjectionReleaseIdV1::CssColor4OklchD65FromModeledIec61966Srgb8SolidV1; + +/// No pairwise difference calibration is admitted by #441-A. +/// +/// Keeping this as a nominal, uninhabited type makes absence executable: code +/// cannot mint a distance result, alias an unrelated selector or pass +/// an output/appearance release where a calibration release is required. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum DifferenceCalibrationReleaseIdV1 {} + +impl DifferenceCalibrationReleaseIdV1 { + #[allow(dead_code)] + pub(crate) const fn key(self) -> &'static str { + match self {} + } +} + +/// Polar view derived from the registered rectangular Oklab appearance view. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum OklchViewReleaseId { + PolarFromOttosson20210125OklabV1, +} + +impl OklchViewReleaseId { + pub(crate) const fn key(self) -> &'static str { + match self { + Self::PolarFromOttosson20210125OklabV1 => "polar-from-ottosson-2021-01-25-oklab-v1", + } + } +} + +pub(crate) const OKLCH_VIEW_RELEASE_V1: OklchViewReleaseId = + OklchViewReleaseId::PolarFromOttosson20210125OklabV1; + +/// Finite binary64 appearance coordinate with canonical positive zero. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct FiniteOklchCoordinateV1(u64); + +impl FiniteOklchCoordinateV1 { + fn new(value: f64) -> Result { + if !value.is_finite() { + return Err(NumericDomainError::NonFinite); + } + Ok(Self(if value == 0.0 { + 0.0_f64.to_bits() + } else { + value.to_bits() + })) + } + + fn get(self) -> f64 { + f64::from_bits(self.0) + } +} + +/// Immutable polar Oklch view of the output projection's admitted occurrence. +/// +/// `UndefinedExact` remains a distinct appearance state. Mapping it to a CSS +/// numeric token happens later under +/// [`CssOklchHueSerializationReleaseIdV1`], never inside this view. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct OklchViewV1 { + release: OklchViewReleaseId, + l: FiniteOklchCoordinateV1, + c: FiniteOklchCoordinateV1, + hue: HueState, +} + +impl OklchViewV1 { + pub(crate) const fn release(self) -> OklchViewReleaseId { + self.release + } + + pub(crate) fn l(self) -> f64 { + self.l.get() + } + + pub(crate) fn c(self) -> f64 { + self.c.get() + } + + pub(crate) const fn hue(self) -> HueState { + self.hue + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum OklchViewFieldV1 { + OklabL, + OklabA, + OklabB, + OklchChroma, + OklchHue, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct OklchViewDerivationErrorV1 { + release: OklchViewReleaseId, + field: OklchViewFieldV1, + reason: NumericDomainError, +} + +impl OklchViewDerivationErrorV1 { + pub(crate) const fn release(self) -> OklchViewReleaseId { + self.release + } + + pub(crate) const fn field(self) -> OklchViewFieldV1 { + self.field + } + + pub(crate) const fn reason(self) -> NumericDomainError { + self.reason + } +} + +fn oklch_view_error( + field: OklchViewFieldV1, + reason: NumericDomainError, +) -> OklchViewDerivationErrorV1 { + OklchViewDerivationErrorV1 { + release: OKLCH_VIEW_RELEASE_V1, + field, + reason, + } +} + +/// Derive the named polar view before any CSS serialization policy runs. +/// +/// Hue is derived solely from the rectangular coordinates. Encoded-source +/// facts belong to serialization policy and cannot change this view. +fn derive_oklch_view_v1(oklab: [f64; 3]) -> Result { + let [l, a, b] = oklab; + let l = FiniteOklchCoordinateV1::new(l) + .map_err(|reason| oklch_view_error(OklchViewFieldV1::OklabL, reason))?; + FiniteOklchCoordinateV1::new(a) + .map_err(|reason| oklch_view_error(OklchViewFieldV1::OklabA, reason))?; + FiniteOklchCoordinateV1::new(b) + .map_err(|reason| oklch_view_error(OklchViewFieldV1::OklabB, reason))?; + let c = FiniteOklchCoordinateV1::new(a.hypot(b)) + .map_err(|reason| oklch_view_error(OklchViewFieldV1::OklchChroma, reason))?; + let hue = if a == 0.0 && b == 0.0 { + HueState::UndefinedExact + } else { + let degrees = b.atan2(a).to_degrees(); + let canonical = if degrees < 0.0 { + degrees + 360.0 + } else { + degrees + }; + HueState::Defined( + HueAngle::new(canonical) + .map_err(|reason| oklch_view_error(OklchViewFieldV1::OklchHue, reason))?, + ) + }; + Ok(OklchViewV1 { + release: OKLCH_VIEW_RELEASE_V1, + l, + c, + hue, + }) +} + +/// Exact decimal serialization policy carried by the projection certificate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum CssOklchNumberEncodingReleaseIdV1 { + LPercent5C6Hue3V1, +} + +/// Hue serialization is output syntax, not occurrence hue identity. +/// +/// Exact encoded greys and an exact rectangular Oklab origin serialize as the +/// harmless numeric CSS convention `0`. This never changes an appearance +/// view's [`crate::lcs_occurrence::HueState`] to `Defined(0°)` and introduces no +/// tolerance or perceptual-achromaticity threshold. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum CssOklchHueSerializationReleaseIdV1 { + ExactSourceGreyOrRectangularOriginToZeroV1, +} + +/// This projection release performs no explicit output-gamut mapping step. +/// +/// The name deliberately makes no identity or round-trip claim about a later +/// inverse conversion, quantizer or host renderer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum OutputGamutTreatmentV1 { + NoExplicitProjectionGamutMapV1, +} + +/// The release choice is made before any coordinates or output bytes exist. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct OutputProjectionRequestV1 { + source: ModeledLcsOccurrenceV1, + release: OutputProjectionReleaseIdV1, +} + +impl OutputProjectionRequestV1 { + pub(crate) const fn new( + source: ModeledLcsOccurrenceV1, + release: OutputProjectionReleaseIdV1, + ) -> Self { + Self { source, release } + } + + pub(crate) const fn source(self) -> ModeledLcsOccurrenceV1 { + self.source + } + + pub(crate) const fn release(self) -> OutputProjectionReleaseIdV1 { + self.release + } +} + +/// A solid CSS Color 4 value. There is intentionally no alpha field or +/// constructor accepting opacity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CssColor4OklchD65SolidV1(String); + +impl CssColor4OklchD65SolidV1 { + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum OutputProjectionFieldV1 { + OklchLightness, + OklchHueState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum OutputProjectionNumericErrorV1 { + LightnessOutsideSourceDomain, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum OutputProjectionErrorV1 { + Source(ModeledLcsOccurrenceFormationErrorV1), + OklabView(AppearanceStateDerivationErrorV1), + OklchView(OklchViewDerivationErrorV1), + Numeric { + release: OutputProjectionReleaseIdV1, + field: OutputProjectionFieldV1, + reason: OutputProjectionNumericErrorV1, + }, + UnsupportedHueState { + release: OutputProjectionReleaseIdV1, + field: OutputProjectionFieldV1, + hue: HueState, + }, +} + +/// All inputs and versioned policies needed to replay one projection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct OutputProjectionCertificateV1 { + source: ModeledLcsOccurrenceV1, + release: OutputProjectionReleaseIdV1, + oklab_release: OklabViewReleaseId, + oklch_release: OklchViewReleaseId, + number_encoding: CssOklchNumberEncodingReleaseIdV1, + hue_serialization: CssOklchHueSerializationReleaseIdV1, + gamut_treatment: OutputGamutTreatmentV1, +} + +impl OutputProjectionCertificateV1 { + pub(crate) const fn source(self) -> ModeledLcsOccurrenceV1 { + self.source + } + + pub(crate) const fn source_occurrence(self) -> LcsOccurrence { + self.source.occurrence() + } + + pub(crate) const fn source_provenance(self) -> ModeledTristimulusProvenanceV1 { + self.source.provenance() + } + + pub(crate) const fn source_signal(self) -> ColorSignal { + self.source.signal() + } + + pub(crate) const fn release(self) -> OutputProjectionReleaseIdV1 { + self.release + } + + pub(crate) const fn oklab_release(self) -> OklabViewReleaseId { + self.oklab_release + } + + pub(crate) const fn oklch_release(self) -> OklchViewReleaseId { + self.oklch_release + } + + pub(crate) const fn number_encoding(self) -> CssOklchNumberEncodingReleaseIdV1 { + self.number_encoding + } + + pub(crate) const fn hue_serialization(self) -> CssOklchHueSerializationReleaseIdV1 { + self.hue_serialization + } + + pub(crate) const fn gamut_treatment(self) -> OutputGamutTreatmentV1 { + self.gamut_treatment + } + + /// Re-run source provenance, view math and serialization under the same + /// release. Equality with the certified value is the byte replay check. + pub(crate) fn replay(self) -> Result { + project_output_v1(OutputProjectionRequestV1::new(self.source, self.release)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct OutputProjectionV1 { + oklch_view: OklchViewV1, + value: CssColor4OklchD65SolidV1, + certificate: OutputProjectionCertificateV1, +} + +impl OutputProjectionV1 { + pub(crate) const fn oklch_view(&self) -> OklchViewV1 { + self.oklch_view + } + + pub(crate) fn value(&self) -> &CssColor4OklchD65SolidV1 { + &self.value + } + + pub(crate) const fn certificate(&self) -> OutputProjectionCertificateV1 { + self.certificate + } +} + +fn numeric_error( + release: OutputProjectionReleaseIdV1, + field: OutputProjectionFieldV1, + reason: OutputProjectionNumericErrorV1, +) -> OutputProjectionErrorV1 { + OutputProjectionErrorV1::Numeric { + release, + field, + reason, + } +} + +fn project_css_color4_oklch_d65_from_modeled_srgb8_solid_v1( + source: ModeledLcsOccurrenceV1, + release: OutputProjectionReleaseIdV1, +) -> Result { + source.verify().map_err(OutputProjectionErrorV1::Source)?; + + // The occurrence sample, not the encoded source channels, owns appearance + // geometry. Provenance is used only to prove that this narrow output + // release may serialize the occurrence under its registered policies. + let oklab = derive_oklab_view_v1(source.occurrence().sample().xyz()) + .map_err(OutputProjectionErrorV1::OklabView)?; + let source_srgb8 = source.signal().srgb8(); + let oklch_view = derive_oklch_view_v1([oklab.l(), oklab.a(), oklab.b()]) + .map_err(OutputProjectionErrorV1::OklchView)?; + let l = oklch_view.l(); + if !(0.0..=1.0).contains(&l) { + return Err(numeric_error( + release, + OutputProjectionFieldV1::OklchLightness, + OutputProjectionNumericErrorV1::LightnessOutsideSourceDomain, + )); + } + + let hue_degrees = if source_srgb8.is_achromatic() { + 0.0 + } else { + match oklch_view.hue() { + HueState::Defined(angle) => angle.degrees(), + HueState::UndefinedExact => 0.0, + hue @ HueState::PowerlessBy(_) => { + return Err(OutputProjectionErrorV1::UnsupportedHueState { + release, + field: OutputProjectionFieldV1::OklchHueState, + hue, + }); + } + } + }; + + let value = CssColor4OklchD65SolidV1(format!( + "oklch({:.5}% {:.6} {:.3})", + l * 100.0, + oklch_view.c(), + hue_degrees, + )); + let certificate = OutputProjectionCertificateV1 { + source, + release, + oklab_release: OKLAB_VIEW_RELEASE_V1, + oklch_release: oklch_view.release(), + number_encoding: CssOklchNumberEncodingReleaseIdV1::LPercent5C6Hue3V1, + hue_serialization: + CssOklchHueSerializationReleaseIdV1::ExactSourceGreyOrRectangularOriginToZeroV1, + gamut_treatment: OutputGamutTreatmentV1::NoExplicitProjectionGamutMapV1, + }; + Ok(OutputProjectionV1 { + oklch_view, + value, + certificate, + }) +} + +/// Execute exactly the release selected in the request. There is no +/// result-dependent release selection or fallback. +pub(crate) fn project_output_v1( + request: OutputProjectionRequestV1, +) -> Result { + match request.release() { + OutputProjectionReleaseIdV1::CssColor4OklchD65FromModeledIec61966Srgb8SolidV1 => { + project_css_color4_oklch_d65_from_modeled_srgb8_solid_v1( + request.source(), + request.release(), + ) + } + } +} diff --git a/crates/labcolors-core/src/output_projection_tests.rs b/crates/labcolors-core/src/output_projection_tests.rs new file mode 100644 index 00000000..0beeeb4d --- /dev/null +++ b/crates/labcolors-core/src/output_projection_tests.rs @@ -0,0 +1,231 @@ +use std::any::TypeId; + +use crate::Srgb8; +use crate::lcs_occurrence::{ + AdaptingLuminanceCdM2, AppearanceContextId, AppearanceContextSchemaReleaseId, + BackgroundLuminanceRatio, ColorSignal, IEC_SRGB_D65_XYZ_FRAME_V1, LcsOccurrence, + ModeledLcsOccurrenceFormationErrorV1, ModeledLcsOccurrenceV1, ModeledTristimulusDerivationV1, + OKLAB_VIEW_RELEASE_V1, SurroundProfileId, derive_modeled_tristimulus_v1, +}; +use crate::output_projection::{ + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, CssOklchHueSerializationReleaseIdV1, + CssOklchNumberEncodingReleaseIdV1, DifferenceCalibrationReleaseIdV1, OKLCH_VIEW_RELEASE_V1, + OutputGamutTreatmentV1, OutputProjectionErrorV1, OutputProjectionReleaseIdV1, + OutputProjectionRequestV1, OutputProjectionV1, project_output_v1, +}; +use crate::spaces::oklab::oklab_to_srgb_linear; +use crate::spaces::srgb::srgb8_from_linear; + +fn context(adapting_luminance_cd_m2: f64) -> AppearanceContextId { + AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + IEC_SRGB_D65_XYZ_FRAME_V1, + AdaptingLuminanceCdM2::try_new(adapting_luminance_cd_m2).unwrap(), + BackgroundLuminanceRatio::try_new(0.2).unwrap(), + SurroundProfileId::AverageV1, + ) +} + +fn modeled(bytes: [u8; 3]) -> ModeledTristimulusDerivationV1 { + derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new(bytes))).unwrap() +} + +fn source(bytes: [u8; 3], adapting_luminance_cd_m2: f64) -> ModeledLcsOccurrenceV1 { + let modeled = modeled(bytes); + let occurrence = + LcsOccurrence::in_context(modeled.sample(), context(adapting_luminance_cd_m2)).unwrap(); + ModeledLcsOccurrenceV1::bind(occurrence, modeled).unwrap() +} + +fn project(bytes: [u8; 3], adapting_luminance_cd_m2: f64) -> OutputProjectionV1 { + project_output_v1(OutputProjectionRequestV1::new( + source(bytes, adapting_luminance_cd_m2), + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, + )) + .unwrap() +} + +fn difference_release_is_uninhabited(value: DifferenceCalibrationReleaseIdV1) -> ! { + match value {} +} + +#[test] +fn release_kinds_are_nominal_and_difference_registry_is_empty() { + assert_ne!( + TypeId::of::(), + TypeId::of::(), + ); + let _: fn(DifferenceCalibrationReleaseIdV1) -> ! = difference_release_is_uninhabited; + assert_eq!( + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1.key(), + "css-color-4-oklch-d65-from-modeled-iec61966-srgb8-solid-v1", + ); +} + +#[test] +fn projector_signature_has_no_alpha_metric_or_fallback_input() { + let projector: fn( + OutputProjectionRequestV1, + ) -> Result = project_output_v1; + assert!( + projector(OutputProjectionRequestV1::new( + source([0x44, 0x88, 0xCC], 64.0), + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, + )) + .is_ok() + ); +} + +#[test] +fn source_binding_rejects_bytes_from_an_unrelated_modeled_derivation() { + let occurrence_model = modeled([0x44, 0x88, 0xCC]); + let unrelated_model = modeled([0xCC, 0x88, 0x44]); + let occurrence = LcsOccurrence::in_context(occurrence_model.sample(), context(64.0)).unwrap(); + + assert_eq!( + ModeledLcsOccurrenceV1::bind(occurrence, unrelated_model), + Err( + ModeledLcsOccurrenceFormationErrorV1::OccurrenceSampleMismatch { + occurrence: occurrence_model.sample(), + modeled: unrelated_model.sample(), + } + ), + ); +} + +#[test] +fn certificate_replays_source_view_formula_and_exact_css_bytes() { + let projected = project([0x44, 0x88, 0xCC], 64.0); + let certificate = projected.certificate(); + + assert_eq!(certificate.replay().unwrap(), projected); + assert_eq!( + certificate.release(), + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, + ); + assert_eq!(certificate.oklab_release(), OKLAB_VIEW_RELEASE_V1); + assert_eq!(certificate.oklch_release(), OKLCH_VIEW_RELEASE_V1); + assert_eq!( + certificate.oklch_release().key(), + "polar-from-ottosson-2021-01-25-oklab-v1", + ); + assert_eq!( + certificate.number_encoding(), + CssOklchNumberEncodingReleaseIdV1::LPercent5C6Hue3V1, + ); + assert_eq!( + certificate.hue_serialization(), + CssOklchHueSerializationReleaseIdV1::ExactSourceGreyOrRectangularOriginToZeroV1, + ); + assert_eq!( + certificate.gamut_treatment(), + OutputGamutTreatmentV1::NoExplicitProjectionGamutMapV1, + ); + assert_eq!( + certificate.source_signal().srgb8().bytes(), + [0x44, 0x88, 0xCC] + ); + assert_eq!( + certificate.source_provenance(), + certificate.source().derivation().provenance(), + ); +} + +#[test] +fn occurrence_context_remains_in_certificate_identity_not_css_geometry() { + let first = project([0x44, 0x88, 0xCC], 32.0); + let second = project([0x44, 0x88, 0xCC], 64.0); + + assert_eq!(first.value(), second.value()); + assert_ne!(first.certificate(), second.certificate()); + assert_ne!( + first.certificate().source_occurrence(), + second.certificate().source_occurrence(), + ); +} + +#[test] +fn solid_css_has_no_alpha_and_exact_encoded_greys_use_the_release_zero_convention() { + for byte in u8::MIN..=u8::MAX { + let projected = project([byte; 3], 64.0); + let css = projected.value().as_str(); + let view = projected.oklch_view(); + match view.hue() { + crate::lcs_occurrence::HueState::UndefinedExact => assert_eq!(view.c(), 0.0), + crate::lcs_occurrence::HueState::Defined(_) => assert!(view.c() > 0.0), + crate::lcs_occurrence::HueState::PowerlessBy(_) => { + panic!("pure polar view introduced a powerless policy state") + } + } + assert!(css.starts_with("oklch(") && css.ends_with(')'), "{css}"); + assert!(!css.contains('/'), "solid release emitted alpha: {css}"); + assert!(css.ends_with(" 0.000)"), "grey hue policy drifted: {css}"); + assert!(!css.contains("-0."), "signed zero escaped: {css}"); + } + + assert!(matches!( + project([u8::MAX; 3], 64.0).oklch_view().hue(), + crate::lcs_occurrence::HueState::Defined(_), + )); +} + +fn parse_solid_css(css: &str) -> [f64; 3] { + let inner = css + .strip_prefix("oklch(") + .and_then(|value| value.strip_suffix(')')) + .expect("registered solid CSS shape"); + assert!(!inner.contains('/')); + let mut fields = inner.split_whitespace(); + let l = fields + .next() + .and_then(|value| value.strip_suffix('%')) + .expect("percentage lightness") + .parse::() + .unwrap() + / 100.0; + let c = fields.next().unwrap().parse::().unwrap(); + let h = fields.next().unwrap().parse::().unwrap(); + assert!(fields.next().is_none()); + [l, c, h] +} + +fn replay_css_through_existing_inverse_to_srgb8(css: &str) -> Srgb8 { + let [l, c, h] = parse_solid_css(css); + let (sin, cos) = h.to_radians().sin_cos(); + srgb8_from_linear(oklab_to_srgb_linear([l, c * cos, c * sin])) +} + +#[test] +fn registered_precision_replays_a_non_aligned_lattice_through_existing_inverse_clamp_and_round() { + // 16^3 points including both ends. This is a deterministic regression + // corpus, not a claim about every CSS implementation or all 16.7M inputs. + let steps: Vec = (0_u16..=255).step_by(17).map(|value| value as u8).collect(); + assert_eq!(steps.first(), Some(&0)); + assert_eq!(steps.last(), Some(&255)); + + for &red in &steps { + for &green in &steps { + for &blue in &steps { + let expected = Srgb8::new([red, green, blue]); + let projected = project(expected.bytes(), 64.0); + assert_eq!( + replay_css_through_existing_inverse_to_srgb8(projected.value().as_str()), + expected, + "CSS byte replay drifted: {}", + projected.value().as_str(), + ); + } + } + } +} + +#[test] +fn fixed_css_values_pin_formula_order_and_solid_serialization() { + for (bytes, expected) in [ + ([0x00, 0x00, 0x00], "oklch(0.00000% 0.000000 0.000)"), + ([0xFF, 0xFF, 0xFF], "oklch(100.00000% 0.000000 0.000)"), + ([0xFF, 0x00, 0x00], "oklch(62.79554% 0.257683 29.234)"), + ] { + assert_eq!(project(bytes, 64.0).value().as_str(), expected); + } +} diff --git a/crates/labcolors-core/src/pair.rs b/crates/labcolors-core/src/pair.rs deleted file mode 100644 index 83423c50..00000000 --- a/crates/labcolors-core/src/pair.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! P1: frozen Pair frontend поверх общей point graph algebra. -//! -//! Модуль не выбирает «сторону пары», не двигает цвет по Oklab и не содержит -//! собственного compositor-а или evaluator switch. Он только лоуверит -//! замороженный authoring tag в одну физическую цепочку и передаёт конечный -//! candidate domain общему joint engine. - -use crate::Srgb8; -use crate::appearance::{ - EncodedPointPaintV1, PaintId, PointOpacityOverSurfaceV1, ResolvedOccurrence, SurfaceInputPortId, -}; -use crate::composition::AdmittedOpacityV1; -use crate::constraints::{ExactSrgb8IdentityV1, Wcag22Srgb8V1}; -use crate::joint::{ - CandidateOrdinalV1, CandidateSetErrorV1, DeclaredTotalOrderV1, JointCandidateSetV1, - JointCandidateTupleV1, JointConstraintIdV1, JointExecutionRecordV1, JointProgramErrorV1, - JointVisibleTargetV1, PointwiseFullHardReportV1, PointwiseHardFeasibilityV1, - PointwiseJointHardConstraintV1, PointwiseJointPointProgramV1, PointwiseJointReportErrorV1, - PointwiseSelectedRecheckErrorV1, PointwiseVerifiedSelectionV1, SelectionPolicyErrorV1, - StaticJointObservationV1, -}; -use crate::wcag22::Wcag22CriterionV1; - -const ROOT_SURFACE: SurfaceInputPortId = SurfaceInputPortId::new(1); -const FILL_PAINT: PaintId = PaintId::new(1); -const LABEL_PAINT: PaintId = PaintId::new(2); -const LABEL_CONSTRAINT: JointConstraintIdV1 = JointConstraintIdV1::new(1); - -/// Один канонический fill Paint, действительно применённый к page Surface. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct LoweredPairFillV1 { - paint: EncodedPointPaintV1, - occurrence: ResolvedOccurrence, -} - -impl LoweredPairFillV1 { - pub(crate) const fn paint(self) -> EncodedPointPaintV1 { - self.paint - } - - pub(crate) const fn occurrence(&self) -> &ResolvedOccurrence { - &self.occurrence - } - - pub(crate) fn visible(self) -> Srgb8 { - Srgb8::new(self.occurrence.visible()) - } -} - -/// Один frontend-proposed label Paint. Ordinal задаёт только identity; порядок -/// предпочтения приходит отдельным declared total order. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct PairLabelCandidateV1 { - ordinal: CandidateOrdinalV1, - source: Srgb8, -} - -impl PairLabelCandidateV1 { - pub(crate) const fn new(ordinal: CandidateOrdinalV1, source: Srgb8) -> Self { - Self { ordinal, source } - } - - pub(crate) const fn source(self) -> Srgb8 { - self.source - } -} - -/// Hard requirement PairLabel. Отсутствие пола означает пустой hard-set, а не -/// тождественный `Exact(candidate, candidate)`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum PairLabelRequirementV1 { - None, - Wcag22(Wcag22CriterionV1), -} - -#[derive(Debug, Clone, PartialEq)] -enum PairSelectionEvidenceV1 { - Unconstrained(PointwiseVerifiedSelectionV1), - Wcag22(PointwiseVerifiedSelectionV1), -} - -/// Полное fresh evidence одной выбранной Pair-кандидатуры. -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct VerifiedPairV1 { - evidence: PairSelectionEvidenceV1, -} - -impl VerifiedPairV1 { - fn execution(&self) -> &JointExecutionRecordV1 { - let executions = match &self.evidence { - PairSelectionEvidenceV1::Unconstrained(evidence) => evidence.fresh_executions(), - PairSelectionEvidenceV1::Wcag22(evidence) => evidence.fresh_executions(), - }; - executions.first().unwrap_or_else(|| { - unreachable!("one selected Pair tuple over one case has one execution") - }) - } - - pub(crate) fn ordinal(&self) -> CandidateOrdinalV1 { - match &self.evidence { - PairSelectionEvidenceV1::Unconstrained(evidence) => evidence.ordinal(), - PairSelectionEvidenceV1::Wcag22(evidence) => evidence.ordinal(), - } - } - - pub(crate) fn fill_paint(&self) -> EncodedPointPaintV1 { - self.execution().lower_paint() - } - - pub(crate) fn label_paint(&self) -> EncodedPointPaintV1 { - self.execution().upper_paint() - } - - pub(crate) fn fill_occurrence(&self) -> &ResolvedOccurrence { - self.execution().lower_occurrence() - } - - pub(crate) fn label_occurrence(&self) -> &ResolvedOccurrence { - self.execution().upper_occurrence() - } -} - -#[derive(Debug, Clone, PartialEq)] -pub(crate) enum PairLoweringErrorV1 { - Candidate(CandidateSetErrorV1), - Program(JointProgramErrorV1), - ExactReport(PointwiseJointReportErrorV1), - WcagReport(PointwiseJointReportErrorV1), - Policy(SelectionPolicyErrorV1), - ExactInfeasible(Box>), - WcagInfeasible(Box>), - ExactRecheck(PointwiseSelectedRecheckErrorV1), - WcagRecheck(PointwiseSelectedRecheckErrorV1), -} - -/// Материализовать fill occurrence без role-specific эвристики. -pub(crate) fn lower_fill( - source: Srgb8, - opacity: AdmittedOpacityV1, - backdrop: Srgb8, -) -> LoweredPairFillV1 { - let paint = EncodedPointPaintV1::from_admitted(FILL_PAINT, source, opacity); - let occurrence = - PointOpacityOverSurfaceV1::evaluate_admitted(source.bytes(), opacity, backdrop.bytes()); - LoweredPairFillV1 { paint, occurrence } -} - -/// Выбрать один label Paint из полного frontend candidate domain. Fill Paint -/// фиксирован authoring representation-ом, но каждый tuple исполняет обе -/// linked occurrences; hard report и fresh recheck используют тот же kernel. -pub(crate) fn select_label_candidates( - fill_source: Srgb8, - fill_opacity: AdmittedOpacityV1, - candidates: Vec, - declared_order: Vec, - backdrop: Srgb8, - requirement: PairLabelRequirementV1, -) -> Result { - let tuples = candidates - .into_iter() - .map(|candidate| { - JointCandidateTupleV1::new( - candidate.ordinal, - EncodedPointPaintV1::from_admitted(FILL_PAINT, fill_source, fill_opacity), - EncodedPointPaintV1::from_admitted( - LABEL_PAINT, - candidate.source, - AdmittedOpacityV1::OPAQUE, - ), - ) - }) - .collect(); - let candidates = JointCandidateSetV1::new(tuples).map_err(PairLoweringErrorV1::Candidate)?; - let policy = DeclaredTotalOrderV1::new(&candidates, declared_order) - .map_err(PairLoweringErrorV1::Policy)?; - let observation = StaticJointObservationV1::one_case(ROOT_SURFACE, backdrop); - - match requirement { - PairLabelRequirementV1::None => { - let program = PointwiseJointPointProgramV1::with_evaluator( - ExactSrgb8IdentityV1, - ROOT_SURFACE, - FILL_PAINT, - LABEL_PAINT, - Vec::new(), - ) - .map_err(PairLoweringErrorV1::Program)?; - let report = program - .evaluate_static(candidates, observation) - .map_err(PairLoweringErrorV1::ExactReport)?; - let feasible = match report.classify() { - PointwiseHardFeasibilityV1::NonEmpty(feasible) => feasible, - PointwiseHardFeasibilityV1::Infeasible(report) => { - return Err(PairLoweringErrorV1::ExactInfeasible(Box::new(report))); - } - }; - let verified = feasible - .select(policy) - .recheck() - .map_err(PairLoweringErrorV1::ExactRecheck)?; - Ok(VerifiedPairV1 { - evidence: PairSelectionEvidenceV1::Unconstrained(verified), - }) - } - PairLabelRequirementV1::Wcag22(criterion) => { - let constraints = vec![PointwiseJointHardConstraintV1::new( - LABEL_CONSTRAINT, - JointVisibleTargetV1::Upper, - criterion, - )]; - let program = PointwiseJointPointProgramV1::with_evaluator( - Wcag22Srgb8V1, - ROOT_SURFACE, - FILL_PAINT, - LABEL_PAINT, - constraints, - ) - .map_err(PairLoweringErrorV1::Program)?; - let report = program - .evaluate_static(candidates, observation) - .map_err(PairLoweringErrorV1::WcagReport)?; - let feasible = match report.classify() { - PointwiseHardFeasibilityV1::NonEmpty(feasible) => feasible, - PointwiseHardFeasibilityV1::Infeasible(report) => { - return Err(PairLoweringErrorV1::WcagInfeasible(Box::new(report))); - } - }; - let verified = feasible - .select(policy) - .recheck() - .map_err(PairLoweringErrorV1::WcagRecheck)?; - Ok(VerifiedPairV1 { - evidence: PairSelectionEvidenceV1::Wcag22(verified), - }) - } - } -} diff --git a/crates/labcolors-core/src/pair_label_tests.rs b/crates/labcolors-core/src/pair_label_tests.rs deleted file mode 100644 index b3371a2b..00000000 --- a/crates/labcolors-core/src/pair_label_tests.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! P1 Pair frontend: emitted fill Surface, joint hard selection and anti-vacuum. - -use crate::Srgb8; -use crate::composition::AdmittedOpacityV1; -use crate::config::fixture::labui_reference; -use crate::config::{LadderSource, RoleRecipe}; -use crate::joint::CandidateOrdinalV1; -use crate::pair::{ - PairLabelCandidateV1, PairLabelRequirementV1, lower_fill, select_label_candidates, -}; -use crate::semantic::{NamedRoleTable, RoleChroma, RoleSpec, resolve_named_set}; -use crate::solve::{BgInput, Floor, SolveFailure}; -use crate::spaces::vc::ViewingConditions; -use crate::wcag22::Wcag22CriterionV1; - -fn enc(hex: &str) -> [f64; 3] { - crate::spaces::srgb::srgb_encoded_from_hex(hex).unwrap() -} - -#[test] -fn fill_is_one_exact_opaque_occurrence_without_pair_heuristic() { - let source = Srgb8::new([0x00, 0x7A, 0xFF]); - let backdrop = Srgb8::new([0xFF; 3]); - let fill = lower_fill(source, AdmittedOpacityV1::OPAQUE, backdrop); - - assert_eq!(fill.paint().source(), source); - assert_eq!(fill.paint().opacity(), AdmittedOpacityV1::OPAQUE); - assert_eq!(fill.visible(), source); - assert_eq!( - fill.occurrence().certificate().backdrop_rgb(), - backdrop.bytes() - ); -} - -#[test] -fn wcag_joint_selection_rejects_preferred_tuple_and_selects_legal_tuple() { - let dark_fill = Srgb8::new([0x10; 3]); - let preferred_dark = Srgb8::new([0x20; 3]); - let legal_light = Srgb8::new([0xFF; 3]); - let verified = select_label_candidates( - dark_fill, - AdmittedOpacityV1::OPAQUE, - vec![ - PairLabelCandidateV1::new(CandidateOrdinalV1::new(1), preferred_dark), - PairLabelCandidateV1::new(CandidateOrdinalV1::new(2), legal_light), - ], - vec![CandidateOrdinalV1::new(1), CandidateOrdinalV1::new(2)], - Srgb8::new([0xEE; 3]), - PairLabelRequirementV1::Wcag22(Wcag22CriterionV1::Sc143TextDefault), - ) - .unwrap(); - - assert_eq!(verified.ordinal(), CandidateOrdinalV1::new(2)); - assert_eq!(verified.fill_occurrence().visible(), dark_fill.bytes()); - assert_eq!(verified.label_occurrence().visible(), legal_light.bytes()); - assert_eq!( - verified.label_occurrence().certificate().backdrop_rgb(), - dark_fill.bytes() - ); -} - -#[test] -fn pair_fill_and_pair_label_share_the_same_emitted_surface() { - let mut config = labui_reference(); - config.roles.push(( - "badge-label-brand".into(), - RoleRecipe::PairLabel { - source: LadderSource::Brand, - fraction: 0.461, - floor: Floor::AaUi, - }, - )); - let table = config.compile_named_role_table().unwrap(); - - for (background, vc) in [ - ("#FFFFFF", ViewingConditions::srgb()), - ("#101012", ViewingConditions::dim_surround()), - ] { - let set = resolve_named_set(&BgInput::solid(background).unwrap(), &table, &vc).unwrap(); - let fill = set - .iter() - .find(|(name, _)| name == "badge-fill-brand") - .unwrap() - .1 - .translucent() - .unwrap(); - let label = set - .iter() - .find(|(name, _)| name == "badge-label-brand") - .unwrap() - .1 - .solved() - .unwrap(); - - assert_eq!(fill.alpha().to_bits(), 1.0_f64.to_bits()); - assert_eq!(fill.tint_hex(), fill.composite_hex()); - let ratio = crate::wcag::contrast_ratio(enc(label.hex()), enc(fill.composite_hex())); - assert!(ratio >= 3.0, "{background}: got {ratio}"); - } -} - -#[test] -fn pair_result_is_independent_of_client_role_names() { - let blue = [0.0, 0.47843137254901963, 1.0]; - let tint = crate::ladder::LadderTint::new([blue; 4]).unwrap(); - let entries = |fill: &str, label: &str| { - vec![ - (fill.into(), RoleSpec::PairFill { tint }), - ( - label.into(), - RoleSpec::PairLabel { - tint, - fraction: 0.461, - floor: Floor::AaUi, - surface_alpha_light: 1.0, - surface_alpha_dark: 1.0, - }, - ), - ] - }; - let left = NamedRoleTable::new(entries("x", "y"), Vec::new(), RoleChroma::Neutral).unwrap(); - let right = NamedRoleTable::new( - entries("danger-primary", "surface-hover"), - Vec::new(), - RoleChroma::Neutral, - ) - .unwrap(); - let bg = BgInput::solid("#FFFFFF").unwrap(); - let a = resolve_named_set(&bg, &left, &ViewingConditions::srgb()).unwrap(); - let b = resolve_named_set(&bg, &right, &ViewingConditions::srgb()).unwrap(); - - assert_eq!(a[0].1, b[0].1); - assert_eq!(a[1].1, b[1].1); -} - -#[test] -fn nonopaque_pairlabel_transport_is_rejected_before_execution() { - let tint = crate::ladder::LadderTint::new([[0.0, 0.0, 0.0]; 4]).unwrap(); - let error = NamedRoleTable::new( - vec![( - "label".into(), - RoleSpec::PairLabel { - tint, - fraction: 0.5, - floor: Floor::AaUi, - surface_alpha_light: 0.122, - surface_alpha_dark: 0.122, - }, - )], - Vec::new(), - RoleChroma::Neutral, - ) - .unwrap_err(); - - assert!(matches!(error, SolveFailure::InvalidInput(message) if message.contains("opaque"))); -} - -#[test] -fn floor_none_has_no_tautological_exact_constraint() { - let verified = select_label_candidates( - Srgb8::new([0x80; 3]), - AdmittedOpacityV1::OPAQUE, - vec![PairLabelCandidateV1::new( - CandidateOrdinalV1::new(1), - Srgb8::new([0x81; 3]), - )], - vec![CandidateOrdinalV1::new(1)], - Srgb8::new([0x00; 3]), - PairLabelRequirementV1::None, - ) - .unwrap(); - - assert_eq!(verified.ordinal(), CandidateOrdinalV1::new(1)); -} diff --git a/crates/labcolors-core/src/point_support.rs b/crates/labcolors-core/src/point_support.rs index 15116897..a61baa55 100644 --- a/crates/labcolors-core/src/point_support.rs +++ b/crates/labcolors-core/src/point_support.rs @@ -26,8 +26,14 @@ use crate::numerics::{ NumericalEvidenceClassV2, NumericalFallbackStatusV1, NumericalProofIdV2, NumericalSiteIdV2, StableNumericalOutcomeV2, numerical_registry_v2, }; -use crate::observation::{ObservationSchemaMismatchV1, RevisionBoundObservationV1, ScenarioId}; -use crate::session::SessionObservationBindingPermitV1; +use crate::observation::{ + CanonicalObservationSchemaV1, ObservationError, ObservationSchemaMismatchV1, + RevisionBoundObservationV1, ScenarioId, canonicalize_observation_schema, +}; +use crate::session::{ + SessionDecision, SessionEvidenceV1, SessionObservationBindingPermitV1, SessionPlanV1, + private as session_private, +}; use crate::wcag22::{Wcag22CriterionV1, Wcag22MeasurementV1, measure_wcag22_srgb8}; const DROP_BASIS_POINTS_SCALE: u16 = 10_000; @@ -139,18 +145,18 @@ pub(crate) enum PointSupportCompileErrorV1 { NumericalRegistryInvariant, } -/// Private compiler output for one role table. It owns its canonical surface -/// schema, but preserves declared occurrence order because that order is the -/// client-owned packed ordinal and witness order. Prebound surface indices are -/// only a cache: runtime keyed bindings remain truth and every cached position -/// is checked against its exact port before use. +/// Private compiler output for one role table. It owns the canonical shared +/// surface schema, but preserves declared occurrence order because that order +/// is the client-owned packed ordinal and witness order. Runtime observations +/// share this exact schema backing, so prebound positions require no keyed +/// lookup or per-case schema scan. #[derive(Debug, PartialEq, Eq)] #[cfg_attr(test, derive(Clone))] pub(crate) struct CompiledPointSupportRecheckV1 { physical_program: PhysicalProgramIdentityV1, composition_profile: CompositionProfileV1, occurrences: Vec, - surface_schema: Vec, + surface_schema: CanonicalObservationSchemaV1, surface_indices: Vec, baselines: Vec>, } @@ -177,34 +183,39 @@ impl CompiledPointSupportRecheckV1 { return Err(PointSupportCompileErrorV1::InactivePlan); } - let mut paint_definitions = Vec::new(); - paint_definitions - .try_reserve_exact(occurrences.len()) - .map_err(|_| PointSupportCompileErrorV1::ResourceExhausted)?; - paint_definitions.extend(occurrences.iter().map(|occurrence| occurrence.paint)); - paint_definitions.sort_unstable_by_key(|paint| paint.id()); - if let Some(drift) = paint_definitions - .windows(2) - .find(|window| window[0].id() == window[1].id() && window[0] != window[1]) { - return Err(PointSupportCompileErrorV1::PaintDefinitionMismatch( - drift[0].id(), - )); + let mut paint_definitions = Vec::new(); + paint_definitions + .try_reserve_exact(occurrences.len()) + .map_err(|_| PointSupportCompileErrorV1::ResourceExhausted)?; + paint_definitions.extend(occurrences.iter().map(|occurrence| occurrence.paint)); + paint_definitions.sort_unstable_by_key(|paint| paint.id()); + if let Some(drift) = paint_definitions + .windows(2) + .find(|window| window[0].id() == window[1].id() && window[0] != window[1]) + { + return Err(PointSupportCompileErrorV1::PaintDefinitionMismatch( + drift[0].id(), + )); + } } - let mut occurrence_identities = Vec::new(); - occurrence_identities - .try_reserve_exact(occurrences.len()) - .map_err(|_| PointSupportCompileErrorV1::ResourceExhausted)?; - occurrence_identities.extend(occurrences.iter().map(|occurrence| occurrence.occurrence)); - occurrence_identities.sort_unstable(); - if let Some(duplicate) = occurrence_identities - .windows(2) - .find(|window| window[0] == window[1]) { - return Err(PointSupportCompileErrorV1::DuplicateOccurrence( - duplicate[0], - )); + let mut occurrence_identities = Vec::new(); + occurrence_identities + .try_reserve_exact(occurrences.len()) + .map_err(|_| PointSupportCompileErrorV1::ResourceExhausted)?; + occurrence_identities + .extend(occurrences.iter().map(|occurrence| occurrence.occurrence)); + occurrence_identities.sort_unstable(); + if let Some(duplicate) = occurrence_identities + .windows(2) + .find(|window| window[0] == window[1]) + { + return Err(PointSupportCompileErrorV1::DuplicateOccurrence( + duplicate[0], + )); + } } let actual_profile = PointOpacityOverSurfaceV1::composition_profile(); @@ -233,6 +244,13 @@ impl CompiledPointSupportRecheckV1 { .map_err(|_| PointSupportCompileErrorV1::SurfaceSchemaInvariant)?; surface_indices.push(index); } + let surface_schema = canonicalize_observation_schema(surface_schema).map_err(|error| { + if matches!(error, ObservationError::ResourceExhausted) { + PointSupportCompileErrorV1::ResourceExhausted + } else { + PointSupportCompileErrorV1::SurfaceSchemaInvariant + } + })?; let mut baselines = Vec::new(); baselines @@ -297,54 +315,35 @@ impl CompiledPointSupportRecheckV1 { } pub(crate) fn surface_schema(&self) -> &[SurfaceInputPortId] { - &self.surface_schema - } - - pub(crate) fn into_session_recheck(self) -> BoundPointSupportRecheckV1 { - let Self { - physical_program, - composition_profile, - occurrences, - surface_schema, - surface_indices, - baselines, - } = self; - BoundPointSupportRecheckV1 { - physical_program, - composition_profile, - occurrences, - surface_schema, - surface_indices, - baselines, - } + self.surface_schema.as_slice() } } -#[derive(Debug, PartialEq, Eq)] -#[cfg_attr(test, derive(Clone))] -pub(crate) struct BoundPointSupportRecheckV1 { - physical_program: PhysicalProgramIdentityV1, - composition_profile: CompositionProfileV1, - occurrences: Vec, - surface_schema: Vec, - surface_indices: Vec, - baselines: Vec>, -} +impl session_private::PlanSealed for CompiledPointSupportRecheckV1 {} -impl BoundPointSupportRecheckV1 { - pub(crate) const fn composition_profile(&self) -> CompositionProfileV1 { - self.composition_profile +impl SessionPlanV1 for CompiledPointSupportRecheckV1 { + type OwnerLease = (); + type Verified = VerifiedPointSupportV1; + type Violation = PointSupportViolationV1; + type Error = PointSupportEvaluationErrorV1; + + fn try_acquire_owner(&self) -> Option { + Some(()) } - pub(crate) fn surface_schema(&self) -> &[SurfaceInputPortId] { + fn observation_schema<'a>( + &'a self, + _owner: &'a Self::OwnerLease, + ) -> &'a CanonicalObservationSchemaV1 { &self.surface_schema } - pub(crate) fn evaluate( - &self, + fn evaluate( + &mut self, + _owner: &Self::OwnerLease, observation: RevisionBoundObservationV1, _permit: SessionObservationBindingPermitV1, - ) -> Result { + ) -> Result, Self::Error> { let assessment = evaluate_bound_point_support(self, &observation)?; Ok(assessment.bind(observation)) } @@ -629,7 +628,10 @@ impl PointSupportAssessmentV1 { /// Bind by move only. There is no allocation, recomputation or other /// fallible work after the consuming evaluator has completed assessment. - fn bind(self, observation: RevisionBoundObservationV1) -> PointSupportDecisionV1 { + fn bind( + self, + observation: RevisionBoundObservationV1, + ) -> SessionDecision { let failed = self.has_failure(); let Self { physical_program, @@ -655,9 +657,9 @@ impl PointSupportAssessmentV1 { first_stability_failure_index, }; if failed { - PointSupportDecisionV1::Violation(PointSupportViolationV1(report)) + SessionDecision::Violation(PointSupportViolationV1(report)) } else { - PointSupportDecisionV1::Verified(VerifiedPointSupportV1(report)) + SessionDecision::Verified(VerifiedPointSupportV1(report)) } } } @@ -744,6 +746,14 @@ impl RevisionBoundPointSupportReportV1 { #[cfg_attr(test, derive(Clone))] pub(crate) struct VerifiedPointSupportV1(RevisionBoundPointSupportReportV1); +impl session_private::EvidenceSealed for VerifiedPointSupportV1 {} + +impl SessionEvidenceV1 for VerifiedPointSupportV1 { + fn observation(&self) -> &RevisionBoundObservationV1 { + self.report().observation() + } +} + impl VerifiedPointSupportV1 { pub(crate) const fn report(&self) -> &RevisionBoundPointSupportReportV1 { &self.0 @@ -754,19 +764,20 @@ impl VerifiedPointSupportV1 { #[cfg_attr(test, derive(Clone))] pub(crate) struct PointSupportViolationV1(RevisionBoundPointSupportReportV1); +impl session_private::EvidenceSealed for PointSupportViolationV1 {} + +impl SessionEvidenceV1 for PointSupportViolationV1 { + fn observation(&self) -> &RevisionBoundObservationV1 { + self.report().observation() + } +} + impl PointSupportViolationV1 { pub(crate) const fn report(&self) -> &RevisionBoundPointSupportReportV1 { &self.0 } } -#[derive(Debug, PartialEq)] -#[cfg_attr(test, derive(Clone))] -pub(crate) enum PointSupportDecisionV1 { - Verified(VerifiedPointSupportV1), - Violation(PointSupportViolationV1), -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct EnabledBaselineV1 { composition: SourceOverCertificateV1, @@ -779,7 +790,7 @@ struct EnabledBaselineV1 { } fn evaluate_bound_point_support( - plan: &BoundPointSupportRecheckV1, + plan: &CompiledPointSupportRecheckV1, observation: &RevisionBoundObservationV1, ) -> Result { if plan.occurrences.len() != plan.surface_indices.len() @@ -787,9 +798,12 @@ fn evaluate_bound_point_support( { return Err(PointSupportEvaluationErrorV1::CompiledPlanInvariant); } - observation - .validate_surface_schema(&plan.surface_schema) - .map_err(PointSupportEvaluationErrorV1::ObservationSchemaMismatch)?; + if !observation.shares_schema_backing_with(&plan.surface_schema) { + observation + .validate_surface_schema(plan.surface_schema.as_slice()) + .map_err(PointSupportEvaluationErrorV1::ObservationSchemaMismatch)?; + return Err(PointSupportEvaluationErrorV1::CompiledPlanInvariant); + } let cell_count = observation .physical_case_count() @@ -807,41 +821,31 @@ fn evaluate_bound_point_support( let mut first_required_failure_index = None; let mut first_stability_failure_index = None; - for (case_index, case) in observation.set().cases().iter().enumerate() { - for (occurrence_index, requirement) in plan.occurrences.iter().enumerate() { - let surface_index = *plan - .surface_indices - .get(occurrence_index) - .ok_or(PointSupportEvaluationErrorV1::CompiledPlanInvariant)?; - let baseline = plan - .baselines - .get(occurrence_index) - .ok_or(PointSupportEvaluationErrorV1::CompiledPlanInvariant)?; - let binding = case.bindings().get(surface_index).ok_or( + for case_index in 0..observation.physical_case_count() { + let values = observation + .physical_values(case_index) + .ok_or(PointSupportEvaluationErrorV1::CompiledPlanInvariant)?; + for (occurrence_index, ((requirement, surface_index), baseline)) in plan + .occurrences + .iter() + .zip(&plan.surface_indices) + .zip(&plan.baselines) + .enumerate() + { + let backdrop = values.get(*surface_index).copied().ok_or( PointSupportEvaluationErrorV1::ObservationSchemaMismatch( ObservationSchemaMismatchV1::new( case_index, - surface_index, + *surface_index, Some(requirement.surface), None, ), ), )?; - if binding.port() != requirement.surface { - return Err(PointSupportEvaluationErrorV1::ObservationSchemaMismatch( - ObservationSchemaMismatchV1::new( - case_index, - surface_index, - Some(requirement.surface), - Some(binding.port()), - ), - )); - } - let backdrop = binding.value(); let physical = PointOpacityOverSurfaceV1::evaluate_admitted( requirement.paint.source().bytes(), requirement.paint.opacity(), - backdrop.bytes(), + backdrop.srgb8().bytes(), ); let exact = match requirement.exact_invocation { diff --git a/crates/labcolors-core/src/point_support_tests.rs b/crates/labcolors-core/src/point_support_tests.rs index b377db60..25565796 100644 --- a/crates/labcolors-core/src/point_support_tests.rs +++ b/crates/labcolors-core/src/point_support_tests.rs @@ -3,6 +3,7 @@ use crate::appearance::{ EncodedPointPaintV1, OccurrenceId, PaintId, PhysicalProgramIdentityV1, SurfaceInputPortId, }; use crate::composition::{AdmittedOpacityV1, CompositionProfileV1}; +use crate::lcs_occurrence::ColorSignal; use crate::observation::{ ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, ObservedScenarioSetInput, Revision, ScenarioId, ScenarioInput, SurfaceInputBinding, @@ -15,7 +16,7 @@ use crate::point_support::{ PointSupportStabilityAnchorV1, PointSupportStabilityAssessmentV1, PointSupportStabilityDecisionV1, PointSupportStabilityPolicyV1, }; -use crate::session::{PointSupportSessionStateV1, PointSupportSessionV1}; +use crate::session::{Session, SessionPlanV1, SessionState}; use crate::wcag22::Wcag22CriterionV1; const STREAM: ObservationStreamId = ObservationStreamId::new(31); @@ -59,6 +60,60 @@ fn compiled( .unwrap() } +#[test] +fn point_support_session_owns_exactly_one_canonical_schema_handle() { + let requirements = compiled(vec![occurrence( + OCCURRENCE_A, + SURFACE_A, + paint(PAINT_A, [0; 3], 1.0), + Some([0; 3]), + PointSupportCriterionRequirementV1::NotRequested, + PointSupportStabilityPolicyV1::Disabled, + )]); + + assert_eq!( + requirements.observation_schema(&()).strong_count_for_test(), + 1, + ); + + let schema_ptr = requirements.observation_schema(&()).backing_ptr_for_test(); + let mut session = Session::new(STREAM, requirements); + assert_eq!( + session + .plan() + .observation_schema(&()) + .strong_count_for_test(), + 1, + ); + + let report_schema_ptr = match session + .update(observed_update(1, [(1, vec![(SURFACE_A, [0; 3])])])) + .unwrap() + { + SessionState::Ready { current } => current.report().observation().schema_ptr_for_test(), + _ => panic!("the exact point-support requirement must verify"), + }; + assert_eq!(report_schema_ptr, schema_ptr); + assert_eq!( + session + .plan() + .observation_schema(&()) + .strong_count_for_test(), + 2, + ); + + session + .update(observed_update(1, [(1, vec![(SURFACE_A, [0; 3])])])) + .unwrap(); + assert_eq!( + session + .plan() + .observation_schema(&()) + .strong_count_for_test(), + 2, + ); +} + fn observed_update( revision: u64, scenarios: impl IntoIterator)>, @@ -73,7 +128,12 @@ fn observed_update( id: ScenarioId::new(id), bindings: bindings .into_iter() - .map(|(port, value)| SurfaceInputBinding::new(port, Srgb8::new(value))) + .map(|(port, value)| { + SurfaceInputBinding::new( + port, + ColorSignal::from_srgb8(Srgb8::new(value)), + ) + }) .collect(), }) .collect(), @@ -106,8 +166,8 @@ fn multi_paint_declared_order_and_direct_provenance_are_preserved() { assert_eq!(SURFACE_A.value(), 21); assert_eq!(OCCURRENCE_A.value(), 11); - let mut session = PointSupportSessionV1::new(STREAM, requirements); - let PointSupportSessionStateV1::Ready { current } = session + let mut session = Session::new(STREAM, requirements); + let SessionState::Ready { current } = session .update(observed_update( 1, [(9, vec![(SURFACE_A, [0; 3]), (SURFACE_B, [255; 3])])], @@ -142,6 +202,129 @@ fn multi_paint_declared_order_and_direct_provenance_are_preserved() { assert_eq!(cells[1].provenance(), &[ScenarioId::new(9)]); } +#[test] +fn duplicate_raw_scenarios_share_one_physical_case_without_cartesian_expansion() { + let requirements = compiled(vec![ + occurrence( + OCCURRENCE_A, + SURFACE_A, + paint(PAINT_A, [0; 3], 0.5), + Some([128; 3]), + PointSupportCriterionRequirementV1::NotRequested, + PointSupportStabilityPolicyV1::Disabled, + ), + occurrence( + OCCURRENCE_B, + SURFACE_B, + paint(PAINT_B, [255; 3], 0.5), + Some([128; 3]), + PointSupportCriterionRequirementV1::NotRequested, + PointSupportStabilityPolicyV1::Disabled, + ), + ]); + let mut session = Session::new(STREAM, requirements); + + crate::composition::reset_source_over_evaluation_count(); + let SessionState::Failed { cause, previous } = session + .update(observed_update( + 1, + [ + // Same complete physical tuple, deliberately repeated with + // non-canonical IDs and binding order. + (90, vec![(SURFACE_B, [0; 3]), (SURFACE_A, [255; 3])]), + (10, vec![(SURFACE_A, [255; 3]), (SURFACE_B, [0; 3])]), + // A second anti-correlated tuple must remain one whole case; + // it must not be crossed with either value from the first. + (50, vec![(SURFACE_B, [255; 3]), (SURFACE_A, [0; 3])]), + ], + )) + .unwrap() + else { + panic!("the second physical case violates both required exact identities"); + }; + assert!(previous.is_none()); + + let report = cause.report(); + assert_eq!(report.observation().physical_case_count(), 2); + assert_eq!( + report.observation().physical_values(0), + Some( + &[ + ColorSignal::from_srgb8(Srgb8::new([0; 3])), + ColorSignal::from_srgb8(Srgb8::new([255; 3])), + ][..] + ) + ); + assert_eq!( + report.observation().physical_values(1), + Some( + &[ + ColorSignal::from_srgb8(Srgb8::new([255; 3])), + ColorSignal::from_srgb8(Srgb8::new([0; 3])), + ][..] + ) + ); + assert_eq!( + report.observation().provenance(0), + Some(&[ScenarioId::new(50)][..]) + ); + assert_eq!( + report.observation().provenance(1), + Some(&[ScenarioId::new(10), ScenarioId::new(90)][..]) + ); + + let cells: Vec<_> = report.cells().collect(); + assert_eq!( + cells.len(), + 4, + "two cases times two occurrences, not six raw cells" + ); + assert_eq!( + crate::composition::source_over_evaluation_count(), + 4, + "compose exactly once per (unique physical case, occurrence)" + ); + + assert_eq!(cells[0].case_index(), 0); + assert_eq!(cells[0].occurrence(), OCCURRENCE_A); + assert_eq!(cells[0].composition().backdrop_rgb(), [0; 3]); + assert_eq!(cells[0].provenance(), &[ScenarioId::new(50)]); + assert!(matches!( + cells[0].exact(), + PointSupportExactAssessmentV1::RequiredFailure(_) + )); + assert_eq!(cells[1].case_index(), 0); + assert_eq!(cells[1].occurrence(), OCCURRENCE_B); + assert_eq!(cells[1].composition().backdrop_rgb(), [255; 3]); + assert_eq!(cells[1].provenance(), &[ScenarioId::new(50)]); + assert!(matches!( + cells[1].exact(), + PointSupportExactAssessmentV1::RequiredFailure(_) + )); + + for (cell, occurrence, backdrop) in [ + (cells[2], OCCURRENCE_A, [255; 3]), + (cells[3], OCCURRENCE_B, [0; 3]), + ] { + assert_eq!(cell.case_index(), 1); + assert_eq!(cell.occurrence(), occurrence); + assert_eq!(cell.composition().backdrop_rgb(), backdrop); + assert_eq!( + cell.provenance(), + &[ScenarioId::new(10), ScenarioId::new(90)] + ); + assert!(matches!( + cell.exact(), + PointSupportExactAssessmentV1::RequiredPass(_) + )); + } + assert_eq!( + report.exact_aggregate(), + PointSupportExactAggregateV1::RequiredFailure, + "one unique violating case fails the whole recheck" + ); +} + #[test] fn exact_wcag_and_stability_are_independent_axes_and_baseline_binds_once() { let drop_all = PointSupportDropFractionV1::try_from_basis_points(10_000).unwrap(); @@ -164,8 +347,8 @@ fn exact_wcag_and_stability_are_independent_axes_and_baseline_binds_once() { "the baseline is composed exactly once at compile/bind" ); - let mut session = PointSupportSessionV1::new(STREAM, requirements); - let PointSupportSessionStateV1::Failed { cause, previous } = session + let mut session = Session::new(STREAM, requirements); + let SessionState::Failed { cause, previous } = session .update(observed_update(1, [(44, vec![(SURFACE_A, [255; 3])])])) .unwrap() else { @@ -273,8 +456,8 @@ fn all_four_wcag_criterion_identities_survive_the_full_support_path() { }) .collect(), ); - let mut session = PointSupportSessionV1::new(STREAM, requirements); - let PointSupportSessionStateV1::Ready { current } = session + let mut session = Session::new(STREAM, requirements); + let SessionState::Ready { current } = session .update(observed_update(1, [(1, vec![(SURFACE_A, [255; 3])])])) .unwrap() else { @@ -335,8 +518,8 @@ fn wholly_inactive_plan_is_rejected_but_an_inactive_composition_cell_is_allowed( ) .expect("one active axis makes the whole full-support plan meaningful"); assert_eq!(mixed.surface_schema(), &[SURFACE_A, SURFACE_B]); - let mut mixed_session = PointSupportSessionV1::new(STREAM, mixed); - let PointSupportSessionStateV1::Ready { current } = mixed_session + let mut mixed_session = Session::new(STREAM, mixed); + let SessionState::Ready { current } = mixed_session .update(observed_update( 1, [(1, vec![(SURFACE_A, [17; 3]), (SURFACE_B, [3; 3])])], @@ -423,8 +606,8 @@ fn every_stability_anchor_survives_compile_evaluate_and_typed_evidence() { }) .collect(), ); - let mut session = PointSupportSessionV1::new(STREAM, requirements); - let PointSupportSessionStateV1::Ready { current } = session + let mut session = Session::new(STREAM, requirements); + let SessionState::Ready { current } = session .update(observed_update(1, [(91, vec![(SURFACE_A, [255; 3])])])) .unwrap() else { diff --git a/crates/labcolors-core/src/program.rs b/crates/labcolors-core/src/program.rs new file mode 100644 index 00000000..592d6530 --- /dev/null +++ b/crates/labcolors-core/src/program.rs @@ -0,0 +1,4014 @@ +//! Внутренний кандидат декларативного контракта цветовой программы. +//! +//! Модуль не публикуется до завершения emission, attachment и атомарной +//! транзакционной границы terminal C7c. Его закрытая поверхность уже служит +//! единственным concrete seam для внутренних проверок и дальнейших срезов. +//! +//! Клиент один раз описывает физический граф через [`DraftV1`]: исходные +//! сигналы, решаемые цели, Paint, Surface, их [`OccurrenceIdV1`], ограничения +//! и выходы. Идентификаторы непрозрачны: Core не выводит из них семантику. +//! [`DraftV1::compile`] проверяет граф целиком и возвращает [`OwnerV1`] — +//! единственного владельца конкретной скомпилированной эпохи. +//! +//! [`OwnerV1::instantiate`] создаёт потоковую [`SessionV1`]. На горячем пути +//! [`OwnerV1::update`] принимает физические сценарии в каноническом порядке +//! входов и атомарно возвращает [`ProjectionV1`] без повторного решения в +//! адаптере. Исторические доказательства принадлежат Session, но только тот же +//! Owner разрешает обновления и операции. +//! +//! Состояния проецируются однозначно: +//! +//! | Состояние | Операции | +//! |---|---| +//! | `Waiting` + `Empty` | нет | +//! | `Waiting` + допущенный `Unknown` | `Remove` для каждого выхода | +//! | `Ready` | `Set` для каждого выхода | +//! | `Stale` | `Remove` для каждого выхода | +//! | `Failed` | `Remove` для каждого выхода | +//! +//! Прошлый Verified-сертификат остаётся в evidence для диагностики, но не +//! разрешает эмиссию: он относится к прошлому наблюдению, а не к текущему +//! неизвестному или нарушающему контексту. Непустая сырая голова без текущего +//! Verified-сертификата также отзывает выходы: это закрывает передачу sink от +//! одной Session другой Session того же Owner. +//! +//! [`CertificateV1::Verified`] хранит выбранное состояние, все клетки +//! доказательства и сертифицированные выходы. [`CertificateV1::Conflict`] +//! хранит исчерпывающий конфликт по всем рассмотренным состояниям. +//! [`ContentIdentityV1`] идентифицирует каноническое содержание, но не даёт +//! полномочий живого [`OwnerV1`]. + +#![forbid(unreachable_pub)] + +use core::iter::FusedIterator; +use core::marker::PhantomData; +use core::slice; + +use crate::Srgb8; +use crate::appearance::{OccurrenceId, OpacityInputId, PaintId, SurfaceId, SurfaceInputPortId}; +use crate::composition::CompositionProfileV1; +use crate::constraints::{ + ApplicableWcag22EvaluationErrorV1, ExactSrgb8IdentityV1, ProgramVisiblePointBindingV1, + ProgramVisiblePointPassEvidence, ProgramVisiblePointViolationEvidence, Wcag22Srgb8V1, +}; +use crate::joint::FiniteJointOrderErrorV1; +use crate::lcs_occurrence::{ + AdaptingLuminanceCdM2, AdmittedSrgb8TristimulusBindingV1, AppearanceContextDomainErrorV1, + AppearanceContextFieldV1 as CoreAppearanceContextFieldV1, AppearanceContextId, + AppearanceContextSchemaReleaseId, BackgroundLuminanceRatio, ColorSignal, ColorSignalViewV1, + ColorimetricFrameId, ColorimetricFrameReleaseId, IEC_SRGB_D65_XYZ_FRAME_V1, + ModeledLcsOccurrenceFormationErrorV1, NumericDomainError, ObserverProfileId, + OccurrenceFormationError, ReferenceWhiteId, SurroundProfileId, + TristimulusComponentV1 as CoreTristimulusComponentV1, TristimulusDomainErrorV1, + TristimulusSample, TristimulusScale, +}; +use crate::numerics::NumericalDecisionEvidenceV1; +use crate::observation::{ + ObservationError, ObservationHeadViewV1, ObservationSchemaMismatchV1, ObservationStreamId, + Revision, ScenarioId, SchemaOrderedScenarioSourceV1, UnknownReasonId, +}; +use crate::program_session::{ + CompiledCoreProgramV1, CompositionProfile, ConstraintId, ConstraintInvocation, + CoreProgramConstraintInvocationV1, CoreProgramDraftErrorV1, CoreProgramDraftV1, + CoreProgramEvaluatorErrorV1, CoreProgramEvaluatorsV1, CoreProgramPassEvidenceV1, + CoreProgramViolationEvidenceV1, DeclaredJointSelectionV1, JointCandidateStateV1, Occurrence, + OpacityInput, OutputBinding, OutputSlotId, Paint, ProgramCompileError, ProgramConflictV1, + ProgramConstraintCellV1, ProgramConstraintResultV1, ProgramContentIdentityV1, ProgramOutputV1, + ProgramSessionEvaluationError, ProgramSessionInstantiateError, ProgramSessionPlan, + ProgramVerifiedV1, Source, SourceId, Surface, Target, TargetCandidateChoiceV1, + TargetCandidateId, TargetCandidateV1 as CoreTargetCandidateV1, TargetId, +}; +use crate::session::{Session, SessionState, SessionUpdateError}; +use crate::wcag22::{ + Wcag22ClientDeclaredNotApplicableV1, Wcag22CriterionV1, Wcag22EvaluationErrorV1, + Wcag22LuminanceBoundsQ55V1, Wcag22ProfileIdV1, wcag22_profile_v1, +}; + +type CoreVerifiedV1 = ProgramVerifiedV1; +type CoreConflictV1 = ProgramConflictV1; +type CoreProgramPlanV1 = ProgramSessionPlan; +type CoreProgramSessionV1 = Session; +type CoreProgramStateV1 = SessionState; +type CoreProgramPlanErrorV1 = ProgramSessionEvaluationError; +type CoreProgramConstraintCellV1 = ProgramConstraintCellV1; +type CoreExactPassEvidenceV1 = ProgramVisiblePointPassEvidence; +type CoreExactViolationEvidenceV1 = ProgramVisiblePointViolationEvidence; +type CoreWcag22PassEvidenceV1 = ProgramVisiblePointPassEvidence; +type CoreWcag22ViolationEvidenceV1 = ProgramVisiblePointViolationEvidence; + +macro_rules! authored_id { + ($doc:literal, $name:ident, $core:ty) => { + #[doc = $doc] + #[repr(transparent)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] + #[must_use] + pub(crate) struct $name($core); + + impl $name { + /// Создаёт непрозрачный идентификатор из клиентского числового ключа. + pub(crate) const fn new(value: u32) -> Self { + Self(<$core>::new(value)) + } + + /// Возвращает исходный клиентский числовой ключ. + pub(crate) const fn value(self) -> u32 { + self.0.value() + } + + const fn from_core(value: $core) -> Self { + Self(value) + } + + const fn into_core(self) -> $core { + self.0 + } + } + + impl core::hash::Hash for $name { + fn hash(&self, state: &mut H) { + core::hash::Hash::hash(&self.value(), state); + } + } + }; +} + +macro_rules! projected_id { + ($doc:literal, $name:ident, $core:ty) => { + #[doc = $doc] + #[repr(transparent)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] + #[must_use] + pub(crate) struct $name($core); + + impl $name { + const fn from_core(value: $core) -> Self { + Self(value) + } + + /// Возвращает числовой ключ сохранённой provenance. + pub(crate) const fn value(self) -> u32 { + self.0.value() + } + } + + impl core::hash::Hash for $name { + fn hash(&self, state: &mut H) { + core::hash::Hash::hash(&self.value(), state); + } + } + }; +} + +authored_id!( + "Идентификатор объявленного исходного сигнала.", + SourceIdV1, + SourceId +); +authored_id!("Идентификатор решаемой цели.", TargetIdV1, TargetId); +authored_id!( + "Идентификатор конечного кандидата одной цели.", + TargetCandidateIdV1, + TargetCandidateId +); +authored_id!( + "Идентификатор объявленного входа прозрачности.", + OpacityInputIdV1, + OpacityInputId +); +authored_id!("Идентификатор узла Paint.", PaintIdV1, PaintId); +authored_id!( + "Идентификатор входного порта динамической поверхности.", + SurfaceInputPortIdV1, + SurfaceInputPortId +); +authored_id!("Идентификатор узла Surface.", SurfaceIdV1, SurfaceId); +authored_id!( + "Идентификатор физического наложения Paint на Surface.", + OccurrenceIdV1, + OccurrenceId +); +authored_id!( + "Идентификатор проверяемого ограничения.", + ConstraintIdV1, + ConstraintId +); +authored_id!( + "Идентификатор клиентского выходного слота.", + OutputSlotIdV1, + OutputSlotId +); +projected_id!( + "Идентификатор потока, сохранённый в наблюдении.", + StreamIdV1, + ObservationStreamId +); +projected_id!( + "Идентификатор сценария, сохранённый как provenance.", + ScenarioIdV1, + ScenarioId +); + +/// Один физический кандидат конечной цели. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct TargetCandidateV1(CoreTargetCandidateV1); + +impl TargetCandidateV1 { + /// Связывает непрозрачный ID кандидата с конкретным encoded sRGB8 сигналом. + pub(crate) const fn new(id: TargetCandidateIdV1, source: Srgb8) -> Self { + Self(CoreTargetCandidateV1::from_srgb8(id.into_core(), source)) + } +} + +/// Выбор одного кандидата для одной цели в совместном состоянии. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct JointChoiceV1(TargetCandidateChoiceV1); + +impl JointChoiceV1 { + /// Создаёт типизированную пару `цель → кандидат`. + pub(crate) const fn new(target: TargetIdV1, candidate: TargetCandidateIdV1) -> Self { + Self(TargetCandidateChoiceV1::new( + target.into_core(), + candidate.into_core(), + )) + } +} + +/// Полное явно объявленное состояние всех конечных целей. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct JointStateV1(JointCandidateStateV1); + +impl JointStateV1 { + /// Создаёт состояние из одного выбора для каждой конечной цели. + pub(crate) fn new(choices: Vec) -> Self { + Self(JointCandidateStateV1::new( + choices.into_iter().map(|choice| choice.0).collect(), + )) + } +} + +/// Зарегистрированный режим окружения CIECAM16. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SurroundV1 { + /// Среднее освещение окружения. + Average, + /// Приглушённое освещение окружения. + Dim, + /// Тёмное окружение. + Dark, +} + +impl SurroundV1 { + const fn into_core(self) -> SurroundProfileId { + match self { + Self::Average => SurroundProfileId::AverageV1, + Self::Dim => SurroundProfileId::DimV1, + Self::Dark => SurroundProfileId::DarkV1, + } + } +} + +/// Поле входного контекста восприятия, не прошедшее admission. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AppearanceContextFieldV1 { + /// Адаптирующая яркость в кд/м². + AdaptingLuminanceCdM2, + /// Безразмерное отношение фоновой яркости `Y_b/Y_w`. + BackgroundLuminanceRatioYbYw, +} + +/// Числовая причина отказа при формировании контекста восприятия. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NumericDomainErrorV1 { + /// Значение не является конечным числом. + NonFinite, + /// Значение отрицательно. + Negative, + /// Значение должно быть строго положительным. + NotPositive, + /// Значение превышает единицу. + AboveOne, + /// Угол оттенка находится вне полуинтервала `[0°, 360°)`. + HueOutOfRange, +} + +/// Закрытая классификация отказа admission контекста восприятия. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AppearanceContextErrorKindV1 { + /// Клиентское значение находится вне объявленного домена. + Domain, + /// Нарушен внутренний инвариант закрытого преобразования. + InternalInvariant, +} + +/// Типизированный отказ admission контекста восприятия. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AppearanceContextErrorV1 { + kind: AppearanceContextErrorKindV1, + field: Option, + reason: Option, +} + +impl AppearanceContextErrorV1 { + /// Возвращает класс отказа. + pub(crate) const fn kind(self) -> AppearanceContextErrorKindV1 { + self.kind + } + + /// Возвращает отвергнутое поле, когда Core смог его локализовать. + pub(crate) const fn field(self) -> Option { + self.field + } + + /// Возвращает точную числовую причину, если отказ относится к входному домену. + pub(crate) const fn reason(self) -> Option { + self.reason + } + + fn from_core(error: AppearanceContextDomainErrorV1) -> Self { + let field = match error.field() { + CoreAppearanceContextFieldV1::AdaptingLuminanceCdM2 => { + AppearanceContextFieldV1::AdaptingLuminanceCdM2 + } + CoreAppearanceContextFieldV1::BackgroundLuminanceRatio => { + AppearanceContextFieldV1::BackgroundLuminanceRatioYbYw + } + }; + let reason = match error.reason() { + NumericDomainError::NonFinite => Some(NumericDomainErrorV1::NonFinite), + NumericDomainError::Negative => Some(NumericDomainErrorV1::Negative), + NumericDomainError::NotPositive => Some(NumericDomainErrorV1::NotPositive), + NumericDomainError::AboveOne => Some(NumericDomainErrorV1::AboveOne), + NumericDomainError::HueOutOfRange => None, + }; + match reason { + Some(reason) => Self { + kind: AppearanceContextErrorKindV1::Domain, + field: Some(field), + reason: Some(reason), + }, + None => Self { + kind: AppearanceContextErrorKindV1::InternalInvariant, + field: Some(field), + reason: None, + }, + } + } +} + +/// Неизменяемый допущенный контекст восприятия. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct AppearanceContextV1(AppearanceContextId); + +impl AppearanceContextV1 { + const fn from_core(context: AppearanceContextId) -> Self { + Self(context) + } + + /// Допускает явные входы CIECAM16 для encoded sRGB8/D65. + /// + /// `background_luminance_ratio_yb_yw` — безразмерное `Y_b/Y_w` в `(0, 1]`, + /// а не абсолютная яркость. + pub(crate) fn try_new( + adapting_luminance_cd_m2: f64, + background_luminance_ratio_yb_yw: f64, + surround: SurroundV1, + ) -> Result { + let adapting_luminance_cd_m2 = AdaptingLuminanceCdM2::try_new(adapting_luminance_cd_m2) + .map_err(AppearanceContextErrorV1::from_core)?; + let background_luminance_ratio = + BackgroundLuminanceRatio::try_new(background_luminance_ratio_yb_yw) + .map_err(AppearanceContextErrorV1::from_core)?; + Ok(Self(AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + IEC_SRGB_D65_XYZ_FRAME_V1, + adapting_luminance_cd_m2, + background_luminance_ratio, + surround.into_core(), + ))) + } + + /// Возвращает допущенную адаптирующую яркость в кд/м². + pub(crate) fn adapting_luminance_cd_m2(self) -> f64 { + self.0.adapting_luminance_cd_m2() + } + + /// Возвращает допущенное безразмерное отношение `Y_b/Y_w`. + pub(crate) fn background_luminance_ratio_yb_yw(self) -> f64 { + self.0.background_luminance_ratio() + } + + /// Возвращает зарегистрированный режим окружения. + pub(crate) const fn surround(self) -> SurroundV1 { + match self.0.surround_profile() { + SurroundProfileId::AverageV1 => SurroundV1::Average, + SurroundProfileId::DimV1 => SurroundV1::Dim, + SurroundProfileId::DarkV1 => SurroundV1::Dark, + } + } +} + +/// Закрытая классификация ошибки компиляции. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CompileErrorKindV1 { + /// Повторно объявлен исходный сигнал. + DuplicateSource, + /// Повторно объявлена цель. + DuplicateTarget, + /// В одной цели повторно объявлен ID кандидата. + DuplicateTargetCandidate, + /// Два кандидата одной цели задают одинаковый сигнал. + DuplicateTargetCandidateSignal, + /// Повторно объявлен вход прозрачности. + DuplicateOpacityInput, + /// Повторно объявлен входной порт поверхности. + DuplicateSurfaceInputPort, + /// Объявленный входной порт поверхности не используется. + UnusedSurfaceInputPort, + /// Один входной порт привязан к нескольким Surface. + DuplicateSurfaceInputBinding, + /// Повторно объявлен Paint. + DuplicatePaint, + /// Повторно объявлен Surface. + DuplicateSurface, + /// Повторно объявлен Occurrence. + DuplicateOccurrence, + /// Повторно объявлено ограничение. + DuplicateConstraint, + /// Повторно объявлен выходной слот. + DuplicateOutputSlot, + /// Цель ссылается на отсутствующий исходный сигнал. + MissingTargetSource, + /// Paint ссылается на отсутствующую цель. + MissingPaintTarget, + /// Paint ссылается на отсутствующий Paint. + MissingPaintSource, + /// Paint ссылается на отсутствующий вход прозрачности. + MissingPaintOpacityInput, + /// Surface ссылается на отсутствующий входной порт. + MissingSurfaceInputPort, + /// Surface ссылается на отсутствующий Occurrence. + MissingSurfaceOccurrence, + /// Occurrence ссылается на отсутствующий Paint. + MissingOccurrencePaint, + /// Occurrence ссылается на отсутствующий backdrop Surface. + MissingOccurrenceBackdrop, + /// Ограничение ссылается на отсутствующий Occurrence. + MissingConstraintOccurrence, + /// Выход ссылается на отсутствующий Paint. + MissingOutputPaint, + /// Граф Paint содержит цикл. + PaintCycle, + /// Граф рендера содержит цикл Surface/Occurrence. + RenderCycle, + /// Значение прозрачности находится вне `[0, 1]` или не конечно. + OpacityOutOfDomain, + /// Конечная цель не содержит кандидатов. + EmptyTargetDomain, + /// Конечная цель не участвует ни в одном ограничении. + UnconstrainedTarget, + /// Конечные цели образуют несвязанные компоненты. + DisconnectedFiniteTargets, + /// Выходной Paint не покрыт ни одним ограничением. + UnassessedOutput, + /// Для конечных целей не объявлен совместный порядок. + MissingJointSelection, + /// Совместный порядок объявлен без конечных целей. + JointSelectionWithoutTargets, + /// Состояние повторяет одну цель. + JointStateDuplicateTarget, + /// В состоянии отсутствует конечная цель. + JointStateMissingTarget, + /// Состояние ссылается на неизвестную цель. + JointStateUnknownTarget, + /// Состояние ссылается на неизвестного кандидата. + JointStateUnknownCandidate, + /// Совместный порядок не является полным конечным порядком. + InvalidJointOrder, + /// Не объявлено ни одного динамического входа поверхности. + EmptySurfaceInputPortSet, + /// Не объявлено ни одного физического Occurrence. + EmptyOccurrenceSet, + /// Не объявлено ни одного ограничения. + EmptyConstraintSet, + /// Не объявлено ни одного выхода. + EmptyOutputSet, + /// Для компиляции недостаточно памяти или адресного пространства. + ResourceExhausted, + /// Нарушен внутренний инвариант закрытого компилятора. + InternalInvariant, +} + +/// Типизированный ID узла, к которому относится ошибка компиляции. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CompileErrorHandleV1 { + /// Исходный сигнал. + Source(SourceIdV1), + /// Цель. + Target(TargetIdV1), + /// Кандидат цели. + TargetCandidate(TargetCandidateIdV1), + /// Вход прозрачности. + OpacityInput(OpacityInputIdV1), + /// Paint. + Paint(PaintIdV1), + /// Входной порт Surface. + SurfaceInputPort(SurfaceInputPortIdV1), + /// Surface. + Surface(SurfaceIdV1), + /// Occurrence. + Occurrence(OccurrenceIdV1), + /// Ограничение. + Constraint(ConstraintIdV1), + /// Выходной слот. + OutputSlot(OutputSlotIdV1), +} + +impl CompileErrorHandleV1 { + /// Возвращает клиентский числовой ключ независимо от пространства ID. + pub(crate) const fn value(self) -> u32 { + match self { + Self::Source(value) => value.value(), + Self::Target(value) => value.value(), + Self::TargetCandidate(value) => value.value(), + Self::OpacityInput(value) => value.value(), + Self::Paint(value) => value.value(), + Self::SurfaceInputPort(value) => value.value(), + Self::Surface(value) => value.value(), + Self::Occurrence(value) => value.value(), + Self::Constraint(value) => value.value(), + Self::OutputSlot(value) => value.value(), + } + } +} + +/// Точные участники одного цикла зависимостей Paint. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct PaintCycleV1 { + paints: Vec, +} + +impl PaintCycleV1 { + /// Возвращает участников цикла в каноническом порядке диагностики. + pub(crate) fn paints(&self) -> impl ExactSizeIterator + '_ { + self.paints.iter().copied().map(PaintIdV1::from_core) + } +} + +/// Точные участники одного цикла рендера. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct RenderCycleV1 { + surfaces: Vec, + occurrences: Vec, +} + +impl RenderCycleV1 { + /// Возвращает Surface-участников цикла. + pub(crate) fn surfaces(&self) -> impl ExactSizeIterator + '_ { + self.surfaces.iter().copied().map(SurfaceIdV1::from_core) + } + + /// Возвращает Occurrence-участников цикла. + pub(crate) fn occurrences(&self) -> impl ExactSizeIterator + '_ { + self.occurrences + .iter() + .copied() + .map(OccurrenceIdV1::from_core) + } +} + +/// Точная причина отказа явно объявленного конечного совместного порядка. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum JointOrderErrorV1 { + /// Одно измерение не содержит кандидатов. + EmptyDomain { + /// Индекс пустого измерения. + dimension: usize, + }, + /// Декартова мощность измерений не представима в `usize`. + CardinalityOverflow, + /// Явный порядок не содержит состояний. + EmptyOrder, + /// Состояние содержит неверное число измерений. + TupleArity { + /// Индекс состояния. + state: usize, + /// Требуемое число измерений. + expected: usize, + /// Фактическое число измерений. + actual: usize, + }, + /// Ординал кандидата выходит за домен измерения. + OrdinalOutOfDomain { + /// Индекс состояния. + state: usize, + /// Индекс измерения. + dimension: usize, + /// Некорректный ординал. + ordinal: usize, + /// Мощность домена измерения. + domain_len: usize, + }, + /// Два состояния задают один и тот же кортеж. + DuplicateTuple { + /// Индекс первого состояния. + first_state: usize, + /// Индекс повторного состояния. + duplicate_state: usize, + }, + /// Порядок не покрывает полный декартов домен. + IncompleteOrder { + /// Требуемое число состояний. + expected: usize, + /// Фактическое число состояний. + actual: usize, + }, + /// Для проверки порядка недостаточно ресурсов. + ResourceExhausted, +} + +/// Атомарная и полная ошибка компиляции объявленной программы. +/// +/// Enum авторитетен; [`Self::kind`], [`Self::primary_handle`] и +/// [`Self::related_handle`] — только удобные проекции полного payload. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum CompileErrorV1 { + /// Повторно объявлен исходный сигнал. + DuplicateSource { + /// Повторный ID. + source: SourceIdV1, + }, + /// Повторно объявлена цель. + DuplicateTarget { + /// Повторный ID. + target: TargetIdV1, + }, + /// Цель ссылается на отсутствующий исходный сигнал. + MissingTargetSource { + /// Ошибочная цель. + target: TargetIdV1, + /// Отсутствующий исходный сигнал. + source: SourceIdV1, + }, + /// Повторно объявлен вход прозрачности. + DuplicateOpacityInput { + /// Повторный ID. + input: OpacityInputIdV1, + }, + /// Повторно объявлен входной порт поверхности. + DuplicateSurfaceInputPort { + /// Повторный ID. + input: SurfaceInputPortIdV1, + }, + /// Объявленный входной порт не используется. + UnusedSurfaceInputPort { + /// Неиспользуемый ID. + input: SurfaceInputPortIdV1, + }, + /// Один входной порт привязан к двум Surface. + DuplicateSurfaceInputBinding { + /// Повторно привязанный порт. + input: SurfaceInputPortIdV1, + /// Первая Surface. + first: SurfaceIdV1, + /// Повторная Surface. + duplicate: SurfaceIdV1, + }, + /// Повторно объявлен Paint. + DuplicatePaint { + /// Повторный ID. + paint: PaintIdV1, + }, + /// Повторно объявлен Surface. + DuplicateSurface { + /// Повторный ID. + surface: SurfaceIdV1, + }, + /// Повторно объявлен Occurrence. + DuplicateOccurrence { + /// Повторный ID. + occurrence: OccurrenceIdV1, + }, + /// Paint ссылается на отсутствующую цель. + MissingPaintTarget { + /// Ошибочный Paint. + paint: PaintIdV1, + /// Отсутствующая цель. + target: TargetIdV1, + }, + /// Paint ссылается на отсутствующий исходный Paint. + MissingPaintSource { + /// Ошибочный Paint. + paint: PaintIdV1, + /// Отсутствующий исходный Paint. + source: PaintIdV1, + }, + /// Paint ссылается на отсутствующий вход прозрачности. + MissingPaintOpacityInput { + /// Ошибочный Paint. + paint: PaintIdV1, + /// Отсутствующий вход. + input: OpacityInputIdV1, + }, + /// Surface ссылается на отсутствующий входной порт. + MissingSurfaceInputPort { + /// Ошибочная Surface. + surface: SurfaceIdV1, + /// Отсутствующий порт. + input: SurfaceInputPortIdV1, + }, + /// Surface ссылается на отсутствующий Occurrence. + MissingSurfaceOccurrence { + /// Ошибочная Surface. + surface: SurfaceIdV1, + /// Отсутствующий Occurrence. + occurrence: OccurrenceIdV1, + }, + /// Occurrence ссылается на отсутствующий Paint. + MissingOccurrencePaint { + /// Ошибочный Occurrence. + occurrence: OccurrenceIdV1, + /// Отсутствующий Paint. + paint: PaintIdV1, + }, + /// Occurrence ссылается на отсутствующий backdrop Surface. + MissingOccurrenceBackdrop { + /// Ошибочный Occurrence. + occurrence: OccurrenceIdV1, + /// Отсутствующая Surface. + surface: SurfaceIdV1, + }, + /// Обнаружен цикл зависимостей Paint. + PaintCycle(PaintCycleV1), + /// Обнаружен цикл Surface/Occurrence. + RenderCycle(RenderCycleV1), + /// Вход прозрачности не является конечным числом в `[0, 1]`. + OpacityOutOfDomain { + /// Ошибочный вход. + input: OpacityInputIdV1, + }, + /// Конечная цель не содержит кандидатов. + EmptyTargetDomain { + /// Пустая цель. + target: TargetIdV1, + }, + /// В одной цели повторно объявлен ID кандидата. + DuplicateTargetCandidate { + /// Цель кандидата. + target: TargetIdV1, + /// Повторный кандидат. + candidate: TargetCandidateIdV1, + }, + /// Два кандидата одной цели имеют одинаковый физический сигнал. + DuplicateTargetCandidateSignal { + /// Цель кандидатов. + target: TargetIdV1, + /// Первый кандидат. + first: TargetCandidateIdV1, + /// Повторный кандидат. + duplicate: TargetCandidateIdV1, + /// Совпавший encoded sRGB8 сигнал. + encoded_srgb8: Srgb8, + }, + /// Конечная цель не участвует ни в одном ограничении. + UnconstrainedTarget { + /// Неограниченная цель. + target: TargetIdV1, + }, + /// Конечные цели образуют несвязанные компоненты. + DisconnectedFiniteTargets, + /// Выходной Paint не покрыт ни одним ограничением. + UnassessedOutput { + /// Непроверенный выход. + output: OutputSlotIdV1, + /// Его Paint. + paint: PaintIdV1, + }, + /// Для конечных целей не объявлен совместный порядок. + MissingJointSelection, + /// Совместный порядок объявлен без конечных целей. + JointSelectionWithoutTargets, + /// Состояние повторяет одну цель. + JointStateDuplicateTarget { + /// Индекс состояния. + state: usize, + /// Повторная цель. + target: TargetIdV1, + }, + /// В состоянии отсутствует конечная цель. + JointStateMissingTarget { + /// Индекс состояния. + state: usize, + /// Отсутствующая цель. + target: TargetIdV1, + }, + /// Состояние ссылается на неизвестную цель. + JointStateUnknownTarget { + /// Индекс состояния. + state: usize, + /// Неизвестная цель. + target: TargetIdV1, + }, + /// Состояние ссылается на неизвестного кандидата. + JointStateUnknownCandidate { + /// Индекс состояния. + state: usize, + /// Цель кандидата. + target: TargetIdV1, + /// Неизвестный кандидат. + candidate: TargetCandidateIdV1, + }, + /// Явный совместный порядок не является полным конечным порядком. + InvalidJointOrder(JointOrderErrorV1), + /// Не объявлено ни одного динамического входа поверхности. + EmptySurfaceInputPortSet, + /// Не объявлено ни одного физического Occurrence. + EmptyOccurrenceSet, + /// Не объявлено ни одного ограничения. + EmptyConstraintSet, + /// Не объявлено ни одного выхода. + EmptyOutputSet, + /// Повторно объявлено ограничение. + DuplicateConstraint { + /// Повторный ID. + constraint: ConstraintIdV1, + }, + /// Ограничение ссылается на отсутствующий Occurrence. + MissingConstraintOccurrence { + /// Ошибочное ограничение. + constraint: ConstraintIdV1, + /// Отсутствующий Occurrence. + occurrence: OccurrenceIdV1, + }, + /// Повторно объявлен выходной слот. + DuplicateOutputSlot { + /// Повторный ID. + output: OutputSlotIdV1, + }, + /// Выход ссылается на отсутствующий Paint. + MissingOutputPaint { + /// Ошибочный выход. + output: OutputSlotIdV1, + /// Отсутствующий Paint. + paint: PaintIdV1, + }, + /// Для компиляции недостаточно ресурсов. + ResourceExhausted, + /// Нарушен внутренний инвариант закрытого компилятора. + InternalInvariant, +} + +impl CompileErrorV1 { + /// Возвращает стабильный класс ошибки без потери полного payload. + pub(crate) const fn kind(&self) -> CompileErrorKindV1 { + use CompileErrorKindV1 as Kind; + + match self { + Self::DuplicateSource { .. } => Kind::DuplicateSource, + Self::DuplicateTarget { .. } => Kind::DuplicateTarget, + Self::MissingTargetSource { .. } => Kind::MissingTargetSource, + Self::DuplicateOpacityInput { .. } => Kind::DuplicateOpacityInput, + Self::DuplicateSurfaceInputPort { .. } => Kind::DuplicateSurfaceInputPort, + Self::UnusedSurfaceInputPort { .. } => Kind::UnusedSurfaceInputPort, + Self::DuplicateSurfaceInputBinding { .. } => Kind::DuplicateSurfaceInputBinding, + Self::DuplicatePaint { .. } => Kind::DuplicatePaint, + Self::DuplicateSurface { .. } => Kind::DuplicateSurface, + Self::DuplicateOccurrence { .. } => Kind::DuplicateOccurrence, + Self::MissingPaintTarget { .. } => Kind::MissingPaintTarget, + Self::MissingPaintSource { .. } => Kind::MissingPaintSource, + Self::MissingPaintOpacityInput { .. } => Kind::MissingPaintOpacityInput, + Self::MissingSurfaceInputPort { .. } => Kind::MissingSurfaceInputPort, + Self::MissingSurfaceOccurrence { .. } => Kind::MissingSurfaceOccurrence, + Self::MissingOccurrencePaint { .. } => Kind::MissingOccurrencePaint, + Self::MissingOccurrenceBackdrop { .. } => Kind::MissingOccurrenceBackdrop, + Self::PaintCycle(_) => Kind::PaintCycle, + Self::RenderCycle(_) => Kind::RenderCycle, + Self::OpacityOutOfDomain { .. } => Kind::OpacityOutOfDomain, + Self::EmptyTargetDomain { .. } => Kind::EmptyTargetDomain, + Self::DuplicateTargetCandidate { .. } => Kind::DuplicateTargetCandidate, + Self::DuplicateTargetCandidateSignal { .. } => Kind::DuplicateTargetCandidateSignal, + Self::UnconstrainedTarget { .. } => Kind::UnconstrainedTarget, + Self::DisconnectedFiniteTargets => Kind::DisconnectedFiniteTargets, + Self::UnassessedOutput { .. } => Kind::UnassessedOutput, + Self::MissingJointSelection => Kind::MissingJointSelection, + Self::JointSelectionWithoutTargets => Kind::JointSelectionWithoutTargets, + Self::JointStateDuplicateTarget { .. } => Kind::JointStateDuplicateTarget, + Self::JointStateMissingTarget { .. } => Kind::JointStateMissingTarget, + Self::JointStateUnknownTarget { .. } => Kind::JointStateUnknownTarget, + Self::JointStateUnknownCandidate { .. } => Kind::JointStateUnknownCandidate, + Self::InvalidJointOrder(_) => Kind::InvalidJointOrder, + Self::EmptySurfaceInputPortSet => Kind::EmptySurfaceInputPortSet, + Self::EmptyOccurrenceSet => Kind::EmptyOccurrenceSet, + Self::EmptyConstraintSet => Kind::EmptyConstraintSet, + Self::EmptyOutputSet => Kind::EmptyOutputSet, + Self::DuplicateConstraint { .. } => Kind::DuplicateConstraint, + Self::MissingConstraintOccurrence { .. } => Kind::MissingConstraintOccurrence, + Self::DuplicateOutputSlot { .. } => Kind::DuplicateOutputSlot, + Self::MissingOutputPaint { .. } => Kind::MissingOutputPaint, + Self::ResourceExhausted => Kind::ResourceExhausted, + Self::InternalInvariant => Kind::InternalInvariant, + } + } + + /// Возвращает основной типизированный ID, если ошибка локализуема одним узлом. + pub(crate) const fn primary_handle(&self) -> Option { + use CompileErrorHandleV1 as Handle; + + match self { + Self::DuplicateSource { source } => Some(Handle::Source(*source)), + Self::DuplicateTarget { target } + | Self::EmptyTargetDomain { target } + | Self::UnconstrainedTarget { target } + | Self::JointStateDuplicateTarget { target, .. } + | Self::JointStateMissingTarget { target, .. } + | Self::JointStateUnknownTarget { target, .. } + | Self::JointStateUnknownCandidate { target, .. } + | Self::MissingTargetSource { target, .. } + | Self::DuplicateTargetCandidate { target, .. } + | Self::DuplicateTargetCandidateSignal { target, .. } => Some(Handle::Target(*target)), + Self::DuplicateOpacityInput { input } | Self::OpacityOutOfDomain { input } => { + Some(Handle::OpacityInput(*input)) + } + Self::DuplicateSurfaceInputPort { input } + | Self::UnusedSurfaceInputPort { input } + | Self::DuplicateSurfaceInputBinding { input, .. } => { + Some(Handle::SurfaceInputPort(*input)) + } + Self::DuplicatePaint { paint } + | Self::MissingPaintTarget { paint, .. } + | Self::MissingPaintSource { paint, .. } + | Self::MissingPaintOpacityInput { paint, .. } => Some(Handle::Paint(*paint)), + Self::DuplicateSurface { surface } + | Self::MissingSurfaceInputPort { surface, .. } + | Self::MissingSurfaceOccurrence { surface, .. } => Some(Handle::Surface(*surface)), + Self::DuplicateOccurrence { occurrence } + | Self::MissingOccurrencePaint { occurrence, .. } + | Self::MissingOccurrenceBackdrop { occurrence, .. } => { + Some(Handle::Occurrence(*occurrence)) + } + Self::UnassessedOutput { output, .. } + | Self::DuplicateOutputSlot { output } + | Self::MissingOutputPaint { output, .. } => Some(Handle::OutputSlot(*output)), + Self::DuplicateConstraint { constraint } + | Self::MissingConstraintOccurrence { constraint, .. } => { + Some(Handle::Constraint(*constraint)) + } + Self::PaintCycle(_) + | Self::RenderCycle(_) + | Self::DisconnectedFiniteTargets + | Self::MissingJointSelection + | Self::JointSelectionWithoutTargets + | Self::InvalidJointOrder(_) + | Self::EmptySurfaceInputPortSet + | Self::EmptyOccurrenceSet + | Self::EmptyConstraintSet + | Self::EmptyOutputSet + | Self::ResourceExhausted + | Self::InternalInvariant => None, + } + } + + /// Возвращает связанный типизированный ID для ошибки отношения двух узлов. + pub(crate) const fn related_handle(&self) -> Option { + use CompileErrorHandleV1 as Handle; + + match self { + Self::MissingTargetSource { source, .. } => Some(Handle::Source(*source)), + Self::DuplicateSurfaceInputBinding { duplicate, .. } => { + Some(Handle::Surface(*duplicate)) + } + Self::MissingPaintTarget { target, .. } => Some(Handle::Target(*target)), + Self::MissingPaintSource { source, .. } => Some(Handle::Paint(*source)), + Self::MissingPaintOpacityInput { input, .. } => Some(Handle::OpacityInput(*input)), + Self::MissingSurfaceInputPort { input, .. } => Some(Handle::SurfaceInputPort(*input)), + Self::MissingSurfaceOccurrence { occurrence, .. } + | Self::MissingConstraintOccurrence { occurrence, .. } => { + Some(Handle::Occurrence(*occurrence)) + } + Self::MissingOccurrencePaint { paint, .. } + | Self::UnassessedOutput { paint, .. } + | Self::MissingOutputPaint { paint, .. } => Some(Handle::Paint(*paint)), + Self::MissingOccurrenceBackdrop { surface, .. } => Some(Handle::Surface(*surface)), + Self::DuplicateTargetCandidate { candidate, .. } + | Self::DuplicateTargetCandidateSignal { + duplicate: candidate, + .. + } + | Self::JointStateUnknownCandidate { candidate, .. } => { + Some(Handle::TargetCandidate(*candidate)) + } + Self::DuplicateSource { .. } + | Self::DuplicateTarget { .. } + | Self::DuplicateOpacityInput { .. } + | Self::DuplicateSurfaceInputPort { .. } + | Self::UnusedSurfaceInputPort { .. } + | Self::DuplicatePaint { .. } + | Self::DuplicateSurface { .. } + | Self::DuplicateOccurrence { .. } + | Self::PaintCycle(_) + | Self::RenderCycle(_) + | Self::OpacityOutOfDomain { .. } + | Self::EmptyTargetDomain { .. } + | Self::UnconstrainedTarget { .. } + | Self::DisconnectedFiniteTargets + | Self::MissingJointSelection + | Self::JointSelectionWithoutTargets + | Self::JointStateDuplicateTarget { .. } + | Self::JointStateMissingTarget { .. } + | Self::JointStateUnknownTarget { .. } + | Self::InvalidJointOrder(_) + | Self::EmptySurfaceInputPortSet + | Self::EmptyOccurrenceSet + | Self::EmptyConstraintSet + | Self::EmptyOutputSet + | Self::DuplicateConstraint { .. } + | Self::DuplicateOutputSlot { .. } + | Self::ResourceExhausted + | Self::InternalInvariant => None, + } + } +} + +/// Холодный декларативный builder канонической Program IR. +#[must_use] +pub(crate) struct DraftV1 { + inner: CoreProgramDraftV1, +} + +/// Ошибка изменения Draft до компиляции. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DraftErrorV1 { + /// Совместный порядок уже объявлен и не может быть молча заменён. + JointSelectionAlreadyDeclared, +} + +impl DraftV1 { + /// Создаёт пустой Draft. + pub(crate) fn new() -> Self { + Self { + inner: CoreProgramDraftV1::new(), + } + } + + /// Объявляет неизменяемый исходный encoded sRGB8 сигнал. + pub(crate) fn push_source(&mut self, id: SourceIdV1, source: Srgb8) -> &mut Self { + self.inner + .push_source(Source::new(id.into_core(), ColorSignal::from_srgb8(source))); + self + } + + /// Объявляет цель, физически равную исходному сигналу. + pub(crate) fn push_fixed_target(&mut self, id: TargetIdV1, source: SourceIdV1) -> &mut Self { + self.inner + .push_target(Target::fixed(id.into_core(), source.into_core())); + self + } + + /// Объявляет решаемую цель с конечным набором физических кандидатов. + pub(crate) fn push_finite_target( + &mut self, + id: TargetIdV1, + source: SourceIdV1, + candidates: Vec, + ) -> &mut Self { + self.inner.push_target(Target::finite( + id.into_core(), + source.into_core(), + candidates + .into_iter() + .map(|candidate| candidate.0) + .collect(), + )); + self + } + + /// Один раз задаёт полный порядок совместных состояний конечных целей. + pub(crate) fn set_joint_selection( + &mut self, + states: Vec, + ) -> Result<&mut Self, DraftErrorV1> { + self.inner + .set_joint_selection(DeclaredJointSelectionV1::new( + states.into_iter().map(|state| state.0).collect(), + )) + .map_err(|error| match error { + CoreProgramDraftErrorV1::JointSelectionAlreadyDeclared => { + DraftErrorV1::JointSelectionAlreadyDeclared + } + })?; + Ok(self) + } + + /// Объявляет один динамический вход поверхности. + pub(crate) fn push_surface_input_port(&mut self, input: SurfaceInputPortIdV1) -> &mut Self { + self.inner.push_surface_input_port(input.into_core()); + self + } + + /// Объявляет числовой вход прозрачности; домен проверяется при компиляции. + pub(crate) fn push_opacity_input(&mut self, id: OpacityInputIdV1, value: f64) -> &mut Self { + self.inner + .push_opacity_input(OpacityInput::new(id.into_core(), value)); + self + } + + /// Объявляет непрозрачный Paint, связанный с целью. + pub(crate) fn push_solid_paint(&mut self, id: PaintIdV1, target: TargetIdV1) -> &mut Self { + self.inner.push_paint(Paint::Solid { + id: id.into_core(), + target: target.into_core(), + }); + self + } + + /// Объявляет Paint как прозрачную версию другого Paint. + pub(crate) fn push_opacity_paint( + &mut self, + id: PaintIdV1, + source: PaintIdV1, + opacity: OpacityInputIdV1, + ) -> &mut Self { + self.inner.push_paint(Paint::Opacity { + id: id.into_core(), + source: source.into_core(), + opacity: opacity.into_core(), + }); + self + } + + /// Объявляет Surface, значение которой поступает из runtime-сценария. + pub(crate) fn push_input_surface( + &mut self, + id: SurfaceIdV1, + input: SurfaceInputPortIdV1, + ) -> &mut Self { + self.inner.push_surface(Surface::Input { + id: id.into_core(), + input: input.into_core(), + }); + self + } + + /// Объявляет Surface как видимый результат другого Occurrence. + pub(crate) fn push_occurrence_surface( + &mut self, + id: SurfaceIdV1, + occurrence: OccurrenceIdV1, + ) -> &mut Self { + self.inner.push_surface(Surface::FromOccurrence { + id: id.into_core(), + occurrence: occurrence.into_core(), + }); + self + } + + /// Объявляет encoded-sRGB8 source-over Occurrence в явном контексте. + pub(crate) fn push_source_over_occurrence( + &mut self, + id: OccurrenceIdV1, + subject: PaintIdV1, + against: SurfaceIdV1, + context: AppearanceContextV1, + ) -> &mut Self { + self.inner.push_occurrence(Occurrence::new( + id.into_core(), + subject.into_core(), + against.into_core(), + CompositionProfile::EncodedSrgb8SourceOverV1, + context.0, + )); + self + } + + /// Добавляет обязательное точное сравнение видимого sRGB8 результата. + pub(crate) fn push_exact_hard( + &mut self, + id: ConstraintIdV1, + occurrence: OccurrenceIdV1, + expected: Srgb8, + ) -> &mut Self { + self.inner.push_hard_constraint(ConstraintInvocation::hard( + id.into_core(), + occurrence.into_core(), + CoreProgramConstraintInvocationV1::ExactSrgb8(expected), + )); + self + } + + /// Добавляет диагностическое точное сравнение, не влияющее на выбор. + pub(crate) fn push_exact_report_only( + &mut self, + id: ConstraintIdV1, + occurrence: OccurrenceIdV1, + expected: Srgb8, + ) -> &mut Self { + self.inner + .push_report_constraint(ConstraintInvocation::report_only( + id.into_core(), + occurrence.into_core(), + CoreProgramConstraintInvocationV1::ExactSrgb8(expected), + )); + self + } + + /// Добавляет обязательный критерий WCAG 2.2 для видимого результата. + pub(crate) fn push_wcag22_hard( + &mut self, + id: ConstraintIdV1, + occurrence: OccurrenceIdV1, + criterion: Wcag22CriterionV1, + ) -> &mut Self { + self.inner.push_hard_constraint(ConstraintInvocation::hard( + id.into_core(), + occurrence.into_core(), + CoreProgramConstraintInvocationV1::Wcag22Srgb8(criterion), + )); + self + } + + /// Добавляет диагностический критерий WCAG 2.2, не влияющий на выбор. + pub(crate) fn push_wcag22_report_only( + &mut self, + id: ConstraintIdV1, + occurrence: OccurrenceIdV1, + criterion: Wcag22CriterionV1, + ) -> &mut Self { + self.inner + .push_report_constraint(ConstraintInvocation::report_only( + id.into_core(), + occurrence.into_core(), + CoreProgramConstraintInvocationV1::Wcag22Srgb8(criterion), + )); + self + } + + /// Связывает клиентский выходной слот с итоговым Paint. + pub(crate) fn push_output(&mut self, output: OutputSlotIdV1, paint: PaintIdV1) -> &mut Self { + self.inner + .push_output(OutputBinding::new(output.into_core(), paint.into_core())); + self + } + + /// Атомарно проверяет и компилирует весь граф. + pub(crate) fn compile(self) -> Result { + let compiled = self.inner.compile().map_err(map_program_compile_error)?; + Ok(OwnerV1::from_compiled(compiled)) + } +} + +impl Default for DraftV1 { + fn default() -> Self { + Self::new() + } +} + +/// Непрозрачный сильный владелец одной точной скомпилированной Program. +/// +/// Созданные им Session изменяются только через эту же аллокацию. Уничтожение +/// Owner отзывает обновления и операции, но исторические evidence остаются в +/// Session. +pub(crate) struct OwnerV1 { + compiled: CompiledCoreProgramV1, +} + +/// Верхние границы числа клеток в новом сертификате одного Observed-update. +/// +/// Границы относятся только к текущим клеткам доказательства. Они не включают +/// сохранённый прошлый сертификат, observation/provenance, выходы, операции или +/// байты конкретного транспорта. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct EvidenceCellBoundsV1 { + verified_cells: usize, + conflict_cells: usize, +} + +impl EvidenceCellBoundsV1 { + /// Максимум клеток успешного сертификата. + pub(crate) const fn verified_cells(self) -> usize { + self.verified_cells + } + + /// Максимум клеток исчерпывающего конфликтного сертификата. + pub(crate) const fn conflict_cells(self) -> usize { + self.conflict_cells + } +} + +/// Закрытая причина невозможности вычислить границы сертификата. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EvidenceBoundsErrorV1 { + /// Произведение числа сценариев, ограничений и состояний не помещается в + /// адресное пространство платформы. + CardinalityOverflow, +} + +/// Отказ доступа из-за несовпадения точной owner-эпохи. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AccessErrorV1 { + /// Session была создана другой аллокацией Owner. + OwnerMismatch, +} + +impl OwnerV1 { + /// Внутренняя передача из канонического компилятора. + pub(crate) const fn from_compiled(compiled: CompiledCoreProgramV1) -> Self { + Self { compiled } + } + + /// Возвращает каноническую identity скомпилированного содержания. + /// + /// Identity доступна до первого update, но не заменяет полномочия этой + /// конкретной owner-эпохи. + pub(crate) fn content_identity(&self) -> ContentIdentityV1 { + ContentIdentityV1::from_core(self.compiled.content_identity()) + } + + /// Вычисляет верхние границы клеток для prospective Observed-update. + /// + /// `scenario_count` — число объявленных клиентом сценариев до admission. + /// Core сам схлопывает физически одинаковые сценарии, поэтому фактический + /// сертификат может быть короче. Нулевое значение разрешено только как + /// чистый арифметический preflight; пустой Observed-update по-прежнему не + /// допускается. Запрос не создаёт Session и не меняет состояние. + pub(crate) fn evidence_cell_bounds( + &self, + scenario_count: usize, + ) -> Result { + self.compiled + .evidence_cell_bounds(scenario_count) + .map(|(verified_cells, conflict_cells)| EvidenceCellBoundsV1 { + verified_cells, + conflict_cells, + }) + .ok_or(EvidenceBoundsErrorV1::CardinalityOverflow) + } + + /// Число значений Surface в каждом schema-ordered сценарии. + pub(crate) fn surface_input_port_count(&self) -> usize { + self.compiled.surface_input_ports().len() + } + + /// Канонический порядок входных портов для однократного binding на хосте. + pub(crate) fn surface_input_ports( + &self, + ) -> impl ExactSizeIterator + '_ { + self.compiled + .surface_input_ports() + .iter() + .copied() + .map(SurfaceInputPortIdV1::from_core) + } + + /// Канонический порядок непрозрачных выходных слотов. + pub(crate) fn output_slots(&self) -> impl ExactSizeIterator + '_ { + self.compiled + .outputs() + .map(|(slot, _paint)| OutputSlotIdV1::from_core(slot)) + } + + /// Проецирует операции только для Session этой точной owner-эпохи. + /// + /// Равенство [`ContentIdentityV1`] не даёт полномочий. + pub(crate) fn project<'owner, 'session>( + &'owner self, + session: &'session SessionV1, + ) -> Result, AccessErrorV1> { + if !self.compiled.owns_session(&session.session) { + return Err(AccessErrorV1::OwnerMismatch); + } + Ok(ProjectionV1 { + evidence: session.evidence(), + owner: self, + scope: BorrowScopeV1::new(self, session), + }) + } + + /// Атомарно допускает update и возвращает его неизменяемую проекцию. + /// + /// Несовпадение Owner проверяется до admission, аллокаций и вычисления. + pub(crate) fn update<'owner, 'session>( + &'owner self, + session: &'session mut SessionV1, + update: UpdateV1<'_>, + ) -> Result, UpdateErrorV1> { + if !self.compiled.owns_session(&session.session) { + return Err(UpdateErrorV1::OwnerMismatch); + } + session.apply_update(update)?; + Ok(ProjectionV1 { + evidence: session.evidence(), + owner: self, + scope: BorrowScopeV1::new(self, session), + }) + } + + /// Создаёт Session, привязанную к одному непрозрачному stream ID. + pub(crate) fn instantiate(&self, stream_id: u32) -> Result { + let stream = ObservationStreamId::new(stream_id); + let session = self + .compiled + .instantiate(stream) + .map_err(InstantiateErrorV1::from_core)?; + Ok(SessionV1 { + scenario_order_scratch: Vec::new(), + session, + }) + } +} + +/// Один заимствованный физический сценарий в скомпилированном schema order. +/// +/// ID сценария — непрозрачная provenance. `values` содержит ровно один encoded +/// sRGB8 на каждый [`OwnerV1::surface_input_ports`] в том же порядке. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ScenarioV1<'a> { + scenario_id: u32, + values: &'a [Srgb8], +} + +impl<'a> ScenarioV1<'a> { + /// Создаёт один одновременный физический кортеж. + pub(crate) const fn new(scenario_id: u32, values: &'a [Srgb8]) -> Self { + Self { + scenario_id, + values, + } + } +} + +/// Одно revision-bound обновление; stream принадлежит Session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UpdateV1<'a> { + /// Согласованные физические сценарии в порядке скомпилированной схемы. + Observed { + /// Монотонная ревизия входного наблюдения. + revision: u64, + /// Одновременные сценарии физического контекста. + scenarios: &'a [ScenarioV1<'a>], + }, + /// Наблюдение явно недоступно; Core не изобретает фон. + Unknown { + /// Монотонная ревизия входного наблюдения. + revision: u64, + /// Непрозрачная клиентская причина недоступности. + reason_id: u32, + }, +} + +/// Непрозрачная изменяемая Session одной Program и одного stream. +pub(crate) struct SessionV1 { + scenario_order_scratch: Vec, + session: CoreProgramSessionV1, +} + +impl SessionV1 { + /// Возвращает исторические evidence без права на операции. + pub(crate) fn evidence(&self) -> EvidenceViewV1<'_> { + EvidenceViewV1 { + session: &self.session, + } + } + + fn apply_update(&mut self, update: UpdateV1<'_>) -> Result<(), UpdateErrorV1> { + match update { + UpdateV1::Observed { + revision, + scenarios, + } => { + let source = ScenarioSourceV1(scenarios); + self.session + .update_schema_ordered( + Revision::new(revision), + &source, + &mut self.scenario_order_scratch, + ) + .map_err(map_session_update_error)?; + } + UpdateV1::Unknown { + revision, + reason_id, + } => { + self.session + .update_unknown(Revision::new(revision), UnknownReasonId::new(reason_id)) + .map_err(map_session_update_error)?; + } + } + Ok(()) + } +} + +struct ScenarioSourceV1<'a>(&'a [ScenarioV1<'a>]); + +impl SchemaOrderedScenarioSourceV1 for ScenarioSourceV1<'_> { + fn scenario_count(&self) -> usize { + self.0.len() + } + + fn scenario_id(&self, scenario_index: usize) -> ScenarioId { + ScenarioId::new(self.0[scenario_index].scenario_id) + } + + fn value_count(&self, scenario_index: usize) -> usize { + self.0[scenario_index].values.len() + } + + fn value(&self, scenario_index: usize, binding_index: usize) -> Srgb8 { + self.0[scenario_index].values[binding_index] + } +} + +/// Закрытая классификация lifecycle Session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StateKindV1 { + /// Допущенного вычислимого наблюдения ещё нет; сырая голова может быть `Unknown`. + Waiting, + /// Текущая ревизия сертифицирована. + Ready, + /// Новое наблюдение недоступно; прошлый сертификат сохранён для диагностики. + Stale, + /// Текущая ревизия имеет исчерпывающий конфликт. + Failed, +} + +/// Текущая сырая голова наблюдений независимо от evaluator lifecycle. +/// +/// Непустая голова хранит stream provenance, но не полномочия на операции. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ObservationHeadV1 { + /// Наблюдений ещё не было. + Empty, + /// Наблюдение явно недоступно. + Unknown { + /// Stream наблюдения. + stream: StreamIdV1, + /// Ревизия наблюдения. + revision: u64, + /// Непрозрачная причина недоступности. + reason_id: u32, + }, + /// Принят физический набор сценариев. + Observed { + /// Stream наблюдения. + stream: StreamIdV1, + /// Ревизия наблюдения. + revision: u64, + }, +} + +/// Заимствованное историческое evidence, принадлежащее Session. +#[derive(Clone, Copy)] +pub(crate) struct EvidenceViewV1<'a> { + session: &'a CoreProgramSessionV1, +} + +impl<'a> EvidenceViewV1<'a> { + const fn state(self) -> &'a CoreProgramStateV1 { + self.session.state() + } + + /// Возвращает lifecycle-класс текущего состояния. + pub(crate) const fn kind(self) -> StateKindV1 { + match self.state() { + SessionState::Waiting => StateKindV1::Waiting, + SessionState::Ready { .. } => StateKindV1::Ready, + SessionState::Stale { .. } => StateKindV1::Stale, + SessionState::Failed { .. } => StateKindV1::Failed, + } + } + + /// Возвращает сырую голову наблюдений вместе с provenance. + pub(crate) fn observation_head(self) -> ObservationHeadV1 { + match self.session.raw_head() { + ObservationHeadViewV1::Empty => ObservationHeadV1::Empty, + ObservationHeadViewV1::Unknown(unknown) => ObservationHeadV1::Unknown { + stream: StreamIdV1::from_core(unknown.stream()), + revision: unknown.revision().value(), + reason_id: unknown.reason().value(), + }, + ObservationHeadViewV1::Observed(observation) => ObservationHeadV1::Observed { + stream: StreamIdV1::from_core(observation.stream()), + revision: observation.revision().value(), + }, + } + } + + /// Индекс cause-сертификата в [`Self::certificates`] для `Failed`. + pub(crate) const fn cause_certificate_index(self) -> Option { + match self.state() { + SessionState::Failed { .. } => Some(0), + SessionState::Waiting | SessionState::Ready { .. } | SessionState::Stale { .. } => None, + } + } + + /// Сертификаты в каноническом порядке одного снимка. + pub(crate) fn certificates( + self, + ) -> impl ExactSizeIterator> + FusedIterator + 'a { + let (first, second) = match self.state() { + SessionState::Waiting => (None, None), + SessionState::Ready { current } | SessionState::Stale { previous: current } => { + (Some(CertificateV1::verified(current)), None) + } + SessionState::Failed { cause, previous } => ( + Some(CertificateV1::conflict(cause)), + previous.as_ref().map(CertificateV1::verified), + ), + }; + CertificatesV1::new(first, second) + } +} + +/// Нулевой lifetime-маркер точной пары Owner и неизменяемого снимка Session. +#[derive(Clone, Copy)] +struct BorrowScopeV1<'owner, 'session> { + _scope: PhantomData<(&'owner OwnerV1, &'session SessionV1)>, +} + +impl<'owner, 'session> BorrowScopeV1<'owner, 'session> { + const fn new(_owner: &'owner OwnerV1, _session: &'session SessionV1) -> Self { + Self { + _scope: PhantomData, + } + } +} + +/// Проверенная Owner-and-snapshot проекция evidence и операций. +#[derive(Clone, Copy)] +pub(crate) struct ProjectionV1<'owner, 'session> { + evidence: EvidenceViewV1<'session>, + owner: &'owner OwnerV1, + scope: BorrowScopeV1<'owner, 'session>, +} + +impl<'owner, 'session> ProjectionV1<'owner, 'session> { + /// Возвращает историческое evidence этого снимка. + pub(crate) const fn evidence(self) -> EvidenceViewV1<'session> { + self.evidence + } + + /// Возвращает полную каноническую последовательность операций состояния. + pub(crate) fn operations( + self, + ) -> impl ExactSizeIterator> + FusedIterator { + let inner = match self.evidence.state() { + SessionState::Waiting + if matches!( + self.evidence.session.raw_head(), + ObservationHeadViewV1::Empty + ) => + { + OperationSourceV1::Empty + } + SessionState::Ready { current } => { + debug_assert_eq!(current.outputs().len(), self.owner.compiled.output_count()); + debug_assert!( + current + .outputs() + .iter() + .enumerate() + .all(|(index, output)| self.owner.compiled.output_slot_at(index) + == Some(output.output())) + ); + OperationSourceV1::Set { + outputs: current.outputs().iter(), + certificate: VerifiedCertificateV1 { inner: current }, + scope: self.scope, + } + } + // `Waiting + Empty` — единственное состояние без действия и без + // полномочий на sink. После admission сырой головы любое состояние + // без текущего Verified-доказательства подчиняется одному закону + // отзыва. Так же fail-closed обрабатывается внутренне недостижимое + // сегодня сочетание `Waiting + Observed`. + SessionState::Waiting | SessionState::Stale { .. } | SessionState::Failed { .. } => { + OperationSourceV1::Remove { + slots: OwnerOutputSlotsV1::new(&self.owner.compiled), + scope: self.scope, + } + } + }; + OperationsV1 { inner } + } +} + +/// Collision-resistant адрес канонического физического содержания Program. +/// +/// Identity не идентифицирует owner-эпоху и не даёт runtime-полномочий. +#[repr(transparent)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct ContentIdentityV1([u8; 32]); + +impl ContentIdentityV1 { + const fn from_core(value: ProgramContentIdentityV1) -> Self { + Self(*value.as_bytes()) + } + + /// Возвращает 256-битное каноническое представление identity. + pub(crate) const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +/// Доказательство прохождения всех hard-клеток на полном physical support. +#[derive(Clone, Copy)] +pub(crate) struct VerifiedCertificateV1<'a> { + inner: &'a CoreVerifiedV1, +} + +impl<'a> VerifiedCertificateV1<'a> { + /// Возвращает identity скомпилированного содержания. + pub(crate) const fn content_identity(self) -> ContentIdentityV1 { + ContentIdentityV1::from_core(self.inner.report().content_identity()) + } + + /// Возвращает точное наблюдение, на котором выдан сертификат. + pub(crate) const fn observation(self) -> ObservationV1<'a> { + ObservationV1 { + inner: self.inner.report().observation(), + } + } + + /// Возвращает индекс выбранного состояния или `None` для fixed Program. + pub(crate) const fn selected_state_index(self) -> Option { + self.inner.selected_state_index() + } + + /// Возвращает все `case × constraint` клетки выбранного состояния. + pub(crate) fn cells( + self, + ) -> impl ExactSizeIterator> + FusedIterator + 'a { + self.inner + .report() + .cells() + .iter() + .map(VerifiedCellV1::from_core) + } + + /// Возвращает все сертифицированные выходы в каноническом порядке. + pub(crate) fn outputs( + self, + ) -> impl ExactSizeIterator> + FusedIterator + 'a { + self.inner + .outputs() + .iter() + .map(CertifiedOutputV1::from_core) + } +} + +/// Исчерпывающее доказательство, что каждое состояние нарушает hard-клетку. +#[derive(Clone, Copy)] +pub(crate) struct ConflictCertificateV1<'a> { + inner: &'a CoreConflictV1, +} + +impl<'a> ConflictCertificateV1<'a> { + /// Возвращает identity скомпилированного содержания. + pub(crate) const fn content_identity(self) -> ContentIdentityV1 { + ContentIdentityV1::from_core(self.inner.report().content_identity()) + } + + /// Возвращает точное наблюдение, вызвавшее конфликт. + pub(crate) const fn observation(self) -> ObservationV1<'a> { + ObservationV1 { + inner: self.inner.report().observation(), + } + } + + /// Возвращает число исчерпывающе рассмотренных состояний. + pub(crate) const fn considered_state_count(self) -> usize { + self.inner.considered_state_count() + } + + /// Возвращает все `state × case × constraint` клетки конфликта. + pub(crate) fn cells( + self, + ) -> impl ExactSizeIterator> + FusedIterator + 'a { + self.inner + .report() + .cells() + .iter() + .map(ConflictCellV1::from_core) + } +} + +/// Закрытая заимствованная проекция одного Core-owned сертификата. +/// +/// Сертификат заимствует только историю Session и может пережить Owner, +/// разрешивший исходную проекцию. +#[derive(Clone, Copy)] +pub(crate) enum CertificateV1<'a> { + /// Все hard-клетки полного support прошли. + Verified(VerifiedCertificateV1<'a>), + /// Каждое рассмотренное состояние нарушает хотя бы одну hard-клетку. + Conflict(ConflictCertificateV1<'a>), +} + +impl<'a> CertificateV1<'a> { + const fn verified(value: &'a CoreVerifiedV1) -> Self { + Self::Verified(VerifiedCertificateV1 { inner: value }) + } + + const fn conflict(value: &'a CoreConflictV1) -> Self { + Self::Conflict(ConflictCertificateV1 { inner: value }) + } + + /// Возвращает identity скомпилированного содержания. + pub(crate) const fn content_identity(self) -> ContentIdentityV1 { + match self { + Self::Verified(value) => value.content_identity(), + Self::Conflict(value) => value.content_identity(), + } + } + + /// Возвращает точное revision-bound наблюдение сертификата. + pub(crate) const fn observation(self) -> ObservationV1<'a> { + match self { + Self::Verified(value) => value.observation(), + Self::Conflict(value) => value.observation(), + } + } + + #[cfg(test)] + pub(crate) fn observation_backing_ptr_for_test(self) -> *const () { + self.observation().inner.backing_ptr_for_test() + } +} + +/// Точное revision-bound наблюдение, сохранённое сертификатом. +#[derive(Clone, Copy)] +pub(crate) struct ObservationV1<'a> { + inner: &'a crate::observation::RevisionBoundObservationV1, +} + +impl<'a> ObservationV1<'a> { + /// Возвращает stream provenance наблюдения. + pub(crate) const fn stream(self) -> StreamIdV1 { + StreamIdV1::from_core(self.inner.stream()) + } + + /// Возвращает ревизию наблюдения. + pub(crate) const fn revision(self) -> u64 { + self.inner.revision().value() + } + + /// Возвращает каноническую schema, общую для всех физических cases. + /// + /// Позиция `i` соответствует позиции `i` в [`PhysicalCaseV1::values`]. + pub(crate) fn surface_input_ports( + self, + ) -> impl ExactSizeIterator + FusedIterator + 'a { + self.inner + .schema() + .iter() + .copied() + .map(SurfaceInputPortIdV1::from_core) + } + + /// Возвращает канонические уникальные физические cases. + /// + /// Дубликаты значений схлопываются, а их ID сохраняются в provenance. + pub(crate) fn physical_cases( + self, + ) -> impl ExactSizeIterator> + FusedIterator + 'a { + (0..self.inner.physical_case_count()).map(move |index| PhysicalCaseV1 { + observation: self.inner, + index, + }) + } +} + +/// Закрытое семейство сигналов физического case. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SignalV1 { + /// Encoded sRGB8 в IEC 61966-2-1 с белой точкой D65. + Iec61966Srgb8D65(Srgb8), +} + +/// Один канонический физический case и его полная provenance. +#[derive(Clone, Copy)] +pub(crate) struct PhysicalCaseV1<'a> { + observation: &'a crate::observation::RevisionBoundObservationV1, + index: usize, +} + +impl<'a> PhysicalCaseV1<'a> { + /// Возвращает значения case в каноническом schema order. + pub(crate) fn values(self) -> impl ExactSizeIterator + FusedIterator + 'a { + self.observation + .physical_values(self.index) + .expect("physical case originates from the same observation") + .iter() + .copied() + .map(|signal| match signal.view() { + ColorSignalViewV1::Iec61966Srgb8D65(value) => SignalV1::Iec61966Srgb8D65(value), + }) + } + + /// Возвращает все scenario ID, схлопнутые в этот физический case. + pub(crate) fn provenance( + self, + ) -> impl ExactSizeIterator + FusedIterator + 'a { + self.observation + .provenance(self.index) + .expect("physical case originates from the same observation") + .iter() + .copied() + .map(ScenarioIdV1::from_core) + } +} + +/// Роль одной constraint-клетки в выборе. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConstraintModeV1 { + /// Нарушение запрещает состояние. + Hard, + /// Результат сохраняется, но не влияет на выбор. + ReportOnly, +} + +/// Одна клетка `case × constraint` выбранного или fixed состояния. +#[derive(Clone, Copy)] +pub(crate) struct VerifiedCellV1<'a> { + inner: &'a CoreProgramConstraintCellV1, +} + +impl<'a> VerifiedCellV1<'a> { + const fn from_core(inner: &'a CoreProgramConstraintCellV1) -> Self { + Self { inner } + } + + /// Возвращает индекс физического case. + pub(crate) const fn case_index(self) -> usize { + self.inner.case_index() + } + + /// Возвращает ID ограничения. + pub(crate) const fn constraint(self) -> ConstraintIdV1 { + ConstraintIdV1::from_core(self.inner.constraint()) + } + + /// Возвращает ID проверенного Occurrence. + pub(crate) const fn occurrence(self) -> OccurrenceIdV1 { + OccurrenceIdV1::from_core(self.inner.target()) + } + + /// Возвращает роль ограничения в выборе. + pub(crate) const fn mode(self) -> ConstraintModeV1 { + project_constraint_mode(self.inner) + } + + /// Возвращает типизированное сохранённое evidence. + pub(crate) fn assessment(self) -> AssessmentV1<'a> { + project_assessment(self.inner) + } +} + +/// Одна исчерпывающая клетка `state × case × constraint` конфликта. +#[derive(Clone, Copy)] +pub(crate) struct ConflictCellV1<'a> { + inner: &'a CoreProgramConstraintCellV1, +} + +impl<'a> ConflictCellV1<'a> { + const fn from_core(inner: &'a CoreProgramConstraintCellV1) -> Self { + Self { inner } + } + + /// Возвращает индекс рассмотренного состояния. + pub(crate) const fn state_index(self) -> usize { + self.inner.candidate_state_index() + } + + /// Возвращает индекс физического case. + pub(crate) const fn case_index(self) -> usize { + self.inner.case_index() + } + + /// Возвращает ID ограничения. + pub(crate) const fn constraint(self) -> ConstraintIdV1 { + ConstraintIdV1::from_core(self.inner.constraint()) + } + + /// Возвращает ID проверенного Occurrence. + pub(crate) const fn occurrence(self) -> OccurrenceIdV1 { + OccurrenceIdV1::from_core(self.inner.target()) + } + + /// Возвращает роль ограничения в выборе. + pub(crate) const fn mode(self) -> ConstraintModeV1 { + project_constraint_mode(self.inner) + } + + /// Возвращает типизированное сохранённое evidence. + pub(crate) fn assessment(self) -> AssessmentV1<'a> { + project_assessment(self.inner) + } +} + +const fn project_constraint_mode(cell: &CoreProgramConstraintCellV1) -> ConstraintModeV1 { + if cell.is_hard() { + ConstraintModeV1::Hard + } else { + ConstraintModeV1::ReportOnly + } +} + +fn project_assessment(cell: &CoreProgramConstraintCellV1) -> AssessmentV1<'_> { + match cell.result() { + ProgramConstraintResultV1::Pass(CoreProgramPassEvidenceV1::ExactSrgb8(evidence)) => { + AssessmentV1::ExactSrgb8(ExactSrgb8EvidenceV1 { + inner: ExactSrgb8EvidenceRefV1::Pass(evidence), + }) + } + ProgramConstraintResultV1::Violation(CoreProgramViolationEvidenceV1::ExactSrgb8( + evidence, + )) => AssessmentV1::ExactSrgb8(ExactSrgb8EvidenceV1 { + inner: ExactSrgb8EvidenceRefV1::Violation(evidence), + }), + ProgramConstraintResultV1::Pass(CoreProgramPassEvidenceV1::Wcag22Srgb8(evidence)) => { + AssessmentV1::Wcag22Srgb8(Wcag22Srgb8EvidenceV1 { + inner: Wcag22Srgb8EvidenceRefV1::Pass(evidence), + }) + } + ProgramConstraintResultV1::Violation(CoreProgramViolationEvidenceV1::Wcag22Srgb8( + evidence, + )) => AssessmentV1::Wcag22Srgb8(Wcag22Srgb8EvidenceV1 { + inner: Wcag22Srgb8EvidenceRefV1::Violation(evidence), + }), + } +} + +/// Закрытое семейство сохранённого evaluator evidence. +#[derive(Clone, Copy)] +pub(crate) enum AssessmentV1<'a> { + /// Evidence точного сравнения encoded sRGB8. + ExactSrgb8(ExactSrgb8EvidenceV1<'a>), + /// Evidence применимого критерия WCAG 2.2. + Wcag22Srgb8(Wcag22Srgb8EvidenceV1<'a>), +} + +impl<'a> AssessmentV1<'a> { + /// Возвращает несовместимый с противоположным исход классификатора. + pub(crate) const fn verdict(self) -> VerdictV1 { + match self { + Self::ExactSrgb8(value) => value.verdict(), + Self::Wcag22Srgb8(value) => value.verdict(), + } + } + + /// Возвращает общую физическую и моделированную привязку точки. + pub(crate) fn binding(self) -> PointBindingV1<'a> { + match self { + Self::ExactSrgb8(value) => value.binding(), + Self::Wcag22Srgb8(value) => value.binding(), + } + } +} + +/// Несовместимые сохранённые исходы классификатора. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VerdictV1 { + /// Критерий доказан. + Pass, + /// Критерий доказанно нарушен. + Violation, +} + +#[derive(Clone, Copy)] +enum ExactSrgb8EvidenceRefV1<'a> { + Pass(&'a CoreExactPassEvidenceV1), + Violation(&'a CoreExactViolationEvidenceV1), +} + +/// Evidence точного sRGB8 сравнения с физикой и моделированным контекстом. +#[derive(Clone, Copy)] +pub(crate) struct ExactSrgb8EvidenceV1<'a> { + inner: ExactSrgb8EvidenceRefV1<'a>, +} + +impl<'a> ExactSrgb8EvidenceV1<'a> { + /// Возвращает сохранённый исход классификатора. + pub(crate) const fn verdict(self) -> VerdictV1 { + match self.inner { + ExactSrgb8EvidenceRefV1::Pass(_) => VerdictV1::Pass, + ExactSrgb8EvidenceRefV1::Violation(_) => VerdictV1::Violation, + } + } + + /// Возвращает ожидаемый encoded sRGB8 результат. + pub(crate) fn expected(self) -> Srgb8 { + match self.inner { + ExactSrgb8EvidenceRefV1::Pass(value) => value.target(), + ExactSrgb8EvidenceRefV1::Violation(value) => value.target(), + } + } + + /// Возвращает физическую и моделированную привязку точки. + pub(crate) fn binding(self) -> PointBindingV1<'a> { + let value = match self.inner { + ExactSrgb8EvidenceRefV1::Pass(value) => value.binding(), + ExactSrgb8EvidenceRefV1::Violation(value) => value.binding(), + }; + PointBindingV1 { inner: value } + } +} + +#[derive(Clone, Copy)] +enum Wcag22Srgb8EvidenceRefV1<'a> { + Pass(&'a CoreWcag22PassEvidenceV1), + Violation(&'a CoreWcag22ViolationEvidenceV1), +} + +/// WCAG 2.2 evidence вместе с физикой и моделированным контекстом. +#[derive(Clone, Copy)] +pub(crate) struct Wcag22Srgb8EvidenceV1<'a> { + inner: Wcag22Srgb8EvidenceRefV1<'a>, +} + +impl<'a> Wcag22Srgb8EvidenceV1<'a> { + /// Возвращает сохранённый исход классификатора. + pub(crate) const fn verdict(self) -> VerdictV1 { + match self.inner { + Wcag22Srgb8EvidenceRefV1::Pass(_) => VerdictV1::Pass, + Wcag22Srgb8EvidenceRefV1::Violation(_) => VerdictV1::Violation, + } + } + + /// Возвращает версию применённого WCAG 2.2 профиля. + pub(crate) fn profile_id(self) -> Wcag22ProfileIdV1 { + match self.inner { + Wcag22Srgb8EvidenceRefV1::Pass(value) => value.measurement().value().profile_id(), + Wcag22Srgb8EvidenceRefV1::Violation(value) => value.measurement().value().profile_id(), + } + } + + /// Возвращает применённый критерий. + pub(crate) fn criterion(self) -> Wcag22CriterionV1 { + match self.inner { + Wcag22Srgb8EvidenceRefV1::Pass(value) => value.measurement().value().criterion(), + Wcag22Srgb8EvidenceRefV1::Violation(value) => value.measurement().value().criterion(), + } + } + + /// Возвращает сертифицированные границы яркости foreground. + pub(crate) fn foreground_luminance(self) -> Wcag22LuminanceBoundsQ55V1 { + let measurement = match self.inner { + Wcag22Srgb8EvidenceRefV1::Pass(value) => value.measurement().value().measurement(), + Wcag22Srgb8EvidenceRefV1::Violation(value) => value.measurement().value().measurement(), + }; + measurement.foreground_luminance + } + + /// Возвращает сертифицированные границы яркости background. + pub(crate) fn background_luminance(self) -> Wcag22LuminanceBoundsQ55V1 { + let measurement = match self.inner { + Wcag22Srgb8EvidenceRefV1::Pass(value) => value.measurement().value().measurement(), + Wcag22Srgb8EvidenceRefV1::Violation(value) => value.measurement().value().measurement(), + }; + measurement.background_luminance + } + + /// Возвращает числовое доказательство устойчивости решения. + pub(crate) fn numerical_evidence(self) -> &'a NumericalDecisionEvidenceV1 { + match self.inner { + Wcag22Srgb8EvidenceRefV1::Pass(value) => value.measurement().value().evidence(), + Wcag22Srgb8EvidenceRefV1::Violation(value) => value.measurement().value().evidence(), + } + } + + /// Возвращает физическую и моделированную привязку точки. + pub(crate) fn binding(self) -> PointBindingV1<'a> { + let value = match self.inner { + Wcag22Srgb8EvidenceRefV1::Pass(value) => value.binding(), + Wcag22Srgb8EvidenceRefV1::Violation(value) => value.binding(), + }; + PointBindingV1 { inner: value } + } +} + +/// Общая привязка физической композиции и моделированного tristimulus/context. +#[derive(Clone, Copy)] +pub(crate) struct PointBindingV1<'a> { + inner: &'a ProgramVisiblePointBindingV1, +} + +impl<'a> PointBindingV1<'a> { + /// Возвращает закрытый тип точной физической композиции. + pub(crate) const fn physical(self) -> PhysicalPointV1<'a> { + match self.inner.physical().occurrence().profile() { + CompositionProfileV1::EncodedSrgb8SourceOverV1 => { + PhysicalPointV1::EncodedSrgb8SourceOver(EncodedSrgb8SourceOverV1 { + inner: self.inner, + }) + } + } + } + + /// Возвращает закрытый тип допущенного моделированного сигнала. + pub(crate) const fn modeled(self) -> ModeledPointV1<'a> { + match self.inner.modeled_lcs().provenance().binding() { + AdmittedSrgb8TristimulusBindingV1::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1 => { + ModeledPointV1::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1( + ModeledTristimulusV1 { inner: self.inner }, + ) + } + } + } +} + +/// Закрытое семейство точной физической композиции. +#[derive(Clone, Copy)] +pub(crate) enum PhysicalPointV1<'a> { + /// Encoded-sRGB8 source-over композиция. + EncodedSrgb8SourceOver(EncodedSrgb8SourceOverV1<'a>), +} + +/// Точная привязка одного encoded-sRGB8 source-over Occurrence. +#[derive(Clone, Copy)] +pub(crate) struct EncodedSrgb8SourceOverV1<'a> { + inner: &'a ProgramVisiblePointBindingV1, +} + +impl EncodedSrgb8SourceOverV1<'_> { + /// Возвращает ID накладываемого Paint. + pub(crate) const fn subject_paint(self) -> PaintIdV1 { + PaintIdV1::from_core(self.inner.physical().program_occurrence().subject()) + } + + /// Возвращает ID backdrop Surface. + pub(crate) const fn backdrop_surface(self) -> SurfaceIdV1 { + SurfaceIdV1::from_core( + self.inner + .physical() + .program_occurrence() + .backdrop_surface(), + ) + } + + /// Возвращает исходный encoded sRGB8 subject до композиции. + pub(crate) const fn subject(self) -> Srgb8 { + Srgb8::new(self.inner.physical().occurrence().subject_rgb()) + } + + /// Возвращает точную прозрачность subject в `[0, 1]`. + pub(crate) const fn opacity(self) -> f64 { + f64::from_bits(self.inner.physical().occurrence().subject_opacity_bits()) + } + + /// Возвращает observed encoded sRGB8 backdrop. + pub(crate) const fn backdrop(self) -> Srgb8 { + Srgb8::new(self.inner.physical().occurrence().backdrop_rgb()) + } + + /// Возвращает видимый encoded sRGB8 результат композиции. + pub(crate) const fn visible(self) -> Srgb8 { + Srgb8::new(self.inner.physical().occurrence().output_rgb()) + } +} + +/// Закрытое семейство provenance моделированного tristimulus. +#[derive(Clone, Copy)] +pub(crate) enum ModeledPointV1<'a> { + /// IEC sRGB8 → CIE 1931 2° XYZ D65 с относительным `Y=1`. + Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1(ModeledTristimulusV1<'a>), +} + +/// Допущенный моделированный tristimulus и его контекст восприятия. +#[derive(Clone, Copy)] +pub(crate) struct ModeledTristimulusV1<'a> { + inner: &'a ProgramVisiblePointBindingV1, +} + +impl ModeledTristimulusV1<'_> { + /// Возвращает относительные координаты CIE XYZ. + pub(crate) fn xyz(self) -> [f64; 3] { + self.inner.modeled_lcs().derivation().sample().xyz() + } + + /// Возвращает явный контекст, использованный при моделировании. + pub(crate) const fn appearance_context(self) -> AppearanceContextV1 { + AppearanceContextV1(self.inner.modeled_lcs().occurrence().context()) + } +} + +/// Один Core-сертифицированный выходной Paint. +#[derive(Clone, Copy)] +pub(crate) struct CertifiedOutputV1<'a> { + inner: &'a ProgramOutputV1, +} + +impl<'a> CertifiedOutputV1<'a> { + const fn from_core(inner: &'a ProgramOutputV1) -> Self { + Self { inner } + } + + /// Возвращает клиентский выходной слот. + pub(crate) const fn output_slot(self) -> OutputSlotIdV1 { + OutputSlotIdV1::from_core((*self.inner).output()) + } + + /// Возвращает ID сертифицированного Paint. + pub(crate) const fn paint(self) -> PaintIdV1 { + PaintIdV1::from_core((*self.inner).paint().id()) + } + + /// Возвращает исходный encoded sRGB8 сигнал Paint. + pub(crate) const fn source(self) -> Srgb8 { + (*self.inner).paint().source() + } + + /// Возвращает сертифицированную прозрачность Paint. + pub(crate) const fn opacity(self) -> f64 { + (*self.inner).paint().opacity().value() + } +} + +/// Операция установки, структурно связанная с точным Verified-сертификатом. +#[derive(Clone, Copy)] +pub(crate) struct SetV1<'owner, 'session> { + output: &'session ProgramOutputV1, + certificate: VerifiedCertificateV1<'session>, + _scope: BorrowScopeV1<'owner, 'session>, +} + +impl<'session> SetV1<'_, 'session> { + /// Возвращает изменяемый клиентский выходной слот. + pub(crate) const fn output_slot(self) -> OutputSlotIdV1 { + OutputSlotIdV1::from_core((*self.output).output()) + } + + /// Возвращает исходный encoded sRGB8 сигнал результата. + pub(crate) const fn source(self) -> Srgb8 { + (*self.output).paint().source() + } + + /// Возвращает прозрачность результата. + pub(crate) const fn opacity(self) -> f64 { + (*self.output).paint().opacity().value() + } + + /// Возвращает сертификат, разрешивший эту операцию. + pub(crate) const fn certificate(self) -> VerifiedCertificateV1<'session> { + self.certificate + } +} + +/// Операция удаления результата без сертификата для текущего контекста. +#[derive(Clone, Copy)] +pub(crate) struct RemoveV1<'owner, 'session> { + output_slot: OutputSlotIdV1, + _scope: BorrowScopeV1<'owner, 'session>, +} + +impl RemoveV1<'_, '_> { + /// Возвращает удаляемый клиентский выходной слот. + pub(crate) const fn output_slot(self) -> OutputSlotIdV1 { + self.output_slot + } +} + +/// Полное закрытое множество операций над непрозрачными выходными слотами. +/// +/// Каждый payload заимствует точные Owner и снимок Session. Скопированные +/// slot/source/opacity — только данные: runtime обязан перепроверить живую +/// пару непосредственно перед одним атомарным sink commit. +#[derive(Clone, Copy)] +pub(crate) enum OperationV1<'owner, 'session> { + /// Установить сертифицированный результат. + Set(SetV1<'owner, 'session>), + /// Удалить результат, когда текущий контекст не сертифицирован. + Remove(RemoveV1<'owner, 'session>), +} + +struct CertificatesV1<'a> { + values: [Option>; 2], + index: usize, +} + +impl<'a> CertificatesV1<'a> { + fn new(first: Option>, second: Option>) -> Self { + Self { + values: [first, second], + index: 0, + } + } +} + +impl<'a> Iterator for CertificatesV1<'a> { + type Item = CertificateV1<'a>; + + fn next(&mut self) -> Option { + while self.index < self.values.len() { + let value = self.values[self.index]; + self.index += 1; + if value.is_some() { + return value; + } + } + None + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.values[self.index..] + .iter() + .filter(|value| value.is_some()) + .count(); + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for CertificatesV1<'_> {} +impl FusedIterator for CertificatesV1<'_> {} + +struct OwnerOutputSlotsV1<'owner> { + compiled: &'owner CompiledCoreProgramV1, + index: usize, + len: usize, +} + +impl<'owner> OwnerOutputSlotsV1<'owner> { + fn new(compiled: &'owner CompiledCoreProgramV1) -> Self { + Self { + compiled, + index: 0, + len: compiled.output_count(), + } + } +} + +impl Iterator for OwnerOutputSlotsV1<'_> { + type Item = OutputSlotIdV1; + + fn next(&mut self) -> Option { + if self.index == self.len { + return None; + } + let output = self.compiled.output_slot_at(self.index)?; + self.index += 1; + Some(OutputSlotIdV1::from_core(output)) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.len - self.index; + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for OwnerOutputSlotsV1<'_> {} +impl FusedIterator for OwnerOutputSlotsV1<'_> {} + +enum OperationSourceV1<'owner, 'session> { + Empty, + Set { + outputs: slice::Iter<'session, ProgramOutputV1>, + certificate: VerifiedCertificateV1<'session>, + scope: BorrowScopeV1<'owner, 'session>, + }, + Remove { + slots: OwnerOutputSlotsV1<'owner>, + scope: BorrowScopeV1<'owner, 'session>, + }, +} + +struct OperationsV1<'owner, 'session> { + inner: OperationSourceV1<'owner, 'session>, +} + +impl<'owner, 'session> Iterator for OperationsV1<'owner, 'session> { + type Item = OperationV1<'owner, 'session>; + + fn next(&mut self) -> Option { + match &mut self.inner { + OperationSourceV1::Empty => None, + OperationSourceV1::Set { + outputs, + certificate, + scope, + } => { + let output = outputs.next()?; + Some(OperationV1::Set(SetV1 { + output, + certificate: *certificate, + _scope: *scope, + })) + } + OperationSourceV1::Remove { slots, scope } => Some(OperationV1::Remove(RemoveV1 { + output_slot: slots.next()?, + _scope: *scope, + })), + } + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = match &self.inner { + OperationSourceV1::Empty => 0, + OperationSourceV1::Set { outputs, .. } => outputs.len(), + OperationSourceV1::Remove { slots, .. } => slots.len(), + }; + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for OperationsV1<'_, '_> {} +impl FusedIterator for OperationsV1<'_, '_> {} + +/// Закрытая классификация ошибки создания Session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InstantiateErrorKindV1 { + /// Для создания Session недостаточно ресурсов. + ResourceExhausted, + /// Нарушен внутренний инвариант скомпилированной Program. + InternalInvariant, +} + +/// Непрозрачная ошибка создания Session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct InstantiateErrorV1 { + kind: InstantiateErrorKindV1, +} + +impl InstantiateErrorV1 { + const fn new(kind: InstantiateErrorKindV1) -> Self { + Self { kind } + } + + fn from_core(error: ProgramSessionInstantiateError) -> Self { + let kind = match error { + ProgramSessionInstantiateError::ResourceExhausted => { + InstantiateErrorKindV1::ResourceExhausted + } + ProgramSessionInstantiateError::InternalInvariant => { + InstantiateErrorKindV1::InternalInvariant + } + }; + Self::new(kind) + } + + /// Возвращает стабильный класс ошибки. + pub(crate) const fn kind(self) -> InstantiateErrorKindV1 { + self.kind + } +} + +impl From for InstantiateErrorV1 { + fn from(kind: InstantiateErrorKindV1) -> Self { + Self::new(kind) + } +} + +/// Закрытая классификация ошибки одного атомарного update. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UpdateErrorKindV1 { + /// Session принадлежит другой точной owner-эпохе. + OwnerMismatch, + /// Наблюдение нарушает скомпилированную schema. + InvalidObservation, + /// Ревизия старше уже принятой. + RevisionOutOfOrder, + /// Та же ревизия содержит другой payload. + RevisionConflict, + /// Для admission или вычисления недостаточно ресурсов. + ResourceExhausted, + /// Зарегистрированный evaluator не смог выполнить оценку. + EvaluationFailed, + /// Нарушен внутренний инвариант. + InternalInvariant, +} + +/// Фаза update, в которой закончился ограниченный ресурс. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UpdatePhaseV1 { + /// Admission и канонизация физического наблюдения. + ObservationAdmission, + /// Вычисление, поиск и финальная перепроверка Program. + ProgramEvaluation, +} + +/// Точный отказ зарегистрированного evaluator-а. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum EvaluatorFailureV1 { + /// Отказ зарегистрированного WCAG 2.2 evaluator-а. + Wcag22Srgb8 { + /// Версия evaluator-а и его численного доказательства. + profile_id: Wcag22ProfileIdV1, + /// Точная исходная ошибка WCAG 2.2 без строковой переклассификации. + source: Wcag22EvaluationErrorV1, + }, +} + +/// Точная причина расхождения observation со скомпилированным binding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ObservationBindingFailureV1 { + /// Скомпилированная schema не содержит ни одного входного порта. + EmptyCompiledSurfaceInputSchema, + /// Один входной порт повторён в скомпилированной schema. + DuplicateCompiledSurfaceInputPort { + /// Повторённый непрозрачный ID порта. + input: SurfaceInputPortIdV1, + }, + /// Update относится к другому observation stream. + StreamMismatch { + /// Stream, принадлежащий Session. + expected: StreamIdV1, + /// Stream отвергнутого update. + actual: StreamIdV1, + }, + /// Один входной порт повторён внутри физического сценария. + DuplicateSurfaceInputBinding { + /// Provenance ошибочного сценария. + scenario: ScenarioIdV1, + /// Повторённый входной порт. + input: SurfaceInputPortIdV1, + }, + /// В физическом сценарии отсутствует обязательный входной порт. + MissingSurfaceInputBinding { + /// Provenance ошибочного сценария. + scenario: ScenarioIdV1, + /// Отсутствующий входной порт. + input: SurfaceInputPortIdV1, + }, + /// Физический сценарий содержит неизвестный входной порт. + UnexpectedSurfaceInputBinding { + /// Provenance ошибочного сценария. + scenario: ScenarioIdV1, + /// Неизвестный входной порт. + input: SurfaceInputPortIdV1, + }, + /// Канонический порядок портов разошёлся со скомпилированной schema. + SchemaMismatch { + /// Индекс физического сценария. + case_index: usize, + /// Первый несовпавший индекс binding. + binding_index: usize, + /// Ожидаемый порт либо конец скомпилированной schema. + expected: Option, + /// Фактический порт либо конец observation schema. + actual: Option, + }, +} + +/// Зарегистрированная identity XYZ-frame в диагностике закрытого Core. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ColorimetricFrameV1 { + /// CIE 1931 2°, IEC 61966-2-1 D65, относительная шкала `Y=1`, XYZ v1. + Iec61966Srgb8D65XyzRelativeY1V1, + /// Зарезервированный frame hostile-теста, недостижимый в production. + #[cfg(test)] + MutationSentinelV1, +} + +/// Компонент XYZ в точной диагностике. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TristimulusComponentV1 { + /// Компонент X. + X, + /// Компонент Y. + Y, + /// Компонент Z. + Z, +} + +/// Точная конечная XYZ-точка и её зарегистрированный frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct TristimulusSampleV1 { + /// Биты IEEE-754 сохраняют точный диагностический payload и отделяют + /// равенство записи от семантики сравнения floating-point. + xyz_bits: [u64; 3], + frame: ColorimetricFrameV1, +} + +impl TristimulusSampleV1 { + fn from_core(sample: TristimulusSample) -> Self { + Self { + xyz_bits: sample.xyz().map(f64::to_bits), + frame: map_colorimetric_frame(sample.frame()), + } + } + + /// Возвращает точные конечные XYZ-компоненты. + pub(crate) fn xyz(self) -> [f64; 3] { + self.xyz_bits.map(f64::from_bits) + } + + /// Возвращает зарегистрированный frame точки. + pub(crate) const fn frame(self) -> ColorimetricFrameV1 { + self.frame + } +} + +/// Точная причина, по которой Core не сформировал modeled LCS occurrence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ModeledOccurrenceFailureV1 { + /// Детерминированное преобразование получило недопустимую XYZ-компоненту. + Tristimulus { + /// Ошибочная компонента. + component: TristimulusComponentV1, + /// Точная числовая причина. + reason: NumericDomainErrorV1, + }, + /// Stimulus и appearance context принадлежат разным frame. + FrameMismatch { + /// Frame modeled stimulus. + stimulus: ColorimetricFrameV1, + /// Frame appearance context. + context: ColorimetricFrameV1, + }, + /// Повторное вычисление provenance получило недопустимую XYZ-компоненту. + ProvenanceReplayFailed { + /// Ошибочная компонента. + component: TristimulusComponentV1, + /// Точная числовая причина. + reason: NumericDomainErrorV1, + }, + /// Записанная modeled-точка не совпала с повторным вычислением provenance. + RecordedSampleDoesNotReplay { + /// Записанная точка. + recorded: TristimulusSampleV1, + /// Повторно вычисленная точка. + replayed: TristimulusSampleV1, + }, + /// Точка occurrence не совпала с modeled provenance. + OccurrenceSampleMismatch { + /// Точка occurrence. + occurrence: TristimulusSampleV1, + /// Точка modeled provenance. + modeled: TristimulusSampleV1, + }, +} + +/// Точное недопустимое protocol-состояние зарегистрированного evaluator-а. +#[derive(Debug, Clone, PartialEq, Eq)] +#[expect( + clippy::enum_variant_names, + reason = "the variant name preserves evaluator provenance as this closed family grows" +)] +pub(crate) enum EvaluatorProtocolFailureV1 { + /// WCAG evaluator вернул kernel-ошибку, недостижимую для typed Program. + Wcag22Kernel { + /// Версия evaluator-а и его численного доказательства. + profile_id: Wcag22ProfileIdV1, + /// Точная исходная kernel-ошибка. + source: Wcag22EvaluationErrorV1, + }, + /// Hard-вызов получил только клиентскую декларацию неприменимости. + Wcag22ReportOnly { + /// Версия evaluator-а и его численного доказательства. + profile_id: Wcag22ProfileIdV1, + /// Точная клиентская декларация. + declaration: Wcag22ClientDeclaredNotApplicableV1, + }, + /// Evaluator проверил другой критерий. + Wcag22CriterionMismatch { + /// Запрошенный критерий. + requested: Wcag22CriterionV1, + /// Фактически проверенный критерий. + evaluated: Wcag22CriterionV1, + }, +} + +/// Машиночитаемая identity нарушенного внутреннего контракта. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UpdateInvariantV1 { + /// Заимствованный matching Owner не удержал свою эпоху живой. + OwnerAuthority, + /// Каноническая observation schema разошлась со скомпилированным binding. + ObservationBinding, + /// Сохранённое evidence не принадлежит допускаемому observation. + EvidenceBinding, + /// Applicable evaluator вернул недопустимое protocol-состояние. + EvaluatorProtocol, + /// Physical и modeled точки одного evaluator-вызова разошлись. + PhysicalModeledBinding, + /// Зарегистрированный сигнал не сформировал свой LCS occurrence. + ModeledOccurrenceFormation, + /// Один выбранный state дал разные выходы в физических сценариях. + OutputCaseInvariance, + /// Детерминированная финальная перепроверка разошлась с поиском. + SelectionRecheck, + /// Закрытая программа нарушила собственную структуру исполнения. + ProgramEvaluation, +} + +/// Нарушенный внутренний контракт с точными subject и witness-фактами. +/// +/// Эти варианты недостижимы через типизированный boundary input. Payload нужен +/// для детерминированной диагностики и не превращает breach в цветовой verdict. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum UpdateInvariantFailureV1 { + /// Заимствованный matching Owner не удержал свою эпоху живой. + OwnerAuthority, + /// Каноническая observation schema разошлась со скомпилированным binding. + ObservationBinding { + /// Точное расхождение schema или stream. + source: ObservationBindingFailureV1, + }, + /// Сохранённое evidence не принадлежит допускаемому observation. + EvidenceBinding, + /// Applicable evaluator вернул недопустимое protocol-состояние. + EvaluatorProtocol { + /// Индекс физического сценария. + case_index: usize, + /// Непрозрачный ID проверяемого ограничения. + constraint: ConstraintIdV1, + /// Непрозрачный ID физического occurrence. + occurrence: OccurrenceIdV1, + /// Точный appearance context evaluator-вызова. + context: AppearanceContextV1, + /// Недопустимое protocol-состояние. + source: EvaluatorProtocolFailureV1, + }, + /// Physical и modeled точки одного evaluator-вызова разошлись. + PhysicalModeledBinding { + /// Индекс физического сценария. + case_index: usize, + /// Непрозрачный ID проверяемого ограничения. + constraint: ConstraintIdV1, + /// Непрозрачный ID физического occurrence. + occurrence: OccurrenceIdV1, + /// Точный appearance context evaluator-вызова. + context: AppearanceContextV1, + /// Физическая encoded sRGB8-точка. + physical: Srgb8, + /// Modeled encoded sRGB8-точка. + modeled: Srgb8, + }, + /// Зарегистрированный сигнал не сформировал свой LCS occurrence. + ModeledOccurrenceFormation { + /// Индекс физического сценария. + case_index: usize, + /// Непрозрачный ID occurrence. + occurrence: OccurrenceIdV1, + /// Intended appearance context формирования. + context: AppearanceContextV1, + /// Точная причина отказа формирования. + source: ModeledOccurrenceFailureV1, + }, + /// Один выбранный state дал разные выходы в физических сценариях. + OutputCaseInvariance { + /// Непрозрачный ID выходного слота. + output: OutputSlotIdV1, + /// Первый сценарий, задавший ожидаемое значение. + first_case: usize, + /// Сценарий с отличающимся значением. + actual_case: usize, + }, + /// Детерминированная финальная перепроверка разошлась с поиском. + SelectionRecheck { + /// Индекс выбранного joint state. + state_index: usize, + /// Индекс физического сценария. + case_index: usize, + /// Непрозрачный ID ограничения. + constraint: ConstraintIdV1, + /// Непрозрачный ID occurrence. + occurrence: OccurrenceIdV1, + /// Число hard-нарушений на финальной перепроверке. + hard_violation_count: usize, + }, + /// Закрытая программа нарушила собственную структуру исполнения. + ProgramEvaluation, +} + +impl UpdateInvariantFailureV1 { + /// Возвращает стабильную identity нарушенного контракта. + pub(crate) const fn contract(&self) -> UpdateInvariantV1 { + match self { + Self::OwnerAuthority => UpdateInvariantV1::OwnerAuthority, + Self::ObservationBinding { .. } => UpdateInvariantV1::ObservationBinding, + Self::EvidenceBinding => UpdateInvariantV1::EvidenceBinding, + Self::EvaluatorProtocol { .. } => UpdateInvariantV1::EvaluatorProtocol, + Self::PhysicalModeledBinding { .. } => UpdateInvariantV1::PhysicalModeledBinding, + Self::ModeledOccurrenceFormation { .. } => { + UpdateInvariantV1::ModeledOccurrenceFormation + } + Self::OutputCaseInvariance { .. } => UpdateInvariantV1::OutputCaseInvariance, + Self::SelectionRecheck { .. } => UpdateInvariantV1::SelectionRecheck, + Self::ProgramEvaluation => UpdateInvariantV1::ProgramEvaluation, + } + } +} + +/// Точная ошибка одного атомарного update. +/// +/// Любой variant оставляет observation head и lifecycle-состояние Core +/// неизменными. [`UpdateErrorKindV1`] — только удобная производная проекция: +/// авторитетные IDs и факты отказа находятся в этом enum. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum UpdateErrorV1 { + /// Session создана другой точной owner-эпохой. + OwnerMismatch, + /// Наблюдение не содержит ни одного физического сценария. + EmptyScenarioSet, + /// Один scenario ID повторён внутри наблюдения. + DuplicateScenarioId { + /// Повторённая непрозрачная provenance. + scenario: ScenarioIdV1, + }, + /// Число значений сценария не равно скомпилированной schema. + ScenarioValueCountMismatch { + /// Непрозрачная provenance ошибочного сценария. + scenario: ScenarioIdV1, + /// Число входов в скомпилированной schema. + expected: usize, + /// Фактическое число переданных значений. + actual: usize, + }, + /// Входная ревизия старше уже принятой. + RevisionOutOfOrder { + /// Текущая принятая ревизия. + current: u64, + /// Отвергнутая входная ревизия. + incoming: u64, + }, + /// Та же ревизия содержит другой payload. + RevisionConflict { + /// Ревизия с неоднозначным содержанием. + revision: u64, + }, + /// Ограниченный ресурс закончился до commit. + ResourceExhausted { + /// Фаза, которой не хватило ресурса. + phase: UpdatePhaseV1, + }, + /// Зарегистрированный evaluator не смог выполнить оценку. + EvaluationFailed { + /// Индекс физического сценария в каноническом наблюдении. + case_index: usize, + /// Непрозрачный ID проверяемого ограничения. + constraint: ConstraintIdV1, + /// Непрозрачный ID физического occurrence. + occurrence: OccurrenceIdV1, + /// Точный допущенный appearance context evaluator-вызова. + context: AppearanceContextV1, + /// Точная причина отказа и identity evaluator-а. + source: EvaluatorFailureV1, + }, + /// Нарушен внутренний контракт закрытого Core. + InternalInvariant { + /// Точный факт нарушения; identity выводится через [`UpdateInvariantFailureV1::contract`]. + source: UpdateInvariantFailureV1, + }, +} + +impl UpdateErrorV1 { + /// Возвращает стабильный класс ошибки без потери её payload. + pub(crate) const fn kind(&self) -> UpdateErrorKindV1 { + match self { + Self::OwnerMismatch => UpdateErrorKindV1::OwnerMismatch, + Self::EmptyScenarioSet + | Self::DuplicateScenarioId { .. } + | Self::ScenarioValueCountMismatch { .. } => UpdateErrorKindV1::InvalidObservation, + Self::RevisionOutOfOrder { .. } => UpdateErrorKindV1::RevisionOutOfOrder, + Self::RevisionConflict { .. } => UpdateErrorKindV1::RevisionConflict, + Self::ResourceExhausted { .. } => UpdateErrorKindV1::ResourceExhausted, + Self::EvaluationFailed { .. } => UpdateErrorKindV1::EvaluationFailed, + Self::InternalInvariant { .. } => UpdateErrorKindV1::InternalInvariant, + } + } +} + +fn map_joint_order_error(error: FiniteJointOrderErrorV1) -> JointOrderErrorV1 { + match error { + FiniteJointOrderErrorV1::EmptyDomain { dimension } => { + JointOrderErrorV1::EmptyDomain { dimension } + } + FiniteJointOrderErrorV1::CardinalityOverflow => JointOrderErrorV1::CardinalityOverflow, + FiniteJointOrderErrorV1::EmptyOrder => JointOrderErrorV1::EmptyOrder, + FiniteJointOrderErrorV1::TupleArity { + tuple, + expected, + actual, + } => JointOrderErrorV1::TupleArity { + state: tuple, + expected, + actual, + }, + FiniteJointOrderErrorV1::OrdinalOutOfDomain { + tuple, + dimension, + ordinal, + domain_len, + } => JointOrderErrorV1::OrdinalOutOfDomain { + state: tuple, + dimension, + ordinal, + domain_len, + }, + FiniteJointOrderErrorV1::DuplicateTuple { first, duplicate } => { + JointOrderErrorV1::DuplicateTuple { + first_state: first, + duplicate_state: duplicate, + } + } + FiniteJointOrderErrorV1::IncompleteOrder { expected, actual } => { + JointOrderErrorV1::IncompleteOrder { expected, actual } + } + FiniteJointOrderErrorV1::ResourceExhausted => JointOrderErrorV1::ResourceExhausted, + } +} + +fn map_program_compile_error(error: ProgramCompileError) -> CompileErrorV1 { + match error { + ProgramCompileError::DuplicateSource { source } => CompileErrorV1::DuplicateSource { + source: SourceIdV1::from_core(source), + }, + ProgramCompileError::DuplicateTarget { target } => CompileErrorV1::DuplicateTarget { + target: TargetIdV1::from_core(target), + }, + ProgramCompileError::MissingTargetSource { target, source } => { + CompileErrorV1::MissingTargetSource { + target: TargetIdV1::from_core(target), + source: SourceIdV1::from_core(source), + } + } + ProgramCompileError::DuplicateOpacityInput { input } => { + CompileErrorV1::DuplicateOpacityInput { + input: OpacityInputIdV1::from_core(input), + } + } + ProgramCompileError::DuplicateSurfaceInputPort { input } => { + CompileErrorV1::DuplicateSurfaceInputPort { + input: SurfaceInputPortIdV1::from_core(input), + } + } + ProgramCompileError::UnusedSurfaceInputPort { input } => { + CompileErrorV1::UnusedSurfaceInputPort { + input: SurfaceInputPortIdV1::from_core(input), + } + } + ProgramCompileError::DuplicateSurfaceInputBinding { + input, + first, + duplicate, + } => CompileErrorV1::DuplicateSurfaceInputBinding { + input: SurfaceInputPortIdV1::from_core(input), + first: SurfaceIdV1::from_core(first), + duplicate: SurfaceIdV1::from_core(duplicate), + }, + ProgramCompileError::DuplicatePaint { paint } => CompileErrorV1::DuplicatePaint { + paint: PaintIdV1::from_core(paint), + }, + ProgramCompileError::DuplicateSurface { surface } => CompileErrorV1::DuplicateSurface { + surface: SurfaceIdV1::from_core(surface), + }, + ProgramCompileError::DuplicateOccurrence { occurrence } => { + CompileErrorV1::DuplicateOccurrence { + occurrence: OccurrenceIdV1::from_core(occurrence), + } + } + ProgramCompileError::MissingPaintTarget { paint, target } => { + CompileErrorV1::MissingPaintTarget { + paint: PaintIdV1::from_core(paint), + target: TargetIdV1::from_core(target), + } + } + ProgramCompileError::MissingPaintSource { paint, source } => { + CompileErrorV1::MissingPaintSource { + paint: PaintIdV1::from_core(paint), + source: PaintIdV1::from_core(source), + } + } + ProgramCompileError::MissingPaintOpacityInput { paint, input } => { + CompileErrorV1::MissingPaintOpacityInput { + paint: PaintIdV1::from_core(paint), + input: OpacityInputIdV1::from_core(input), + } + } + ProgramCompileError::MissingSurfaceInputPort { surface, input } => { + CompileErrorV1::MissingSurfaceInputPort { + surface: SurfaceIdV1::from_core(surface), + input: SurfaceInputPortIdV1::from_core(input), + } + } + ProgramCompileError::MissingSurfaceOccurrence { + surface, + occurrence, + } => CompileErrorV1::MissingSurfaceOccurrence { + surface: SurfaceIdV1::from_core(surface), + occurrence: OccurrenceIdV1::from_core(occurrence), + }, + ProgramCompileError::MissingOccurrencePaint { occurrence, paint } => { + CompileErrorV1::MissingOccurrencePaint { + occurrence: OccurrenceIdV1::from_core(occurrence), + paint: PaintIdV1::from_core(paint), + } + } + ProgramCompileError::MissingOccurrenceBackdrop { + occurrence, + surface, + } => CompileErrorV1::MissingOccurrenceBackdrop { + occurrence: OccurrenceIdV1::from_core(occurrence), + surface: SurfaceIdV1::from_core(surface), + }, + ProgramCompileError::PaintCycle { paints } => { + CompileErrorV1::PaintCycle(PaintCycleV1 { paints }) + } + ProgramCompileError::RenderCycle { + surfaces, + occurrences, + } => CompileErrorV1::RenderCycle(RenderCycleV1 { + surfaces, + occurrences, + }), + ProgramCompileError::OpacityOutOfDomain { input } => CompileErrorV1::OpacityOutOfDomain { + input: OpacityInputIdV1::from_core(input), + }, + ProgramCompileError::EmptyTargetDomain { target } => CompileErrorV1::EmptyTargetDomain { + target: TargetIdV1::from_core(target), + }, + ProgramCompileError::DuplicateTargetCandidate { target, candidate } => { + CompileErrorV1::DuplicateTargetCandidate { + target: TargetIdV1::from_core(target), + candidate: TargetCandidateIdV1::from_core(candidate), + } + } + ProgramCompileError::DuplicateTargetCandidateSignal { + target, + first, + duplicate, + signal, + } => CompileErrorV1::DuplicateTargetCandidateSignal { + target: TargetIdV1::from_core(target), + first: TargetCandidateIdV1::from_core(first), + duplicate: TargetCandidateIdV1::from_core(duplicate), + encoded_srgb8: signal.srgb8(), + }, + ProgramCompileError::UnconstrainedTarget { target } => { + CompileErrorV1::UnconstrainedTarget { + target: TargetIdV1::from_core(target), + } + } + ProgramCompileError::DisconnectedFiniteTargets => CompileErrorV1::DisconnectedFiniteTargets, + ProgramCompileError::UnassessedOutput { output, paint } => { + CompileErrorV1::UnassessedOutput { + output: OutputSlotIdV1::from_core(output), + paint: PaintIdV1::from_core(paint), + } + } + ProgramCompileError::MissingJointSelection => CompileErrorV1::MissingJointSelection, + ProgramCompileError::JointSelectionWithoutTargets => { + CompileErrorV1::JointSelectionWithoutTargets + } + ProgramCompileError::JointStateDuplicateTarget { state, target } => { + CompileErrorV1::JointStateDuplicateTarget { + state, + target: TargetIdV1::from_core(target), + } + } + ProgramCompileError::JointStateMissingTarget { state, target } => { + CompileErrorV1::JointStateMissingTarget { + state, + target: TargetIdV1::from_core(target), + } + } + ProgramCompileError::JointStateUnknownTarget { state, target } => { + CompileErrorV1::JointStateUnknownTarget { + state, + target: TargetIdV1::from_core(target), + } + } + ProgramCompileError::JointStateUnknownCandidate { + state, + target, + candidate, + } => CompileErrorV1::JointStateUnknownCandidate { + state, + target: TargetIdV1::from_core(target), + candidate: TargetCandidateIdV1::from_core(candidate), + }, + ProgramCompileError::InvalidJointOrder(error) => { + CompileErrorV1::InvalidJointOrder(map_joint_order_error(error)) + } + ProgramCompileError::EmptyObservationGroup { .. } => { + CompileErrorV1::EmptySurfaceInputPortSet + } + ProgramCompileError::EmptyOccurrenceSet => CompileErrorV1::EmptyOccurrenceSet, + ProgramCompileError::EmptyConstraintSet => CompileErrorV1::EmptyConstraintSet, + ProgramCompileError::EmptyOutputSet => CompileErrorV1::EmptyOutputSet, + ProgramCompileError::DuplicateConstraint { constraint } => { + CompileErrorV1::DuplicateConstraint { + constraint: ConstraintIdV1::from_core(constraint), + } + } + ProgramCompileError::MissingConstraintOccurrence { + constraint, + occurrence, + } => CompileErrorV1::MissingConstraintOccurrence { + constraint: ConstraintIdV1::from_core(constraint), + occurrence: OccurrenceIdV1::from_core(occurrence), + }, + ProgramCompileError::DuplicateOutputSlot { output } => { + CompileErrorV1::DuplicateOutputSlot { + output: OutputSlotIdV1::from_core(output), + } + } + ProgramCompileError::MissingOutputPaint { output, paint } => { + CompileErrorV1::MissingOutputPaint { + output: OutputSlotIdV1::from_core(output), + paint: PaintIdV1::from_core(paint), + } + } + ProgramCompileError::ResourceExhausted => CompileErrorV1::ResourceExhausted, + ProgramCompileError::InternalInvariant => CompileErrorV1::InternalInvariant, + } +} + +const fn map_colorimetric_frame(frame: ColorimetricFrameId) -> ColorimetricFrameV1 { + match ( + frame.observer(), + frame.reference_white(), + frame.scale(), + frame.release(), + ) { + ( + ObserverProfileId::Cie1931TwoDegreeV1, + ReferenceWhiteId::Iec61966D65ChromaticityV1, + TristimulusScale::RelativeY1, + ColorimetricFrameReleaseId::XyzV1, + ) => ColorimetricFrameV1::Iec61966Srgb8D65XyzRelativeY1V1, + #[cfg(test)] + ( + ObserverProfileId::Cie1931TwoDegreeV1, + ReferenceWhiteId::Iec61966D65ChromaticityV1, + TristimulusScale::RelativeY1, + ColorimetricFrameReleaseId::MutationSentinelV1, + ) => ColorimetricFrameV1::MutationSentinelV1, + } +} + +const fn map_numeric_domain_error(error: NumericDomainError) -> NumericDomainErrorV1 { + match error { + NumericDomainError::NonFinite => NumericDomainErrorV1::NonFinite, + NumericDomainError::Negative => NumericDomainErrorV1::Negative, + NumericDomainError::NotPositive => NumericDomainErrorV1::NotPositive, + NumericDomainError::AboveOne => NumericDomainErrorV1::AboveOne, + NumericDomainError::HueOutOfRange => NumericDomainErrorV1::HueOutOfRange, + } +} + +const fn map_tristimulus_component( + component: CoreTristimulusComponentV1, +) -> TristimulusComponentV1 { + match component { + CoreTristimulusComponentV1::X => TristimulusComponentV1::X, + CoreTristimulusComponentV1::Y => TristimulusComponentV1::Y, + CoreTristimulusComponentV1::Z => TristimulusComponentV1::Z, + } +} + +const fn map_tristimulus_domain_error( + error: TristimulusDomainErrorV1, +) -> (TristimulusComponentV1, NumericDomainErrorV1) { + ( + map_tristimulus_component(error.component()), + map_numeric_domain_error(error.reason()), + ) +} + +fn map_modeled_occurrence_error( + error: ModeledLcsOccurrenceFormationErrorV1, +) -> ModeledOccurrenceFailureV1 { + match error { + ModeledLcsOccurrenceFormationErrorV1::Tristimulus(source) => { + let (component, reason) = map_tristimulus_domain_error(source); + ModeledOccurrenceFailureV1::Tristimulus { component, reason } + } + ModeledLcsOccurrenceFormationErrorV1::Formation( + OccurrenceFormationError::FrameMismatch { stimulus, context }, + ) => ModeledOccurrenceFailureV1::FrameMismatch { + stimulus: map_colorimetric_frame(stimulus), + context: map_colorimetric_frame(context), + }, + ModeledLcsOccurrenceFormationErrorV1::ProvenanceReplayFailed(source) => { + let (component, reason) = map_tristimulus_domain_error(source); + ModeledOccurrenceFailureV1::ProvenanceReplayFailed { component, reason } + } + ModeledLcsOccurrenceFormationErrorV1::RecordedSampleDoesNotReplay { + recorded, + replayed, + } => ModeledOccurrenceFailureV1::RecordedSampleDoesNotReplay { + recorded: TristimulusSampleV1::from_core(recorded), + replayed: TristimulusSampleV1::from_core(replayed), + }, + ModeledLcsOccurrenceFormationErrorV1::OccurrenceSampleMismatch { + occurrence, + modeled, + } => ModeledOccurrenceFailureV1::OccurrenceSampleMismatch { + occurrence: TristimulusSampleV1::from_core(occurrence), + modeled: TristimulusSampleV1::from_core(modeled), + }, + } +} + +fn map_observation_schema_mismatch( + error: ObservationSchemaMismatchV1, +) -> ObservationBindingFailureV1 { + let (case_index, binding_index, expected, actual) = error.into_parts(); + ObservationBindingFailureV1::SchemaMismatch { + case_index, + binding_index, + expected: expected.map(SurfaceInputPortIdV1::from_core), + actual: actual.map(SurfaceInputPortIdV1::from_core), + } +} + +fn map_session_update_error(error: SessionUpdateError) -> UpdateErrorV1 { + match error { + // A borrowed matching owner keeps the exact Rc generation alive for + // the whole transaction; expiry here is therefore an internal breach. + SessionUpdateError::OwnerExpired => UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::OwnerAuthority, + }, + SessionUpdateError::Observation(error) => map_observation_error(error), + SessionUpdateError::Plan(error) => map_plan_error(error), + SessionUpdateError::EvidenceBindingInvariant => UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::EvidenceBinding, + }, + } +} + +fn map_observation_error(error: ObservationError) -> UpdateErrorV1 { + match error { + ObservationError::EmptyCompiledSurfaceInputSchema => UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::ObservationBinding { + source: ObservationBindingFailureV1::EmptyCompiledSurfaceInputSchema, + }, + }, + ObservationError::DuplicateCompiledSurfaceInputPort { input } => { + UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::ObservationBinding { + source: ObservationBindingFailureV1::DuplicateCompiledSurfaceInputPort { + input: SurfaceInputPortIdV1::from_core(input), + }, + }, + } + } + ObservationError::StreamMismatch { expected, actual } => UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::ObservationBinding { + source: ObservationBindingFailureV1::StreamMismatch { + expected: StreamIdV1::from_core(expected), + actual: StreamIdV1::from_core(actual), + }, + }, + }, + ObservationError::EmptyScenarioSet => UpdateErrorV1::EmptyScenarioSet, + ObservationError::DuplicateScenarioId { scenario } => UpdateErrorV1::DuplicateScenarioId { + scenario: ScenarioIdV1::from_core(scenario), + }, + ObservationError::SchemaOrderedValueCountMismatch { + scenario, + expected, + actual, + } => UpdateErrorV1::ScenarioValueCountMismatch { + scenario: ScenarioIdV1::from_core(scenario), + expected, + actual, + }, + ObservationError::DuplicateSurfaceInputBinding { scenario, input } => { + UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::ObservationBinding { + source: ObservationBindingFailureV1::DuplicateSurfaceInputBinding { + scenario: ScenarioIdV1::from_core(scenario), + input: SurfaceInputPortIdV1::from_core(input), + }, + }, + } + } + ObservationError::MissingSurfaceInputBinding { scenario, input } => { + UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::ObservationBinding { + source: ObservationBindingFailureV1::MissingSurfaceInputBinding { + scenario: ScenarioIdV1::from_core(scenario), + input: SurfaceInputPortIdV1::from_core(input), + }, + }, + } + } + ObservationError::UnexpectedSurfaceInputBinding { scenario, input } => { + UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::ObservationBinding { + source: ObservationBindingFailureV1::UnexpectedSurfaceInputBinding { + scenario: ScenarioIdV1::from_core(scenario), + input: SurfaceInputPortIdV1::from_core(input), + }, + }, + } + } + ObservationError::RevisionOutOfOrder { current, incoming } => { + UpdateErrorV1::RevisionOutOfOrder { + current: current.value(), + incoming: incoming.value(), + } + } + ObservationError::RevisionConflict { revision } => UpdateErrorV1::RevisionConflict { + revision: revision.value(), + }, + ObservationError::ResourceExhausted => UpdateErrorV1::ResourceExhausted { + phase: UpdatePhaseV1::ObservationAdmission, + }, + } +} + +fn map_plan_error(error: CoreProgramPlanErrorV1) -> UpdateErrorV1 { + match error { + ProgramSessionEvaluationError::ObservationSchemaMismatch(source) => { + UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::ObservationBinding { + source: map_observation_schema_mismatch(source), + }, + } + } + ProgramSessionEvaluationError::ResourceExhausted => UpdateErrorV1::ResourceExhausted { + phase: UpdatePhaseV1::ProgramEvaluation, + }, + ProgramSessionEvaluationError::Evaluator { + case_index, + constraint, + occurrence, + context, + source, + } => match map_evaluator_error(source) { + Ok(source) => UpdateErrorV1::EvaluationFailed { + case_index, + constraint: ConstraintIdV1::from_core(constraint), + occurrence: OccurrenceIdV1::from_core(occurrence), + context: AppearanceContextV1::from_core(context), + source, + }, + Err(source) => UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::EvaluatorProtocol { + case_index, + constraint: ConstraintIdV1::from_core(constraint), + occurrence: OccurrenceIdV1::from_core(occurrence), + context: AppearanceContextV1::from_core(context), + source, + }, + }, + }, + ProgramSessionEvaluationError::ProgramTargetBinding { + case_index, + constraint, + occurrence, + context, + physical, + modeled, + } => UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::PhysicalModeledBinding { + case_index, + constraint: ConstraintIdV1::from_core(constraint), + occurrence: OccurrenceIdV1::from_core(occurrence), + context: AppearanceContextV1::from_core(context), + physical, + modeled, + }, + }, + ProgramSessionEvaluationError::ModeledOccurrence { + case_index, + occurrence, + context, + source, + } => UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::ModeledOccurrenceFormation { + case_index, + occurrence: OccurrenceIdV1::from_core(occurrence), + context: AppearanceContextV1::from_core(context), + source: map_modeled_occurrence_error(source), + }, + }, + ProgramSessionEvaluationError::OutputVariesAcrossCases { + output, + first_case, + actual_case, + } => UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::OutputCaseInvariance { + output: OutputSlotIdV1::from_core(output), + first_case, + actual_case, + }, + }, + ProgramSessionEvaluationError::FinalRecheckViolation { + state_index, + case_index, + constraint, + target, + hard_violation_count, + } => UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::SelectionRecheck { + state_index, + case_index, + constraint: ConstraintIdV1::from_core(constraint), + occurrence: OccurrenceIdV1::from_core(target), + hard_violation_count, + }, + }, + ProgramSessionEvaluationError::InternalInvariant => UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::ProgramEvaluation, + }, + } +} + +fn map_evaluator_error( + error: CoreProgramEvaluatorErrorV1, +) -> Result { + match error { + CoreProgramEvaluatorErrorV1::ExactSrgb8(source) => match source {}, + CoreProgramEvaluatorErrorV1::Wcag22Srgb8(source) => { + let profile_id = wcag22_profile_v1().profile_id; + match source { + ApplicableWcag22EvaluationErrorV1::Kernel( + source @ (Wcag22EvaluationErrorV1::ArtifactInvariantViolation { .. } + | Wcag22EvaluationErrorV1::EvidenceRegistryMismatch(_)), + ) => Ok(EvaluatorFailureV1::Wcag22Srgb8 { profile_id, source }), + ApplicableWcag22EvaluationErrorV1::Kernel(source) => { + Err(EvaluatorProtocolFailureV1::Wcag22Kernel { profile_id, source }) + } + ApplicableWcag22EvaluationErrorV1::ReportOnly { + profile_id, + declaration, + } => Err(EvaluatorProtocolFailureV1::Wcag22ReportOnly { + profile_id, + declaration, + }), + ApplicableWcag22EvaluationErrorV1::CriterionMismatch { + requested, + evaluated, + } => Err(EvaluatorProtocolFailureV1::Wcag22CriterionMismatch { + requested, + evaluated, + }), + } + } + } +} + +#[cfg(test)] +mod update_error_projection_tests { + use super::*; + use crate::wcag22::Wcag22ClientDeclaredNotApplicableV1; + + fn context() -> AppearanceContextV1 { + AppearanceContextV1::try_new(64.0, 0.2, SurroundV1::Average).unwrap() + } + + fn map_wcag_error(source: ApplicableWcag22EvaluationErrorV1) -> UpdateErrorV1 { + let context = context(); + map_plan_error(ProgramSessionEvaluationError::Evaluator { + case_index: 7, + constraint: ConstraintId::new(11), + occurrence: OccurrenceId::new(13), + context: context.0, + source: CoreProgramEvaluatorErrorV1::Wcag22Srgb8(source), + }) + } + + #[test] + fn resource_exhaustion_retains_its_exact_update_phase() { + let observation = map_observation_error(ObservationError::ResourceExhausted); + assert_eq!(observation.kind(), UpdateErrorKindV1::ResourceExhausted); + assert_eq!( + observation, + UpdateErrorV1::ResourceExhausted { + phase: UpdatePhaseV1::ObservationAdmission, + } + ); + let evaluation = map_plan_error(ProgramSessionEvaluationError::ResourceExhausted); + assert_eq!(evaluation.kind(), UpdateErrorKindV1::ResourceExhausted); + assert_eq!( + evaluation, + UpdateErrorV1::ResourceExhausted { + phase: UpdatePhaseV1::ProgramEvaluation, + } + ); + } + + #[test] + fn evaluator_artifact_failure_retains_every_actionable_fact() { + let error = map_wcag_error(ApplicableWcag22EvaluationErrorV1::Kernel( + Wcag22EvaluationErrorV1::ArtifactInvariantViolation { + criterion: Wcag22CriterionV1::Sc143TextLargeScale, + foreground: [1, 2, 3], + background: [4, 5, 6], + }, + )); + assert_eq!(error.kind(), UpdateErrorKindV1::EvaluationFailed); + assert_eq!( + error, + UpdateErrorV1::EvaluationFailed { + case_index: 7, + constraint: ConstraintIdV1::new(11), + occurrence: OccurrenceIdV1::new(13), + context: context(), + source: EvaluatorFailureV1::Wcag22Srgb8 { + profile_id: wcag22_profile_v1().profile_id, + source: Wcag22EvaluationErrorV1::ArtifactInvariantViolation { + criterion: Wcag22CriterionV1::Sc143TextLargeScale, + foreground: [1, 2, 3], + background: [4, 5, 6], + }, + }, + } + ); + + let error = map_wcag_error(ApplicableWcag22EvaluationErrorV1::Kernel( + Wcag22EvaluationErrorV1::EvidenceRegistryMismatch("registry-vs-proof".into()), + )); + assert_eq!(error.kind(), UpdateErrorKindV1::EvaluationFailed); + let UpdateErrorV1::EvaluationFailed { + source: + EvaluatorFailureV1::Wcag22Srgb8 { + profile_id, + source: Wcag22EvaluationErrorV1::EvidenceRegistryMismatch(message), + }, + .. + } = error + else { + panic!("registry mismatch must remain an evaluator failure"); + }; + assert_eq!(profile_id, wcag22_profile_v1().profile_id); + assert_eq!(message, "registry-vs-proof"); + } + + #[test] + fn impossible_wcag_protocol_states_are_not_misreported_as_colour_failures() { + let profile_id = wcag22_profile_v1().profile_id; + let declaration = Wcag22ClientDeclaredNotApplicableV1::try_new("client-scope").unwrap(); + let cases = [ + ( + ApplicableWcag22EvaluationErrorV1::Kernel(Wcag22EvaluationErrorV1::InvalidSrgb8 { + field: "foreground", + reason: "typed Program cannot create this".into(), + }), + EvaluatorProtocolFailureV1::Wcag22Kernel { + profile_id, + source: Wcag22EvaluationErrorV1::InvalidSrgb8 { + field: "foreground", + reason: "typed Program cannot create this".into(), + }, + }, + ), + ( + ApplicableWcag22EvaluationErrorV1::Kernel( + Wcag22EvaluationErrorV1::EmptyNotApplicableReason, + ), + EvaluatorProtocolFailureV1::Wcag22Kernel { + profile_id, + source: Wcag22EvaluationErrorV1::EmptyNotApplicableReason, + }, + ), + ( + ApplicableWcag22EvaluationErrorV1::ReportOnly { + profile_id, + declaration: declaration.clone(), + }, + EvaluatorProtocolFailureV1::Wcag22ReportOnly { + profile_id, + declaration, + }, + ), + ( + ApplicableWcag22EvaluationErrorV1::CriterionMismatch { + requested: Wcag22CriterionV1::Sc143TextDefault, + evaluated: Wcag22CriterionV1::Sc1411UiComponentOrState, + }, + EvaluatorProtocolFailureV1::Wcag22CriterionMismatch { + requested: Wcag22CriterionV1::Sc143TextDefault, + evaluated: Wcag22CriterionV1::Sc1411UiComponentOrState, + }, + ), + ]; + for (source, expected) in cases { + let error = map_wcag_error(source); + assert_eq!(error.kind(), UpdateErrorKindV1::InternalInvariant); + let expected = UpdateInvariantFailureV1::EvaluatorProtocol { + case_index: 7, + constraint: ConstraintIdV1::new(11), + occurrence: OccurrenceIdV1::new(13), + context: context(), + source: expected, + }; + assert_eq!(expected.contract(), UpdateInvariantV1::EvaluatorProtocol); + assert_eq!(error, UpdateErrorV1::InternalInvariant { source: expected }); + } + } + + #[test] + fn every_observation_binding_failure_retains_its_exact_payload() { + let cases = [ + ( + ObservationError::EmptyCompiledSurfaceInputSchema, + ObservationBindingFailureV1::EmptyCompiledSurfaceInputSchema, + ), + ( + ObservationError::DuplicateCompiledSurfaceInputPort { + input: SurfaceInputPortId::new(3), + }, + ObservationBindingFailureV1::DuplicateCompiledSurfaceInputPort { + input: SurfaceInputPortIdV1::new(3), + }, + ), + ( + ObservationError::StreamMismatch { + expected: ObservationStreamId::new(5), + actual: ObservationStreamId::new(7), + }, + ObservationBindingFailureV1::StreamMismatch { + expected: StreamIdV1::from_core(ObservationStreamId::new(5)), + actual: StreamIdV1::from_core(ObservationStreamId::new(7)), + }, + ), + ( + ObservationError::DuplicateSurfaceInputBinding { + scenario: ScenarioId::new(11), + input: SurfaceInputPortId::new(13), + }, + ObservationBindingFailureV1::DuplicateSurfaceInputBinding { + scenario: ScenarioIdV1::from_core(ScenarioId::new(11)), + input: SurfaceInputPortIdV1::new(13), + }, + ), + ( + ObservationError::MissingSurfaceInputBinding { + scenario: ScenarioId::new(17), + input: SurfaceInputPortId::new(19), + }, + ObservationBindingFailureV1::MissingSurfaceInputBinding { + scenario: ScenarioIdV1::from_core(ScenarioId::new(17)), + input: SurfaceInputPortIdV1::new(19), + }, + ), + ( + ObservationError::UnexpectedSurfaceInputBinding { + scenario: ScenarioId::new(23), + input: SurfaceInputPortId::new(29), + }, + ObservationBindingFailureV1::UnexpectedSurfaceInputBinding { + scenario: ScenarioIdV1::from_core(ScenarioId::new(23)), + input: SurfaceInputPortIdV1::new(29), + }, + ), + ]; + + for (source, expected) in cases { + let error = map_observation_error(source); + assert_eq!(error.kind(), UpdateErrorKindV1::InternalInvariant); + assert_eq!( + error, + UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::ObservationBinding { source: expected }, + } + ); + } + } + + #[test] + fn every_modeled_occurrence_failure_has_an_isomorphic_boundary_witness() { + use crate::lcs_occurrence::{ + MUTATION_SENTINEL_XYZ_FRAME_V1, ModeledLcsOccurrenceFormationErrorV1, + OccurrenceFormationError, TristimulusSample, + }; + + let domain = TristimulusSample::try_from_xyz_for_test( + [f64::NAN, 0.2, 0.3], + IEC_SRGB_D65_XYZ_FRAME_V1, + ) + .unwrap_err(); + let recorded = + TristimulusSample::try_from_xyz_for_test([0.1, 0.2, 0.3], IEC_SRGB_D65_XYZ_FRAME_V1) + .unwrap(); + let replayed = + TristimulusSample::try_from_xyz_for_test([0.1, 0.2, 0.4], IEC_SRGB_D65_XYZ_FRAME_V1) + .unwrap(); + + let cases = [ + ( + ModeledLcsOccurrenceFormationErrorV1::Tristimulus(domain), + ModeledOccurrenceFailureV1::Tristimulus { + component: TristimulusComponentV1::X, + reason: NumericDomainErrorV1::NonFinite, + }, + ), + ( + ModeledLcsOccurrenceFormationErrorV1::Formation( + OccurrenceFormationError::FrameMismatch { + stimulus: IEC_SRGB_D65_XYZ_FRAME_V1, + context: MUTATION_SENTINEL_XYZ_FRAME_V1, + }, + ), + ModeledOccurrenceFailureV1::FrameMismatch { + stimulus: ColorimetricFrameV1::Iec61966Srgb8D65XyzRelativeY1V1, + context: ColorimetricFrameV1::MutationSentinelV1, + }, + ), + ( + ModeledLcsOccurrenceFormationErrorV1::ProvenanceReplayFailed(domain), + ModeledOccurrenceFailureV1::ProvenanceReplayFailed { + component: TristimulusComponentV1::X, + reason: NumericDomainErrorV1::NonFinite, + }, + ), + ( + ModeledLcsOccurrenceFormationErrorV1::RecordedSampleDoesNotReplay { + recorded, + replayed, + }, + ModeledOccurrenceFailureV1::RecordedSampleDoesNotReplay { + recorded: TristimulusSampleV1::from_core(recorded), + replayed: TristimulusSampleV1::from_core(replayed), + }, + ), + ( + ModeledLcsOccurrenceFormationErrorV1::OccurrenceSampleMismatch { + occurrence: recorded, + modeled: replayed, + }, + ModeledOccurrenceFailureV1::OccurrenceSampleMismatch { + occurrence: TristimulusSampleV1::from_core(recorded), + modeled: TristimulusSampleV1::from_core(replayed), + }, + ), + ]; + + for (source, expected) in cases { + assert_eq!(map_modeled_occurrence_error(source), expected); + } + } + + #[test] + fn every_unreachable_core_failure_keeps_its_subject_and_witness_facts() { + use crate::lcs_occurrence::{ + MUTATION_SENTINEL_XYZ_FRAME_V1, ModeledLcsOccurrenceFormationErrorV1, + OccurrenceFormationError, + }; + use crate::observation::ObservationSchemaMismatchV1; + + let assert_invariant = |error: UpdateErrorV1, source: UpdateInvariantFailureV1| { + assert_eq!(error.kind(), UpdateErrorKindV1::InternalInvariant); + assert_eq!( + error, + UpdateErrorV1::InternalInvariant { + source: source.clone(), + } + ); + assert_eq!( + source.contract(), + match source { + UpdateInvariantFailureV1::OwnerAuthority => { + UpdateInvariantV1::OwnerAuthority + } + UpdateInvariantFailureV1::ObservationBinding { .. } => { + UpdateInvariantV1::ObservationBinding + } + UpdateInvariantFailureV1::EvidenceBinding => { + UpdateInvariantV1::EvidenceBinding + } + UpdateInvariantFailureV1::EvaluatorProtocol { .. } => { + UpdateInvariantV1::EvaluatorProtocol + } + UpdateInvariantFailureV1::PhysicalModeledBinding { .. } => { + UpdateInvariantV1::PhysicalModeledBinding + } + UpdateInvariantFailureV1::ModeledOccurrenceFormation { .. } => { + UpdateInvariantV1::ModeledOccurrenceFormation + } + UpdateInvariantFailureV1::OutputCaseInvariance { .. } => { + UpdateInvariantV1::OutputCaseInvariance + } + UpdateInvariantFailureV1::SelectionRecheck { .. } => { + UpdateInvariantV1::SelectionRecheck + } + UpdateInvariantFailureV1::ProgramEvaluation => { + UpdateInvariantV1::ProgramEvaluation + } + } + ); + }; + + assert_invariant( + map_session_update_error(SessionUpdateError::OwnerExpired), + UpdateInvariantFailureV1::OwnerAuthority, + ); + assert_invariant( + map_session_update_error(SessionUpdateError::EvidenceBindingInvariant), + UpdateInvariantFailureV1::EvidenceBinding, + ); + assert_invariant( + map_observation_error(ObservationError::EmptyCompiledSurfaceInputSchema), + UpdateInvariantFailureV1::ObservationBinding { + source: ObservationBindingFailureV1::EmptyCompiledSurfaceInputSchema, + }, + ); + + assert_invariant( + map_plan_error(ProgramSessionEvaluationError::ObservationSchemaMismatch( + ObservationSchemaMismatchV1::new(2, 3, None, None), + )), + UpdateInvariantFailureV1::ObservationBinding { + source: ObservationBindingFailureV1::SchemaMismatch { + case_index: 2, + binding_index: 3, + expected: None, + actual: None, + }, + }, + ); + assert_invariant( + map_plan_error(ProgramSessionEvaluationError::ProgramTargetBinding { + case_index: 2, + constraint: ConstraintId::new(3), + occurrence: OccurrenceId::new(4), + context: context().0, + physical: Srgb8::new([1, 2, 3]), + modeled: Srgb8::new([3, 2, 1]), + }), + UpdateInvariantFailureV1::PhysicalModeledBinding { + case_index: 2, + constraint: ConstraintIdV1::new(3), + occurrence: OccurrenceIdV1::new(4), + context: context(), + physical: Srgb8::new([1, 2, 3]), + modeled: Srgb8::new([3, 2, 1]), + }, + ); + assert_invariant( + map_plan_error(ProgramSessionEvaluationError::ModeledOccurrence { + case_index: 2, + occurrence: OccurrenceId::new(4), + context: context().0, + source: ModeledLcsOccurrenceFormationErrorV1::Formation( + OccurrenceFormationError::FrameMismatch { + stimulus: IEC_SRGB_D65_XYZ_FRAME_V1, + context: MUTATION_SENTINEL_XYZ_FRAME_V1, + }, + ), + }), + UpdateInvariantFailureV1::ModeledOccurrenceFormation { + case_index: 2, + occurrence: OccurrenceIdV1::new(4), + context: context(), + source: ModeledOccurrenceFailureV1::FrameMismatch { + stimulus: ColorimetricFrameV1::Iec61966Srgb8D65XyzRelativeY1V1, + context: ColorimetricFrameV1::MutationSentinelV1, + }, + }, + ); + assert_invariant( + map_plan_error(ProgramSessionEvaluationError::OutputVariesAcrossCases { + output: OutputSlotId::new(5), + first_case: 1, + actual_case: 2, + }), + UpdateInvariantFailureV1::OutputCaseInvariance { + output: OutputSlotIdV1::new(5), + first_case: 1, + actual_case: 2, + }, + ); + assert_invariant( + map_plan_error(ProgramSessionEvaluationError::FinalRecheckViolation { + state_index: 1, + case_index: 2, + constraint: ConstraintId::new(3), + target: OccurrenceId::new(4), + hard_violation_count: 1, + }), + UpdateInvariantFailureV1::SelectionRecheck { + state_index: 1, + case_index: 2, + constraint: ConstraintIdV1::new(3), + occurrence: OccurrenceIdV1::new(4), + hard_violation_count: 1, + }, + ); + assert_invariant( + map_plan_error(ProgramSessionEvaluationError::InternalInvariant), + UpdateInvariantFailureV1::ProgramEvaluation, + ); + } +} + +#[cfg(test)] +mod operation_scope_tests { + use super::*; + + #[test] + fn operation_scope_is_a_zero_sized_borrow_marker() { + assert_eq!(core::mem::size_of::>(), 0); + } +} + +#[cfg(test)] +mod compile_error_projection_tests { + use super::*; + + #[test] + fn nested_joint_resource_exhaustion_keeps_its_exact_reason_and_site_kind() { + let error = map_program_compile_error(ProgramCompileError::InvalidJointOrder( + FiniteJointOrderErrorV1::ResourceExhausted, + )); + + assert_eq!(error.kind(), CompileErrorKindV1::InvalidJointOrder); + assert_eq!( + error, + CompileErrorV1::InvalidJointOrder(JointOrderErrorV1::ResourceExhausted) + ); + } +} diff --git a/crates/labcolors-core/src/program_api_tests.rs b/crates/labcolors-core/src/program_api_tests.rs new file mode 100644 index 00000000..0c6bbd9f --- /dev/null +++ b/crates/labcolors-core/src/program_api_tests.rs @@ -0,0 +1,58 @@ +//! Минимальный внутренний контракт кандидата поверхности Program. + +use crate::{Srgb8, program}; + +#[test] +fn staged_program_api_is_module_qualified_without_transport_prefixes() { + let source = program::SourceIdV1::new(1); + let target = program::TargetIdV1::new(2); + let input = program::SurfaceInputPortIdV1::new(3); + let paint = program::PaintIdV1::new(4); + let surface = program::SurfaceIdV1::new(5); + let occurrence = program::OccurrenceIdV1::new(6); + let constraint = program::ConstraintIdV1::new(7); + let output = program::OutputSlotIdV1::new(8); + let context = + program::AppearanceContextV1::try_new(64.0, 0.2, program::SurroundV1::Average).unwrap(); + let mut draft = program::DraftV1::new(); + + draft.push_source(source, Srgb8::new([0, 0, 0])); + draft.push_fixed_target(target, source); + draft.push_surface_input_port(input); + draft.push_solid_paint(paint, target); + draft.push_input_surface(surface, input); + draft.push_source_over_occurrence(occurrence, paint, surface, context); + draft.push_exact_hard(constraint, occurrence, Srgb8::new([0, 0, 0])); + draft.push_output(output, paint); + + let owner = draft.compile().unwrap(); + let mut session = owner.instantiate(1).unwrap(); + let white = [Srgb8::new([255, 255, 255])]; + let scenarios = [program::ScenarioV1::new(1, &white)]; + let projection = owner + .update( + &mut session, + program::UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + let Some(program::OperationV1::Set(set)) = projection.operations().next() else { + panic!("the exact program must emit one certified Set"); + }; + assert_eq!(set.output_slot(), output); + assert_eq!(set.source(), Srgb8::new([0, 0, 0])); +} + +#[test] +fn staged_internal_failure_keeps_fact_and_contract_as_one_consistent_value() { + let source = program::UpdateInvariantFailureV1::OwnerAuthority; + assert_eq!( + source.contract(), + program::UpdateInvariantV1::OwnerAuthority + ); + + let error = program::UpdateErrorV1::InternalInvariant { source }; + assert_eq!(error.kind(), program::UpdateErrorKindV1::InternalInvariant); +} diff --git a/crates/labcolors-core/src/program_boundary_tests.rs b/crates/labcolors-core/src/program_boundary_tests.rs new file mode 100644 index 00000000..0f133d37 --- /dev/null +++ b/crates/labcolors-core/src/program_boundary_tests.rs @@ -0,0 +1,1107 @@ +//! Internal compile-and-runtime contract for the staged concrete Core Program seam. +//! +//! These tests deliberately exercise only the closed concrete boundary types; +//! the module stays crate-private until the terminal public cut is complete. + +use core::iter::FusedIterator; + +use crate::Srgb8; +use crate::program::{ + AppearanceContextErrorKindV1, AppearanceContextFieldV1, AppearanceContextV1, AssessmentV1, + CertificateV1, CompileErrorHandleV1, CompileErrorKindV1, CompileErrorV1, ConstraintIdV1, + DraftErrorV1, DraftV1, EvidenceBoundsErrorV1, InstantiateErrorV1, JointChoiceV1, + JointOrderErrorV1, JointStateV1, ModeledPointV1, NumericDomainErrorV1, ObservationHeadV1, + OccurrenceIdV1, OpacityInputIdV1, OperationV1, OutputSlotIdV1, OwnerV1, PaintIdV1, + PhysicalPointV1, ProjectionV1, ScenarioV1, SessionV1, SignalV1, SourceIdV1, StateKindV1, + SurfaceIdV1, SurfaceInputPortIdV1, SurroundV1, TargetCandidateIdV1, TargetCandidateV1, + TargetIdV1, UpdateErrorKindV1, UpdateErrorV1, UpdateV1, VerdictV1, +}; +use crate::wcag22::Wcag22CriterionV1; + +fn exact_size(iterator: I) -> I { + iterator +} + +fn compile_error(draft: DraftV1) -> CompileErrorV1 { + match draft.compile() { + Ok(_) => panic!("the invalid authored program must not compile"), + Err(error) => error, + } +} + +#[allow(dead_code)] +fn wasm_can_use_only_the_concrete_owner_and_session( + owner: &OwnerV1, + session: &mut SessionV1, + scenarios: &[ScenarioV1<'_>], +) -> Result<(), InstantiateErrorV1> { + let _independent_session = owner.instantiate(0xA11CE)?; + let update = UpdateV1::Observed { + revision: 1, + scenarios, + }; + let view = owner + .update(session, update) + .expect("well-formed owner-bound update"); + assert_projection_is_owner_bound(view); + Ok(()) +} + +fn assert_projection_is_owner_bound(projection: ProjectionV1<'_, '_>) { + let view = projection.evidence(); + let _kind: StateKindV1 = view.kind(); + match view.observation_head() { + ObservationHeadV1::Empty => {} + ObservationHeadV1::Unknown { + stream, + revision, + reason_id, + } => { + let _ = stream.value(); + let _: u64 = revision; + let _: u32 = reason_id; + } + ObservationHeadV1::Observed { stream, revision } => { + let _ = stream.value(); + let _: u64 = revision; + } + } + let certificates = exact_size(view.certificates()); + let certificate_count = certificates.len(); + for certificate in certificates { + let _: &[u8; 32] = certificate.content_identity().as_bytes(); + let observation = certificate.observation(); + let _stream_id = observation.stream().value(); + let _revision = observation.revision(); + for port in exact_size(observation.surface_input_ports()) { + let _: SurfaceInputPortIdV1 = port; + } + for case in exact_size(observation.physical_cases()) { + for value in exact_size(case.values()) { + let SignalV1::Iec61966Srgb8D65(value) = value; + let _: Srgb8 = value; + } + for scenario in exact_size(case.provenance()) { + let _ = scenario.value(); + } + } + macro_rules! inspect_cell { + ($cell:expr) => {{ + let cell = $cell; + let _ = cell.case_index(); + let _ = cell.constraint().value(); + let _ = cell.occurrence().value(); + let _ = cell.mode(); + let assessment = cell.assessment(); + let _: VerdictV1 = assessment.verdict(); + match assessment { + AssessmentV1::ExactSrgb8(evidence) => { + let _: Srgb8 = evidence.expected(); + } + AssessmentV1::Wcag22Srgb8(evidence) => { + let _ = evidence.profile_id(); + let _ = evidence.criterion(); + let _ = evidence.foreground_luminance(); + let _ = evidence.background_luminance(); + let _ = evidence.numerical_evidence(); + } + } + let binding = assessment.binding(); + let PhysicalPointV1::EncodedSrgb8SourceOver(physical) = binding.physical(); + let _ = physical.subject_paint().value(); + let _ = physical.backdrop_surface().value(); + let _: Srgb8 = physical.subject(); + let _ = physical.opacity(); + let _: Srgb8 = physical.backdrop(); + let _: Srgb8 = physical.visible(); + let ModeledPointV1::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1(modeled) = + binding.modeled(); + let _: [f64; 3] = modeled.xyz(); + let context = modeled.appearance_context(); + let _ = context.adapting_luminance_cd_m2(); + let _ = context.background_luminance_ratio_yb_yw(); + let _ = context.surround(); + }}; + } + match certificate { + CertificateV1::Verified(verified) => { + let _ = verified.selected_state_index(); + for cell in exact_size(verified.cells()) { + inspect_cell!(cell); + } + for output in exact_size(verified.outputs()) { + let _ = output.output_slot().value(); + let _ = output.paint().value(); + let _: Srgb8 = output.source(); + let _ = output.opacity(); + } + } + CertificateV1::Conflict(conflict) => { + let _ = conflict.considered_state_count(); + for cell in exact_size(conflict.cells()) { + let _ = cell.state_index(); + inspect_cell!(cell); + } + } + } + } + for operation in exact_size(projection.operations()) { + match operation { + OperationV1::Set(set) => { + let _: OutputSlotIdV1 = set.output_slot(); + let _: Srgb8 = set.source(); + assert!(set.opacity().is_finite() && (0.0..=1.0).contains(&set.opacity())); + let _ = set.certificate().content_identity(); + } + OperationV1::Remove(remove) => { + let _: OutputSlotIdV1 = remove.output_slot(); + } + } + } + let _ = certificate_count; +} + +#[allow(dead_code)] +fn unknown_is_revision_bound_without_a_stream_or_generation_field( + owner: &OwnerV1, + session: &mut SessionV1, +) { + let update = UpdateV1::Unknown { + revision: 2, + reason_id: 7, + }; + let _ = owner.update(session, update); +} + +#[allow(dead_code)] +fn owner_mismatch_is_a_closed_boundary_error(error: UpdateErrorV1) { + assert_eq!(error.kind(), UpdateErrorKindV1::OwnerMismatch); +} + +#[test] +fn staged_boundary_uses_only_closed_concrete_types() { + // Reaching this test means the concrete seam compiled without importing + // Program, evaluator traits, Session, or numeric generations. + assert_eq!(core::mem::size_of::(), 3); +} + +fn fixed_nested_draft( + opacity_value: f64, + target_source: SourceIdV1, + declared_input: SurfaceInputPortIdV1, + used_input: SurfaceInputPortIdV1, +) -> DraftV1 { + let source = SourceIdV1::new(1); + let target = TargetIdV1::new(2); + let opacity = OpacityInputIdV1::new(3); + let solid = PaintIdV1::new(4); + let translucent = PaintIdV1::new(5); + let input_surface = SurfaceIdV1::new(6); + let nested_surface = SurfaceIdV1::new(7); + let first_occurrence = OccurrenceIdV1::new(8); + let second_occurrence = OccurrenceIdV1::new(9); + let exact = ConstraintIdV1::new(10); + let wcag = ConstraintIdV1::new(11); + let output = OutputSlotIdV1::new(12); + let context = AppearanceContextV1::try_new(64.0, 0.2, SurroundV1::Dim).unwrap(); + + let mut draft = DraftV1::new(); + draft.push_source(source, Srgb8::new([0; 3])); + draft.push_fixed_target(target, target_source); + draft.push_surface_input_port(declared_input); + draft.push_opacity_input(opacity, opacity_value); + draft.push_solid_paint(solid, target); + draft.push_opacity_paint(translucent, solid, opacity); + draft.push_input_surface(input_surface, used_input); + draft.push_source_over_occurrence(first_occurrence, translucent, input_surface, context); + draft.push_occurrence_surface(nested_surface, first_occurrence); + draft.push_source_over_occurrence(second_occurrence, solid, nested_surface, context); + draft.push_exact_hard(exact, first_occurrence, Srgb8::new([0; 3])); + draft.push_wcag22_report_only(wcag, second_occurrence, Wcag22CriterionV1::Sc143TextDefault); + draft.push_output(output, translucent); + draft +} + +fn attach_target_assessment(draft: &mut DraftV1, target: TargetIdV1) { + let paint = PaintIdV1::new(770); + let occurrence = OccurrenceIdV1::new(771); + let constraint = ConstraintIdV1::new(772); + let context = AppearanceContextV1::try_new(64.0, 0.2, SurroundV1::Average).unwrap(); + draft.push_solid_paint(paint, target); + draft.push_source_over_occurrence(occurrence, paint, SurfaceIdV1::new(6), context); + draft.push_exact_report_only(constraint, occurrence, Srgb8::new([0; 3])); +} + +fn joint_draft(hard: bool) -> DraftV1 { + let source = SourceIdV1::new(1); + let target = TargetIdV1::new(2); + let black = TargetCandidateIdV1::new(3); + let white = TargetCandidateIdV1::new(4); + let input = SurfaceInputPortIdV1::new(5); + let paint = PaintIdV1::new(6); + let surface = SurfaceIdV1::new(7); + let occurrence = OccurrenceIdV1::new(8); + let constraint = ConstraintIdV1::new(9); + let output = OutputSlotIdV1::new(10); + let context = AppearanceContextV1::try_new(64.0, 0.2, SurroundV1::Average).unwrap(); + + let mut draft = DraftV1::new(); + draft.push_source(source, Srgb8::new([0; 3])); + draft.push_finite_target( + target, + source, + vec![ + TargetCandidateV1::new(black, Srgb8::new([0; 3])), + TargetCandidateV1::new(white, Srgb8::new([255; 3])), + ], + ); + draft + .set_joint_selection(vec![ + JointStateV1::new(vec![JointChoiceV1::new(target, black)]), + JointStateV1::new(vec![JointChoiceV1::new(target, white)]), + ]) + .unwrap(); + draft.push_surface_input_port(input); + draft.push_solid_paint(paint, target); + draft.push_input_surface(surface, input); + draft.push_source_over_occurrence(occurrence, paint, surface, context); + if hard { + draft.push_exact_hard(constraint, occurrence, Srgb8::new([128; 3])); + } else { + draft.push_exact_report_only(constraint, occurrence, Srgb8::new([0; 3])); + } + draft.push_output(output, paint); + draft +} + +#[test] +fn evidence_cell_bounds_cover_fixed_and_joint_evaluation_laws() { + let input = SurfaceInputPortIdV1::new(50); + let fixed = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input) + .compile() + .unwrap(); + + let empty = fixed.evidence_cell_bounds(0).unwrap(); + assert_eq!(empty.verified_cells(), 0); + assert_eq!(empty.conflict_cells(), 0); + + // The second fixed constraint is report-only. Counting only hard + // constraints would under-reserve a successful certificate. + let fixed_bounds = fixed.evidence_cell_bounds(3).unwrap(); + assert_eq!(fixed_bounds.verified_cells(), 6); + assert_eq!(fixed_bounds.conflict_cells(), 6); + + let report_only_joint = joint_draft(false).compile().unwrap(); + let report_only_bounds = report_only_joint.evidence_cell_bounds(4).unwrap(); + assert_eq!(report_only_bounds.verified_cells(), 4); + assert_eq!(report_only_bounds.conflict_cells(), 0); + + let hard_joint = joint_draft(true).compile().unwrap(); + let hard_bounds = hard_joint.evidence_cell_bounds(4).unwrap(); + assert_eq!(hard_bounds.verified_cells(), 4); + assert_eq!(hard_bounds.conflict_cells(), 8); +} + +#[test] +fn evidence_cell_bounds_report_both_checked_multiplication_overflows() { + let input = SurfaceInputPortIdV1::new(50); + let two_constraints = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input) + .compile() + .unwrap(); + assert!(matches!( + two_constraints.evidence_cell_bounds(usize::MAX), + Err(EvidenceBoundsErrorV1::CardinalityOverflow) + )); + + // One constraint keeps the first product representable; the two-state + // joint order forces the independent exhaustive-conflict product to fail. + let two_states = joint_draft(true).compile().unwrap(); + assert!(matches!( + two_states.evidence_cell_bounds(usize::MAX), + Err(EvidenceBoundsErrorV1::CardinalityOverflow) + )); +} + +#[test] +fn evidence_cell_bounds_cover_actual_joint_conflict_and_duplicate_case_reduction() { + let joint = joint_draft(true).compile().unwrap(); + let mut joint_session = joint.instantiate(21).unwrap(); + let red = [Srgb8::new([255, 0, 0])]; + let blue = [Srgb8::new([0, 0, 255])]; + let unique_scenarios = [ScenarioV1::new(1, &red), ScenarioV1::new(2, &blue)]; + let joint_bounds = joint.evidence_cell_bounds(unique_scenarios.len()).unwrap(); + let projection = joint + .update( + &mut joint_session, + UpdateV1::Observed { + revision: 1, + scenarios: &unique_scenarios, + }, + ) + .unwrap(); + let Some(CertificateV1::Conflict(certificate)) = projection.evidence().certificates().next() + else { + panic!("both authored joint states must violate the hard exact constraint"); + }; + assert_eq!(certificate.cells().len(), joint_bounds.conflict_cells()); + + let input = SurfaceInputPortIdV1::new(50); + let fixed = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input) + .compile() + .unwrap(); + let mut fixed_session = fixed.instantiate(22).unwrap(); + let white = [Srgb8::new([0xFF; 3])]; + let duplicate_scenarios = [ScenarioV1::new(1, &white), ScenarioV1::new(2, &white)]; + let fixed_bounds = fixed + .evidence_cell_bounds(duplicate_scenarios.len()) + .unwrap(); + let projection = fixed + .update( + &mut fixed_session, + UpdateV1::Observed { + revision: 1, + scenarios: &duplicate_scenarios, + }, + ) + .unwrap(); + let Some(CertificateV1::Verified(certificate)) = projection.evidence().certificates().next() + else { + panic!("duplicate physical scenarios must preserve a valid certificate"); + }; + assert!(certificate.cells().len() < fixed_bounds.verified_cells()); +} + +#[test] +fn evidence_cell_bounds_query_is_pure_across_session_updates() { + let input = SurfaceInputPortIdV1::new(50); + let owner = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input) + .compile() + .unwrap(); + let expected = owner.evidence_cell_bounds(1).unwrap(); + assert_eq!(expected.verified_cells(), 2); + assert_eq!(expected.conflict_cells(), 2); + + let mut session = owner.instantiate(13).unwrap(); + let after_instantiation = owner.evidence_cell_bounds(1).unwrap(); + assert_eq!( + after_instantiation.verified_cells(), + expected.verified_cells() + ); + assert_eq!( + after_instantiation.conflict_cells(), + expected.conflict_cells() + ); + + let white = [Srgb8::new([0xFF; 3])]; + let scenarios = [ScenarioV1::new(1, &white)]; + { + let projection = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + let Some(CertificateV1::Verified(certificate)) = + projection.evidence().certificates().next() + else { + panic!("the fixed admissible program must produce Verified evidence"); + }; + assert_eq!(certificate.cells().len(), expected.verified_cells()); + } + + let after_update = owner.evidence_cell_bounds(1).unwrap(); + assert_eq!(after_update.verified_cells(), expected.verified_cells()); + assert_eq!(after_update.conflict_cells(), expected.conflict_cells()); + let projection = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 2, + scenarios: &scenarios, + }, + ) + .unwrap(); + assert_eq!(projection.evidence().kind(), StateKindV1::Ready); +} + +#[test] +fn staged_authoring_lowers_the_actual_closed_program_and_returns_canonical_input_ports() { + let source = SourceIdV1::new(91); + let target = TargetIdV1::new(72); + let gray = TargetCandidateIdV1::new(8); + let black = TargetCandidateIdV1::new(3); + let paint = PaintIdV1::new(54); + let high_input = SurfaceInputPortIdV1::new(900); + let low_input = SurfaceInputPortIdV1::new(2); + let high_surface = SurfaceIdV1::new(401); + let low_surface = SurfaceIdV1::new(400); + let high_occurrence = OccurrenceIdV1::new(301); + let low_occurrence = OccurrenceIdV1::new(300); + let exact = ConstraintIdV1::new(201); + let high_wcag = ConstraintIdV1::new(203); + let low_wcag = ConstraintIdV1::new(202); + let output = OutputSlotIdV1::new(101); + let context = AppearanceContextV1::try_new(64.0, 0.2, SurroundV1::Average).unwrap(); + + let mut draft = DraftV1::new(); + draft.push_source(source, Srgb8::new([0x80; 3])); + draft.push_finite_target( + target, + source, + vec![ + TargetCandidateV1::new(gray, Srgb8::new([0x80; 3])), + TargetCandidateV1::new(black, Srgb8::new([0; 3])), + ], + ); + draft + .set_joint_selection(vec![ + JointStateV1::new(vec![JointChoiceV1::new(target, gray)]), + JointStateV1::new(vec![JointChoiceV1::new(target, black)]), + ]) + .unwrap(); + // Deliberately reverse numeric order. Core, not the caller, owns the hot + // scenario schema order returned below. + draft.push_surface_input_port(high_input); + draft.push_surface_input_port(low_input); + draft.push_solid_paint(paint, target); + draft.push_input_surface(high_surface, high_input); + draft.push_input_surface(low_surface, low_input); + draft.push_source_over_occurrence(high_occurrence, paint, high_surface, context); + draft.push_source_over_occurrence(low_occurrence, paint, low_surface, context); + draft.push_exact_report_only(exact, high_occurrence, Srgb8::new([0; 3])); + draft.push_wcag22_hard( + high_wcag, + high_occurrence, + Wcag22CriterionV1::Sc143TextDefault, + ); + draft.push_wcag22_hard( + low_wcag, + low_occurrence, + Wcag22CriterionV1::Sc143TextDefault, + ); + draft.push_output(output, paint); + + let owner = draft.compile().unwrap(); + assert_eq!( + owner.surface_input_ports().collect::>(), + [low_input, high_input] + ); + let mut session = owner.instantiate(44).unwrap(); + let white = [Srgb8::new([0xFF; 3]), Srgb8::new([0xFF; 3])]; + let scenarios = [ScenarioV1::new(7, &white)]; + let ready = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + assert_eq!(ready.evidence().kind(), StateKindV1::Ready); + let mut operations = ready.operations(); + let Some(OperationV1::Set(set)) = operations.next() else { + panic!("Ready must emit one Set operation"); + }; + assert_eq!(set.output_slot(), output); + assert_eq!(set.source(), Srgb8::new([0; 3])); + assert_eq!(set.opacity(), 1.0); + assert_eq!(set.certificate().observation().revision(), 1); + assert!(operations.next().is_none()); +} + +#[test] +fn authored_compile_is_atomic_and_projects_a_closed_error() { + let source = SourceIdV1::new(1); + let input = SurfaceInputPortIdV1::new(50); + let mut draft = fixed_nested_draft(1.0, source, input, input); + draft.push_source(source, Srgb8::new([0xFF; 3])); + + let error = match draft.compile() { + Ok(_) => panic!("duplicate declaration must not compile"), + Err(error) => error, + }; + assert_eq!(error.kind(), CompileErrorKindV1::DuplicateSource); + assert_eq!( + error.primary_handle(), + Some(CompileErrorHandleV1::Source(source)) + ); + assert_eq!(error.related_handle(), None); +} + +#[test] +fn every_physical_constructor_and_both_remaining_constraint_modes_execute() { + let input = SurfaceInputPortIdV1::new(50); + let draft = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input); + let owner = draft.compile().unwrap(); + assert_eq!(owner.surface_input_ports().collect::>(), [input]); + + let mut session = owner.instantiate(13).unwrap(); + let white = [Srgb8::new([0xFF; 3])]; + let scenarios = [ScenarioV1::new(1, &white)]; + let state = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + assert_eq!(state.evidence().kind(), StateKindV1::Ready); + let Some(CertificateV1::Verified(certificate)) = state.evidence().certificates().next() else { + panic!("a fixed target must produce one Verified certificate"); + }; + assert_eq!(certificate.selected_state_index(), None); + let mut operations = state.operations(); + let Some(OperationV1::Set(set)) = operations.next() else { + panic!("Ready must emit one Set operation"); + }; + assert_eq!(set.output_slot(), OutputSlotIdV1::new(12)); + assert_eq!(set.source(), Srgb8::new([0; 3])); + assert_eq!(set.opacity(), 1.0); + assert_eq!(set.certificate().observation().revision(), 1); + assert!(operations.next().is_none()); +} + +#[test] +fn observed_violation_removes_outputs_but_retains_previous_certificate_as_evidence() { + let input = SurfaceInputPortIdV1::new(50); + let output = OutputSlotIdV1::new(12); + let owner = fixed_nested_draft(0.5, SourceIdV1::new(1), input, input) + .compile() + .unwrap(); + let mut session = owner.instantiate(13).unwrap(); + + let black = [Srgb8::new([0; 3])]; + let black_scenarios = [ScenarioV1::new(1, &black)]; + let ready = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &black_scenarios, + }, + ) + .unwrap(); + assert!(matches!( + ready.operations().next(), + Some(OperationV1::Set(_)) + )); + + let white = [Srgb8::new([0xFF; 3])]; + let white_scenarios = [ScenarioV1::new(2, &white)]; + let failed = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 2, + scenarios: &white_scenarios, + }, + ) + .unwrap(); + assert_eq!(failed.evidence().kind(), StateKindV1::Failed); + let certificates = failed.evidence().certificates().collect::>(); + assert_eq!(certificates.len(), 2); + assert!(matches!(certificates[0], CertificateV1::Conflict(_))); + let CertificateV1::Verified(previous) = certificates[1] else { + panic!("the previous certificate must remain available as diagnostics"); + }; + assert_eq!(previous.observation().revision(), 1); + + let mut operations = failed.operations(); + let Some(OperationV1::Remove(remove)) = operations.next() else { + panic!("a known violation of the current context must remove the old output"); + }; + assert_eq!(remove.output_slot(), output); + assert!(operations.next().is_none()); +} + +#[test] +fn unknown_context_removes_outputs_but_retains_previous_certificate_as_evidence() { + let input = SurfaceInputPortIdV1::new(50); + let output = OutputSlotIdV1::new(12); + let owner = fixed_nested_draft(0.5, SourceIdV1::new(1), input, input) + .compile() + .unwrap(); + let mut session = owner.instantiate(13).unwrap(); + + let black = [Srgb8::new([0; 3])]; + let black_scenarios = [ScenarioV1::new(1, &black)]; + let ready = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &black_scenarios, + }, + ) + .unwrap(); + assert!(matches!( + ready.operations().next(), + Some(OperationV1::Set(_)) + )); + + let stale = owner + .update( + &mut session, + UpdateV1::Unknown { + revision: 2, + reason_id: 9, + }, + ) + .unwrap(); + assert_eq!(stale.evidence().kind(), StateKindV1::Stale); + let certificates = stale.evidence().certificates().collect::>(); + assert_eq!(certificates.len(), 1); + let CertificateV1::Verified(previous) = certificates[0] else { + panic!("the previous certificate must remain available as diagnostics"); + }; + assert_eq!(previous.observation().revision(), 1); + assert!(matches!( + stale.evidence().observation_head(), + ObservationHeadV1::Unknown { revision: 2, .. } + )); + + let mut operations = stale.operations(); + let Some(OperationV1::Remove(remove)) = operations.next() else { + panic!("unknown current context cannot authorize the old output"); + }; + assert_eq!(remove.output_slot(), output); + assert!(operations.next().is_none()); +} + +#[test] +fn owner_and_update_errors_preserve_content_and_input_identity() { + let input = SurfaceInputPortIdV1::new(50); + let owner = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input) + .compile() + .unwrap(); + let owner_identity = owner.content_identity(); + let mut session = owner.instantiate(13).unwrap(); + + let no_scenarios = []; + let error = match owner.update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &no_scenarios, + }, + ) { + Ok(_) => panic!("an empty physical domain must fail"), + Err(error) => error, + }; + assert_eq!(error.kind(), UpdateErrorKindV1::InvalidObservation); + assert_eq!(error, UpdateErrorV1::EmptyScenarioSet); + + let white = [Srgb8::new([0xFF; 3])]; + let duplicate = [ScenarioV1::new(70, &white), ScenarioV1::new(70, &white)]; + let error = match owner.update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &duplicate, + }, + ) { + Ok(_) => panic!("duplicate scenario provenance must fail"), + Err(error) => error, + }; + assert_eq!(error.kind(), UpdateErrorKindV1::InvalidObservation); + let UpdateErrorV1::DuplicateScenarioId { scenario } = error else { + panic!("duplicate provenance must retain its scenario ID"); + }; + assert_eq!(scenario.value(), 70); + + let no_values = []; + let malformed = [ScenarioV1::new(71, &no_values)]; + let error = match owner.update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &malformed, + }, + ) { + Ok(_) => panic!("schema-short boundary input must fail"), + Err(error) => error, + }; + assert_eq!(error.kind(), UpdateErrorKindV1::InvalidObservation); + let UpdateErrorV1::ScenarioValueCountMismatch { + scenario, + expected, + actual, + } = error + else { + panic!("schema mismatch must retain its exact scenario and arity"); + }; + assert_eq!(scenario.value(), 71); + assert_eq!(expected, 1); + assert_eq!(actual, 0); + assert_eq!(session.evidence().kind(), StateKindV1::Waiting); + assert_eq!( + session.evidence().observation_head(), + ObservationHeadV1::Empty + ); + + let valid = [ScenarioV1::new(72, &white)]; + let projection = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 2, + scenarios: &valid, + }, + ) + .unwrap(); + let certificate = projection.evidence().certificates().next().unwrap(); + assert_eq!(owner_identity, certificate.content_identity()); + + let error = match owner.update( + &mut session, + UpdateV1::Unknown { + revision: 1, + reason_id: 9, + }, + ) { + Ok(_) => panic!("older revision must fail"), + Err(error) => error, + }; + assert_eq!(error.kind(), UpdateErrorKindV1::RevisionOutOfOrder); + assert_eq!( + error, + UpdateErrorV1::RevisionOutOfOrder { + current: 2, + incoming: 1, + } + ); + + let error = match owner.update( + &mut session, + UpdateV1::Unknown { + revision: 2, + reason_id: 9, + }, + ) { + Ok(_) => panic!("changed payload at the same revision must fail"), + Err(error) => error, + }; + assert_eq!(error.kind(), UpdateErrorKindV1::RevisionConflict); + assert_eq!(error, UpdateErrorV1::RevisionConflict { revision: 2 }); + assert_eq!(session.evidence().kind(), StateKindV1::Ready); + let ObservationHeadV1::Observed { stream, revision } = session.evidence().observation_head() + else { + panic!("failed update must keep the last admitted observation"); + }; + assert_eq!(stream.value(), 13); + assert_eq!(revision, 2); + + let foreign = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input) + .compile() + .unwrap(); + assert_eq!(foreign.content_identity(), owner.content_identity()); + let error = match foreign.update( + &mut session, + UpdateV1::Unknown { + revision: 3, + reason_id: 9, + }, + ) { + Ok(_) => panic!("equal content must not confer owner authority"), + Err(error) => error, + }; + assert_eq!(error.kind(), UpdateErrorKindV1::OwnerMismatch); + assert_eq!(error, UpdateErrorV1::OwnerMismatch); +} + +#[test] +fn certificate_and_set_retain_the_same_nonunit_opacity() { + let source = SourceIdV1::new(1); + let target = TargetIdV1::new(2); + let opacity = OpacityInputIdV1::new(3); + let solid = PaintIdV1::new(4); + let translucent = PaintIdV1::new(5); + let input = SurfaceInputPortIdV1::new(6); + let surface = SurfaceIdV1::new(7); + let occurrence = OccurrenceIdV1::new(8); + let constraint = ConstraintIdV1::new(9); + let output = OutputSlotIdV1::new(10); + let context = AppearanceContextV1::try_new(64.0, 0.2, SurroundV1::Average).unwrap(); + let mut draft = DraftV1::new(); + draft.push_source(source, Srgb8::new([0; 3])); + draft.push_fixed_target(target, source); + draft.push_surface_input_port(input); + draft.push_opacity_input(opacity, 0.5); + draft.push_solid_paint(solid, target); + draft.push_opacity_paint(translucent, solid, opacity); + draft.push_input_surface(surface, input); + draft.push_source_over_occurrence(occurrence, translucent, surface, context); + draft.push_exact_hard(constraint, occurrence, Srgb8::new([0x80; 3])); + draft.push_output(output, translucent); + + let owner = draft.compile().unwrap(); + let mut session = owner.instantiate(17).unwrap(); + let white = [Srgb8::new([0xFF; 3])]; + let scenarios = [ScenarioV1::new(1, &white)]; + let state = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + let Some(CertificateV1::Verified(certificate)) = state.evidence().certificates().next() else { + panic!("the exact emitted midpoint must be verified"); + }; + let AssessmentV1::ExactSrgb8(assessment) = certificate.cells().next().unwrap().assessment() + else { + panic!("the authored exact constraint must retain Exact evidence"); + }; + let PhysicalPointV1::EncodedSrgb8SourceOver(physical) = assessment.binding().physical(); + assert_eq!(physical.opacity().to_bits(), 0.5_f64.to_bits()); + assert_eq!(physical.visible(), Srgb8::new([0x80; 3])); + assert_eq!( + certificate.outputs().next().unwrap().opacity().to_bits(), + physical.opacity().to_bits() + ); + + let Some(OperationV1::Set(set)) = state.operations().next() else { + panic!("the verified output must emit one Set"); + }; + assert_eq!(set.opacity().to_bits(), physical.opacity().to_bits()); +} + +#[test] +fn invalid_context_and_opacity_are_typed_and_fail_closed() { + let context_error = AppearanceContextV1::try_new(64.0, 1.01, SurroundV1::Dark).unwrap_err(); + assert_eq!(context_error.kind(), AppearanceContextErrorKindV1::Domain); + assert_eq!( + context_error.field(), + Some(AppearanceContextFieldV1::BackgroundLuminanceRatioYbYw) + ); + assert_eq!(context_error.reason(), Some(NumericDomainErrorV1::AboveOne)); + + let input = SurfaceInputPortIdV1::new(50); + let error = match fixed_nested_draft(f64::NAN, SourceIdV1::new(1), input, input).compile() { + Ok(_) => panic!("non-finite opacity must not compile"), + Err(error) => error, + }; + assert_eq!(error.kind(), CompileErrorKindV1::OpacityOutOfDomain); + assert_eq!( + error.primary_handle(), + Some(CompileErrorHandleV1::OpacityInput(OpacityInputIdV1::new(3))) + ); + assert_eq!(error.related_handle(), None); +} + +#[test] +fn relational_compile_errors_keep_both_typed_handles() { + let input = SurfaceInputPortIdV1::new(50); + let missing_source = SourceIdV1::new(99); + let error = match fixed_nested_draft(1.0, missing_source, input, input).compile() { + Ok(_) => panic!("a target cannot reference an undeclared source"), + Err(error) => error, + }; + assert_eq!(error.kind(), CompileErrorKindV1::MissingTargetSource); + assert_eq!( + error.primary_handle(), + Some(CompileErrorHandleV1::Target(TargetIdV1::new(2))) + ); + assert_eq!( + error.related_handle(), + Some(CompileErrorHandleV1::Source(missing_source)) + ); +} + +#[test] +fn declared_and_referenced_surface_inputs_cannot_drift() { + let declared = SurfaceInputPortIdV1::new(50); + let missing = SurfaceInputPortIdV1::new(51); + let error = match fixed_nested_draft(1.0, SourceIdV1::new(1), declared, missing).compile() { + Ok(_) => panic!("an undeclared physical input must not compile"), + Err(error) => error, + }; + assert_eq!(error.kind(), CompileErrorKindV1::MissingSurfaceInputPort); + assert_eq!( + error.primary_handle(), + Some(CompileErrorHandleV1::Surface(SurfaceIdV1::new(6))) + ); + assert_eq!( + error.related_handle(), + Some(CompileErrorHandleV1::SurfaceInputPort(missing)) + ); +} + +#[test] +fn declared_input_ports_form_an_exact_bijection_with_input_surfaces() { + let input = SurfaceInputPortIdV1::new(50); + let extra = SurfaceInputPortIdV1::new(51); + let mut unused = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input); + unused.push_surface_input_port(extra); + let error = match unused.compile() { + Ok(_) => panic!("an unused declared input must not compile"), + Err(error) => error, + }; + assert_eq!(error.kind(), CompileErrorKindV1::UnusedSurfaceInputPort); + assert_eq!( + error.primary_handle(), + Some(CompileErrorHandleV1::SurfaceInputPort(extra)) + ); + + let duplicate_surface = SurfaceIdV1::new(60); + let mut duplicate = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input); + duplicate.push_input_surface(duplicate_surface, input); + let error = match duplicate.compile() { + Ok(_) => panic!("two input surfaces must not bind one declared port"), + Err(error) => error, + }; + assert_eq!( + error.kind(), + CompileErrorKindV1::DuplicateSurfaceInputBinding + ); + assert_eq!( + error.primary_handle(), + Some(CompileErrorHandleV1::SurfaceInputPort(input)) + ); + assert_eq!( + error.related_handle(), + Some(CompileErrorHandleV1::Surface(duplicate_surface)) + ); + assert_eq!( + error, + CompileErrorV1::DuplicateSurfaceInputBinding { + input, + first: SurfaceIdV1::new(6), + duplicate: duplicate_surface, + } + ); +} + +#[test] +fn duplicate_candidate_signal_preserves_both_candidates_and_exact_stimulus() { + let input = SurfaceInputPortIdV1::new(50); + let target = TargetIdV1::new(70); + let first = TargetCandidateIdV1::new(701); + let duplicate = TargetCandidateIdV1::new(702); + let encoded_srgb8 = Srgb8::new([17, 33, 65]); + let mut draft = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input); + draft.push_finite_target( + target, + SourceIdV1::new(1), + vec![ + TargetCandidateV1::new(first, encoded_srgb8), + TargetCandidateV1::new(duplicate, encoded_srgb8), + ], + ); + attach_target_assessment(&mut draft, target); + + assert_eq!( + compile_error(draft), + CompileErrorV1::DuplicateTargetCandidateSignal { + target, + first, + duplicate, + encoded_srgb8, + } + ); +} + +#[test] +fn joint_diagnostics_preserve_state_and_total_order_details() { + let input = SurfaceInputPortIdV1::new(50); + let target = TargetIdV1::new(70); + let first = TargetCandidateIdV1::new(701); + let second = TargetCandidateIdV1::new(702); + let candidates = vec![ + TargetCandidateV1::new(first, Srgb8::new([0; 3])), + TargetCandidateV1::new(second, Srgb8::new([255; 3])), + ]; + + let mut duplicate_target = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input); + duplicate_target.push_finite_target(target, SourceIdV1::new(1), candidates.clone()); + attach_target_assessment(&mut duplicate_target, target); + duplicate_target + .set_joint_selection(vec![JointStateV1::new(vec![ + JointChoiceV1::new(target, first), + JointChoiceV1::new(target, second), + ])]) + .unwrap(); + assert_eq!( + compile_error(duplicate_target), + CompileErrorV1::JointStateDuplicateTarget { state: 0, target } + ); + + let mut incomplete = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input); + incomplete.push_finite_target(target, SourceIdV1::new(1), candidates); + attach_target_assessment(&mut incomplete, target); + incomplete + .set_joint_selection(vec![JointStateV1::new(vec![JointChoiceV1::new( + target, first, + )])]) + .unwrap(); + assert_eq!( + compile_error(incomplete), + CompileErrorV1::InvalidJointOrder(JointOrderErrorV1::IncompleteOrder { + expected: 2, + actual: 1, + }) + ); +} + +#[test] +fn dependency_cycles_retain_all_typed_core_members_without_reallocation() { + let input = SurfaceInputPortIdV1::new(50); + let first_paint = PaintIdV1::new(70); + let second_paint = PaintIdV1::new(71); + let mut paint_cycle = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input); + paint_cycle.push_opacity_paint(first_paint, second_paint, OpacityInputIdV1::new(3)); + paint_cycle.push_opacity_paint(second_paint, first_paint, OpacityInputIdV1::new(3)); + let error = compile_error(paint_cycle); + let CompileErrorV1::PaintCycle(cycle) = error else { + panic!("expected an exact paint cycle") + }; + assert_eq!( + cycle.paints().collect::>(), + [first_paint, second_paint] + ); + + let cyclic_surface = SurfaceIdV1::new(80); + let cyclic_occurrence = OccurrenceIdV1::new(81); + let context = AppearanceContextV1::try_new(64.0, 0.2, SurroundV1::Average).unwrap(); + let mut render_cycle = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input); + render_cycle.push_occurrence_surface(cyclic_surface, cyclic_occurrence); + render_cycle.push_source_over_occurrence( + cyclic_occurrence, + PaintIdV1::new(4), + cyclic_surface, + context, + ); + let error = compile_error(render_cycle); + let CompileErrorV1::RenderCycle(cycle) = error else { + panic!("expected an exact render cycle") + }; + assert_eq!(cycle.surfaces().collect::>(), [cyclic_surface]); + assert_eq!(cycle.occurrences().collect::>(), [cyclic_occurrence]); +} + +#[test] +fn the_code_owned_observation_group_reports_authored_port_semantics() { + assert_eq!( + compile_error(DraftV1::new()), + CompileErrorV1::EmptySurfaceInputPortSet + ); +} + +#[test] +fn singleton_joint_order_cannot_be_silently_replaced() { + let mut draft = DraftV1::new(); + draft.set_joint_selection(vec![]).unwrap(); + let error = match draft.set_joint_selection(vec![]) { + Ok(_) => panic!("a singleton declaration must not be replaced"), + Err(error) => error, + }; + assert_eq!(error, DraftErrorV1::JointSelectionAlreadyDeclared); +} 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 new file mode 100644 index 00000000..88afd5a1 --- /dev/null +++ b/crates/labcolors-core/src/program_joint_integration_tests.rs @@ -0,0 +1,1305 @@ +use crate::Srgb8; +use crate::appearance::{OccurrenceId, PaintId, SurfaceId, SurfaceInputPortId}; +use crate::constraints::{ + CountingProgramWcag22Srgb8V1, FinalRecheckMutantProgramEvaluatorV1, Wcag22Srgb8V1, +}; +use crate::joint::FiniteJointOrderErrorV1; +use crate::lcs_occurrence::{ + AdaptingLuminanceCdM2, AppearanceContextId, AppearanceContextSchemaReleaseId, + BackgroundLuminanceRatio, ColorSignal, IEC_SRGB_D65_XYZ_FRAME_V1, SurroundProfileId, +}; +use crate::observation::{ + ObservationGroupId, ObservationHeadViewV1, ObservationPayloadInput, ObservationStreamId, + ObservationUpdateInput, ObservedScenarioSetInput, Revision, ScenarioId, ScenarioInput, + SurfaceInputBinding, +}; +use crate::program_session::{ + CompositionProfile, ConstraintId, ConstraintInvocation, ConstraintSet, + DeclaredJointSelectionV1, JointCandidateStateV1, ObservationGroup, Occurrence, OutputBinding, + OutputSlotId, Paint, Program, ProgramCompileError, ProgramSessionEvaluationError, Source, + SourceId, Surface, Target, TargetCandidateChoiceV1, TargetCandidateId, TargetCandidateV1, + TargetDomainV1, TargetId, checked_program_evaluation_cell_counts_for_test, + fail_program_preflight_reservation_for_test, +}; +use crate::session::{SessionState, SessionUpdateError}; +use crate::wcag22::Wcag22CriterionV1; + +const SOURCE: SourceId = SourceId::new(1); +const SURFACE_PORT: SurfaceInputPortId = SurfaceInputPortId::new(2); +const PAINT: PaintId = PaintId::new(10); +const BACKDROP: SurfaceId = SurfaceId::new(20); +const OCCURRENCE: OccurrenceId = OccurrenceId::new(30); +const OUTPUT: OutputSlotId = OutputSlotId::new(40); +const TARGET: TargetId = TargetId::new(50); +const FIRST: TargetCandidateId = TargetCandidateId::new(60); +const SECOND: TargetCandidateId = TargetCandidateId::new(61); +const GROUP: ObservationGroupId = ObservationGroupId::new(70); +const STREAM: ObservationStreamId = ObservationStreamId::new(80); +const UPPER_SOURCE: SourceId = SourceId::new(81); +const UPPER_PAINT: PaintId = PaintId::new(82); +const DERIVED_SURFACE: SurfaceId = SurfaceId::new(83); +const UPPER_OCCURRENCE: OccurrenceId = OccurrenceId::new(84); +const UPPER_OUTPUT: OutputSlotId = OutputSlotId::new(85); +const UPPER_TARGET: TargetId = TargetId::new(86); +const UPPER_FIRST: TargetCandidateId = TargetCandidateId::new(87); +const UPPER_SECOND: TargetCandidateId = TargetCandidateId::new(88); + +fn appearance_context() -> AppearanceContextId { + AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + IEC_SRGB_D65_XYZ_FRAME_V1, + AdaptingLuminanceCdM2::try_new(64.0).unwrap(), + BackgroundLuminanceRatio::try_new(0.2).unwrap(), + SurroundProfileId::AverageV1, + ) +} + +fn signal(value: u8) -> ColorSignal { + ColorSignal::from_srgb8(Srgb8::new([value; 3])) +} + +fn candidate(id: TargetCandidateId, value: u8) -> TargetCandidateV1 { + TargetCandidateV1::new(id, signal(value)) +} + +fn state(candidate: TargetCandidateId) -> JointCandidateStateV1 { + JointCandidateStateV1::new(vec![TargetCandidateChoiceV1::new(TARGET, candidate)]) +} + +fn target(candidates: Vec) -> Target { + Target::finite(TARGET, SOURCE, candidates) +} + +fn program( + hard: Vec>, + report_only: Vec>, + candidates: Vec, + order: Vec, +) -> Program { + Program::new( + vec![Source::new(SOURCE, signal(0))], + vec![target(candidates)], + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![], + vec![Paint::Solid { + id: PAINT, + target: TARGET, + }], + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + vec![Occurrence::new( + OCCURRENCE, + PAINT, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + )], + ConstraintSet::new(hard, report_only), + vec![OutputBinding::new(OUTPUT, PAINT)], + Wcag22Srgb8V1, + ) + .with_joint_selection(DeclaredJointSelectionV1::new(order)) +} + +fn update(revision: u64, backdrop: u8) -> ObservationUpdateInput { + ObservationUpdateInput { + stream: STREAM, + revision: Revision::new(revision), + payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { + scenarios: vec![ScenarioInput { + id: ScenarioId::new(1), + bindings: vec![SurfaceInputBinding::new(SURFACE_PORT, signal(backdrop))], + }], + }), + } +} + +fn paired_state( + lower: TargetCandidateId, + upper: TargetCandidateId, + reverse_choices: bool, +) -> JointCandidateStateV1 { + let mut choices = vec![ + TargetCandidateChoiceV1::new(TARGET, lower), + TargetCandidateChoiceV1::new(UPPER_TARGET, upper), + ]; + if reverse_choices { + choices.reverse(); + } + JointCandidateStateV1::new(choices) +} + +fn nested_two_target_program( + reverse_targets: bool, + reverse_choices: bool, +) -> Program { + let lower = Target::finite( + TARGET, + SOURCE, + vec![candidate(FIRST, 0x00), candidate(SECOND, 0xFF)], + ); + let upper = Target::finite( + UPPER_TARGET, + UPPER_SOURCE, + vec![candidate(UPPER_FIRST, 0x55), candidate(UPPER_SECOND, 0xFF)], + ); + let mut targets = vec![lower, upper]; + if reverse_targets { + targets.reverse(); + } + + Program::new( + vec![ + Source::new(SOURCE, signal(0)), + Source::new(UPPER_SOURCE, signal(0)), + ], + targets, + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![], + vec![ + Paint::Solid { + id: PAINT, + target: TARGET, + }, + Paint::Solid { + id: UPPER_PAINT, + target: UPPER_TARGET, + }, + ], + vec![ + Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }, + Surface::FromOccurrence { + id: DERIVED_SURFACE, + occurrence: OCCURRENCE, + }, + ], + vec![ + Occurrence::new( + OCCURRENCE, + PAINT, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + ), + Occurrence::new( + UPPER_OCCURRENCE, + UPPER_PAINT, + DERIVED_SURFACE, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + ), + ], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + UPPER_OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![], + ), + vec![ + OutputBinding::new(OUTPUT, PAINT), + OutputBinding::new(UPPER_OUTPUT, UPPER_PAINT), + ], + Wcag22Srgb8V1, + ) + .with_joint_selection(DeclaredJointSelectionV1::new(vec![ + paired_state(FIRST, UPPER_FIRST, reverse_choices), + paired_state(SECOND, UPPER_FIRST, reverse_choices), + paired_state(FIRST, UPPER_SECOND, reverse_choices), + paired_state(SECOND, UPPER_SECOND, reverse_choices), + ])) +} + +#[derive(Clone, Copy)] +struct AlphaRenamedJointIds { + lower_source: SourceId, + upper_source: SourceId, + lower_target: TargetId, + upper_target: TargetId, + lower_first: TargetCandidateId, + lower_second: TargetCandidateId, + upper_first: TargetCandidateId, + upper_second: TargetCandidateId, +} + +fn alpha_renamed_nested_program( + ids: AlphaRenamedJointIds, + permute_declarations: bool, +) -> Program { + let mut sources = vec![ + Source::new(ids.lower_source, signal(0)), + Source::new(ids.upper_source, signal(0)), + ]; + let mut lower_candidates = vec![ + candidate(ids.lower_first, 0x00), + candidate(ids.lower_second, 0xFF), + ]; + let mut upper_candidates = vec![ + candidate(ids.upper_first, 0x55), + candidate(ids.upper_second, 0xFF), + ]; + if permute_declarations { + sources.reverse(); + lower_candidates.reverse(); + upper_candidates.reverse(); + } + let mut targets = vec![ + Target::finite(ids.lower_target, ids.lower_source, lower_candidates), + Target::finite(ids.upper_target, ids.upper_source, upper_candidates), + ]; + if permute_declarations { + targets.reverse(); + } + + let state = |lower, upper| { + let mut choices = vec![ + TargetCandidateChoiceV1::new(ids.lower_target, lower), + TargetCandidateChoiceV1::new(ids.upper_target, upper), + ]; + if permute_declarations { + choices.reverse(); + } + JointCandidateStateV1::new(choices) + }; + + Program::new( + sources, + targets, + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![], + vec![ + Paint::Solid { + id: PAINT, + target: ids.lower_target, + }, + Paint::Solid { + id: UPPER_PAINT, + target: ids.upper_target, + }, + ], + vec![ + Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }, + Surface::FromOccurrence { + id: DERIVED_SURFACE, + occurrence: OCCURRENCE, + }, + ], + vec![ + Occurrence::new( + OCCURRENCE, + PAINT, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + ), + Occurrence::new( + UPPER_OCCURRENCE, + UPPER_PAINT, + DERIVED_SURFACE, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + ), + ], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + UPPER_OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![], + ), + vec![ + OutputBinding::new(OUTPUT, PAINT), + OutputBinding::new(UPPER_OUTPUT, UPPER_PAINT), + ], + Wcag22Srgb8V1, + ) + .with_joint_selection(DeclaredJointSelectionV1::new(vec![ + state(ids.lower_first, ids.upper_first), + state(ids.lower_second, ids.upper_first), + state(ids.lower_first, ids.upper_second), + state(ids.lower_second, ids.upper_second), + ])) +} + +#[test] +fn authored_finite_target_values_keep_only_opaque_identity_and_explicit_policy() { + let first = candidate(FIRST, 0x66); + assert_eq!(TARGET.value(), 50); + assert_eq!(FIRST.value(), 60); + assert_eq!(first.id(), FIRST); + assert_eq!(first.signal(), signal(0x66)); + + let target = target(vec![first]); + assert_eq!(target.id(), TARGET); + assert_eq!(target.source(), SOURCE); + let TargetDomainV1::Finite(candidates) = target.domain() else { + panic!("target must retain its explicit finite domain"); + }; + assert_eq!(candidates, &[first]); + + let choice = TargetCandidateChoiceV1::new(TARGET, FIRST); + assert_eq!(choice.target(), TARGET); + assert_eq!(choice.candidate(), FIRST); + let state = JointCandidateStateV1::new(vec![choice]); + assert_eq!(state.choices(), &[choice]); + let order = DeclaredJointSelectionV1::new(vec![state.clone()]); + assert_eq!(order.states(), &[state]); +} + +#[test] +fn terminal_safety_rejects_an_output_outside_every_assessment_cone() { + let error = match Program::new( + vec![ + Source::new(SOURCE, signal(0)), + Source::new(UPPER_SOURCE, signal(0xFF)), + ], + vec![ + Target::fixed(TARGET, SOURCE), + Target::fixed(UPPER_TARGET, UPPER_SOURCE), + ], + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![], + vec![ + Paint::Solid { + id: PAINT, + target: TARGET, + }, + Paint::Solid { + id: UPPER_PAINT, + target: UPPER_TARGET, + }, + ], + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + vec![Occurrence::new( + OCCURRENCE, + PAINT, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + )], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![], + ), + vec![OutputBinding::new(UPPER_OUTPUT, UPPER_PAINT)], + Wcag22Srgb8V1, + ) + .compile() + { + Ok(_) => panic!("unassessed output must not compile"), + Err(error) => error, + }; + assert_eq!( + error, + ProgramCompileError::UnassessedOutput { + output: UPPER_OUTPUT, + paint: UPPER_PAINT, + } + ); +} + +#[test] +fn terminal_safety_rejects_an_unconstrained_finite_target() { + let error = match Program::new( + vec![ + Source::new(SOURCE, signal(0)), + Source::new(UPPER_SOURCE, signal(0xFF)), + ], + vec![ + target(vec![candidate(FIRST, 0), candidate(SECOND, 0xFF)]), + Target::fixed(UPPER_TARGET, UPPER_SOURCE), + ], + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![], + vec![Paint::Solid { + id: UPPER_PAINT, + target: UPPER_TARGET, + }], + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + vec![Occurrence::new( + UPPER_OCCURRENCE, + UPPER_PAINT, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + )], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + UPPER_OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![], + ), + vec![OutputBinding::new(UPPER_OUTPUT, UPPER_PAINT)], + Wcag22Srgb8V1, + ) + .with_joint_selection(DeclaredJointSelectionV1::new(vec![ + state(FIRST), + state(SECOND), + ])) + .compile() + { + Ok(_) => panic!("unconstrained finite target must not compile"), + Err(error) => error, + }; + assert_eq!( + error, + ProgramCompileError::UnconstrainedTarget { target: TARGET } + ); +} + +#[test] +fn independent_finite_target_components_are_rejected_before_global_product_search() { + let error = match Program::new( + vec![ + Source::new(SOURCE, signal(0)), + Source::new(UPPER_SOURCE, signal(0)), + ], + vec![ + target(vec![candidate(FIRST, 0), candidate(SECOND, 0xFF)]), + Target::finite( + UPPER_TARGET, + UPPER_SOURCE, + vec![candidate(UPPER_FIRST, 0x55), candidate(UPPER_SECOND, 0xFF)], + ), + ], + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![], + vec![ + Paint::Solid { + id: PAINT, + target: TARGET, + }, + Paint::Solid { + id: UPPER_PAINT, + target: UPPER_TARGET, + }, + ], + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + vec![ + Occurrence::new( + OCCURRENCE, + PAINT, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + ), + Occurrence::new( + UPPER_OCCURRENCE, + UPPER_PAINT, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + ), + ], + ConstraintSet::new( + vec![ + ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + ), + ConstraintInvocation::hard( + ConstraintId::new(2), + UPPER_OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + ), + ], + vec![], + ), + vec![ + OutputBinding::new(OUTPUT, PAINT), + OutputBinding::new(UPPER_OUTPUT, UPPER_PAINT), + ], + Wcag22Srgb8V1, + ) + .with_joint_selection(DeclaredJointSelectionV1::new(vec![ + paired_state(FIRST, UPPER_FIRST, false), + paired_state(SECOND, UPPER_FIRST, false), + paired_state(FIRST, UPPER_SECOND, false), + paired_state(SECOND, UPPER_SECOND, false), + ])) + .compile() + { + Ok(_) => panic!("independent components must not enter one global product search"), + Err(error) => error, + }; + assert_eq!(error, ProgramCompileError::DisconnectedFiniteTargets); +} + +#[test] +fn candidate_passing_one_hard_cell_and_failing_another_is_never_certified() { + let compiled = program( + vec![ + ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + ), + ConstraintInvocation::hard( + ConstraintId::new(2), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextDefault, + ), + ], + vec![], + vec![candidate(FIRST, 0x66), candidate(SECOND, 0xFF)], + vec![state(FIRST), state(SECOND)], + ) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let SessionState::Ready { current } = session.update(update(1, 0x00)).unwrap() else { + panic!("the later all-hard-feasible candidate must be selected"); + }; + assert_eq!(current.selected_state_index(), Some(1)); + assert_eq!(current.outputs().len(), 1); + assert_eq!(current.outputs()[0].source_signal(), signal(0xFF)); + assert_eq!(current.report().cells().len(), 2); + assert!( + current + .report() + .cells() + .iter() + .all(|cell| cell.candidate_state_index() == 1 && !cell.result().is_violation()) + ); +} + +#[test] +fn report_only_violation_is_retained_but_does_not_select() { + let compiled = program( + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![ConstraintInvocation::report_only( + ConstraintId::new(2), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextDefault, + )], + vec![candidate(FIRST, 0x66), candidate(SECOND, 0xFF)], + vec![state(FIRST), state(SECOND)], + ) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let SessionState::Ready { current } = session.update(update(1, 0x00)).unwrap() else { + panic!("report-only failure must not reject the first hard-feasible state"); + }; + assert_eq!(current.selected_state_index(), Some(0)); + assert_eq!(current.outputs()[0].source_signal(), signal(0x66)); + let cells = current.report().cells(); + assert_eq!(cells.len(), 2); + assert!(!cells[0].result().is_violation()); + assert!(cells[0].is_hard()); + assert!(cells[1].result().is_violation()); + assert!(!cells[1].is_hard()); +} + +#[test] +fn report_only_assessment_admits_output_but_cannot_override_explicit_selection_order() { + let compiled = program( + vec![], + vec![ConstraintInvocation::report_only( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextDefault, + )], + vec![candidate(FIRST, 0x66), candidate(SECOND, 0xFF)], + vec![state(FIRST), state(SECOND)], + ) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let SessionState::Ready { current } = session.update(update(1, 0x00)).unwrap() else { + panic!("report-only assessment must not create hard infeasibility"); + }; + assert_eq!(current.selected_state_index(), Some(0)); + assert_eq!(current.outputs()[0].source_signal(), signal(0x66)); + assert!(current.report().cells()[0].result().is_violation()); + assert!(!current.report().cells()[0].is_hard()); +} + +#[test] +fn no_feasible_joint_state_commits_no_output_and_retains_previous_certificate() { + let compiled = program( + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![], + vec![candidate(FIRST, 0xAA), candidate(SECOND, 0xFF)], + vec![state(FIRST), state(SECOND)], + ) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let SessionState::Ready { current } = session.update(update(1, 0x00)).unwrap() else { + panic!("control update must certify the first state"); + }; + assert_eq!(current.outputs()[0].source_signal(), signal(0xAA)); + + let SessionState::Failed { cause, previous } = session.update(update(2, 0xFF)).unwrap() else { + panic!("all-hard-infeasible domain must become Conflict/Failed"); + }; + assert_eq!(cause.considered_state_count(), 2); + let previous = previous + .as_ref() + .expect("last certificate must be retained"); + assert_eq!(previous.outputs()[0].source_signal(), signal(0xAA)); + assert_eq!(cause.report().cells().len(), 2); + assert!( + cause + .report() + .cells() + .iter() + .all(|cell| { cell.is_hard() && cell.result().is_violation() }) + ); +} + +#[test] +fn candidate_declaration_permutation_preserves_explicit_physical_order() { + let make = |candidates| { + program( + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![], + candidates, + vec![state(FIRST), state(SECOND)], + ) + .compile() + .unwrap() + }; + let first = make(vec![candidate(FIRST, 0x66), candidate(SECOND, 0xFF)]); + let reversed = make(vec![candidate(SECOND, 0xFF), candidate(FIRST, 0x66)]); + let mut first_session = first.instantiate(STREAM).unwrap(); + let mut reversed_session = reversed.instantiate(STREAM).unwrap(); + + let SessionState::Ready { current: first } = first_session.update(update(1, 0x00)).unwrap() + else { + panic!("first program must certify"); + }; + let SessionState::Ready { current: reversed } = + reversed_session.update(update(1, 0x00)).unwrap() + else { + panic!("permuted program must certify"); + }; + assert_eq!( + first.selected_state_index(), + reversed.selected_state_index() + ); + assert_eq!(first.outputs(), reversed.outputs()); +} + +#[test] +fn nested_two_target_selection_ignores_target_and_choice_declaration_order() { + let run = |reverse_targets, reverse_choices| { + let compiled = nested_two_target_program(reverse_targets, reverse_choices) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + let SessionState::Ready { current } = session.update(update(1, 0x00)).unwrap() else { + panic!("the second explicitly ordered joint tuple must certify"); + }; + ( + current.selected_state_index(), + current + .outputs() + .iter() + .map(|output| (output.output(), output.source_signal())) + .collect::>(), + ) + }; + + let canonical = run(false, false); + let permuted = run(true, true); + assert_eq!(canonical, permuted); + assert_eq!(canonical.0, Some(1)); + assert_eq!( + canonical.1, + vec![(OUTPUT, signal(0xFF)), (UPPER_OUTPUT, signal(0x55))] + ); +} + +#[test] +fn bijective_source_target_and_candidate_renaming_preserves_joint_evidence() { + let canonical_ids = AlphaRenamedJointIds { + lower_source: SourceId::new(100), + upper_source: SourceId::new(200), + lower_target: TargetId::new(300), + upper_target: TargetId::new(400), + lower_first: TargetCandidateId::new(500), + lower_second: TargetCandidateId::new(600), + upper_first: TargetCandidateId::new(700), + upper_second: TargetCandidateId::new(800), + }; + // Every authored namespace is alpha-renamed. Source and Target order is + // reversed across logical dimensions, while both candidate domains also + // reverse numeric order. Declaration and per-state choice order are then + // independently permuted; only the explicit logical state order remains. + let renamed_ids = AlphaRenamedJointIds { + lower_source: SourceId::new(920), + upper_source: SourceId::new(110), + lower_target: TargetId::new(840), + upper_target: TargetId::new(230), + lower_first: TargetCandidateId::new(760), + lower_second: TargetCandidateId::new(650), + upper_first: TargetCandidateId::new(540), + upper_second: TargetCandidateId::new(430), + }; + + let run = |program: Program| { + let compiled = program.compile().unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + let SessionState::Ready { current } = session.update(update(1, 0x00)).unwrap() else { + panic!("the second logical state must certify after the first is rejected"); + }; + assert_eq!(current.selected_state_index(), Some(1)); + + let outputs = current + .outputs() + .iter() + .map(|output| (output.output(), output.paint(), output.source_signal())) + .collect::>(); + let cells = current + .report() + .cells() + .iter() + .map(|cell| { + let modeled = cell.modeled_lcs_occurrence(); + ( + cell.candidate_state_index(), + cell.case_index(), + cell.constraint(), + cell.target(), + cell.is_hard(), + cell.result().is_violation(), + modeled, + modeled.signal(), + ) + }) + .collect::>(); + let observation = current.report().observation(); + let physical_cases = (0..observation.physical_case_count()) + .map(|case_index| { + ( + observation.physical_values(case_index).unwrap().to_vec(), + observation.provenance(case_index).unwrap().to_vec(), + ) + }) + .collect::>(); + + ( + current.selected_state_index(), + outputs, + cells, + observation.stream(), + observation.revision(), + physical_cases, + ) + }; + + let canonical = run(alpha_renamed_nested_program(canonical_ids, false)); + let renamed = run(alpha_renamed_nested_program(renamed_ids, true)); + assert_eq!(canonical, renamed); + assert_eq!( + canonical.1, + vec![ + (OUTPUT, canonical.1[0].1, signal(0xFF),), + (UPPER_OUTPUT, canonical.1[1].1, signal(0x55),), + ] + ); +} + +#[test] +fn rejected_state_runs_once_and_selected_state_runs_fresh_recheck_twice() { + let evaluator = CountingProgramWcag22Srgb8V1::default(); + let calls = evaluator.clone(); + let compiled = Program::new( + vec![Source::new(SOURCE, signal(0))], + vec![target(vec![ + candidate(FIRST, 0x55), + candidate(SECOND, 0xFF), + ])], + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![], + vec![Paint::Solid { + id: PAINT, + target: TARGET, + }], + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + vec![Occurrence::new( + OCCURRENCE, + PAINT, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + )], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![], + ), + vec![OutputBinding::new(OUTPUT, PAINT)], + evaluator, + ) + .with_joint_selection(DeclaredJointSelectionV1::new(vec![ + state(FIRST), + state(SECOND), + ])) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let SessionState::Ready { current } = session.update(update(1, 0x00)).unwrap() else { + panic!("the second candidate must certify after a fresh full recheck"); + }; + assert_eq!(current.selected_state_index(), Some(1)); + assert_eq!( + calls.calls(), + vec![ + Srgb8::new([0x55; 3]), + Srgb8::new([0xFF; 3]), + Srgb8::new([0xFF; 3]), + ] + ); +} + +#[test] +fn successful_search_allocations_do_not_scale_with_rejected_states() { + let compile = |candidates, order| { + program( + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![], + candidates, + order, + ) + .compile() + .unwrap() + }; + let direct = compile(vec![candidate(SECOND, 0xFF)], vec![state(SECOND)]); + let after_rejection = compile( + vec![candidate(FIRST, 0x55), candidate(SECOND, 0xFF)], + vec![state(FIRST), state(SECOND)], + ); + let mut direct_session = direct.instantiate(STREAM).unwrap(); + let mut rejected_session = after_rejection.instantiate(STREAM).unwrap(); + + let (_, direct_allocations) = crate::test_support::measured_allocations(|| { + let SessionState::Ready { .. } = direct_session.update(update(1, 0x00)).unwrap() else { + panic!("direct candidate must certify"); + }; + }); + let (_, rejected_allocations) = crate::test_support::measured_allocations(|| { + let SessionState::Ready { current } = rejected_session.update(update(1, 0x00)).unwrap() + else { + panic!("later candidate must certify"); + }; + assert_eq!(current.selected_state_index(), Some(1)); + }); + assert_eq!(rejected_allocations, direct_allocations); +} + +#[test] +fn evaluation_cell_cardinality_checks_both_products_without_a_numeric_cap() { + assert_eq!( + checked_program_evaluation_cell_counts_for_test(3, 2, 4), + Some((6, 24)) + ); + assert_eq!( + checked_program_evaluation_cell_counts_for_test(usize::MAX, 2, 1), + None + ); + assert_eq!( + checked_program_evaluation_cell_counts_for_test(usize::MAX, 1, 2), + None + ); +} + +#[test] +fn every_fallible_joint_preflight_reservation_precedes_evaluator_work() { + for reservation_index in 0..3 { + let evaluator = CountingProgramWcag22Srgb8V1::default(); + let calls = evaluator.clone(); + let compiled = Program::new( + vec![Source::new(SOURCE, signal(0))], + vec![target(vec![ + candidate(FIRST, 0x55), + candidate(SECOND, 0xFF), + ])], + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![], + vec![Paint::Solid { + id: PAINT, + target: TARGET, + }], + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + vec![Occurrence::new( + OCCURRENCE, + PAINT, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + )], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![], + ), + vec![OutputBinding::new(OUTPUT, PAINT)], + evaluator, + ) + .with_joint_selection(DeclaredJointSelectionV1::new(vec![ + state(FIRST), + state(SECOND), + ])) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let error = { + let _failure = fail_program_preflight_reservation_for_test(reservation_index); + match session.update(update(1, 0x00)) { + Ok(_) => panic!("injected preflight failure must abort the update"), + Err(error) => error, + } + }; + assert_eq!( + error, + SessionUpdateError::Plan(ProgramSessionEvaluationError::ResourceExhausted) + ); + assert!(calls.calls().is_empty()); + assert!(matches!(session.state(), SessionState::Waiting)); + } +} + +#[test] +fn every_fallible_fixed_preflight_reservation_precedes_evaluator_work() { + for reservation_index in 0..2 { + let evaluator = CountingProgramWcag22Srgb8V1::default(); + let calls = evaluator.clone(); + let compiled = Program::new( + vec![Source::new(SOURCE, signal(0xFF))], + vec![Target::fixed(TARGET, SOURCE)], + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![], + vec![Paint::Solid { + id: PAINT, + target: TARGET, + }], + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + vec![Occurrence::new( + OCCURRENCE, + PAINT, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + )], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![], + ), + vec![OutputBinding::new(OUTPUT, PAINT)], + evaluator, + ) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let error = { + let _failure = fail_program_preflight_reservation_for_test(reservation_index); + match session.update(update(1, 0x00)) { + Ok(_) => panic!("injected fixed preflight failure must abort the update"), + Err(error) => error, + } + }; + assert_eq!( + error, + SessionUpdateError::Plan(ProgramSessionEvaluationError::ResourceExhausted) + ); + assert!(calls.calls().is_empty()); + assert!(matches!(session.state(), SessionState::Waiting)); + } +} + +fn counting_fixed_program( + evaluator: CountingProgramWcag22Srgb8V1, +) -> crate::program_session::CompiledProgram { + Program::new( + vec![Source::new(SOURCE, signal(0xFF))], + vec![Target::fixed(TARGET, SOURCE)], + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![], + vec![Paint::Solid { + id: PAINT, + target: TARGET, + }], + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + vec![Occurrence::new( + OCCURRENCE, + PAINT, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + )], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![], + ), + vec![OutputBinding::new(OUTPUT, PAINT)], + evaluator, + ) + .compile() + .unwrap() +} + +#[test] +fn expired_program_generation_precedes_composition_and_evaluation_without_allocation() { + let evaluator = CountingProgramWcag22Srgb8V1::default(); + let calls = evaluator.clone(); + let compiled = counting_fixed_program(evaluator); + let mut session = compiled.instantiate(STREAM).unwrap(); + + crate::composition::reset_source_over_evaluation_count(); + let SessionState::Ready { current } = session.update(update(1, 0x00)).unwrap() else { + panic!("control generation must certify"); + }; + assert_eq!(current.report().observation().revision(), Revision::new(1)); + assert_eq!(calls.calls().len(), 1); + assert_eq!(crate::composition::source_over_evaluation_count(), 1); + + drop(compiled); + let expired_update = update(2, 0x00); + let (error, allocations) = crate::test_support::measured_allocations(|| { + session.update(expired_update).map(|_| ()).unwrap_err() + }); + assert_eq!(error, SessionUpdateError::OwnerExpired); + assert_eq!(allocations, 0); + assert_eq!(calls.calls().len(), 1); + assert_eq!(crate::composition::source_over_evaluation_count(), 1); + assert_eq!(session.raw_head().revision(), Some(Revision::new(1))); + let SessionState::Ready { current } = session.state() else { + panic!("expiry must retain the previous committed state"); + }; + assert_eq!(current.report().observation().revision(), Revision::new(1)); +} + +#[test] +fn equivalent_recompiled_owner_is_a_new_generation_and_cannot_revive_old_sessions() { + 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(); + 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), + )); + assert_eq!(first_calls.calls().len(), 1); + assert!(replacement_calls.calls().is_empty()); + assert_eq!(old_session.raw_head().revision(), Some(Revision::new(1))); + + let mut replacement_session = compiled.instantiate(STREAM).unwrap(); + assert!(matches!( + replacement_session.update(update(1, 0x00)).unwrap(), + SessionState::Ready { .. } + )); + assert_eq!(replacement_calls.calls().len(), 1); + assert!(matches!( + replacement_session.raw_head(), + ObservationHeadViewV1::Observed(_) + )); +} + +#[test] +fn final_recheck_violation_is_typed_and_retains_the_previous_certificate() { + let evaluator = FinalRecheckMutantProgramEvaluatorV1::default(); + let control = evaluator.clone(); + let compiled = Program::new( + vec![Source::new(SOURCE, signal(0))], + vec![target(vec![candidate(FIRST, 0xFF)])], + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![], + vec![Paint::Solid { + id: PAINT, + target: TARGET, + }], + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + vec![Occurrence::new( + OCCURRENCE, + PAINT, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + )], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Srgb8::new([0xFF; 3]), + )], + vec![], + ), + vec![OutputBinding::new(OUTPUT, PAINT)], + evaluator, + ) + .with_joint_selection(DeclaredJointSelectionV1::new(vec![state(FIRST)])) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let SessionState::Ready { current } = session.update(update(1, 0x00)).unwrap() else { + panic!("control revision must certify before the mutant is armed"); + }; + assert_eq!(current.outputs()[0].source_signal(), signal(0xFF)); + + control.arm(); + let error = match session.update(update(2, 0x00)) { + Ok(_) => panic!("a failing final recheck must not commit"), + Err(error) => error, + }; + assert_eq!( + error, + SessionUpdateError::Plan(ProgramSessionEvaluationError::FinalRecheckViolation { + state_index: 0, + case_index: 0, + constraint: ConstraintId::new(1), + target: OCCURRENCE, + hard_violation_count: 1, + }) + ); + let SessionState::Ready { current } = session.state() else { + panic!("the previous certificate must remain the sole committed state"); + }; + assert_eq!(current.outputs()[0].source_signal(), signal(0xFF)); +} + +#[test] +fn duplicate_physical_candidate_signal_is_typed_and_declaration_order_invariant() { + let compile = |candidates| match program( + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![], + candidates, + vec![state(FIRST), state(SECOND)], + ) + .compile() + { + Ok(_) => panic!("duplicate physical candidate signals must not compile"), + Err(error) => error, + }; + let canonical = compile(vec![candidate(FIRST, 0x66), candidate(SECOND, 0x66)]); + let permuted = compile(vec![candidate(SECOND, 0x66), candidate(FIRST, 0x66)]); + let expected = ProgramCompileError::DuplicateTargetCandidateSignal { + target: TARGET, + first: FIRST, + duplicate: SECOND, + signal: signal(0x66), + }; + assert_eq!(canonical, expected); + assert_eq!(permuted, expected); +} + +#[test] +fn duplicate_joint_tuple_is_rejected_before_runtime() { + let error = match program( + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![], + vec![candidate(FIRST, 0x66), candidate(SECOND, 0xFF)], + vec![state(FIRST), state(FIRST)], + ) + .compile() + { + Ok(_) => panic!("duplicate tuple must not compile"), + Err(error) => error, + }; + assert_eq!( + error, + ProgramCompileError::InvalidJointOrder(FiniteJointOrderErrorV1::DuplicateTuple { + first: 0, + duplicate: 1, + }) + ); +} diff --git a/crates/labcolors-core/src/program_lcs_integration_tests.rs b/crates/labcolors-core/src/program_lcs_integration_tests.rs new file mode 100644 index 00000000..cb09ebee --- /dev/null +++ b/crates/labcolors-core/src/program_lcs_integration_tests.rs @@ -0,0 +1,596 @@ +use crate::Srgb8; +use crate::appearance::{ + OccurrenceId, OpacityInputId, PaintId, PointOpacityOverSurfaceV1, SurfaceId, SurfaceInputPortId, +}; +use crate::constraints::{ + ExactSrgb8IdentityV1, ProgramPointAssessmentErrorV1, ProgramVisiblePointBindingV1, + Wcag22Srgb8V1, assess_program_point_hard, +}; +use crate::lcs_occurrence::{ + AdaptingLuminanceCdM2, AppearanceContextId, AppearanceContextSchemaReleaseId, AppearanceState, + BackgroundLuminanceRatio, ColorSignal, HueState, IEC_SRGB_D65_XYZ_FRAME_V1, + MODELED_TRISTIMULUS_DERIVATION_CALLS, ModeledLcsOccurrenceV1, SurroundProfileId, +}; +use crate::observation::{ + ObservationGroupId, ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, + ObservedScenarioSetInput, Revision, ScenarioId, ScenarioInput, SurfaceInputBinding, +}; +use crate::program_session::{ + CompiledProgram, CompositionProfile, ConstraintId, ConstraintInvocation, ConstraintSet, + ObservationGroup, Occurrence, OpacityInput, OutputBinding, OutputSlotId, Paint, Program, + ProgramConstraintResultV1, Source, SourceId, Surface, Target, TargetId, +}; +use crate::session::SessionState; +use crate::spaces::cam16::FORWARD_CALLS; +use crate::wcag22::{Wcag22ApplicableDecisionV1, Wcag22CriterionV1}; + +const BLACK_SOURCE: SourceId = SourceId::new(1); +const WHITE_SOURCE: SourceId = SourceId::new(2); +const BLACK_TARGET: TargetId = TargetId::new(1); +const WHITE_TARGET: TargetId = TargetId::new(2); +const SURFACE_PORT: SurfaceInputPortId = SurfaceInputPortId::new(3); +const HALF: OpacityInputId = OpacityInputId::new(4); + +const BLACK_SOLID: PaintId = PaintId::new(10); +const TRANSLUCENT_BLACK: PaintId = PaintId::new(11); +const WHITE_SOLID: PaintId = PaintId::new(12); +const BACKDROP: SurfaceId = SurfaceId::new(20); + +const AVERAGE_OCCURRENCE: OccurrenceId = OccurrenceId::new(30); +const DIM_OCCURRENCE: OccurrenceId = OccurrenceId::new(31); +const AVERAGE_CONSTRAINT: ConstraintId = ConstraintId::new(40); +const DIM_CONSTRAINT: ConstraintId = ConstraintId::new(41); + +const BLACK_OUTPUT: OutputSlotId = OutputSlotId::new(50); +const GROUP: ObservationGroupId = ObservationGroupId::new(60); +const STREAM_A: ObservationStreamId = ObservationStreamId::new(70); +const STREAM_B: ObservationStreamId = ObservationStreamId::new(71); + +fn signal(bytes: [u8; 3]) -> ColorSignal { + ColorSignal::from_srgb8(Srgb8::new(bytes)) +} + +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, + ) +} + +fn observed_white(stream: ObservationStreamId) -> ObservationUpdateInput { + ObservationUpdateInput { + stream, + revision: Revision::new(1), + payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { + scenarios: vec![ScenarioInput { + id: ScenarioId::new(1), + bindings: vec![SurfaceInputBinding::new(SURFACE_PORT, signal([0xFF; 3]))], + }], + }), + } +} + +fn observed_two_backdrops(stream: ObservationStreamId) -> ObservationUpdateInput { + ObservationUpdateInput { + stream, + revision: Revision::new(1), + payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { + scenarios: vec![ + ScenarioInput { + id: ScenarioId::new(1), + bindings: vec![SurfaceInputBinding::new(SURFACE_PORT, signal([0xFF; 3]))], + }, + ScenarioInput { + id: ScenarioId::new(2), + bindings: vec![SurfaceInputBinding::new(SURFACE_PORT, signal([0x80; 3]))], + }, + ], + }), + } +} + +fn compiled_program( + declarations: &[(OccurrenceId, ConstraintId, AppearanceContextId)], + expected_visible: Srgb8, + opacity: f64, + permuted: bool, +) -> CompiledProgram { + let mut sources = vec![ + Source::new(BLACK_SOURCE, signal([0; 3])), + Source::new(WHITE_SOURCE, signal([0xFF; 3])), + ]; + let mut targets = vec![ + Target::fixed(BLACK_TARGET, BLACK_SOURCE), + Target::fixed(WHITE_TARGET, WHITE_SOURCE), + ]; + let mut paints = vec![ + Paint::Solid { + id: BLACK_SOLID, + target: BLACK_TARGET, + }, + Paint::Opacity { + id: TRANSLUCENT_BLACK, + source: BLACK_SOLID, + opacity: HALF, + }, + Paint::Solid { + id: WHITE_SOLID, + target: WHITE_TARGET, + }, + ]; + let mut occurrences = declarations + .iter() + .map(|(occurrence, _, occurrence_context)| { + Occurrence::new( + *occurrence, + TRANSLUCENT_BLACK, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + *occurrence_context, + ) + }) + .collect::>(); + let mut hard = declarations + .iter() + .map(|(occurrence, constraint, _)| { + ConstraintInvocation::hard(*constraint, *occurrence, expected_visible) + }) + .collect::>(); + let mut outputs = vec![OutputBinding::new(BLACK_OUTPUT, TRANSLUCENT_BLACK)]; + + if permuted { + sources.reverse(); + targets.reverse(); + paints.reverse(); + occurrences.reverse(); + hard.reverse(); + outputs.reverse(); + } + + Program::new( + sources, + targets, + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![OpacityInput::new(HALF, opacity)], + paints, + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + occurrences, + ConstraintSet::new(hard, vec![]), + outputs, + ExactSrgb8IdentityV1, + ) + .compile() + .unwrap() +} + +fn compiled_wcag_program(opacity: f64) -> CompiledProgram { + Program::new( + vec![ + Source::new(BLACK_SOURCE, signal([0; 3])), + Source::new(WHITE_SOURCE, signal([0xFF; 3])), + ], + vec![ + Target::fixed(BLACK_TARGET, BLACK_SOURCE), + Target::fixed(WHITE_TARGET, WHITE_SOURCE), + ], + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![OpacityInput::new(HALF, opacity)], + vec![ + Paint::Solid { + id: BLACK_SOLID, + target: BLACK_TARGET, + }, + Paint::Opacity { + id: TRANSLUCENT_BLACK, + source: BLACK_SOLID, + opacity: HALF, + }, + Paint::Solid { + id: WHITE_SOLID, + target: WHITE_TARGET, + }, + ], + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + vec![Occurrence::new( + AVERAGE_OCCURRENCE, + TRANSLUCENT_BLACK, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + context(SurroundProfileId::AverageV1), + )], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + AVERAGE_CONSTRAINT, + AVERAGE_OCCURRENCE, + Wcag22CriterionV1::Sc143TextDefault, + )], + vec![], + ), + vec![OutputBinding::new(BLACK_OUTPUT, TRANSLUCENT_BLACK)], + Wcag22Srgb8V1, + ) + .compile() + .unwrap() +} + +fn compiled_duplicate_constraint_program() -> CompiledProgram { + Program::new( + vec![Source::new(BLACK_SOURCE, signal([0; 3]))], + vec![Target::fixed(BLACK_TARGET, BLACK_SOURCE)], + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![OpacityInput::new(HALF, 0.5)], + vec![ + Paint::Solid { + id: BLACK_SOLID, + target: BLACK_TARGET, + }, + Paint::Opacity { + id: TRANSLUCENT_BLACK, + source: BLACK_SOLID, + opacity: HALF, + }, + ], + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + vec![Occurrence::new( + AVERAGE_OCCURRENCE, + TRANSLUCENT_BLACK, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + context(SurroundProfileId::AverageV1), + )], + ConstraintSet::new( + vec![], + vec![ + ConstraintInvocation::report_only( + AVERAGE_CONSTRAINT, + AVERAGE_OCCURRENCE, + Srgb8::new([0x80; 3]), + ), + ConstraintInvocation::report_only( + DIM_CONSTRAINT, + AVERAGE_OCCURRENCE, + Srgb8::new([0x80; 3]), + ), + ], + ), + vec![OutputBinding::new(BLACK_OUTPUT, TRANSLUCENT_BLACK)], + ExactSrgb8IdentityV1, + ) + .compile() + .unwrap() +} + +fn assert_binding_matches_modeled( + binding: ProgramVisiblePointBindingV1, + modeled: ModeledLcsOccurrenceV1, +) { + assert_eq!(binding.modeled_lcs(), modeled); + assert_eq!( + binding.physical().occurrence().output_rgb(), + modeled.signal().srgb8().bytes(), + ); +} + +#[test] +fn ready_cell_binds_the_actual_visible_signal_to_the_exact_authored_context() { + let average = context(SurroundProfileId::AverageV1); + let compiled = compiled_program( + &[(AVERAGE_OCCURRENCE, AVERAGE_CONSTRAINT, average)], + Srgb8::new([0x80; 3]), + 0.5, + false, + ); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + let state = session.update(observed_white(STREAM_A)).unwrap(); + let SessionState::Ready { current } = state else { + panic!("the exact visible composite must pass"); + }; + let [cell] = current.report().cells() else { + panic!("one physical case times one constraint must produce one cell"); + }; + + let modeled = cell.modeled_lcs_occurrence(); + assert_eq!( + modeled.derivation().provenance().source_signal(), + signal([0x80; 3]), + "provenance must name the visible source-over result, not the Paint source or backdrop", + ); + assert_eq!(modeled.occurrence().context(), average); + let ProgramConstraintResultV1::Pass(evidence) = cell.result() else { + panic!("exact equality must retain typed pass evidence"); + }; + assert_binding_matches_modeled(*evidence.binding(), modeled); + assert_eq!(*evidence.measurement().value(), Srgb8::new([0x80; 3])); + assert_eq!(*evidence.invocation(), Srgb8::new([0x80; 3])); +} + +#[test] +fn identical_physical_bytes_share_tristimulus_but_contextual_cam16_views_diverge() { + let average = context(SurroundProfileId::AverageV1); + let dim = context(SurroundProfileId::DimV1); + let compiled = compiled_program( + &[ + (AVERAGE_OCCURRENCE, AVERAGE_CONSTRAINT, average), + (DIM_OCCURRENCE, DIM_CONSTRAINT, dim), + ], + Srgb8::new([0x80; 3]), + 0.5, + false, + ); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + let state = session.update(observed_white(STREAM_A)).unwrap(); + let SessionState::Ready { current } = state else { + panic!("both context-bound declarations assess the same passing physical point"); + }; + let [average_cell, dim_cell] = current.report().cells() else { + panic!("one case times two constraints must produce two canonical cells"); + }; + let average_modeled = average_cell.modeled_lcs_occurrence(); + let dim_modeled = dim_cell.modeled_lcs_occurrence(); + + assert_eq!( + average_modeled.derivation().provenance().source_signal(), + dim_modeled.derivation().provenance().source_signal(), + ); + assert_eq!( + average_modeled.derivation().sample(), + dim_modeled.derivation().sample(), + ); + assert_eq!(average_modeled.occurrence().context(), average); + assert_eq!(dim_modeled.occurrence().context(), dim); + assert_ne!(average_modeled.occurrence(), dim_modeled.occurrence()); + + let average_state = AppearanceState::derive_v1(average_modeled.occurrence()).unwrap(); + let dim_state = AppearanceState::derive_v1(dim_modeled.occurrence()).unwrap(); + assert_eq!(average_state.oklab(), dim_state.oklab()); + assert_ne!( + average_state.cam16().j().to_bits(), + dim_state.cam16().j().to_bits(), + "CAM16 must be evaluated under each occurrence's own context", + ); +} + +#[test] +fn exact_black_visible_occurrence_keeps_undefined_hue_in_lcs() { + let average = context(SurroundProfileId::AverageV1); + let compiled = compiled_program( + &[(AVERAGE_OCCURRENCE, AVERAGE_CONSTRAINT, average)], + Srgb8::new([0x00; 3]), + 1.0, + false, + ); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + let state = session.update(observed_white(STREAM_A)).unwrap(); + let SessionState::Ready { current } = state else { + panic!("fixture must verify"); + }; + let [cell] = current.report().cells() else { + panic!("the complete report must retain its sole visible occurrence"); + }; + let modeled = cell.modeled_lcs_occurrence(); + assert_eq!(modeled.signal(), signal([0x00; 3])); + let state = AppearanceState::derive_v1(modeled.occurrence()).unwrap(); + assert_eq!(state.cam16().hue(), HueState::UndefinedExact); +} + +#[test] +fn hard_violation_retains_modeled_lcs_and_commits_no_current_outputs() { + let average = context(SurroundProfileId::AverageV1); + let compiled = compiled_program( + &[(AVERAGE_OCCURRENCE, AVERAGE_CONSTRAINT, average)], + Srgb8::new([0x7F; 3]), + 0.5, + false, + ); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + let state = session.update(observed_white(STREAM_A)).unwrap(); + let SessionState::Failed { cause, previous } = state else { + panic!("the mandatory exact constraint must fail"); + }; + assert!( + previous.is_none(), + "a fresh hard failure must not expose any previous Ready outputs", + ); + let [cell] = cause.report().cells() else { + panic!("the complete failed report must retain its sole cell"); + }; + assert!(cell.result().is_violation()); + let modeled = cell.modeled_lcs_occurrence(); + assert_eq!( + modeled.derivation().provenance().source_signal(), + signal([0x80; 3]), + ); + assert_eq!(modeled.occurrence().context(), average); + let ProgramConstraintResultV1::Violation(evidence) = cell.result() else { + panic!("exact mismatch must retain typed violation evidence"); + }; + assert_binding_matches_modeled(*evidence.binding(), modeled); + assert_eq!(*evidence.measurement().value(), Srgb8::new([0x80; 3])); + assert_eq!(*evidence.invocation(), Srgb8::new([0x7F; 3])); + // `ProgramConflictV1` intentionally exposes only `report()`: current + // outputs exist exclusively on the `ProgramVerifiedV1` Ready branch. +} + +#[test] +fn program_wcag_pass_binds_physical_and_modeled_occurrence_to_one_evidence() { + let compiled = compiled_wcag_program(1.0); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + let state = session.update(observed_white(STREAM_A)).unwrap(); + let SessionState::Ready { current } = state else { + panic!("opaque black on white must pass WCAG default text"); + }; + let [cell] = current.report().cells() else { + panic!("one WCAG declaration must produce one report cell"); + }; + let ProgramConstraintResultV1::Pass(evidence) = cell.result() else { + panic!("WCAG pass must retain typed pass evidence"); + }; + let modeled = cell.modeled_lcs_occurrence(); + assert_binding_matches_modeled(*evidence.binding(), modeled); + let measurement = evidence.measurement().value(); + assert_eq!(measurement.decision(), Wcag22ApplicableDecisionV1::Pass); + assert_eq!(measurement.measurement().foreground, [0; 3]); + assert_eq!(measurement.measurement().background, [0xFF; 3]); + assert_eq!( + evidence.binding().physical().occurrence().backdrop_rgb(), + measurement.measurement().background, + ); + assert_eq!(*evidence.invocation(), Wcag22CriterionV1::Sc143TextDefault,); +} + +#[test] +fn program_wcag_violation_retains_coherent_evidence_without_current_outputs() { + let compiled = compiled_wcag_program(0.5); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + let state = session.update(observed_white(STREAM_A)).unwrap(); + let SessionState::Failed { cause, previous } = state else { + panic!("half-black composite on white must violate WCAG default text"); + }; + assert!(previous.is_none()); + let [cell] = cause.report().cells() else { + panic!("one WCAG declaration must produce one failed report cell"); + }; + let ProgramConstraintResultV1::Violation(evidence) = cell.result() else { + panic!("WCAG failure must retain typed violation evidence"); + }; + let modeled = cell.modeled_lcs_occurrence(); + assert_binding_matches_modeled(*evidence.binding(), modeled); + let measurement = evidence.measurement().value(); + assert_eq!(measurement.decision(), Wcag22ApplicableDecisionV1::Fail); + assert_eq!(measurement.measurement().foreground, [0x80; 3]); + assert_eq!(measurement.measurement().background, [0xFF; 3]); + assert_eq!( + evidence.binding().physical().occurrence().backdrop_rgb(), + measurement.measurement().background, + ); + assert_eq!( + modeled.signal(), + signal(measurement.measurement().foreground) + ); +} + +#[test] +fn mismatched_modeled_signal_is_rejected_before_program_evaluation() { + let physical = PointOpacityOverSurfaceV1::evaluate([0; 3], 1.0, [0xFF; 3]).unwrap(); + let mismatched = ModeledLcsOccurrenceV1::from_signal_in_context( + signal([0xFF; 3]), + context(SurroundProfileId::AverageV1), + ) + .unwrap(); + + let error = assess_program_point_hard( + &physical, + mismatched, + &ExactSrgb8IdentityV1, + Srgb8::new([0; 3]), + ) + .expect_err("binding mismatch must be rejected before the evaluator can run"); + let mismatch = match error { + ProgramPointAssessmentErrorV1::Binding(mismatch) => mismatch, + ProgramPointAssessmentErrorV1::Evaluator(unreachable) => match unreachable {}, + }; + assert_eq!(mismatch.physical(), Srgb8::new([0; 3])); + assert_eq!(mismatch.modeled(), Srgb8::new([0xFF; 3])); +} + +#[test] +fn program_derives_lcs_once_per_target_and_case_without_eager_cam16() { + let compiled = compiled_duplicate_constraint_program(); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + MODELED_TRISTIMULUS_DERIVATION_CALLS.with(|calls| calls.set(0)); + FORWARD_CALLS.with(|calls| calls.set(0)); + + let state = session.update(observed_two_backdrops(STREAM_A)).unwrap(); + + let derivations = MODELED_TRISTIMULUS_DERIVATION_CALLS.with(|calls| calls.get()); + let cam16_forwards = FORWARD_CALLS.with(|calls| calls.get()); + let SessionState::Ready { current } = state else { + panic!("report-only constraints must retain the complete two-case result"); + }; + assert_eq!(current.report().cells().len(), 4); + assert_eq!( + derivations, 2, + "two constraints sharing one target must reuse one LCS derivation in each physical case", + ); + assert_eq!( + cam16_forwards, 0, + "Program lowering must not eagerly derive the contextual CAM16 view", + ); +} + +#[test] +fn declaration_permutations_preserve_canonical_lcs_cells_and_output_signals() { + let declarations = [ + ( + AVERAGE_OCCURRENCE, + AVERAGE_CONSTRAINT, + context(SurroundProfileId::AverageV1), + ), + ( + DIM_OCCURRENCE, + DIM_CONSTRAINT, + context(SurroundProfileId::DimV1), + ), + ]; + let canonical = compiled_program(&declarations, Srgb8::new([0x80; 3]), 0.5, false); + let permuted = compiled_program(&declarations, Srgb8::new([0x80; 3]), 0.5, true); + let mut canonical_session = canonical.instantiate(STREAM_A).unwrap(); + let mut permuted_session = permuted.instantiate(STREAM_B).unwrap(); + + let canonical_state = canonical_session.update(observed_white(STREAM_A)).unwrap(); + let permuted_state = permuted_session.update(observed_white(STREAM_B)).unwrap(); + let SessionState::Ready { current: canonical } = canonical_state else { + panic!("canonical declarations must verify"); + }; + let SessionState::Ready { current: permuted } = permuted_state else { + panic!("permuted declarations must verify"); + }; + + let cell_signature = |cell: &crate::program_session::ProgramConstraintCellV1<_>| { + ( + cell.case_index(), + cell.constraint(), + cell.target(), + cell.modeled_lcs_occurrence(), + cell.result().is_violation(), + ) + }; + assert_eq!( + canonical + .report() + .cells() + .iter() + .map(cell_signature) + .collect::>(), + permuted + .report() + .cells() + .iter() + .map(cell_signature) + .collect::>(), + ); + assert_eq!( + canonical + .outputs() + .iter() + .map(|output| (output.output(), output.source_signal())) + .collect::>(), + permuted + .outputs() + .iter() + .map(|output| (output.output(), output.source_signal())) + .collect::>(), + ); +} diff --git a/crates/labcolors-core/src/program_mixed_evaluator_tests.rs b/crates/labcolors-core/src/program_mixed_evaluator_tests.rs new file mode 100644 index 00000000..1085f0a7 --- /dev/null +++ b/crates/labcolors-core/src/program_mixed_evaluator_tests.rs @@ -0,0 +1,2108 @@ +use core::iter::FusedIterator; + +use crate::Srgb8; +use crate::appearance::{OccurrenceId, OpacityInputId, PaintId, SurfaceId, SurfaceInputPortId}; +use crate::constraints::{ + ApplicableWcag22MeasurementV1, ExactConstraintIdentityV1, ExactIdentityCapabilityV1, + ExactIdentityReleaseV1, ProgramVisiblePointBindingV1, +}; +use crate::lcs_occurrence::{ + AdaptingLuminanceCdM2, AppearanceContextId, AppearanceContextSchemaReleaseId, + BackgroundLuminanceRatio, ColorSignal, IEC_SRGB_D65_XYZ_FRAME_V1, + MODELED_TRISTIMULUS_DERIVATION_CALLS, SurroundProfileId, +}; +use crate::observation::{ + ObservationGroupId, ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, + ObservedScenarioSetInput, Revision, ScenarioId, ScenarioInput, SurfaceInputBinding, +}; +use crate::program::{ + AccessErrorV1, AssessmentV1, CertificateV1, ConflictCellV1, ConstraintModeV1, + ExactSrgb8EvidenceV1, ModeledPointV1, ObservationHeadV1, ObservationV1, OperationV1, + OutputSlotIdV1, OwnerV1, PhysicalPointV1, ProjectionV1, ScenarioV1, SignalV1, StateKindV1, + SurroundV1, UpdateErrorKindV1, UpdateV1, VerdictV1, VerifiedCellV1, Wcag22Srgb8EvidenceV1, +}; +use crate::program_session::{ + CORE_PROGRAM_ASSESSMENT_CALLS, CompiledCoreProgramV1, CompositionProfile, ConstraintId, + ConstraintInvocation, ConstraintSet, CoreProgramConstraintInvocationV1, + CoreProgramEvaluatorsV1, CoreProgramPassEvidenceV1, CoreProgramV1, + CoreProgramViolationEvidenceV1, DeclaredJointSelectionV1, JointCandidateStateV1, + ObservationGroup, Occurrence, OpacityInput, OutputBinding, OutputSlotId, Paint, Program, + ProgramConstraintCellV1, ProgramConstraintResultV1, Source, SourceId, Surface, Target, + TargetCandidateChoiceV1, TargetCandidateId, TargetCandidateV1, TargetId, +}; +use crate::session::SessionState; +use crate::wcag22::{Wcag22CriterionV1, wcag22_profile_v1}; + +const SOURCE: SourceId = SourceId::new(1); +const TARGET: TargetId = TargetId::new(2); +const PAINT: PaintId = PaintId::new(3); +const SURFACE: SurfaceId = SurfaceId::new(4); +const SURFACE_PORT: SurfaceInputPortId = SurfaceInputPortId::new(5); +const OCCURRENCE: OccurrenceId = OccurrenceId::new(6); +const EXACT_CONSTRAINT: ConstraintId = ConstraintId::new(7); +const WCAG_CONSTRAINT: ConstraintId = ConstraintId::new(8); +const OUTPUT: OutputSlotId = OutputSlotId::new(9); +const SECOND_OUTPUT: OutputSlotId = OutputSlotId::new(19); +const GROUP: ObservationGroupId = ObservationGroupId::new(10); +const STREAM: ObservationStreamId = ObservationStreamId::new(11); + +fn signal(bytes: [u8; 3]) -> ColorSignal { + ColorSignal::from_srgb8(Srgb8::new(bytes)) +} + +fn context() -> AppearanceContextId { + AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + IEC_SRGB_D65_XYZ_FRAME_V1, + AdaptingLuminanceCdM2::try_new(64.0).unwrap(), + BackgroundLuminanceRatio::try_new(0.2).unwrap(), + SurroundProfileId::AverageV1, + ) +} + +fn observed_white() -> ObservationUpdateInput { + ObservationUpdateInput { + stream: STREAM, + revision: Revision::new(1), + payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { + scenarios: vec![ScenarioInput { + id: ScenarioId::new(1), + bindings: vec![SurfaceInputBinding::new(SURFACE_PORT, signal([0xFF; 3]))], + }], + }), + } +} + +fn observed_backdrops(backdrops: &[[u8; 3]]) -> ObservationUpdateInput { + ObservationUpdateInput { + stream: STREAM, + revision: Revision::new(1), + payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { + scenarios: backdrops + .iter() + .enumerate() + .map(|(index, backdrop)| ScenarioInput { + id: ScenarioId::new(index as u32 + 1), + bindings: vec![SurfaceInputBinding::new(SURFACE_PORT, signal(*backdrop))], + }) + .collect(), + }), + } +} + +fn finite_program(candidate_signals: [[u8; 3]; 2]) -> CompiledCoreProgramV1 { + finite_program_with_outputs(candidate_signals, vec![OutputBinding::new(OUTPUT, PAINT)]) +} + +fn finite_program_with_outputs( + candidate_signals: [[u8; 3]; 2], + outputs: Vec, +) -> CompiledCoreProgramV1 { + const FIRST: TargetCandidateId = TargetCandidateId::new(1); + const SECOND: TargetCandidateId = TargetCandidateId::new(2); + let program: CoreProgramV1 = Program::new( + vec![Source::new(SOURCE, signal(candidate_signals[0]))], + vec![Target::finite( + TARGET, + SOURCE, + vec![ + TargetCandidateV1::new(FIRST, signal(candidate_signals[0])), + TargetCandidateV1::new(SECOND, signal(candidate_signals[1])), + ], + )], + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![], + vec![Paint::Solid { + id: PAINT, + target: TARGET, + }], + vec![Surface::Input { + id: SURFACE, + input: SURFACE_PORT, + }], + vec![Occurrence::new( + OCCURRENCE, + PAINT, + SURFACE, + CompositionProfile::EncodedSrgb8SourceOverV1, + context(), + )], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + WCAG_CONSTRAINT, + OCCURRENCE, + CoreProgramConstraintInvocationV1::Wcag22Srgb8(Wcag22CriterionV1::Sc143TextDefault), + )], + vec![ConstraintInvocation::report_only( + EXACT_CONSTRAINT, + OCCURRENCE, + CoreProgramConstraintInvocationV1::ExactSrgb8(Srgb8::new([0; 3])), + )], + ), + outputs, + CoreProgramEvaluatorsV1, + ); + program + .with_joint_selection(DeclaredJointSelectionV1::new(vec![ + JointCandidateStateV1::new(vec![TargetCandidateChoiceV1::new(TARGET, FIRST)]), + JointCandidateStateV1::new(vec![TargetCandidateChoiceV1::new(TARGET, SECOND)]), + ])) + .compile() + .unwrap() +} + +fn fixed_translucent_program() -> CompiledCoreProgramV1 { + const OPACITY: OpacityInputId = OpacityInputId::new(12); + const TRANSLUCENT_PAINT: PaintId = PaintId::new(13); + Program::new( + vec![Source::new(SOURCE, signal([0; 3]))], + vec![Target::fixed(TARGET, SOURCE)], + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![OpacityInput::new(OPACITY, 0.5)], + vec![ + Paint::Solid { + id: PAINT, + target: TARGET, + }, + Paint::Opacity { + id: TRANSLUCENT_PAINT, + source: PAINT, + opacity: OPACITY, + }, + ], + vec![Surface::Input { + id: SURFACE, + input: SURFACE_PORT, + }], + vec![Occurrence::new( + OCCURRENCE, + TRANSLUCENT_PAINT, + SURFACE, + CompositionProfile::EncodedSrgb8SourceOverV1, + context(), + )], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + EXACT_CONSTRAINT, + OCCURRENCE, + CoreProgramConstraintInvocationV1::ExactSrgb8(Srgb8::new([0x80; 3])), + )], + vec![], + ), + vec![OutputBinding::new(OUTPUT, TRANSLUCENT_PAINT)], + CoreProgramEvaluatorsV1, + ) + .compile() + .unwrap() +} + +fn assert_public_observation_matches_core( + public: ObservationV1<'_>, + core: &crate::observation::RevisionBoundObservationV1, +) { + assert_eq!(public.stream().value(), core.stream().value()); + assert_eq!(public.revision(), core.revision().value()); + assert_eq!( + public + .surface_input_ports() + .map(|port| port.value()) + .collect::>(), + core.schema() + .iter() + .map(|port| port.value()) + .collect::>(), + ); + + let public_cases = public + .physical_cases() + .map(|case| { + let values = case + .values() + .map(|value| match value { + SignalV1::Iec61966Srgb8D65(value) => value, + }) + .collect::>(); + let provenance = case + .provenance() + .map(|scenario| scenario.value()) + .collect::>(); + (values, provenance) + }) + .collect::>(); + let core_cases = (0..core.physical_case_count()) + .map(|case_index| { + let values = core + .physical_values(case_index) + .unwrap() + .iter() + .map(|signal| signal.srgb8()) + .collect::>(); + let provenance = core + .provenance(case_index) + .unwrap() + .iter() + .map(|scenario| scenario.value()) + .collect::>(); + (values, provenance) + }) + .collect::>(); + assert_eq!(public_cases, core_cases); +} + +fn assert_public_binding_matches_core( + public: AssessmentV1<'_>, + core: &ProgramVisiblePointBindingV1, + expected_occurrence: OccurrenceId, +) -> (Srgb8, Srgb8) { + let core_physical = core.physical(); + let core_occurrence = core_physical.occurrence(); + let core_program_occurrence = core_physical.program_occurrence(); + assert_eq!(core_program_occurrence.occurrence(), expected_occurrence); + let PhysicalPointV1::EncodedSrgb8SourceOver(public_physical) = public.binding().physical(); + assert_eq!( + public_physical.subject_paint().value(), + core_program_occurrence.subject().value() + ); + assert_eq!( + public_physical.backdrop_surface().value(), + core_program_occurrence.backdrop_surface().value() + ); + assert_eq!( + public_physical.subject(), + Srgb8::new(core_occurrence.subject_rgb()) + ); + assert_eq!( + public_physical.opacity().to_bits(), + core_occurrence.subject_opacity_bits() + ); + assert_eq!( + public_physical.backdrop(), + Srgb8::new(core_occurrence.backdrop_rgb()) + ); + assert_eq!( + public_physical.visible(), + Srgb8::new(core_occurrence.output_rgb()) + ); + + let ModeledPointV1::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1(public_modeled) = + public.binding().modeled(); + assert_eq!( + public_modeled.xyz().map(f64::to_bits), + core.modeled_lcs() + .derivation() + .sample() + .xyz() + .map(f64::to_bits) + ); + let public_context = public_modeled.appearance_context(); + let core_context = core.modeled_lcs().occurrence().context(); + assert_eq!( + public_context.adapting_luminance_cd_m2().to_bits(), + core_context.adapting_luminance_cd_m2().to_bits() + ); + assert_eq!( + public_context.background_luminance_ratio_yb_yw().to_bits(), + core_context.background_luminance_ratio().to_bits() + ); + let core_surround = match core_context.surround_profile() { + SurroundProfileId::AverageV1 => SurroundV1::Average, + SurroundProfileId::DimV1 => SurroundV1::Dim, + SurroundProfileId::DarkV1 => SurroundV1::Dark, + }; + assert_eq!(public_context.surround(), core_surround); + + (public_physical.visible(), public_physical.backdrop()) +} + +fn assert_public_assessment_matches_core( + public: AssessmentV1<'_>, + core: &ProgramConstraintResultV1, + expected_occurrence: OccurrenceId, +) { + fn assert_exact_matches( + public: ExactSrgb8EvidenceV1<'_>, + verdict: VerdictV1, + expected: Srgb8, + actual: Srgb8, + binding: &ProgramVisiblePointBindingV1, + expected_occurrence: OccurrenceId, + ) { + assert_eq!(public.verdict(), verdict); + assert_eq!(public.expected(), expected); + let (visible, _) = assert_public_binding_matches_core( + AssessmentV1::ExactSrgb8(public), + binding, + expected_occurrence, + ); + assert_eq!(visible, actual); + } + + fn assert_wcag_matches( + public: Wcag22Srgb8EvidenceV1<'_>, + verdict: VerdictV1, + measurement: &ApplicableWcag22MeasurementV1, + binding: &ProgramVisiblePointBindingV1, + expected_occurrence: OccurrenceId, + ) { + assert_eq!(public.verdict(), verdict); + assert_eq!(public.profile_id(), measurement.profile_id()); + assert_eq!(public.criterion(), measurement.criterion()); + assert_eq!( + public.foreground_luminance(), + measurement.measurement().foreground_luminance + ); + assert_eq!( + public.background_luminance(), + measurement.measurement().background_luminance + ); + assert_eq!(public.numerical_evidence(), measurement.evidence()); + let (visible, backdrop) = assert_public_binding_matches_core( + AssessmentV1::Wcag22Srgb8(public), + binding, + expected_occurrence, + ); + assert_eq!(visible, Srgb8::new(measurement.measurement().foreground)); + assert_eq!(backdrop, Srgb8::new(measurement.measurement().background)); + } + + match (public, core) { + ( + AssessmentV1::ExactSrgb8(public), + ProgramConstraintResultV1::Pass(CoreProgramPassEvidenceV1::ExactSrgb8(core)), + ) => assert_exact_matches( + public, + VerdictV1::Pass, + core.target(), + core.actual(), + core.binding(), + expected_occurrence, + ), + ( + AssessmentV1::ExactSrgb8(public), + ProgramConstraintResultV1::Violation(CoreProgramViolationEvidenceV1::ExactSrgb8(core)), + ) => assert_exact_matches( + public, + VerdictV1::Violation, + core.target(), + core.actual(), + core.binding(), + expected_occurrence, + ), + ( + AssessmentV1::Wcag22Srgb8(public), + ProgramConstraintResultV1::Pass(CoreProgramPassEvidenceV1::Wcag22Srgb8(core)), + ) => assert_wcag_matches( + public, + VerdictV1::Pass, + core.measurement().value(), + core.binding(), + expected_occurrence, + ), + ( + AssessmentV1::Wcag22Srgb8(public), + ProgramConstraintResultV1::Violation(CoreProgramViolationEvidenceV1::Wcag22Srgb8(core)), + ) => assert_wcag_matches( + public, + VerdictV1::Violation, + core.measurement().value(), + core.binding(), + expected_occurrence, + ), + _ => panic!("public assessment family or verdict drifted from Core"), + } +} + +fn assert_verified_cell_matches_core( + public: VerifiedCellV1<'_>, + core: &ProgramConstraintCellV1, + selected_state_index: usize, +) { + assert_eq!(core.candidate_state_index(), selected_state_index); + assert_eq!(public.case_index(), core.case_index()); + assert_eq!(public.constraint().value(), core.constraint().value()); + assert_eq!(public.occurrence().value(), core.target().value()); + assert_eq!( + matches!(public.mode(), ConstraintModeV1::Hard), + core.is_hard() + ); + assert_public_assessment_matches_core(public.assessment(), core.result(), core.target()); +} + +fn assert_conflict_cell_matches_core( + public: ConflictCellV1<'_>, + core: &ProgramConstraintCellV1, +) { + assert_eq!(public.state_index(), core.candidate_state_index()); + assert_eq!(public.case_index(), core.case_index()); + assert_eq!(public.constraint().value(), core.constraint().value()); + assert_eq!(public.occurrence().value(), core.target().value()); + assert_eq!( + matches!(public.mode(), ConstraintModeV1::Hard), + core.is_hard() + ); + assert_public_assessment_matches_core(public.assessment(), core.result(), core.target()); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ProjectionProbe { + iterators: usize, + certificates: usize, + cases: usize, + values: usize, + provenance: usize, + cells: usize, + outputs: usize, + operations: usize, + exact_assessments: usize, + wcag_assessments: usize, + iterator_laws_hold: bool, + checksum: u64, +} + +impl ProjectionProbe { + const fn new() -> Self { + Self { + iterators: 0, + certificates: 0, + cases: 0, + values: 0, + provenance: 0, + cells: 0, + outputs: 0, + operations: 0, + exact_assessments: 0, + wcag_assessments: 0, + iterator_laws_hold: true, + checksum: 0, + } + } + + fn mix(&mut self, value: u64) { + self.checksum = self.checksum.rotate_left(1) ^ value; + } + + fn mix_bytes(&mut self, bytes: &[u8]) { + for byte in bytes { + self.mix(u64::from(*byte)); + } + } + + fn mix_srgb8(&mut self, value: Srgb8) { + self.mix_bytes(&value.bytes()); + } +} + +fn consume_exact_fused( + mut iterator: I, + probe: &mut ProjectionProbe, + mut consume: impl FnMut(I::Item, &mut ProjectionProbe), +) where + I: ExactSizeIterator + FusedIterator, +{ + probe.iterators += 1; + let mut remaining = iterator.len(); + let initial_len = remaining; + probe.iterator_laws_hold &= iterator.size_hint() == (remaining, Some(remaining)); + for _ in 0..initial_len { + let Some(value) = iterator.next() else { + probe.iterator_laws_hold = false; + break; + }; + remaining -= 1; + probe.iterator_laws_hold &= iterator.len() == remaining; + probe.iterator_laws_hold &= iterator.size_hint() == (remaining, Some(remaining)); + consume(value, probe); + } + probe.iterator_laws_hold &= remaining == 0; + probe.iterator_laws_hold &= iterator.len() == 0; + probe.iterator_laws_hold &= iterator.next().is_none(); + probe.iterator_laws_hold &= iterator.next().is_none(); +} + +fn consume_public_assessment(assessment: AssessmentV1<'_>, probe: &mut ProjectionProbe) { + probe.mix(match assessment.verdict() { + VerdictV1::Pass => 1, + VerdictV1::Violation => 2, + }); + match assessment { + AssessmentV1::ExactSrgb8(evidence) => { + probe.exact_assessments += 1; + probe.mix_srgb8(evidence.expected()); + } + AssessmentV1::Wcag22Srgb8(evidence) => { + probe.wcag_assessments += 1; + probe.mix_bytes(evidence.profile_id().key().as_bytes()); + probe.mix_bytes(evidence.criterion().key().as_bytes()); + probe.mix(evidence.foreground_luminance().lower()); + probe.mix(evidence.foreground_luminance().upper()); + probe.mix(evidence.background_luminance().lower()); + probe.mix(evidence.background_luminance().upper()); + probe.mix_bytes(evidence.numerical_evidence().class_key().as_bytes()); + } + } + + let PhysicalPointV1::EncodedSrgb8SourceOver(physical) = assessment.binding().physical(); + probe.mix(u64::from(physical.subject_paint().value())); + probe.mix(u64::from(physical.backdrop_surface().value())); + probe.mix_srgb8(physical.subject()); + probe.mix(physical.opacity().to_bits()); + probe.mix_srgb8(physical.backdrop()); + probe.mix_srgb8(physical.visible()); + + let ModeledPointV1::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1(modeled) = + assessment.binding().modeled(); + for coordinate in modeled.xyz() { + probe.mix(coordinate.to_bits()); + } + let context = modeled.appearance_context(); + probe.mix(context.adapting_luminance_cd_m2().to_bits()); + probe.mix(context.background_luminance_ratio_yb_yw().to_bits()); + probe.mix(match context.surround() { + SurroundV1::Average => 1, + SurroundV1::Dim => 2, + SurroundV1::Dark => 3, + }); +} + +fn consume_public_projection(projection: ProjectionV1<'_, '_>) -> ProjectionProbe { + let view = projection.evidence(); + let mut probe = ProjectionProbe::new(); + probe.mix(match view.kind() { + StateKindV1::Waiting => 1, + StateKindV1::Ready => 2, + StateKindV1::Failed => 3, + StateKindV1::Stale => 4, + }); + match view.observation_head() { + ObservationHeadV1::Empty => probe.mix(0), + ObservationHeadV1::Unknown { + stream, + revision, + reason_id, + } => { + probe.mix(1); + probe.mix(u64::from(stream.value())); + probe.mix(revision); + probe.mix(u64::from(reason_id)); + } + ObservationHeadV1::Observed { stream, revision } => { + probe.mix(2); + probe.mix(u64::from(stream.value())); + probe.mix(revision); + } + } + probe.mix( + view.cause_certificate_index() + .map_or(0, |index| index as u64 + 1), + ); + + consume_exact_fused(view.certificates(), &mut probe, |certificate, probe| { + probe.certificates += 1; + probe.mix_bytes(certificate.content_identity().as_bytes()); + let observation = certificate.observation(); + probe.mix(u64::from(observation.stream().value())); + probe.mix(observation.revision()); + consume_exact_fused(observation.surface_input_ports(), probe, |port, probe| { + probe.mix(u64::from(port.value())); + }); + consume_exact_fused(observation.physical_cases(), probe, |case, probe| { + probe.cases += 1; + consume_exact_fused(case.values(), probe, |value, probe| { + probe.values += 1; + let SignalV1::Iec61966Srgb8D65(value) = value; + probe.mix_srgb8(value); + }); + consume_exact_fused(case.provenance(), probe, |scenario, probe| { + probe.provenance += 1; + probe.mix(u64::from(scenario.value())); + }); + }); + match certificate { + CertificateV1::Verified(verified) => { + probe.mix( + verified + .selected_state_index() + .map_or(0, |index| index as u64 + 1), + ); + consume_exact_fused(verified.cells(), probe, |cell, probe| { + probe.cells += 1; + probe.mix(cell.case_index() as u64); + probe.mix(u64::from(cell.constraint().value())); + probe.mix(u64::from(cell.occurrence().value())); + probe.mix(match cell.mode() { + ConstraintModeV1::Hard => 1, + ConstraintModeV1::ReportOnly => 2, + }); + consume_public_assessment(cell.assessment(), probe); + }); + consume_exact_fused(verified.outputs(), probe, |output, probe| { + probe.outputs += 1; + probe.mix(u64::from(output.output_slot().value())); + probe.mix(u64::from(output.paint().value())); + probe.mix_srgb8(output.source()); + probe.mix(output.opacity().to_bits()); + }); + } + CertificateV1::Conflict(conflict) => { + probe.mix(conflict.considered_state_count() as u64); + consume_exact_fused(conflict.cells(), probe, |cell, probe| { + probe.cells += 1; + probe.mix(cell.state_index() as u64); + probe.mix(cell.case_index() as u64); + probe.mix(u64::from(cell.constraint().value())); + probe.mix(u64::from(cell.occurrence().value())); + probe.mix(match cell.mode() { + ConstraintModeV1::Hard => 1, + ConstraintModeV1::ReportOnly => 2, + }); + consume_public_assessment(cell.assessment(), probe); + }); + } + } + }); + consume_exact_fused(projection.operations(), &mut probe, |operation, probe| { + probe.operations += 1; + match operation { + OperationV1::Set(set) => { + probe.mix(1); + probe.mix(u64::from(set.output_slot().value())); + probe.mix_srgb8(set.source()); + probe.mix(set.opacity().to_bits()); + probe.mix_bytes(set.certificate().content_identity().as_bytes()); + probe.mix(set.certificate().observation().revision()); + } + OperationV1::Remove(remove) => { + probe.mix(2); + probe.mix(u64::from(remove.output_slot().value())); + } + } + }); + std::hint::black_box(probe) +} + +fn assert_observed_head(head: ObservationHeadV1, expected_stream: u32, expected_revision: u64) { + let ObservationHeadV1::Observed { stream, revision } = head else { + panic!("raw evidence must remain a closed Observed payload"); + }; + assert_eq!(stream.value(), expected_stream); + assert_eq!(revision, expected_revision); +} + +fn assert_unknown_head( + head: ObservationHeadV1, + expected_stream: u32, + expected_revision: u64, + expected_reason: u32, +) { + let ObservationHeadV1::Unknown { + stream, + revision, + reason_id, + } = head + else { + panic!("raw evidence must remain a closed Unknown payload"); + }; + assert_eq!(stream.value(), expected_stream); + assert_eq!(revision, expected_revision); + assert_eq!(reason_id, expected_reason); +} + +#[test] +fn one_program_retains_typed_exact_and_wcag22_outcomes() { + let program: CoreProgramV1 = Program::new( + vec![Source::new(SOURCE, signal([0; 3]))], + vec![Target::fixed(TARGET, SOURCE)], + ObservationGroup::new(GROUP, vec![SURFACE_PORT]), + vec![], + vec![Paint::Solid { + id: PAINT, + target: TARGET, + }], + vec![Surface::Input { + id: SURFACE, + input: SURFACE_PORT, + }], + vec![Occurrence::new( + OCCURRENCE, + PAINT, + SURFACE, + CompositionProfile::EncodedSrgb8SourceOverV1, + context(), + )], + ConstraintSet::new( + vec![ + ConstraintInvocation::hard( + EXACT_CONSTRAINT, + OCCURRENCE, + CoreProgramConstraintInvocationV1::ExactSrgb8(Srgb8::new([0; 3])), + ), + ConstraintInvocation::hard( + WCAG_CONSTRAINT, + OCCURRENCE, + CoreProgramConstraintInvocationV1::Wcag22Srgb8( + Wcag22CriterionV1::Sc143TextDefault, + ), + ), + ], + vec![], + ), + vec![OutputBinding::new(OUTPUT, PAINT)], + CoreProgramEvaluatorsV1, + ); + let compiled = program.compile().unwrap(); + + let mut session = compiled.instantiate(STREAM).unwrap(); + let SessionState::Ready { current } = session.update(observed_white()).unwrap() else { + panic!("opaque black on white must satisfy both authored hard constraints"); + }; + let [exact, wcag] = current.report().cells() else { + panic!("one case times two heterogeneous constraints must produce two cells"); + }; + + let ProgramConstraintResultV1::Pass(CoreProgramPassEvidenceV1::ExactSrgb8(evidence)) = + exact.result() + else { + panic!("the first cell must retain Exact-specific pass evidence"); + }; + assert_eq!( + evidence.identity(), + &ExactConstraintIdentityV1::FinalSrgb8IdentityV1, + ); + assert_eq!(evidence.release(), &ExactIdentityReleaseV1::V1); + assert_eq!( + evidence.capability(), + &ExactIdentityCapabilityV1::FinalOccurrenceSrgb8IdentityV1, + ); + assert_eq!( + evidence.binding().modeled_lcs(), + exact.modeled_lcs_occurrence(), + ); + + let ProgramConstraintResultV1::Pass(CoreProgramPassEvidenceV1::Wcag22Srgb8(evidence)) = + wcag.result() + else { + panic!("the second cell must retain WCAG22-specific pass evidence"); + }; + assert_eq!(evidence.release(), &wcag22_profile_v1().profile_id); + assert_eq!( + evidence.binding().modeled_lcs(), + wcag.modeled_lcs_occurrence(), + ); + assert_ne!( + core::any::type_name_of_val(evidence.identity()), + core::any::type_name_of_val(exact.result()), + ); +} + +#[test] +fn fixed_public_certificate_retains_none_selection_and_nonunit_output_opacity() { + let owner = OwnerV1::from_compiled(fixed_translucent_program()); + let mut session = owner.instantiate(STREAM.value()).unwrap(); + let white = [Srgb8::new([0xFF; 3])]; + let scenarios = [ScenarioV1::new(1, &white)]; + let projection = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + let Some(CertificateV1::Verified(certificate)) = projection.evidence().certificates().next() + else { + panic!("the exact translucent midpoint must be verified"); + }; + assert_eq!(certificate.selected_state_index(), None); + let AssessmentV1::ExactSrgb8(assessment) = certificate.cells().next().unwrap().assessment() + else { + panic!("the fixed Program has one Exact certificate cell"); + }; + let PhysicalPointV1::EncodedSrgb8SourceOver(physical) = assessment.binding().physical(); + assert_eq!(physical.opacity().to_bits(), 0.5_f64.to_bits()); + assert_eq!(physical.visible(), Srgb8::new([0x80; 3])); + assert_eq!( + certificate.outputs().next().unwrap().opacity().to_bits(), + physical.opacity().to_bits() + ); + let Some(OperationV1::Set(set)) = projection.operations().next() else { + panic!("Verified must emit one Set"); + }; + assert_eq!(set.opacity().to_bits(), physical.opacity().to_bits()); +} + +#[test] +fn mixed_families_select_only_a_state_that_passes_every_case_then_recheck_it() { + let compiled = finite_program([[0x80; 3], [0; 3]]); + let mut session = compiled.instantiate(STREAM).unwrap(); + let SessionState::Ready { current } = session + .update(observed_backdrops(&[[0xFF; 3], [0x80; 3]])) + .unwrap() + else { + panic!("the later black state must pass WCAG22 over both physical cases"); + }; + + assert_eq!(current.selected_state_index(), Some(1)); + let cells = current.report().cells(); + assert_eq!(cells.len(), 4); + assert_eq!( + cells + .iter() + .map(|cell| (cell.case_index(), cell.constraint(), cell.is_hard())) + .collect::>(), + vec![ + (0, EXACT_CONSTRAINT, false), + (0, WCAG_CONSTRAINT, true), + (1, EXACT_CONSTRAINT, false), + (1, WCAG_CONSTRAINT, true), + ], + ); + for cell in [cells[0].result(), cells[2].result()] { + assert!(matches!( + cell, + ProgramConstraintResultV1::Pass(CoreProgramPassEvidenceV1::ExactSrgb8(_)) + )); + } + for cell in [cells[1].result(), cells[3].result()] { + assert!(matches!( + cell, + ProgramConstraintResultV1::Pass(CoreProgramPassEvidenceV1::Wcag22Srgb8(_)) + )); + } +} + +#[test] +fn mixed_family_conflict_is_exhaustive_and_keeps_report_only_non_gating() { + let compiled = finite_program([[0x80; 3], [0xFF; 3]]); + let mut session = compiled.instantiate(STREAM).unwrap(); + let SessionState::Failed { cause, previous } = session.update(observed_white()).unwrap() else { + panic!("neither gray nor white satisfies default text contrast on white"); + }; + assert!(previous.is_none()); + assert_eq!(cause.considered_state_count(), 2); + let cells = cause.report().cells(); + assert_eq!(cells.len(), 4); + assert_eq!( + cells + .iter() + .map(|cell| ( + cell.candidate_state_index(), + cell.constraint(), + cell.is_hard() + )) + .collect::>(), + vec![ + (0, EXACT_CONSTRAINT, false), + (0, WCAG_CONSTRAINT, true), + (1, EXACT_CONSTRAINT, false), + (1, WCAG_CONSTRAINT, true), + ], + ); + assert!(cells.iter().all(|cell| cell.result().is_violation())); + assert!(matches!( + cells[0].result(), + ProgramConstraintResultV1::Violation(CoreProgramViolationEvidenceV1::ExactSrgb8(_)) + )); + assert!(matches!( + cells[1].result(), + ProgramConstraintResultV1::Violation(CoreProgramViolationEvidenceV1::Wcag22Srgb8(_)) + )); +} + +#[test] +fn public_projection_preserves_every_exposed_ready_and_conflict_field_against_core() { + let ready_core_owner = finite_program([[0x80; 3], [0; 3]]); + let ready_identity = ready_core_owner.content_identity(); + let ready_public_owner = OwnerV1::from_compiled(finite_program([[0x80; 3], [0; 3]])); + let mut ready_core_session = ready_core_owner.instantiate(STREAM).unwrap(); + let mut ready_public_session = ready_public_owner.instantiate(STREAM.value()).unwrap(); + let ready_backdrops = [[0xFF; 3], [0xFF; 3], [0x80; 3]]; + let SessionState::Ready { + current: core_verified, + } = ready_core_session + .update(observed_backdrops(&ready_backdrops)) + .unwrap() + else { + panic!("black is the first state that passes both canonical physical cases"); + }; + let ready_white = [Srgb8::new([0xFF; 3])]; + let ready_gray = [Srgb8::new([0x80; 3])]; + let ready_scenarios = [ + ScenarioV1::new(1, &ready_white), + ScenarioV1::new(2, &ready_white), + ScenarioV1::new(3, &ready_gray), + ]; + let public_ready = ready_public_owner + .update( + &mut ready_public_session, + UpdateV1::Observed { + revision: 1, + scenarios: &ready_scenarios, + }, + ) + .unwrap(); + let mut public_certificates = public_ready.evidence().certificates(); + assert_eq!(public_certificates.len(), 1); + let Some(CertificateV1::Verified(public_verified)) = public_certificates.next() else { + panic!("Ready must retain exactly one Verified certificate"); + }; + assert!(public_certificates.next().is_none()); + assert!(public_certificates.next().is_none()); + assert_eq!( + public_verified.content_identity().as_bytes(), + ready_identity.as_bytes() + ); + assert_public_observation_matches_core( + public_verified.observation(), + core_verified.report().observation(), + ); + assert_eq!( + public_verified.selected_state_index(), + core_verified.selected_state_index() + ); + let selected_state_index = core_verified.selected_state_index().unwrap(); + let mut public_cells = public_verified.cells(); + let mut core_cells = core_verified.report().cells().iter(); + assert_eq!(public_cells.len(), core_cells.len()); + while let (Some(public), Some(core)) = (public_cells.next(), core_cells.next()) { + assert_verified_cell_matches_core(public, core, selected_state_index); + assert_eq!(public_cells.len(), core_cells.len()); + } + assert!(public_cells.next().is_none()); + assert!(public_cells.next().is_none()); + assert!(core_cells.next().is_none()); + + let mut public_outputs = public_verified.outputs(); + let mut core_outputs = core_verified.outputs().iter(); + assert_eq!(public_outputs.len(), core_outputs.len()); + while let (Some(public), Some(core)) = (public_outputs.next(), core_outputs.next()) { + assert_eq!(public.output_slot().value(), core.output().value()); + assert_eq!(public.paint().value(), core.paint().id().value()); + assert_eq!(public.source(), core.paint().source()); + assert_eq!( + public.opacity().to_bits(), + core.paint().opacity().value().to_bits() + ); + assert_eq!(public_outputs.len(), core_outputs.len()); + } + assert!(public_outputs.next().is_none()); + assert!(public_outputs.next().is_none()); + assert!(core_outputs.next().is_none()); + let mut ready_operations = public_ready.operations(); + assert_eq!(ready_operations.len(), core_verified.outputs().len()); + for core_output in core_verified.outputs() { + let Some(OperationV1::Set(set)) = ready_operations.next() else { + panic!("every certified output must become exactly one Set"); + }; + assert_eq!(set.output_slot().value(), core_output.output().value()); + assert_eq!(set.source(), core_output.paint().source()); + assert_eq!( + set.opacity().to_bits(), + core_output.paint().opacity().value().to_bits() + ); + assert_eq!( + set.certificate().content_identity(), + public_verified.content_identity() + ); + assert_eq!( + set.certificate().observation().revision(), + public_verified.observation().revision() + ); + } + assert!(ready_operations.next().is_none()); + assert!(ready_operations.next().is_none()); + + let conflict_core_owner = finite_program([[0; 3], [0xFF; 3]]); + let conflict_identity = conflict_core_owner.content_identity(); + let conflict_public_owner = OwnerV1::from_compiled(finite_program([[0; 3], [0xFF; 3]])); + let mut conflict_core_session = conflict_core_owner.instantiate(STREAM).unwrap(); + let mut conflict_public_session = conflict_public_owner.instantiate(STREAM.value()).unwrap(); + let conflict_backdrops = [[0xFF; 3], [0; 3]]; + let SessionState::Failed { + cause: core_conflict, + previous: None, + } = conflict_core_session + .update(observed_backdrops(&conflict_backdrops)) + .unwrap() + else { + panic!("neither black nor white passes both opposing physical cases"); + }; + let conflict_white = [Srgb8::new([0xFF; 3])]; + let conflict_black = [Srgb8::new([0; 3])]; + let conflict_scenarios = [ + ScenarioV1::new(1, &conflict_white), + ScenarioV1::new(2, &conflict_black), + ]; + let public_failed = conflict_public_owner + .update( + &mut conflict_public_session, + UpdateV1::Observed { + revision: 1, + scenarios: &conflict_scenarios, + }, + ) + .unwrap(); + let mut public_certificates = public_failed.evidence().certificates(); + assert_eq!(public_certificates.len(), 1); + let Some(CertificateV1::Conflict(public_conflict)) = public_certificates.next() else { + panic!("Failed without previous state must retain one Conflict certificate"); + }; + assert!(public_certificates.next().is_none()); + assert!(public_certificates.next().is_none()); + assert_eq!( + public_conflict.content_identity().as_bytes(), + conflict_identity.as_bytes() + ); + assert_public_observation_matches_core( + public_conflict.observation(), + core_conflict.report().observation(), + ); + assert_eq!( + public_conflict.considered_state_count(), + core_conflict.considered_state_count() + ); + let core_passes = core_conflict + .report() + .cells() + .iter() + .filter(|cell| !cell.result().is_violation()) + .count(); + assert!(core_passes > 0); + assert!(core_passes < core_conflict.report().cells().len()); + let mut public_cells = public_conflict.cells(); + let mut core_cells = core_conflict.report().cells().iter(); + assert_eq!(public_cells.len(), core_cells.len()); + while let (Some(public), Some(core)) = (public_cells.next(), core_cells.next()) { + assert_conflict_cell_matches_core(public, core); + assert_eq!(public_cells.len(), core_cells.len()); + } + assert!(public_cells.next().is_none()); + assert!(public_cells.next().is_none()); + assert!(core_cells.next().is_none()); + let mut failed_operations = public_failed.operations(); + assert_eq!(failed_operations.len(), 1); + assert!(matches!( + failed_operations.next(), + Some(OperationV1::Remove(_)) + )); + assert!(failed_operations.next().is_none()); + assert!(failed_operations.next().is_none()); +} + +#[test] +fn committed_projection_is_zero_alloc_and_repeats_no_composite_transform_or_evaluator_dispatch() { + crate::composition::reset_source_over_evaluation_count(); + MODELED_TRISTIMULUS_DERIVATION_CALLS.with(|calls| calls.set(0)); + CORE_PROGRAM_ASSESSMENT_CALLS.with(|calls| calls.set(0)); + + let owner = OwnerV1::from_compiled(finite_program([[0; 3], [0xFF; 3]])); + let mut session = owner.instantiate(STREAM.value()).unwrap(); + let white = [Srgb8::new([0xFF; 3])]; + let white_only = [ScenarioV1::new(1, &white)]; + owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &white_only, + }, + ) + .unwrap(); + let Some(CertificateV1::Verified(ready_certificate)) = session.evidence().certificates().next() + else { + panic!("black must be selected for the white-only physical support"); + }; + assert_eq!(ready_certificate.selected_state_index(), Some(0)); + + let ready_compositions = crate::composition::source_over_evaluation_count(); + let ready_derivations = MODELED_TRISTIMULUS_DERIVATION_CALLS.with(core::cell::Cell::get); + let ready_assessments = CORE_PROGRAM_ASSESSMENT_CALLS.with(core::cell::Cell::get); + assert!(ready_compositions > 0); + assert!(ready_derivations > 0); + assert!(ready_assessments > 0); + let (ready_probe, ready_allocations) = crate::test_support::measured_allocations(|| { + consume_public_projection(std::hint::black_box(owner.project(&session).unwrap())) + }); + assert_eq!(ready_allocations, 0); + assert!(ready_probe.iterator_laws_hold); + assert_eq!(ready_probe.certificates, 1); + assert_eq!(ready_probe.cases, 1); + assert_eq!(ready_probe.values, 1); + assert_eq!(ready_probe.provenance, 1); + assert_eq!(ready_probe.cells, 2); + assert_eq!(ready_probe.outputs, 1); + assert_eq!(ready_probe.operations, 1); + assert_eq!(ready_probe.exact_assessments, 1); + assert_eq!(ready_probe.wcag_assessments, 1); + assert_ne!(ready_probe.checksum, 0); + assert_eq!( + crate::composition::source_over_evaluation_count(), + ready_compositions + ); + assert_eq!( + MODELED_TRISTIMULUS_DERIVATION_CALLS.with(core::cell::Cell::get), + ready_derivations + ); + assert_eq!( + CORE_PROGRAM_ASSESSMENT_CALLS.with(core::cell::Cell::get), + ready_assessments + ); + + owner + .update( + &mut session, + UpdateV1::Unknown { + revision: 2, + reason_id: 7, + }, + ) + .unwrap(); + let stale_compositions = crate::composition::source_over_evaluation_count(); + let stale_derivations = MODELED_TRISTIMULUS_DERIVATION_CALLS.with(core::cell::Cell::get); + let stale_assessments = CORE_PROGRAM_ASSESSMENT_CALLS.with(core::cell::Cell::get); + let (stale_probe, stale_allocations) = crate::test_support::measured_allocations(|| { + consume_public_projection(std::hint::black_box(owner.project(&session).unwrap())) + }); + assert_eq!(stale_allocations, 0); + assert!(stale_probe.iterator_laws_hold); + assert_eq!(stale_probe.certificates, 1); + assert_eq!(stale_probe.cells, 2); + assert_eq!(stale_probe.outputs, 1); + assert_eq!(stale_probe.operations, 1); + assert_ne!(stale_probe.checksum, ready_probe.checksum); + assert_eq!( + crate::composition::source_over_evaluation_count(), + stale_compositions + ); + assert_eq!( + MODELED_TRISTIMULUS_DERIVATION_CALLS.with(core::cell::Cell::get), + stale_derivations + ); + assert_eq!( + CORE_PROGRAM_ASSESSMENT_CALLS.with(core::cell::Cell::get), + stale_assessments + ); + + let black = [Srgb8::new([0; 3])]; + let opposing_backdrops = [ScenarioV1::new(1, &white), ScenarioV1::new(2, &black)]; + owner + .update( + &mut session, + UpdateV1::Observed { + revision: 3, + scenarios: &opposing_backdrops, + }, + ) + .unwrap(); + let failed_compositions = crate::composition::source_over_evaluation_count(); + let failed_derivations = MODELED_TRISTIMULUS_DERIVATION_CALLS.with(core::cell::Cell::get); + let failed_assessments = CORE_PROGRAM_ASSESSMENT_CALLS.with(core::cell::Cell::get); + let (failed_probe, failed_allocations) = crate::test_support::measured_allocations(|| { + consume_public_projection(std::hint::black_box(owner.project(&session).unwrap())) + }); + assert_eq!(failed_allocations, 0); + assert!(failed_probe.iterator_laws_hold); + assert_eq!(failed_probe.certificates, 2); + assert_eq!(failed_probe.cases, 3); + assert_eq!(failed_probe.values, 3); + assert_eq!(failed_probe.provenance, 3); + assert_eq!(failed_probe.cells, 10); + assert_eq!(failed_probe.outputs, 1); + assert_eq!(failed_probe.operations, 1); + assert_eq!(failed_probe.exact_assessments, 5); + assert_eq!(failed_probe.wcag_assessments, 5); + assert_ne!(failed_probe.checksum, stale_probe.checksum); + assert_eq!( + crate::composition::source_over_evaluation_count(), + failed_compositions + ); + assert_eq!( + MODELED_TRISTIMULUS_DERIVATION_CALLS.with(core::cell::Cell::get), + failed_derivations + ); + assert_eq!( + CORE_PROGRAM_ASSESSMENT_CALLS.with(core::cell::Cell::get), + failed_assessments + ); +} + +#[test] +fn observation_projection_is_invariant_under_every_scenario_permutation_and_keeps_provenance() { + const PERMUTATIONS: [[usize; 3]; 6] = [ + [0, 1, 2], + [0, 2, 1], + [1, 0, 2], + [1, 2, 0], + [2, 0, 1], + [2, 1, 0], + ]; + const IDS: [u32; 3] = [9, 4, 3]; + const BACKDROPS: [[u8; 3]; 3] = [[0xFF; 3], [0x80; 3], [0xFF; 3]]; + + crate::composition::reset_source_over_evaluation_count(); + MODELED_TRISTIMULUS_DERIVATION_CALLS.with(|calls| calls.set(0)); + CORE_PROGRAM_ASSESSMENT_CALLS.with(|calls| calls.set(0)); + + let core_owner = finite_program([[0x80; 3], [0; 3]]); + let content_identity = core_owner.content_identity(); + let public_owner = OwnerV1::from_compiled(finite_program([[0x80; 3], [0; 3]])); + let mut core_session = core_owner.instantiate(STREAM).unwrap(); + let mut public_session = public_owner.instantiate(STREAM.value()).unwrap(); + let public_values = BACKDROPS.map(|value| [Srgb8::new(value)]); + let mut first_public_backing = None; + let mut evaluation_counts_after_first = None; + + for permutation in PERMUTATIONS { + let core_update = ObservationUpdateInput { + stream: STREAM, + revision: Revision::new(1), + payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { + scenarios: permutation + .iter() + .map(|index| ScenarioInput { + id: ScenarioId::new(IDS[*index]), + bindings: vec![SurfaceInputBinding::new( + SURFACE_PORT, + signal(BACKDROPS[*index]), + )], + }) + .collect(), + }), + }; + let SessionState::Ready { + current: core_verified, + } = core_session.update(core_update).unwrap() + else { + panic!("black must pass both deduplicated physical cases"); + }; + let public_scenarios = + permutation.map(|index| ScenarioV1::new(IDS[index], &public_values[index])); + let public_state = public_owner + .update( + &mut public_session, + UpdateV1::Observed { + revision: 1, + scenarios: &public_scenarios, + }, + ) + .unwrap(); + let Some(CertificateV1::Verified(public_verified)) = + public_state.evidence().certificates().next() + else { + panic!("the canonical observation must keep one Verified certificate"); + }; + assert_eq!( + public_verified.content_identity().as_bytes(), + content_identity.as_bytes() + ); + assert_public_observation_matches_core( + public_verified.observation(), + core_verified.report().observation(), + ); + + let projected_cases = public_verified + .observation() + .physical_cases() + .map(|case| { + let values = case + .values() + .map(|value| match value { + SignalV1::Iec61966Srgb8D65(value) => value, + }) + .collect::>(); + let provenance = case + .provenance() + .map(|scenario| scenario.value()) + .collect::>(); + (values, provenance) + }) + .collect::>(); + assert_eq!( + projected_cases, + [ + (vec![Srgb8::new([0x80; 3])], vec![4]), + (vec![Srgb8::new([0xFF; 3])], vec![3, 9]), + ] + ); + + let backing = CertificateV1::Verified(public_verified).observation_backing_ptr_for_test(); + match first_public_backing { + None => { + first_public_backing = Some(backing); + evaluation_counts_after_first = Some(( + crate::composition::source_over_evaluation_count(), + MODELED_TRISTIMULUS_DERIVATION_CALLS.with(core::cell::Cell::get), + CORE_PROGRAM_ASSESSMENT_CALLS.with(core::cell::Cell::get), + )); + } + Some(first) => { + assert_eq!(backing, first); + assert_eq!( + evaluation_counts_after_first, + Some(( + crate::composition::source_over_evaluation_count(), + MODELED_TRISTIMULUS_DERIVATION_CALLS.with(core::cell::Cell::get), + CORE_PROGRAM_ASSESSMENT_CALLS.with(core::cell::Cell::get), + )) + ); + } + } + } +} + +#[test] +fn concrete_program_projects_ready_and_fail_closed_stale_operations() { + let owner = OwnerV1::from_compiled(finite_program([[0x80; 3], [0; 3]])); + assert_eq!(owner.surface_input_port_count(), 1); + assert_eq!( + owner.output_slots().collect::>(), + [OutputSlotIdV1::new(OUTPUT.value())] + ); + + let mut session = owner.instantiate(11).unwrap(); + let initial = session.evidence(); + assert_eq!(initial.kind(), StateKindV1::Waiting); + assert_eq!(initial.observation_head(), ObservationHeadV1::Empty); + assert_eq!(initial.certificates().len(), 0); + assert_eq!(owner.project(&session).unwrap().operations().len(), 0); + + let white = [Srgb8::new([0xFF; 3])]; + let gray = [Srgb8::new([0x80; 3])]; + let scenarios = [ScenarioV1::new(2, &gray), ScenarioV1::new(1, &white)]; + let ready = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + let ready_evidence = ready.evidence(); + assert_eq!(ready_evidence.kind(), StateKindV1::Ready); + assert_observed_head(ready_evidence.observation_head(), STREAM.value(), 1); + assert_eq!(ready_evidence.cause_certificate_index(), None); + let certificates = ready_evidence.certificates().collect::>(); + assert_eq!(certificates.len(), 1); + assert!(matches!(certificates[0], CertificateV1::Verified(_))); + assert_eq!(certificates[0].observation().revision(), 1); + let ready_backing = certificates[0].observation_backing_ptr_for_test(); + let mut operations = ready.operations(); + let Some(OperationV1::Set(set)) = operations.next() else { + panic!("Ready must emit one Set operation"); + }; + assert_eq!(set.output_slot(), OutputSlotIdV1::new(OUTPUT.value())); + assert_eq!(set.source(), Srgb8::new([0; 3])); + assert_eq!(set.opacity(), 1.0); + assert_eq!( + set.certificate().observation().revision(), + certificates[0].observation().revision() + ); + assert_eq!( + set.certificate().content_identity(), + certificates[0].content_identity() + ); + assert_eq!( + CertificateV1::Verified(set.certificate()).observation_backing_ptr_for_test(), + ready_backing + ); + assert!(operations.next().is_none()); + drop(operations); + drop(certificates); + + let reordered = [ScenarioV1::new(1, &white), ScenarioV1::new(2, &gray)]; + let replay = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &reordered, + }, + ) + .unwrap(); + let replay_certificate = replay.evidence().certificates().next().unwrap(); + assert_eq!( + replay_certificate.observation_backing_ptr_for_test(), + ready_backing, + "scenario permutation at the same revision must be exact idempotence" + ); + + let changed_same_revision = [ScenarioV1::new(1, &white)]; + let error = match owner.update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &changed_same_revision, + }, + ) { + Ok(_) => panic!("changed payload at the same revision must be rejected"), + Err(error) => error, + }; + assert_eq!(error.kind(), UpdateErrorKindV1::RevisionConflict); + assert_eq!(session.evidence().kind(), StateKindV1::Ready); + assert_observed_head(session.evidence().observation_head(), STREAM.value(), 1); + + let stale = owner + .update( + &mut session, + UpdateV1::Unknown { + revision: 2, + reason_id: 7, + }, + ) + .unwrap(); + let stale_evidence = stale.evidence(); + assert_eq!(stale_evidence.kind(), StateKindV1::Stale); + assert_unknown_head(stale_evidence.observation_head(), STREAM.value(), 2, 7); + let certificates = stale_evidence.certificates().collect::>(); + assert_eq!(certificates.len(), 1); + assert!(matches!(certificates[0], CertificateV1::Verified(_))); + assert_eq!(certificates[0].observation().revision(), 1); + assert_eq!( + certificates[0].observation_backing_ptr_for_test(), + ready_backing + ); + let mut operations = stale.operations(); + let Some(OperationV1::Remove(remove)) = operations.next() else { + panic!("Stale must remove an output that lacks current evidence"); + }; + assert_eq!(remove.output_slot(), OutputSlotIdV1::new(OUTPUT.value())); + assert!(operations.next().is_none()); +} + +#[test] +fn unknown_replacement_session_revokes_an_existing_owner_output() { + let owner = OwnerV1::from_compiled(finite_program([[0x80; 3], [0; 3]])); + let white = [Srgb8::new([0xFF; 3])]; + let scenarios = [ScenarioV1::new(1, &white)]; + + let mut first_session = owner.instantiate(11).unwrap(); + let ready = owner + .update( + &mut first_session, + UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + let mut sink = None; + for operation in ready.operations() { + match operation { + OperationV1::Set(set) => sink = Some((set.source(), set.opacity())), + OperationV1::Remove(_) => sink = None, + } + } + assert!( + sink.is_some(), + "the first Session must populate the shared sink" + ); + drop(first_session); + + let mut replacement_session = owner.instantiate(12).unwrap(); + let unknown = owner + .update( + &mut replacement_session, + UpdateV1::Unknown { + revision: 1, + reason_id: 7, + }, + ) + .unwrap(); + assert_eq!(unknown.evidence().kind(), StateKindV1::Waiting); + assert_unknown_head(unknown.evidence().observation_head(), 12, 1, 7); + assert_eq!(unknown.evidence().certificates().len(), 0); + + let mut operations = unknown.operations(); + let Some(OperationV1::Remove(remove)) = operations.next() else { + panic!("an explicit Unknown must revoke an existing owner output during handoff"); + }; + assert_eq!(remove.output_slot(), OutputSlotIdV1::new(OUTPUT.value())); + sink = None; + assert!(operations.next().is_none()); + assert!( + sink.is_none(), + "the replacement Session must not leave stale paint" + ); +} + +#[test] +fn concrete_program_failed_always_removes_but_retains_previous_evidence() { + let white = [Srgb8::new([0xFF; 3])]; + let black = [Srgb8::new([0; 3])]; + let white_only = [ScenarioV1::new(1, &white)]; + + let owner = OwnerV1::from_compiled(finite_program([[0x80; 3], [0xFF; 3]])); + let mut session = owner.instantiate(11).unwrap(); + let failed = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &white_only, + }, + ) + .unwrap(); + let failed_evidence = failed.evidence(); + assert_eq!(failed_evidence.kind(), StateKindV1::Failed); + assert_eq!(failed_evidence.cause_certificate_index(), Some(0)); + let certificates = failed_evidence.certificates().collect::>(); + assert_eq!(certificates.len(), 1); + assert!(matches!(certificates[0], CertificateV1::Conflict(_))); + assert_eq!(certificates[0].observation().revision(), 1); + let mut operations = failed.operations(); + let Some(OperationV1::Remove(remove)) = operations.next() else { + panic!("Failed without previous evidence must emit one Remove operation"); + }; + assert_eq!(remove.output_slot(), OutputSlotIdV1::new(OUTPUT.value())); + assert!(operations.next().is_none()); + + let owner = OwnerV1::from_compiled(finite_program([[0; 3], [0xFF; 3]])); + let mut session = owner.instantiate(12).unwrap(); + let previous = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &white_only, + }, + ) + .unwrap(); + let previous_backing = previous + .evidence() + .certificates() + .next() + .unwrap() + .observation_backing_ptr_for_test(); + let both = [ScenarioV1::new(1, &white), ScenarioV1::new(2, &black)]; + let failed = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 2, + scenarios: &both, + }, + ) + .unwrap(); + let failed_evidence = failed.evidence(); + assert_eq!(failed_evidence.kind(), StateKindV1::Failed); + assert_eq!(failed_evidence.cause_certificate_index(), Some(0)); + let certificates = failed_evidence.certificates().collect::>(); + assert_eq!( + certificates + .iter() + .map(|certificate| match certificate { + CertificateV1::Verified(value) => { + ("verified", value.observation().revision()) + } + CertificateV1::Conflict(value) => { + ("conflict", value.observation().revision()) + } + }) + .collect::>(), + [("conflict", 2), ("verified", 1)] + ); + assert_eq!( + certificates[1].observation_backing_ptr_for_test(), + previous_backing + ); + let mut operations = failed.operations(); + let Some(OperationV1::Remove(remove)) = operations.next() else { + panic!("Failed must remove an output that violates the current context"); + }; + assert_eq!(remove.output_slot(), OutputSlotIdV1::new(OUTPUT.value())); + assert!(operations.next().is_none()); +} + +#[test] +fn concrete_program_rejects_transport_shape_before_core_admission() { + let owner = OwnerV1::from_compiled(finite_program([[0x80; 3], [0; 3]])); + let mut session = owner.instantiate(11).unwrap(); + let empty_values = []; + let malformed = [ScenarioV1::new(1, &empty_values)]; + let error = match owner.update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &malformed, + }, + ) { + Ok(_) => panic!("schema-short public input must fail before Core admission"), + Err(error) => error, + }; + assert_eq!(error.kind(), UpdateErrorKindV1::InvalidObservation); + assert_eq!(session.evidence().kind(), StateKindV1::Waiting); + assert_eq!( + session.evidence().observation_head(), + ObservationHeadV1::Empty + ); + + let white = [Srgb8::new([0xFF; 3])]; + let valid = [ScenarioV1::new(1, &white)]; + owner + .update( + &mut session, + UpdateV1::Observed { + revision: 2, + scenarios: &valid, + }, + ) + .unwrap(); + let duplicate = [ScenarioV1::new(7, &white), ScenarioV1::new(7, &white)]; + let error = match owner.update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &duplicate, + }, + ) { + Ok(_) => panic!("duplicate scenario IDs must precede revision admission"), + Err(error) => error, + }; + assert_eq!(error.kind(), UpdateErrorKindV1::InvalidObservation); + assert_eq!(session.evidence().kind(), StateKindV1::Ready); + assert_observed_head(session.evidence().observation_head(), 11, 2); +} + +#[test] +fn same_content_foreign_owner_is_rejected_before_admission_without_work() { + crate::composition::reset_source_over_evaluation_count(); + MODELED_TRISTIMULUS_DERIVATION_CALLS.with(|calls| calls.set(0)); + CORE_PROGRAM_ASSESSMENT_CALLS.with(|calls| calls.set(0)); + + let compiled_a = finite_program([[0x80; 3], [0; 3]]); + let compiled_b = finite_program([[0x80; 3], [0; 3]]); + assert_eq!(compiled_a.content_identity(), compiled_b.content_identity()); + let owner_a = OwnerV1::from_compiled(compiled_a); + let owner_b = OwnerV1::from_compiled(compiled_b); + let mut session = owner_a.instantiate(STREAM.value()).unwrap(); + let white = [Srgb8::new([0xFF; 3])]; + let observed = [ScenarioV1::new(1, &white)]; + + owner_a + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &observed, + }, + ) + .unwrap(); + let before = session.evidence(); + assert_observed_head(before.observation_head(), STREAM.value(), 1); + let before_backing = before + .certificates() + .next() + .unwrap() + .observation_backing_ptr_for_test(); + let counts = ( + crate::composition::source_over_evaluation_count(), + MODELED_TRISTIMULUS_DERIVATION_CALLS.with(core::cell::Cell::get), + CORE_PROGRAM_ASSESSMENT_CALLS.with(core::cell::Cell::get), + ); + + let (project_mismatch, project_allocations) = crate::test_support::measured_allocations(|| { + matches!( + owner_b.project(std::hint::black_box(&session)), + Err(AccessErrorV1::OwnerMismatch) + ) + }); + assert!(project_mismatch); + assert_eq!(project_allocations, 0); + + let empty = []; + let malformed = [ScenarioV1::new(2, &empty)]; + let (update_mismatch, update_allocations) = crate::test_support::measured_allocations(|| { + matches!( + owner_b.update( + std::hint::black_box(&mut session), + UpdateV1::Observed { + revision: 2, + scenarios: &malformed, + }, + ), + Err(error) if error.kind() == UpdateErrorKindV1::OwnerMismatch + ) + }); + assert!(update_mismatch); + assert_eq!(update_allocations, 0); + assert_eq!( + ( + crate::composition::source_over_evaluation_count(), + MODELED_TRISTIMULUS_DERIVATION_CALLS.with(core::cell::Cell::get), + CORE_PROGRAM_ASSESSMENT_CALLS.with(core::cell::Cell::get), + ), + counts + ); + + let after = session.evidence(); + assert_eq!(after.kind(), StateKindV1::Ready); + assert_observed_head(after.observation_head(), STREAM.value(), 1); + assert_eq!( + after + .certificates() + .next() + .unwrap() + .observation_backing_ptr_for_test(), + before_backing + ); + assert!(owner_a.project(&session).is_ok()); +} + +#[test] +fn raw_observation_head_preserves_unknown_reason_independently_of_lifecycle() { + let owner = OwnerV1::from_compiled(finite_program([[0x80; 3], [0; 3]])); + let mut session = owner.instantiate(STREAM.value()).unwrap(); + assert_eq!( + session.evidence().observation_head(), + ObservationHeadV1::Empty + ); + + owner + .update( + &mut session, + UpdateV1::Unknown { + revision: 1, + reason_id: u32::MAX, + }, + ) + .unwrap(); + let unknown = session.evidence(); + assert_eq!(unknown.kind(), StateKindV1::Waiting); + let ObservationHeadV1::Unknown { + stream, + revision, + reason_id, + } = unknown.observation_head() + else { + panic!("Unknown raw evidence must remain a closed Unknown payload"); + }; + assert_eq!(stream.value(), STREAM.value()); + assert_eq!(revision, 1); + assert_eq!(reason_id, u32::MAX); + + let mut other_session = owner.instantiate(29).unwrap(); + owner + .update( + &mut other_session, + UpdateV1::Unknown { + revision: 1, + reason_id: u32::MAX, + }, + ) + .unwrap(); + assert_ne!( + unknown.observation_head(), + other_session.evidence().observation_head(), + "equal revision and reason on different streams are distinct raw evidence" + ); + + let conflicting = match owner.update( + &mut session, + UpdateV1::Unknown { + revision: 1, + reason_id: 0, + }, + ) { + Ok(_) => panic!("same revision with another reason must conflict"), + Err(error) => error, + }; + assert_eq!(conflicting.kind(), UpdateErrorKindV1::RevisionConflict); + assert_unknown_head( + session.evidence().observation_head(), + STREAM.value(), + 1, + u32::MAX, + ); +} + +#[test] +fn copied_raw_heads_outlive_owner_and_session_without_losing_provenance() { + let (unknown, observed) = { + let owner = OwnerV1::from_compiled(finite_program([[0x80; 3], [0; 3]])); + let mut session = owner.instantiate(u32::MAX).unwrap(); + owner + .update( + &mut session, + UpdateV1::Unknown { + revision: 1, + reason_id: u32::MAX, + }, + ) + .unwrap(); + let unknown = session.evidence().observation_head(); + + let white = [Srgb8::new([0xFF; 3])]; + let scenarios = [ScenarioV1::new(1, &white)]; + owner + .update( + &mut session, + UpdateV1::Observed { + revision: u64::MAX, + scenarios: &scenarios, + }, + ) + .unwrap(); + let observed = session.evidence().observation_head(); + (unknown, observed) + }; + + assert_unknown_head(unknown, u32::MAX, 1, u32::MAX); + assert_observed_head(observed, u32::MAX, u64::MAX); +} + +#[test] +fn expired_owner_preserves_historical_evidence_but_equivalent_owner_has_no_authority() { + let compiled_a = finite_program([[0x80; 3], [0; 3]]); + let compiled_b = finite_program([[0x80; 3], [0; 3]]); + assert_eq!(compiled_a.content_identity(), compiled_b.content_identity()); + let owner_a = OwnerV1::from_compiled(compiled_a); + let owner_b = OwnerV1::from_compiled(compiled_b); + let mut session = owner_a.instantiate(STREAM.value()).unwrap(); + let white = [Srgb8::new([0xFF; 3])]; + let observed = [ScenarioV1::new(1, &white)]; + owner_a + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &observed, + }, + ) + .unwrap(); + + let certificate = session.evidence().certificates().next().unwrap(); + let backing = certificate.observation_backing_ptr_for_test(); + drop(owner_a); + assert_eq!(certificate.observation().revision(), 1); + assert_eq!( + certificate.observation_backing_ptr_for_test(), + backing, + "historical evidence is Session-owned rather than owner-authorized" + ); + + assert!(matches!( + owner_b.project(&session), + Err(AccessErrorV1::OwnerMismatch) + )); + let mismatch = match owner_b.update( + &mut session, + UpdateV1::Unknown { + revision: 2, + reason_id: 9, + }, + ) { + Ok(_) => panic!("equivalent recompile must not revive another owner generation"), + Err(error) => error, + }; + assert_eq!(mismatch.kind(), UpdateErrorKindV1::OwnerMismatch); + assert_eq!( + session + .evidence() + .certificates() + .next() + .unwrap() + .observation_backing_ptr_for_test(), + backing + ); +} + +#[test] +fn raw_head_and_evaluator_lifecycle_form_the_exact_reachable_product() { + let owner = OwnerV1::from_compiled(finite_program([[0x80; 3], [0; 3]])); + let mut session = owner.instantiate(STREAM.value()).unwrap(); + let white = [Srgb8::new([0xFF; 3])]; + let black = [Srgb8::new([0; 3])]; + let white_only = [ScenarioV1::new(1, &white)]; + let opposing = [ScenarioV1::new(1, &white), ScenarioV1::new(2, &black)]; + + owner + .update( + &mut session, + UpdateV1::Unknown { + revision: 1, + reason_id: 3, + }, + ) + .unwrap(); + owner + .update( + &mut session, + UpdateV1::Observed { + revision: 2, + scenarios: &white_only, + }, + ) + .unwrap(); + let ready = session.evidence(); + assert_eq!(ready.kind(), StateKindV1::Ready); + assert_observed_head(ready.observation_head(), STREAM.value(), 2); + + owner + .update( + &mut session, + UpdateV1::Observed { + revision: 3, + scenarios: &opposing, + }, + ) + .unwrap(); + let failed = session.evidence(); + assert_eq!(failed.kind(), StateKindV1::Failed); + assert_observed_head(failed.observation_head(), STREAM.value(), 3); + assert_eq!( + failed + .certificates() + .map(|certificate| certificate.observation().revision()) + .collect::>(), + [3, 2] + ); + + owner + .update( + &mut session, + UpdateV1::Unknown { + revision: 4, + reason_id: 17, + }, + ) + .unwrap(); + let stale = session.evidence(); + assert_eq!(stale.kind(), StateKindV1::Stale); + assert_unknown_head(stale.observation_head(), STREAM.value(), 4, 17); + assert_eq!( + stale + .certificates() + .map(|certificate| certificate.observation().revision()) + .collect::>(), + [2] + ); + + let rejecting_owner = OwnerV1::from_compiled(finite_program([[0x80; 3], [0xFF; 3]])); + let mut rejecting_session = rejecting_owner.instantiate(STREAM.value()).unwrap(); + rejecting_owner + .update( + &mut rejecting_session, + UpdateV1::Observed { + revision: 1, + scenarios: &white_only, + }, + ) + .unwrap(); + assert_eq!(rejecting_session.evidence().kind(), StateKindV1::Failed); + rejecting_owner + .update( + &mut rejecting_session, + UpdateV1::Unknown { + revision: 2, + reason_id: 23, + }, + ) + .unwrap(); + let waiting = rejecting_session.evidence(); + assert_eq!(waiting.kind(), StateKindV1::Waiting); + assert_unknown_head(waiting.observation_head(), STREAM.value(), 2, 23); + assert_eq!(waiting.certificates().len(), 0); +} + +#[test] +fn failed_without_previous_removes_every_output_in_canonical_exact_order() { + let owner = OwnerV1::from_compiled(finite_program_with_outputs( + [[0x80; 3], [0xFF; 3]], + vec![ + OutputBinding::new(SECOND_OUTPUT, PAINT), + OutputBinding::new(OUTPUT, PAINT), + ], + )); + let mut session = owner.instantiate(STREAM.value()).unwrap(); + let white = [Srgb8::new([0xFF; 3])]; + let scenarios = [ScenarioV1::new(1, &white)]; + let failed = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + assert_eq!(failed.evidence().kind(), StateKindV1::Failed); + assert_eq!(failed.evidence().certificates().len(), 1); + + let mut operations = failed.operations(); + assert_eq!(operations.len(), 2); + assert_eq!(operations.size_hint(), (2, Some(2))); + let Some(OperationV1::Remove(first)) = operations.next() else { + panic!("Failed without previous evidence must remove the first output"); + }; + assert_eq!(first.output_slot(), OutputSlotIdV1::new(OUTPUT.value())); + assert_eq!(operations.len(), 1); + assert_eq!(operations.size_hint(), (1, Some(1))); + + let Some(OperationV1::Remove(second)) = operations.next() else { + panic!("Failed without previous evidence must remove the second output"); + }; + assert_eq!( + second.output_slot(), + OutputSlotIdV1::new(SECOND_OUTPUT.value()) + ); + assert_eq!(operations.len(), 0); + assert_eq!(operations.size_hint(), (0, Some(0))); + assert!(operations.next().is_none()); + assert!(operations.next().is_none()); +} + +#[test] +fn ready_sets_and_stale_removes_every_output_in_the_same_canonical_order() { + let owner = OwnerV1::from_compiled(finite_program_with_outputs( + [[0x80; 3], [0; 3]], + vec![ + OutputBinding::new(SECOND_OUTPUT, PAINT), + OutputBinding::new(OUTPUT, PAINT), + ], + )); + let mut session = owner.instantiate(STREAM.value()).unwrap(); + let white = [Srgb8::new([0xFF; 3])]; + let scenarios = [ScenarioV1::new(1, &white)]; + + { + let ready = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + let mut operations = ready.operations(); + assert_eq!(operations.len(), 2); + for expected in [OUTPUT, SECOND_OUTPUT] { + let Some(OperationV1::Set(set)) = operations.next() else { + panic!("Ready must set every compiled output"); + }; + assert_eq!(set.output_slot().value(), expected.value()); + assert_eq!(set.certificate().observation().revision(), 1); + } + assert!(operations.next().is_none()); + } + + let stale = owner + .update( + &mut session, + UpdateV1::Unknown { + revision: 2, + reason_id: 7, + }, + ) + .unwrap(); + let mut operations = stale.operations(); + assert_eq!(operations.len(), 2); + for expected in [OUTPUT, SECOND_OUTPUT] { + let Some(OperationV1::Remove(remove)) = operations.next() else { + panic!("Stale must remove every output that lacks current evidence"); + }; + assert_eq!(remove.output_slot().value(), expected.value()); + } + assert!(operations.next().is_none()); +} diff --git a/crates/labcolors-core/src/program_session.rs b/crates/labcolors-core/src/program_session.rs new file mode 100644 index 00000000..de47e97f --- /dev/null +++ b/crates/labcolors-core/src/program_session.rs @@ -0,0 +1,3194 @@ +//! Private generic point Program compiler and lowering path. +//! +//! The authored graph has no client/UI role vocabulary. Paints are physical +//! source-plus-straight-alpha programs, occurrences are modeled applications of +//! Paint to Surface, constraints declare assessments of those exact +//! occurrences, and outputs bind opaque slots back to Paints. The compiled +//! result owns only admitted, canonical topology; runtime observation, +//! lifecycle and terminal emission belong to the sole revision-bound Session. +//! Output transport remains encoded, while every assessed visible occurrence +//! carries deterministic, context-bound modeled LCS provenance. Neither claim +//! is renderer observation or human-subject evidence. + +use std::marker::PhantomData; +use std::rc::{Rc, Weak}; + +use crate::Srgb8; +use crate::appearance::{ + AdmittedAppearanceBindings, AppearanceBindings, AppearanceGraphSpec, AppearanceWorkspace, + BindingError, ColorInputId, CompileError, CompiledAppearanceGraph, CompiledColorInputSlotV1, + CompiledOccurrenceSlotV1, CompiledPaintSlotV1, EncodedPointPaintV1, OccurrenceId, + OccurrenceSpec, OpacityInputId, PaintId, PaintSpec, SurfaceId, SurfaceInputPortId, SurfaceSpec, +}; +use crate::composition::CompositionProfileV1; +use crate::constraints::{ + Evaluator, ExactSrgb8IdentityV1, HardDecision, ProgramConstraintContentV1, + ProgramPointAssessmentErrorV1, ProgramPointEvaluatorContentV1, ProgramPointEvaluatorV1, + ProgramPointInvocation, ProgramPointTargetV1, ProgramVisiblePointBindingV1, + ProgramVisiblePointPassEvidence, ProgramVisiblePointViolationEvidence, Wcag22Srgb8V1, + assess_program_point_hard, +}; +use crate::joint::{ + AdmittedFiniteJointOrderV1, FiniteDomainOrdinalV1, FiniteJointOrderErrorV1, + admit_finite_joint_order_v1, +}; +use crate::lcs_occurrence::{ + AppearanceContextId, ColorSignal, ModeledLcsOccurrenceFormationErrorV1, ModeledLcsOccurrenceV1, +}; +use crate::observation::{ + CanonicalObservationSchemaV1, ObservationError, ObservationGroupId, + ObservationSchemaMismatchV1, ObservationStreamId, RevisionBoundObservationV1, + canonicalize_observation_schema, +}; +use crate::session::{ + Session, SessionDecision, SessionEvidenceV1, SessionObservationBindingPermitV1, SessionPlanV1, + private as session_private, +}; +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); + +impl SourceId { + pub const fn new(value: u32) -> Self { + Self(value) + } + + pub const fn value(self) -> u32 { + self.0 + } +} + +/// One immutable encoded source owned by a [`Program`]. Sources carry data, +/// never solver freedom and never appear directly in Paint topology. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Source { + id: SourceId, + signal: ColorSignal, +} + +impl Source { + pub const fn new(id: SourceId, signal: ColorSignal) -> Self { + Self { id, signal } + } + + pub const fn id(self) -> SourceId { + self.id + } + + pub const fn signal(self) -> ColorSignal { + self.signal + } +} + +/// Opaque identity of one jointly selected finite target. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct TargetId(u32); + +impl TargetId { + pub const fn new(value: u32) -> Self { + Self(value) + } + + pub const fn value(self) -> u32 { + self.0 + } +} + +/// Opaque identity of one candidate inside a finite target domain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct TargetCandidateId(u32); + +impl TargetCandidateId { + pub const fn new(value: u32) -> Self { + Self(value) + } + + pub const fn value(self) -> u32 { + self.0 + } +} + +/// One candidate signal in a finite target domain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TargetCandidateV1 { + id: TargetCandidateId, + signal: ColorSignal, +} + +impl TargetCandidateV1 { + pub const fn new(id: TargetCandidateId, signal: ColorSignal) -> Self { + Self { id, signal } + } + + /// Bind one encoded sRGB8 candidate through the sole admitted signal + /// profile. Package authoring never carries a free-form profile tag. + pub const fn from_srgb8(id: TargetCandidateId, value: Srgb8) -> Self { + Self::new(id, ColorSignal::from_srgb8(value)) + } + + pub const fn id(self) -> TargetCandidateId { + self.id + } + + pub const fn signal(self) -> ColorSignal { + self.signal + } +} + +/// Closed authored freedom of one Target. Fixed targets use their Source +/// signal exactly; finite targets can select only from the explicit domain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TargetDomainV1 { + Fixed, + Finite(Vec), +} + +/// A Paint-addressable target distinct from both source data and appearance +/// storage. Only finite targets participate in declared joint selection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Target { + id: TargetId, + source: SourceId, + domain: TargetDomainV1, +} + +impl Target { + pub const fn new(id: TargetId, source: SourceId, domain: TargetDomainV1) -> Self { + Self { id, source, domain } + } + + pub const fn fixed(id: TargetId, source: SourceId) -> Self { + Self::new(id, source, TargetDomainV1::Fixed) + } + + pub const fn finite( + id: TargetId, + source: SourceId, + candidates: Vec, + ) -> Self { + Self::new(id, source, TargetDomainV1::Finite(candidates)) + } + + pub const fn id(&self) -> TargetId { + self.id + } + + pub const fn source(&self) -> SourceId { + self.source + } + + pub const fn domain(&self) -> &TargetDomainV1 { + &self.domain + } +} + +/// One typed target/candidate assignment inside a declared joint state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct TargetCandidateChoiceV1 { + target: TargetId, + candidate: TargetCandidateId, +} + +impl TargetCandidateChoiceV1 { + pub const fn new(target: TargetId, candidate: TargetCandidateId) -> Self { + Self { target, candidate } + } + + pub const fn target(self) -> TargetId { + self.target + } + + pub const fn candidate(self) -> TargetCandidateId { + self.candidate + } +} + +/// One complete joint candidate state. Choices are keyed, so authored choice +/// order has no physical meaning. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JointCandidateStateV1 { + choices: Vec, +} + +impl JointCandidateStateV1 { + pub const fn new(choices: Vec) -> Self { + Self { choices } + } + + pub fn choices(&self) -> &[TargetCandidateChoiceV1] { + &self.choices + } +} + +/// Explicit total order over every state in the finite product domain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeclaredJointSelectionV1 { + states: Vec, +} + +impl DeclaredJointSelectionV1 { + pub const fn new(states: Vec) -> Self { + Self { states } + } + + pub fn states(&self) -> &[JointCandidateStateV1] { + &self.states + } +} + +/// One immutable straight-alpha binding owned by a [`Program`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct OpacityInput { + id: OpacityInputId, + value: f64, +} + +impl OpacityInput { + pub const fn new(id: OpacityInputId, value: f64) -> Self { + Self { id, value } + } + + pub const fn id(self) -> OpacityInputId { + self.id + } + + pub const fn value(self) -> f64 { + self.value + } +} + +/// Generic point Paint constructor algebra. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Paint { + Solid { + id: PaintId, + target: TargetId, + }, + Opacity { + id: PaintId, + source: PaintId, + opacity: OpacityInputId, + }, +} + +/// Generic point Surface constructor algebra. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Surface { + Input { + id: SurfaceId, + input: SurfaceInputPortId, + }, + FromOccurrence { + id: SurfaceId, + occurrence: OccurrenceId, + }, +} + +/// Closed mathematical composition profile set for this Program version. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompositionProfile { + EncodedSrgb8SourceOverV1, +} + +/// The canonical application of one Paint to one Surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Occurrence { + id: OccurrenceId, + subject: PaintId, + against: SurfaceId, + composition: CompositionProfile, + context: AppearanceContextId, +} + +impl Occurrence { + pub const fn new( + id: OccurrenceId, + subject: PaintId, + against: SurfaceId, + composition: CompositionProfile, + context: AppearanceContextId, + ) -> Self { + Self { + id, + subject, + against, + composition, + context, + } + } + + pub const fn id(self) -> OccurrenceId { + self.id + } + + pub const fn subject(self) -> PaintId { + self.subject + } + + pub const fn against(self) -> SurfaceId { + self.against + } + + pub const fn composition(self) -> CompositionProfile { + self.composition + } + + pub const fn context(self) -> AppearanceContextId { + self.context + } +} + +/// Opaque authored constraint identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ConstraintId(u32); + +impl ConstraintId { + pub const fn new(value: u32) -> Self { + Self(value) + } + + pub const fn value(self) -> u32 { + self.0 + } +} + +/// Opaque authored terminal output identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct OutputSlotId(u32); + +impl OutputSlotId { + pub const fn new(value: u32) -> Self { + Self(value) + } + + pub const fn value(self) -> u32 { + self.0 + } +} + +/// Type-level marker for a mandatory constraint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HardModeV1 {} + +/// Type-level marker for a diagnostic-only constraint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReportModeV1 {} + +/// One typed evaluator invocation over one exact visible occurrence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConstraintInvocation { + id: ConstraintId, + target: OccurrenceId, + invocation: Invocation, + mode: PhantomData Mode>, +} + +impl ConstraintInvocation { + pub const fn hard(id: ConstraintId, target: OccurrenceId, invocation: Invocation) -> Self { + Self { + id, + target, + invocation, + mode: PhantomData, + } + } +} + +impl ConstraintInvocation { + pub const fn report_only( + id: ConstraintId, + target: OccurrenceId, + invocation: Invocation, + ) -> Self { + Self { + id, + target, + invocation, + mode: PhantomData, + } + } +} + +impl ConstraintInvocation { + pub const fn id(&self) -> ConstraintId { + self.id + } + + pub const fn target(&self) -> OccurrenceId { + self.target + } + + pub const fn invocation(&self) -> &Invocation { + &self.invocation + } +} + +/// The two authored modality domains remain type-separated until compilation. +#[derive(Debug, PartialEq, Eq)] +pub struct ConstraintSet { + hard: Vec>, + report_only: Vec>, +} + +impl ConstraintSet { + pub fn new( + hard: Vec>, + report_only: Vec>, + ) -> Self { + Self { hard, report_only } + } + + pub fn hard(&self) -> &[ConstraintInvocation] { + &self.hard + } + + pub fn report_only(&self) -> &[ConstraintInvocation] { + &self.report_only + } + + fn is_empty(&self) -> bool { + self.hard.is_empty() && self.report_only.is_empty() + } + + fn checked_len(&self) -> Option { + self.hard.len().checked_add(self.report_only.len()) + } +} + +/// Static dispatch contract used by one Program epoch. The invocation, +/// evaluator error, and both evidence branches are one closed type family; +/// no trait object or client-provided callback reaches the evaluation loop. +type ProgramConstraintAssessmentResultV1 = Result< + HardDecision< + ::PassEvidence, + ::ViolationEvidence, + >, + ProgramPointAssessmentErrorV1<::Error>, +>; + +pub(crate) trait ProgramConstraintEvaluatorSetV1: Sized { + type Invocation: Copy; + type PassEvidence; + type ViolationEvidence; + type Error; + + fn assess( + &self, + source: &crate::appearance::ResolvedOccurrence, + modeled_lcs: ModeledLcsOccurrenceV1, + invocation: Self::Invocation, + ) -> ProgramConstraintAssessmentResultV1; + + 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 +where + Evaluation: ProgramPointEvaluatorV1, + ProgramPointInvocation: Copy, +{ + type Invocation = ProgramPointInvocation; + type PassEvidence = ProgramVisiblePointPassEvidence; + type ViolationEvidence = ProgramVisiblePointViolationEvidence; + type Error = >::Error; + + fn assess( + &self, + source: &crate::appearance::ResolvedOccurrence, + modeled_lcs: ModeledLcsOccurrenceV1, + invocation: Self::Invocation, + ) -> ProgramConstraintAssessmentResultV1 { + assess_program_point_hard(source, modeled_lcs, self, invocation) + } + + fn pass_binding(evidence: &Self::PassEvidence) -> ProgramVisiblePointBindingV1 { + *evidence.binding() + } + + fn violation_binding(evidence: &Self::ViolationEvidence) -> ProgramVisiblePointBindingV1 { + *evidence.binding() + } + + fn constraint_content(&self, invocation: Self::Invocation) -> ProgramConstraintContentV1 { + self.program_constraint_content_v1(invocation) + } +} + +#[cfg(test)] +thread_local! { + /// Counts the concrete production evaluator dispatch itself, so certificate + /// projection cannot accidentally recompute a verdict while still reusing + /// the stored physical and modeled witnesses. + pub(crate) static CORE_PROGRAM_ASSESSMENT_CALLS: core::cell::Cell = + const { core::cell::Cell::new(0) }; +} + +/// Generates the code-owned heterogeneous evaluator set as parallel closed +/// unions. Each evidence variant retains the concrete evaluator's physical + +/// LCS binding, identity, release, capability, invocation, measurement, and +/// classifier payload. Adding a family therefore requires a Core code change +/// in this single declaration, not a client-extensible semantic registry. +macro_rules! define_core_program_evaluators_v1 { + ($( + $variant:ident { + evaluator: $evaluator:ty = $evaluator_value:expr, + invocation: $invocation:ty + } + ),+ $(,)?) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) enum CoreProgramConstraintInvocationV1 { + $($variant($invocation)),+ + } + + pub(crate) enum CoreProgramPassEvidenceV1 { + $($variant(ProgramVisiblePointPassEvidence<$evaluator>)),+ + } + + pub(crate) enum CoreProgramViolationEvidenceV1 { + $($variant(ProgramVisiblePointViolationEvidence<$evaluator>)),+ + } + + #[derive(Debug, PartialEq)] + pub(crate) enum CoreProgramEvaluatorErrorV1 { + $($variant(<$evaluator as Evaluator>::Error)),+ + } + + /// The sole production evaluator set for this Program schema version. + /// Dispatch compiles to a direct match over the generated invocation + /// tag; it performs neither virtual dispatch nor lookup allocation. + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] + pub(crate) struct CoreProgramEvaluatorsV1; + + impl ProgramConstraintEvaluatorSetV1 for CoreProgramEvaluatorsV1 { + type Invocation = CoreProgramConstraintInvocationV1; + type PassEvidence = CoreProgramPassEvidenceV1; + type ViolationEvidence = CoreProgramViolationEvidenceV1; + type Error = CoreProgramEvaluatorErrorV1; + + fn assess( + &self, + source: &crate::appearance::ResolvedOccurrence, + modeled_lcs: ModeledLcsOccurrenceV1, + invocation: Self::Invocation, + ) -> ProgramConstraintAssessmentResultV1 { + #[cfg(test)] + CORE_PROGRAM_ASSESSMENT_CALLS.with(|calls| calls.set(calls.get() + 1)); + match invocation { + $(CoreProgramConstraintInvocationV1::$variant(invocation) => { + let evaluator: $evaluator = $evaluator_value; + match assess_program_point_hard( + source, + modeled_lcs, + &evaluator, + invocation, + ) { + Ok(HardDecision::Pass(evidence)) => Ok(HardDecision::Pass( + CoreProgramPassEvidenceV1::$variant(evidence), + )), + Ok(HardDecision::Violation(evidence)) => Ok(HardDecision::Violation( + CoreProgramViolationEvidenceV1::$variant(evidence), + )), + Err(ProgramPointAssessmentErrorV1::Binding(source)) => { + Err(ProgramPointAssessmentErrorV1::Binding(source)) + } + Err(ProgramPointAssessmentErrorV1::Evaluator(source)) => Err( + ProgramPointAssessmentErrorV1::Evaluator( + CoreProgramEvaluatorErrorV1::$variant(source), + ), + ), + } + }),+ + } + } + + fn pass_binding(evidence: &Self::PassEvidence) -> ProgramVisiblePointBindingV1 { + match evidence { + $(CoreProgramPassEvidenceV1::$variant(evidence) => *evidence.binding()),+ + } + } + + fn violation_binding( + evidence: &Self::ViolationEvidence, + ) -> ProgramVisiblePointBindingV1 { + match evidence { + $(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, + ) + }),+ + } + } + } + }; +} + +define_core_program_evaluators_v1! { + ExactSrgb8 { + evaluator: ExactSrgb8IdentityV1 = ExactSrgb8IdentityV1, + invocation: Srgb8 + }, + Wcag22Srgb8 { + evaluator: Wcag22Srgb8V1 = Wcag22Srgb8V1, + invocation: Wcag22CriterionV1 + }, +} + +type ProgramConstraintInvocationOf = + ::Invocation; + +/// Compile-time binding from one terminal slot to one Paint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutputBinding { + output: OutputSlotId, + paint: PaintId, +} + +impl OutputBinding { + pub const fn new(output: OutputSlotId, paint: PaintId) -> Self { + Self { output, paint } + } + + pub const fn output(self) -> OutputSlotId { + self.output + } + + pub const fn paint(self) -> PaintId { + self.paint + } +} + +/// One compile-time atomic correlation boundary for this Program epoch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObservationGroup { + id: ObservationGroupId, + surface_input_ports: Vec, +} + +impl ObservationGroup { + pub const fn new(id: ObservationGroupId, surface_input_ports: Vec) -> Self { + Self { + id, + surface_input_ports, + } + } + + pub const fn id(&self) -> ObservationGroupId { + self.id + } + + pub fn surface_input_ports(&self) -> &[SurfaceInputPortId] { + &self.surface_input_ports + } +} + +/// Immutable generic point Program. +pub struct Program +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + sources: Vec, + targets: Vec, + joint_selection: Option, + observation_group: ObservationGroup, + opacities: Vec, + paints: Vec, + surfaces: Vec, + occurrences: Vec, + constraints: ConstraintSet>, + outputs: Vec, + evaluator: Evaluation, +} + +impl Program +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + #[allow(clippy::too_many_arguments)] + pub fn new( + sources: Vec, + targets: Vec, + observation_group: ObservationGroup, + opacities: Vec, + paints: Vec, + surfaces: Vec, + occurrences: Vec, + constraints: ConstraintSet>, + outputs: Vec, + evaluator: Evaluation, + ) -> Self { + Self { + sources, + targets, + joint_selection: None, + observation_group, + opacities, + paints, + surfaces, + occurrences, + constraints, + outputs, + evaluator, + } + } + + /// Attach the complete explicit order for all finite Target domains. + /// No order is synthesized from target IDs, candidate bytes, or + /// declaration position. + pub fn with_joint_selection(mut self, selection: DeclaredJointSelectionV1) -> Self { + self.joint_selection = Some(selection); + self + } + + pub fn compile(self) -> Result, ProgramCompileError> { + prepare_program(self).map(|epoch| CompiledProgram { + owner_generation: Rc::new(epoch), + }) + } +} + +/// Concrete monomorphized Program boundary for package/WASM lowering. The +/// generic form remains an internal test seam; package code binds only this +/// code-owned evaluator union. +pub(crate) type CoreProgramV1 = Program; + +/// Mutable cold-edge builder for the one concrete Core Program IR. +/// +/// Every pushed value is already an actual Program declaration type. This +/// builder owns no transport tags, names, client taxonomy, or second graph; +/// compilation moves the concrete Program directly into the existing atomic +/// compiler. +pub(crate) struct CoreProgramDraftV1 { + program: CoreProgramV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CoreProgramDraftErrorV1 { + JointSelectionAlreadyDeclared, +} + +impl CoreProgramDraftV1 { + pub(crate) fn new() -> Self { + Self { + program: Program::new( + Vec::new(), + Vec::new(), + ObservationGroup::new(ObservationGroupId::new(0), Vec::new()), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ConstraintSet::new(Vec::new(), Vec::new()), + Vec::new(), + CoreProgramEvaluatorsV1, + ), + } + } + + pub(crate) fn push_source(&mut self, source: Source) { + self.program.sources.push(source); + } + + pub(crate) fn push_target(&mut self, target: Target) { + self.program.targets.push(target); + } + + pub(crate) fn set_joint_selection( + &mut self, + selection: DeclaredJointSelectionV1, + ) -> Result<(), CoreProgramDraftErrorV1> { + if self.program.joint_selection.is_some() { + return Err(CoreProgramDraftErrorV1::JointSelectionAlreadyDeclared); + } + self.program.joint_selection = Some(selection); + Ok(()) + } + + pub(crate) fn push_surface_input_port(&mut self, input: SurfaceInputPortId) { + self.program + .observation_group + .surface_input_ports + .push(input); + } + + pub(crate) fn push_opacity_input(&mut self, opacity: OpacityInput) { + self.program.opacities.push(opacity); + } + + pub(crate) fn push_paint(&mut self, paint: Paint) { + self.program.paints.push(paint); + } + + pub(crate) fn push_surface(&mut self, surface: Surface) { + self.program.surfaces.push(surface); + } + + pub(crate) fn push_occurrence(&mut self, occurrence: Occurrence) { + self.program.occurrences.push(occurrence); + } + + pub(crate) fn push_hard_constraint( + &mut self, + constraint: ConstraintInvocation, + ) { + self.program.constraints.hard.push(constraint); + } + + pub(crate) fn push_report_constraint( + &mut self, + constraint: ConstraintInvocation, + ) { + self.program.constraints.report_only.push(constraint); + } + + pub(crate) fn push_output(&mut self, output: OutputBinding) { + self.program.outputs.push(output); + } + + pub(crate) fn compile(self) -> Result { + self.program.compile() + } +} + +/// Atomic compile failure. No executable partial graph escapes. +#[derive(Debug, PartialEq, Eq)] +pub enum ProgramCompileError { + DuplicateSource { + source: SourceId, + }, + DuplicateTarget { + target: TargetId, + }, + MissingTargetSource { + target: TargetId, + source: SourceId, + }, + DuplicateOpacityInput { + input: OpacityInputId, + }, + DuplicateSurfaceInputPort { + input: SurfaceInputPortId, + }, + UnusedSurfaceInputPort { + input: SurfaceInputPortId, + }, + DuplicateSurfaceInputBinding { + input: SurfaceInputPortId, + first: SurfaceId, + duplicate: SurfaceId, + }, + DuplicatePaint { + paint: PaintId, + }, + DuplicateSurface { + surface: SurfaceId, + }, + DuplicateOccurrence { + occurrence: OccurrenceId, + }, + MissingPaintTarget { + paint: PaintId, + target: TargetId, + }, + MissingPaintSource { + paint: PaintId, + source: PaintId, + }, + MissingPaintOpacityInput { + paint: PaintId, + input: OpacityInputId, + }, + MissingSurfaceInputPort { + surface: SurfaceId, + input: SurfaceInputPortId, + }, + MissingSurfaceOccurrence { + surface: SurfaceId, + occurrence: OccurrenceId, + }, + MissingOccurrencePaint { + occurrence: OccurrenceId, + paint: PaintId, + }, + MissingOccurrenceBackdrop { + occurrence: OccurrenceId, + surface: SurfaceId, + }, + PaintCycle { + paints: Vec, + }, + RenderCycle { + surfaces: Vec, + occurrences: Vec, + }, + OpacityOutOfDomain { + input: OpacityInputId, + }, + EmptyTargetDomain { + target: TargetId, + }, + DuplicateTargetCandidate { + target: TargetId, + candidate: TargetCandidateId, + }, + DuplicateTargetCandidateSignal { + target: TargetId, + first: TargetCandidateId, + duplicate: TargetCandidateId, + signal: ColorSignal, + }, + UnconstrainedTarget { + target: TargetId, + }, + DisconnectedFiniteTargets, + UnassessedOutput { + output: OutputSlotId, + paint: PaintId, + }, + MissingJointSelection, + JointSelectionWithoutTargets, + JointStateDuplicateTarget { + state: usize, + target: TargetId, + }, + JointStateMissingTarget { + state: usize, + target: TargetId, + }, + JointStateUnknownTarget { + state: usize, + target: TargetId, + }, + JointStateUnknownCandidate { + state: usize, + target: TargetId, + candidate: TargetCandidateId, + }, + InvalidJointOrder(FiniteJointOrderErrorV1), + EmptyObservationGroup { + group: ObservationGroupId, + }, + EmptyOccurrenceSet, + EmptyConstraintSet, + EmptyOutputSet, + DuplicateConstraint { + constraint: ConstraintId, + }, + MissingConstraintOccurrence { + constraint: ConstraintId, + occurrence: OccurrenceId, + }, + DuplicateOutputSlot { + output: OutputSlotId, + }, + MissingOutputPaint { + output: OutputSlotId, + paint: PaintId, + }, + ResourceExhausted, + InternalInvariant, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CompiledConstraintModeV1 { + Hard, + ReportOnly, +} + +impl CompiledConstraintModeV1 { + const fn rejects_candidate(self) -> bool { + matches!(self, Self::Hard) + } +} + +struct CompiledPointConstraint { + id: ConstraintId, + target_id: OccurrenceId, + target: CompiledOccurrenceSlotV1, + modeled_occurrence_index: usize, + mode: CompiledConstraintModeV1, + invocation: Invocation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CompiledOutputBinding { + output: OutputSlotId, + paint_id: PaintId, + paint: CompiledPaintSlotV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CompiledOccurrenceContextV1 { + occurrence: OccurrenceId, + target: CompiledOccurrenceSlotV1, + context: AppearanceContextId, +} + +struct CompiledObservationGroupV1 { + id: ObservationGroupId, + schema: CanonicalObservationSchemaV1, +} + +struct CompiledFiniteTargetV1 { + binding: CompiledColorInputSlotV1, + candidates: Box<[ColorSignal]>, +} + +struct CompiledJointSelectionV1 { + order: AdmittedFiniteJointOrderV1, +} + +struct ProgramEpochV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + content_identity: ProgramContentIdentityV1, + evaluator: Evaluation, + graph: CompiledAppearanceGraph, + binding_template: AdmittedAppearanceBindings, + observation_group: CompiledObservationGroupV1, + occurrence_contexts: Box<[CompiledOccurrenceContextV1]>, + constraints: Box<[CompiledPointConstraint>]>, + outputs: Box<[CompiledOutputBinding]>, + finite_targets: Box<[CompiledFiniteTargetV1]>, + joint_selection: Option, +} + +/// Transaction-local strong pin for one exact compiled Program generation. +/// Construction is possible only by upgrading a Session plan's weak binding; +/// the contained epoch never becomes an independently shareable API. +pub(crate) struct ProgramOwnerLeaseV1(Rc>) +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy; + +/// Fully validated immutable Program, not yet attached to runtime. +pub struct CompiledProgram +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + owner_generation: Rc>, +} + +pub(crate) type CompiledCoreProgramV1 = CompiledProgram; + +impl CompiledProgram +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + pub fn observation_group_id(&self) -> ObservationGroupId { + 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() + } + + pub fn constraint_ids(&self) -> impl ExactSizeIterator + '_ { + self.owner_generation + .constraints + .iter() + .map(|constraint| constraint.id) + } + + pub fn outputs(&self) -> impl ExactSizeIterator + '_ { + self.owner_generation + .outputs + .iter() + .map(|output| (output.output, output.paint_id)) + } + + pub(crate) fn output_count(&self) -> usize { + self.owner_generation.outputs.len() + } + + pub(crate) fn evidence_cell_bounds(&self, scenario_count: usize) -> Option<(usize, usize)> { + checked_program_epoch_evaluation_cell_counts(&self.owner_generation, scenario_count) + .map(|counts| (counts.selected, counts.exhaustive_conflict)) + } + + pub(crate) fn output_slot_at(&self, index: usize) -> Option { + self.owner_generation + .outputs + .get(index) + .map(|output| output.output) + } + + #[cfg(test)] + pub(crate) fn observation_schema_strong_count_for_test(&self) -> usize { + self.owner_generation + .observation_group + .schema + .strong_count_for_test() + } + + /// Membership is the exact live owner allocation, never equivalent + /// compiled content. The Session's `Weak` keeps the old control block + /// address reserved until the Session itself is destroyed. + pub(crate) fn owns_session(&self, session: &Session>) -> bool { + core::ptr::eq( + session.plan().owner_generation.as_ptr(), + Rc::as_ptr(&self.owner_generation), + ) + } + + /// Create one independent stream-affine Session for this exact compiled + /// owner generation. Mutable bindings and workspace belong to the Session, + /// while executable graph/evaluator state is reached only through a weak + /// generation binding and expires when this owner is dropped or replaced. + pub(crate) fn instantiate( + &self, + stream: ObservationStreamId, + ) -> Result>, ProgramSessionInstantiateError> { + let bindings = self + .owner_generation + .binding_template + .try_clone_v1() + .map_err(map_session_instantiate_error)?; + let workspace = self + .owner_generation + .graph + .new_workspace() + .map_err(map_session_instantiate_error)?; + let mut modeled_occurrences = Vec::new(); + modeled_occurrences + .try_reserve_exact(self.owner_generation.occurrence_contexts.len()) + .map_err(|_| ProgramSessionInstantiateError::ResourceExhausted)?; + modeled_occurrences.resize(self.owner_generation.occurrence_contexts.len(), None); + Ok(Session::new( + stream, + ProgramSessionPlan { + owner_generation: Rc::downgrade(&self.owner_generation), + bindings, + workspace, + modeled_occurrences, + }, + )) + } +} + +/// Failure while preparing mutable storage for one independent Session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProgramSessionInstantiateError { + ResourceExhausted, + InternalInvariant, +} + +fn map_session_instantiate_error(error: BindingError) -> ProgramSessionInstantiateError { + match error { + BindingError::ResourceExhausted => ProgramSessionInstantiateError::ResourceExhausted, + _ => ProgramSessionInstantiateError::InternalInvariant, + } +} + +/// One evaluator classification retained in the complete Program report. +pub enum ProgramConstraintResultV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + Pass(Evaluation::PassEvidence), + Violation(Evaluation::ViolationEvidence), +} + +impl ProgramConstraintResultV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + pub const fn is_violation(&self) -> bool { + matches!(self, Self::Violation(_)) + } + + fn binding(&self) -> ProgramVisiblePointBindingV1 { + match self { + Self::Pass(evidence) => Evaluation::pass_binding(evidence), + Self::Violation(evidence) => Evaluation::violation_binding(evidence), + } + } + + fn modeled_lcs_occurrence(&self) -> ModeledLcsOccurrenceV1 { + self.binding().modeled_lcs() + } +} + +/// One canonical `physical case × constraint` report cell. +pub struct ProgramConstraintCellV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + candidate_state_index: usize, + case_index: usize, + constraint: ConstraintId, + target: OccurrenceId, + mode: CompiledConstraintModeV1, + result: ProgramConstraintResultV1, +} + +impl ProgramConstraintCellV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + pub const fn candidate_state_index(&self) -> usize { + self.candidate_state_index + } + + pub const fn case_index(&self) -> usize { + self.case_index + } + + pub const fn constraint(&self) -> ConstraintId { + self.constraint + } + + pub const fn target(&self) -> OccurrenceId { + self.target + } + + pub fn modeled_lcs_occurrence(&self) -> ModeledLcsOccurrenceV1 { + self.result.modeled_lcs_occurrence() + } + + pub const fn is_hard(&self) -> bool { + self.mode.rejects_candidate() + } + + pub const fn result(&self) -> &ProgramConstraintResultV1 { + &self.result + } +} + +/// Полная оценка, привязанная к revision. Для selected/fixed результата ячейки +/// идут сначала по physical case, затем по constraint ID. Exhaustive conflict +/// дополнительно упорядочен сначала по joint state. +pub struct ProgramReportV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + content_identity: ProgramContentIdentityV1, + observation: RevisionBoundObservationV1, + cells: Vec>, +} + +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 + } + + pub fn cells(&self) -> &[ProgramConstraintCellV1] { + &self.cells + } +} + +/// One emitted Program Paint routed to an opaque output slot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProgramOutputV1 { + output: OutputSlotId, + paint: EncodedPointPaintV1, +} + +impl ProgramOutputV1 { + pub const fn output(self) -> OutputSlotId { + self.output + } + + pub const fn paint(self) -> EncodedPointPaintV1 { + self.paint + } + + pub const fn source_signal(self) -> ColorSignal { + ColorSignal::from_srgb8(self.paint.source()) + } +} + +/// All hard cells passed over the complete admitted physical support. +pub struct ProgramVerifiedV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + report: ProgramReportV1, + outputs: Vec, + selected_state_index: Option, +} + +impl session_private::EvidenceSealed for ProgramVerifiedV1 where + Evaluation: ProgramConstraintEvaluatorSetV1 +{ +} + +impl SessionEvidenceV1 for ProgramVerifiedV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + fn observation(&self) -> &RevisionBoundObservationV1 { + self.report().observation() + } +} + +impl ProgramVerifiedV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + pub const fn report(&self) -> &ProgramReportV1 { + &self.report + } + + pub fn outputs(&self) -> &[ProgramOutputV1] { + &self.outputs + } + + /// Index inside the authored total order. `None` means this Program has no + /// finite targets and therefore performed validation only. + pub const fn selected_state_index(&self) -> Option { + self.selected_state_index + } +} + +/// Exhaustive hard-infeasibility report. Outputs are absent by construction +/// and therefore cannot be mistaken for committed Paints. +pub struct ProgramConflictV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + report: ProgramReportV1, + considered_state_count: usize, +} + +impl session_private::EvidenceSealed for ProgramConflictV1 where + Evaluation: ProgramConstraintEvaluatorSetV1 +{ +} + +impl SessionEvidenceV1 for ProgramConflictV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + fn observation(&self) -> &RevisionBoundObservationV1 { + self.report().observation() + } +} + +impl ProgramConflictV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + pub const fn report(&self) -> &ProgramReportV1 { + &self.report + } + + pub const fn considered_state_count(&self) -> usize { + self.considered_state_count + } +} + +/// Program execution failure before Session commit. +#[derive(Debug, PartialEq, Eq)] +pub enum ProgramSessionEvaluationError { + ObservationSchemaMismatch(ObservationSchemaMismatchV1), + ResourceExhausted, + Evaluator { + case_index: usize, + constraint: ConstraintId, + occurrence: OccurrenceId, + context: AppearanceContextId, + source: EvaluationError, + }, + ProgramTargetBinding { + case_index: usize, + constraint: ConstraintId, + occurrence: OccurrenceId, + context: AppearanceContextId, + physical: Srgb8, + modeled: Srgb8, + }, + ModeledOccurrence { + case_index: usize, + occurrence: OccurrenceId, + context: AppearanceContextId, + source: ModeledLcsOccurrenceFormationErrorV1, + }, + OutputVariesAcrossCases { + output: OutputSlotId, + first_case: usize, + actual_case: usize, + }, + FinalRecheckViolation { + state_index: usize, + case_index: usize, + constraint: ConstraintId, + target: OccurrenceId, + hard_violation_count: usize, + }, + InternalInvariant, +} + +type ProgramEvaluatorError = ::Error; + +type ProgramSessionEvaluationResult = Result< + SessionDecision, ProgramConflictV1>, + ProgramSessionEvaluationError>, +>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ProgramEvaluationCellCountsV1 { + selected: usize, + exhaustive_conflict: usize, +} + +fn checked_program_evaluation_cell_counts( + physical_case_count: usize, + constraint_count: usize, + state_count: usize, + can_conflict: bool, +) -> Option { + let selected = physical_case_count.checked_mul(constraint_count)?; + let exhaustive_conflict = if can_conflict { + selected.checked_mul(state_count)? + } else { + 0 + }; + Some(ProgramEvaluationCellCountsV1 { + selected, + exhaustive_conflict, + }) +} + +fn checked_program_epoch_evaluation_cell_counts( + epoch: &ProgramEpochV1, + physical_case_count: usize, +) -> Option +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + let state_count = epoch + .joint_selection + .as_ref() + .map(|selection| selection.order.state_count()) + // Without joint selection the epoch has one fixed configuration, so + // the exhaustive-cell multiplier remains the multiplicative identity. + .unwrap_or(1); + let can_conflict = epoch + .constraints + .iter() + .any(|constraint| constraint.mode.rejects_candidate()); + checked_program_evaluation_cell_counts( + physical_case_count, + epoch.constraints.len(), + state_count, + can_conflict, + ) +} + +#[cfg(test)] +pub(crate) fn checked_program_evaluation_cell_counts_for_test( + physical_case_count: usize, + constraint_count: usize, + state_count: usize, +) -> Option<(usize, usize)> { + checked_program_evaluation_cell_counts(physical_case_count, constraint_count, state_count, true) + .map(|counts| (counts.selected, counts.exhaustive_conflict)) +} + +#[cfg(test)] +std::thread_local! { + static PROGRAM_PREFLIGHT_FAILURE_AT: std::cell::Cell> = const { + std::cell::Cell::new(None) + }; + static PROGRAM_PREFLIGHT_FAILURE_ACTIVE: std::cell::Cell = const { + std::cell::Cell::new(false) + }; +} + +#[cfg(test)] +pub(crate) struct ProgramPreflightFailureGuardV1 { + _not_send: PhantomData>, +} + +#[cfg(test)] +impl Drop for ProgramPreflightFailureGuardV1 { + fn drop(&mut self) { + PROGRAM_PREFLIGHT_FAILURE_AT.with(|failure| failure.set(None)); + PROGRAM_PREFLIGHT_FAILURE_ACTIVE.with(|active| active.set(false)); + } +} + +#[cfg(test)] +pub(crate) fn fail_program_preflight_reservation_for_test( + reservation_index: usize, +) -> ProgramPreflightFailureGuardV1 { + PROGRAM_PREFLIGHT_FAILURE_ACTIVE.with(|active| { + assert!( + !active.replace(true), + "a preflight failure is already armed" + ); + }); + PROGRAM_PREFLIGHT_FAILURE_AT.with(|failure| failure.set(Some(reservation_index))); + ProgramPreflightFailureGuardV1 { + _not_send: PhantomData, + } +} + +#[cfg(test)] +fn injected_program_preflight_failure() -> bool { + PROGRAM_PREFLIGHT_FAILURE_AT.with(|failure| match failure.get() { + Some(0) => { + failure.set(None); + true + } + Some(remaining) => { + failure.set(Some(remaining - 1)); + false + } + None => false, + }) +} + +fn try_reserve_program_evaluation_buffer( + buffer: &mut Vec, + capacity: usize, +) -> Result<(), ()> { + #[cfg(test)] + if injected_program_preflight_failure() { + return Err(()); + } + buffer.try_reserve_exact(capacity).map_err(|_| ()) +} + +struct PreparedProgramEvaluationBuffersV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + selected_cells: Vec>, + conflict_cells: Vec>, + outputs: Vec, + counts: ProgramEvaluationCellCountsV1, +} + +struct SelectedProgramEvaluationBuffersV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + cells: Vec>, + outputs: Vec, + expected_cell_count: usize, +} + +impl PreparedProgramEvaluationBuffersV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + fn take_selected(&mut self) -> SelectedProgramEvaluationBuffersV1 { + SelectedProgramEvaluationBuffersV1 { + cells: std::mem::take(&mut self.selected_cells), + outputs: std::mem::take(&mut self.outputs), + expected_cell_count: self.counts.selected, + } + } +} + +fn prepare_program_evaluation_buffers( + epoch: &ProgramEpochV1, + observation: &RevisionBoundObservationV1, +) -> Result< + PreparedProgramEvaluationBuffersV1, + ProgramSessionEvaluationError>, +> +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + let counts = + checked_program_epoch_evaluation_cell_counts(epoch, observation.physical_case_count()) + .ok_or(ProgramSessionEvaluationError::ResourceExhausted)?; + + let mut selected_cells = Vec::new(); + try_reserve_program_evaluation_buffer(&mut selected_cells, counts.selected) + .map_err(|()| ProgramSessionEvaluationError::ResourceExhausted)?; + let mut conflict_cells = Vec::new(); + if epoch.joint_selection.is_some() && counts.exhaustive_conflict != 0 { + try_reserve_program_evaluation_buffer(&mut conflict_cells, counts.exhaustive_conflict) + .map_err(|()| ProgramSessionEvaluationError::ResourceExhausted)?; + } + let mut outputs = Vec::new(); + try_reserve_program_evaluation_buffer(&mut outputs, epoch.outputs.len()) + .map_err(|()| ProgramSessionEvaluationError::ResourceExhausted)?; + + Ok(PreparedProgramEvaluationBuffersV1 { + selected_cells, + conflict_cells, + outputs, + counts, + }) +} + +/// Per-Session mutable execution state bound weakly to one immutable compiled +/// owner generation. A transaction pins the generation before raw admission; +/// the Session itself cannot prolong the owner lifetime. +pub(crate) struct ProgramSessionPlan +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + owner_generation: Weak>, + bindings: AdmittedAppearanceBindings, + workspace: AppearanceWorkspace, + modeled_occurrences: Vec>, +} + +impl session_private::PlanSealed for ProgramSessionPlan +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ +} + +impl SessionPlanV1 for ProgramSessionPlan +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + type OwnerLease = ProgramOwnerLeaseV1; + type Verified = ProgramVerifiedV1; + type Violation = ProgramConflictV1; + type Error = ProgramSessionEvaluationError>; + + fn try_acquire_owner(&self) -> Option { + self.owner_generation.upgrade().map(ProgramOwnerLeaseV1) + } + + fn observation_schema<'a>( + &'a self, + owner: &'a Self::OwnerLease, + ) -> &'a CanonicalObservationSchemaV1 { + &owner.0.observation_group.schema + } + + fn evaluate( + &mut self, + owner: &Self::OwnerLease, + observation: RevisionBoundObservationV1, + _permit: SessionObservationBindingPermitV1, + ) -> Result, Self::Error> { + evaluate_program_session(self, &owner.0, observation) + } +} + +fn evaluate_program_session( + plan: &mut ProgramSessionPlan, + epoch: &ProgramEpochV1, + observation: RevisionBoundObservationV1, +) -> ProgramSessionEvaluationResult +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + let Some(selection) = &epoch.joint_selection else { + let mut buffers = prepare_program_evaluation_buffers(epoch, &observation)?; + return collect_program_candidate_into( + plan, + epoch, + observation, + None, + 1, + buffers.take_selected(), + ); + }; + + let state_count = selection.order.state_count(); + let mut buffers = prepare_program_evaluation_buffers(epoch, &observation)?; + for (state_index, tuple) in selection.order.tuples().enumerate() { + apply_joint_candidate(plan, &epoch.finite_targets, tuple)?; + if !scan_program_candidate(plan, epoch, &observation, state_index, None, None)? { + // A selected tuple is never certified from its allocation-free + // search pass. Re-apply and collect fresh terminal evidence. + apply_joint_candidate(plan, &epoch.finite_targets, tuple)?; + match collect_program_candidate_into( + plan, + epoch, + observation.clone(), + Some(state_index), + state_index + 1, + buffers.take_selected(), + )? { + SessionDecision::Verified(verified) => { + return Ok(SessionDecision::Verified(verified)); + } + SessionDecision::Violation(conflict) => { + let first = conflict + .report + .cells + .iter() + .find(|cell| cell.is_hard() && cell.result().is_violation()) + .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; + let hard_violation_count = conflict + .report + .cells + .iter() + .filter(|cell| cell.is_hard() && cell.result().is_violation()) + .count(); + return Err(ProgramSessionEvaluationError::FinalRecheckViolation { + state_index, + case_index: first.case_index, + constraint: first.constraint, + target: first.target, + hard_violation_count, + }); + } + } + } + } + + for (state_index, tuple) in selection.order.tuples().enumerate() { + apply_joint_candidate(plan, &epoch.finite_targets, tuple)?; + if !scan_program_candidate( + plan, + epoch, + &observation, + state_index, + Some(&mut buffers.conflict_cells), + None, + )? { + return Err(ProgramSessionEvaluationError::InternalInvariant); + } + } + if buffers.conflict_cells.len() != buffers.counts.exhaustive_conflict { + return Err(ProgramSessionEvaluationError::InternalInvariant); + } + + Ok(SessionDecision::Violation(ProgramConflictV1 { + report: ProgramReportV1 { + content_identity: epoch.content_identity, + observation, + cells: buffers.conflict_cells, + }, + considered_state_count: state_count, + })) +} + +fn apply_joint_candidate( + plan: &mut ProgramSessionPlan, + targets: &[CompiledFiniteTargetV1], + tuple: &[FiniteDomainOrdinalV1], +) -> Result<(), ProgramSessionEvaluationError>> +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + if targets.len() != tuple.len() { + return Err(ProgramSessionEvaluationError::InternalInvariant); + } + for (target, ordinal) in targets.iter().zip(tuple) { + let candidate = target + .candidates + .get(ordinal.index()) + .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; + plan.bindings + .overwrite_color_at(target.binding, candidate.srgb8()) + .map_err(map_program_execution_binding_error)?; + } + Ok(()) +} + +fn collect_program_candidate_into( + plan: &mut ProgramSessionPlan, + epoch: &ProgramEpochV1, + observation: RevisionBoundObservationV1, + selected_state_index: Option, + considered_state_count: usize, + buffers: SelectedProgramEvaluationBuffersV1, +) -> ProgramSessionEvaluationResult +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + let SelectedProgramEvaluationBuffersV1 { + mut cells, + mut outputs, + expected_cell_count, + } = buffers; + if !cells.is_empty() + || cells.capacity() < expected_cell_count + || !outputs.is_empty() + || outputs.capacity() < epoch.outputs.len() + { + return Err(ProgramSessionEvaluationError::InternalInvariant); + } + let candidate_state_index = selected_state_index.unwrap_or(0); + let has_hard_violation = scan_program_candidate( + plan, + epoch, + &observation, + candidate_state_index, + Some(&mut cells), + Some(&mut outputs), + )?; + if cells.len() != expected_cell_count { + return Err(ProgramSessionEvaluationError::InternalInvariant); + } + let report = ProgramReportV1 { + content_identity: epoch.content_identity, + observation, + cells, + }; + if has_hard_violation { + Ok(SessionDecision::Violation(ProgramConflictV1 { + report, + considered_state_count, + })) + } else { + Ok(SessionDecision::Verified(ProgramVerifiedV1 { + report, + outputs, + selected_state_index, + })) + } +} + +fn scan_program_candidate( + plan: &mut ProgramSessionPlan, + epoch: &ProgramEpochV1, + observation: &RevisionBoundObservationV1, + candidate_state_index: usize, + mut cells: Option<&mut Vec>>, + mut outputs: Option<&mut Vec>, +) -> Result>> +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + let schema = &epoch.observation_group.schema; + if !observation.shares_schema_backing_with(schema) { + observation + .validate_surface_schema(schema.as_slice()) + .map_err(ProgramSessionEvaluationError::ObservationSchemaMismatch)?; + return Err(ProgramSessionEvaluationError::InternalInvariant); + } + + let case_count = observation.physical_case_count(); + let mut has_hard_violation = false; + let mut output_mismatch = None; + for case_index in 0..case_count { + let values = observation + .physical_values(case_index) + .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; + if values.len() != schema.as_slice().len() { + let binding_index = values.len().min(schema.as_slice().len()); + return Err(ProgramSessionEvaluationError::ObservationSchemaMismatch( + ObservationSchemaMismatchV1::new( + case_index, + binding_index, + schema.as_slice().get(binding_index).copied(), + None, + ), + )); + } + plan.bindings + .overwrite_surface_inputs_canonical(schema.as_slice().iter().copied(), |index| { + values[index].srgb8() + }) + .map_err(map_program_execution_binding_error)?; + let evaluation = epoch + .graph + .evaluate_admitted_into(&plan.bindings, &mut plan.workspace) + .map_err(map_program_execution_binding_error)?; + plan.modeled_occurrences.fill(None); + + for constraint in epoch.constraints.iter() { + let source = evaluation + .occurrence_at(constraint.target) + .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; + if source.visible() != source.certificate().output_rgb() { + return Err(ProgramSessionEvaluationError::InternalInvariant); + } + let modeled_lcs_occurrence = match plan + .modeled_occurrences + .get(constraint.modeled_occurrence_index) + .copied() + .flatten() + { + Some(modeled) => modeled, + None => { + let binding = epoch + .occurrence_contexts + .get(constraint.modeled_occurrence_index) + .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; + if binding.occurrence != constraint.target_id + || binding.target != constraint.target + { + return Err(ProgramSessionEvaluationError::InternalInvariant); + } + let modeled = ModeledLcsOccurrenceV1::from_signal_in_context( + ColorSignal::from_srgb8(Srgb8::new(source.visible())), + binding.context, + ) + .map_err(|source| { + ProgramSessionEvaluationError::ModeledOccurrence { + case_index, + occurrence: constraint.target_id, + context: binding.context, + source, + } + })?; + let slot = plan + .modeled_occurrences + .get_mut(constraint.modeled_occurrence_index) + .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; + *slot = Some(modeled); + modeled + } + }; + let decision = Evaluation::assess( + &epoch.evaluator, + source, + modeled_lcs_occurrence, + constraint.invocation, + ) + .map_err(|error| match error { + ProgramPointAssessmentErrorV1::Binding(source) => { + debug_assert_ne!(source.physical(), source.modeled()); + ProgramSessionEvaluationError::ProgramTargetBinding { + case_index, + constraint: constraint.id, + occurrence: constraint.target_id, + context: modeled_lcs_occurrence.occurrence().context(), + physical: source.physical(), + modeled: source.modeled(), + } + } + ProgramPointAssessmentErrorV1::Evaluator(source) => { + ProgramSessionEvaluationError::Evaluator { + case_index, + constraint: constraint.id, + occurrence: constraint.target_id, + context: modeled_lcs_occurrence.occurrence().context(), + source, + } + } + })?; + let result = match decision { + HardDecision::Pass(evidence) => ProgramConstraintResultV1::Pass(evidence), + HardDecision::Violation(evidence) => { + if constraint.mode.rejects_candidate() { + has_hard_violation = true; + } + ProgramConstraintResultV1::Violation(evidence) + } + }; + debug_assert_eq!(result.binding().physical(), source.visible_point_binding()); + debug_assert_eq!(result.binding().modeled_lcs(), modeled_lcs_occurrence); + if let Some(cells) = cells.as_deref_mut() { + cells.push(ProgramConstraintCellV1 { + candidate_state_index, + case_index, + constraint: constraint.id, + target: constraint.target_id, + mode: constraint.mode, + result, + }); + } + } + + if let Some(outputs) = outputs.as_deref_mut() { + for (output_index, output) in epoch.outputs.iter().enumerate() { + let paint = evaluation + .paint_at(output.paint) + .copied() + .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; + if paint.id() != output.paint_id { + return Err(ProgramSessionEvaluationError::InternalInvariant); + } + let routed = ProgramOutputV1 { + output: output.output, + paint, + }; + if case_index == 0 { + outputs.push(routed); + } else if outputs.get(output_index).copied() != Some(routed) + && output_mismatch.is_none() + { + output_mismatch = + Some(ProgramSessionEvaluationError::OutputVariesAcrossCases { + output: output.output, + first_case: 0, + actual_case: case_index, + }); + } + } + } + } + + if let Some(error) = output_mismatch { + Err(error) + } else { + Ok(has_hard_violation) + } +} + +fn map_program_execution_binding_error( + error: BindingError, +) -> ProgramSessionEvaluationError { + match error { + BindingError::ResourceExhausted => ProgramSessionEvaluationError::ResourceExhausted, + _ => ProgramSessionEvaluationError::InternalInvariant, + } +} + +fn validate_surface_input_bijection( + program: &Program, +) -> Result<(), ProgramCompileError> +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + let mut bindings = Vec::new(); + bindings + .try_reserve_exact(program.surfaces.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for surface in &program.surfaces { + if let Surface::Input { id, input } = *surface { + bindings.push((input, id)); + } + } + bindings.sort_unstable(); + + for pair in bindings.windows(2) { + if let [ + (first_input, first_surface), + (duplicate_input, duplicate_surface), + ] = pair + { + if first_input == duplicate_input { + return Err(ProgramCompileError::DuplicateSurfaceInputBinding { + input: *first_input, + first: *first_surface, + duplicate: *duplicate_surface, + }); + } + } + } + + for input in &program.observation_group.surface_input_ports { + if bindings + .binary_search_by_key(input, |(bound, _surface)| *bound) + .is_err() + { + return Err(ProgramCompileError::UnusedSurfaceInputPort { input: *input }); + } + } + Ok(()) +} + +fn prepare_program( + mut program: Program, +) -> Result, ProgramCompileError> +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + if program.observation_group.surface_input_ports.is_empty() { + return Err(ProgramCompileError::EmptyObservationGroup { + group: program.observation_group.id, + }); + } + if program.occurrences.is_empty() { + return Err(ProgramCompileError::EmptyOccurrenceSet); + } + if program.constraints.is_empty() { + return Err(ProgramCompileError::EmptyConstraintSet); + } + if program.outputs.is_empty() { + return Err(ProgramCompileError::EmptyOutputSet); + } + check_render_node_count(program.surfaces.len(), program.occurrences.len())?; + program + .constraints + .checked_len() + .ok_or(ProgramCompileError::ResourceExhausted)?; + canonicalize_sources_and_targets(&mut program)?; + + let graph = lower_graph(&program)? + .compile() + .map_err(map_compile_error)?; + validate_surface_input_bijection(&program)?; + let binding_template = graph + .admit_bindings(&lower_bindings(&program)?) + .map_err(map_binding_compile_error)?; + + let mut surface_input_ports = Vec::new(); + surface_input_ports + .try_reserve_exact(program.observation_group.surface_input_ports.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + surface_input_ports.extend_from_slice(&program.observation_group.surface_input_ports); + surface_input_ports.sort_unstable(); + if !canonical_surface_input_port_sequence_matches( + graph.surface_input_ports(), + &surface_input_ports, + ) { + return Err(ProgramCompileError::InternalInvariant); + } + let observation_schema = canonicalize_observation_schema(surface_input_ports) + .map_err(map_observation_schema_compile_error)?; + + validate_terminal_dependency_cone(&program)?; + 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)?; + let occurrence_contexts = + compact_constraint_contexts(&all_occurrence_contexts, &mut constraints)?; + 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, + observation_group: CompiledObservationGroupV1 { + id: program.observation_group.id, + schema: observation_schema, + }, + occurrence_contexts, + constraints, + outputs, + finite_targets, + joint_selection, + }) +} + +fn canonicalize_sources_and_targets( + program: &mut Program, +) -> Result<(), ProgramCompileError> +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + program.sources.sort_unstable_by_key(|source| source.id); + if let Some(source) = program + .sources + .windows(2) + .find(|pair| pair[0].id == pair[1].id) + .map(|pair| pair[0].id) + { + return Err(ProgramCompileError::DuplicateSource { source }); + } + + program.targets.sort_unstable_by_key(|target| target.id); + if let Some(target) = program + .targets + .windows(2) + .find(|pair| pair[0].id == pair[1].id) + .map(|pair| pair[0].id) + { + return Err(ProgramCompileError::DuplicateTarget { target }); + } + for target in &program.targets { + if program + .sources + .binary_search_by_key(&target.source, |source| source.id) + .is_err() + { + return Err(ProgramCompileError::MissingTargetSource { + target: target.id, + source: target.source, + }); + } + } + for paint in &program.paints { + if let Paint::Solid { id, target } = *paint { + if program + .targets + .binary_search_by_key(&target, |candidate| candidate.id) + .is_err() + { + return Err(ProgramCompileError::MissingPaintTarget { paint: id, target }); + } + } + } + Ok(()) +} + +#[derive(Debug, Clone, Copy)] +enum IndexedPaintDependencyV1 { + Target(usize), + Paint(usize), +} + +#[derive(Debug, Clone, Copy)] +enum IndexedDependencyNodeV1 { + Paint(usize), + Surface(usize), + Occurrence(usize), +} + +struct IndexedProgramDependenciesV1 { + paint_ids: Vec<(PaintId, usize)>, + occurrence_ids: Vec<(OccurrenceId, usize)>, + paint_dependencies: Vec, + surface_occurrences: Vec>, + occurrence_paints: Vec, + occurrence_surfaces: Vec, +} + +impl IndexedProgramDependenciesV1 { + fn paint(&self, id: PaintId) -> Option { + self.paint_ids + .binary_search_by_key(&id, |(candidate, _)| *candidate) + .ok() + .map(|index| self.paint_ids[index].1) + } + + fn occurrence(&self, id: OccurrenceId) -> Option { + self.occurrence_ids + .binary_search_by_key(&id, |(candidate, _)| *candidate) + .ok() + .map(|index| self.occurrence_ids[index].1) + } +} + +fn index_program_dependencies( + program: &Program, +) -> Result +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + let mut paint_ids = Vec::new(); + paint_ids + .try_reserve_exact(program.paints.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + paint_ids.extend(program.paints.iter().enumerate().map(|(index, paint)| { + let id = match *paint { + Paint::Solid { id, .. } | Paint::Opacity { id, .. } => id, + }; + (id, index) + })); + paint_ids.sort_unstable_by_key(|(id, _)| *id); + + let mut surface_ids = Vec::new(); + surface_ids + .try_reserve_exact(program.surfaces.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + surface_ids.extend(program.surfaces.iter().enumerate().map(|(index, surface)| { + let id = match *surface { + Surface::Input { id, .. } | Surface::FromOccurrence { id, .. } => id, + }; + (id, index) + })); + surface_ids.sort_unstable_by_key(|(id, _)| *id); + + let mut occurrence_ids = Vec::new(); + occurrence_ids + .try_reserve_exact(program.occurrences.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + occurrence_ids.extend( + program + .occurrences + .iter() + .enumerate() + .map(|(index, occurrence)| (occurrence.id, index)), + ); + occurrence_ids.sort_unstable_by_key(|(id, _)| *id); + + let paint_ordinal = |id: PaintId| { + paint_ids + .binary_search_by_key(&id, |(candidate, _)| *candidate) + .ok() + .map(|index| paint_ids[index].1) + .ok_or(ProgramCompileError::InternalInvariant) + }; + let surface_ordinal = |id: SurfaceId| { + surface_ids + .binary_search_by_key(&id, |(candidate, _)| *candidate) + .ok() + .map(|index| surface_ids[index].1) + .ok_or(ProgramCompileError::InternalInvariant) + }; + let occurrence_ordinal = |id: OccurrenceId| { + occurrence_ids + .binary_search_by_key(&id, |(candidate, _)| *candidate) + .ok() + .map(|index| occurrence_ids[index].1) + .ok_or(ProgramCompileError::InternalInvariant) + }; + + let mut paint_dependencies = Vec::new(); + paint_dependencies + .try_reserve_exact(program.paints.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for paint in &program.paints { + paint_dependencies.push(match *paint { + Paint::Solid { target, .. } => IndexedPaintDependencyV1::Target( + program + .targets + .binary_search_by_key(&target, |candidate| candidate.id) + .map_err(|_| ProgramCompileError::InternalInvariant)?, + ), + Paint::Opacity { source, .. } => { + IndexedPaintDependencyV1::Paint(paint_ordinal(source)?) + } + }); + } + let mut surface_occurrences = Vec::new(); + surface_occurrences + .try_reserve_exact(program.surfaces.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for surface in &program.surfaces { + surface_occurrences.push(match *surface { + Surface::Input { .. } => None, + Surface::FromOccurrence { occurrence, .. } => Some(occurrence_ordinal(occurrence)?), + }); + } + let mut occurrence_paints = Vec::new(); + let mut occurrence_surfaces = Vec::new(); + occurrence_paints + .try_reserve_exact(program.occurrences.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + occurrence_surfaces + .try_reserve_exact(program.occurrences.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for occurrence in &program.occurrences { + occurrence_paints.push(paint_ordinal(occurrence.subject)?); + occurrence_surfaces.push(surface_ordinal(occurrence.against)?); + } + Ok(IndexedProgramDependenciesV1 { + paint_ids, + occurrence_ids, + paint_dependencies, + surface_occurrences, + occurrence_paints, + occurrence_surfaces, + }) +} + +struct ProgramDependencyScratchV1 { + targets: Vec, + paints: Vec, + surfaces: Vec, + occurrences: Vec, + queue: Vec, +} + +impl ProgramDependencyScratchV1 { + fn new(program: &Program) -> Result + where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, + { + let node_count = program + .paints + .len() + .checked_add(program.surfaces.len()) + .and_then(|count| count.checked_add(program.occurrences.len())) + .ok_or(ProgramCompileError::ResourceExhausted)?; + let mut queue = Vec::new(); + queue + .try_reserve_exact(node_count) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + Ok(Self { + targets: false_slots(program.targets.len())?, + paints: false_slots(program.paints.len())?, + surfaces: false_slots(program.surfaces.len())?, + occurrences: false_slots(program.occurrences.len())?, + queue, + }) + } + + fn scan( + &mut self, + index: &IndexedProgramDependenciesV1, + roots: impl IntoIterator, + ) -> Result<(), ProgramCompileError> { + self.targets.fill(false); + self.paints.fill(false); + self.surfaces.fill(false); + self.occurrences.fill(false); + self.queue.clear(); + for root in roots { + let occurrence = index + .occurrence(root) + .ok_or(ProgramCompileError::InternalInvariant)?; + if !self.occurrences[occurrence] { + self.occurrences[occurrence] = true; + self.queue + .push(IndexedDependencyNodeV1::Occurrence(occurrence)); + } + } + + let mut cursor = 0usize; + while let Some(node) = self.queue.get(cursor).copied() { + cursor += 1; + match node { + IndexedDependencyNodeV1::Occurrence(occurrence) => { + let paint = index.occurrence_paints[occurrence]; + if !self.paints[paint] { + self.paints[paint] = true; + self.queue.push(IndexedDependencyNodeV1::Paint(paint)); + } + let surface = index.occurrence_surfaces[occurrence]; + if !self.surfaces[surface] { + self.surfaces[surface] = true; + self.queue.push(IndexedDependencyNodeV1::Surface(surface)); + } + } + IndexedDependencyNodeV1::Surface(surface) => { + if let Some(occurrence) = index.surface_occurrences[surface] { + if !self.occurrences[occurrence] { + self.occurrences[occurrence] = true; + self.queue + .push(IndexedDependencyNodeV1::Occurrence(occurrence)); + } + } + } + IndexedDependencyNodeV1::Paint(paint) => match index.paint_dependencies[paint] { + IndexedPaintDependencyV1::Target(target) => self.targets[target] = true, + IndexedPaintDependencyV1::Paint(source) => { + if !self.paints[source] { + self.paints[source] = true; + self.queue.push(IndexedDependencyNodeV1::Paint(source)); + } + } + }, + } + } + Ok(()) + } +} + +fn false_slots(len: usize) -> Result, ProgramCompileError> { + let mut slots = Vec::new(); + slots + .try_reserve_exact(len) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + slots.resize(len, false); + Ok(slots) +} + +fn validate_terminal_dependency_cone( + program: &Program, +) -> Result<(), ProgramCompileError> +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + // Preserve the canonical missing-reference diagnostics owned by constraint + // and output compilation before applying the stronger terminal-safety law. + if program + .constraints + .hard + .iter() + .map(|constraint| constraint.target) + .chain( + program + .constraints + .report_only + .iter() + .map(|constraint| constraint.target), + ) + .any(|target| { + !program + .occurrences + .iter() + .any(|occurrence| occurrence.id == target) + }) + || program.outputs.iter().any(|output| { + !program.paints.iter().any(|paint| match *paint { + Paint::Solid { id, .. } | Paint::Opacity { id, .. } => id == output.paint, + }) + }) + { + return Ok(()); + } + + let index = index_program_dependencies(program)?; + let mut scratch = ProgramDependencyScratchV1::new(program)?; + scratch.scan( + &index, + program + .constraints + .hard + .iter() + .map(|constraint| constraint.target) + .chain( + program + .constraints + .report_only + .iter() + .map(|constraint| constraint.target), + ), + )?; + for (target_index, target) in program.targets.iter().enumerate() { + if matches!(&target.domain, TargetDomainV1::Finite(_)) && !scratch.targets[target_index] { + return Err(ProgramCompileError::UnconstrainedTarget { target: target.id }); + } + } + for output in &program.outputs { + let paint_index = index + .paint(output.paint) + .ok_or(ProgramCompileError::InternalInvariant)?; + if !scratch.paints[paint_index] { + return Err(ProgramCompileError::UnassessedOutput { + output: output.output, + paint: output.paint, + }); + } + } + + let finite_count = program + .targets + .iter() + .filter(|target| matches!(&target.domain, TargetDomainV1::Finite(_))) + .count(); + if finite_count > 1 { + let mut has_common_assessment = false; + for target in program + .constraints + .hard + .iter() + .map(|constraint| constraint.target) + .chain( + program + .constraints + .report_only + .iter() + .map(|constraint| constraint.target), + ) + { + scratch.scan(&index, [target])?; + if program.targets.iter().enumerate().all(|(index, target)| { + !matches!(&target.domain, TargetDomainV1::Finite(_)) || scratch.targets[index] + }) { + has_common_assessment = true; + break; + } + } + if !has_common_assessment { + return Err(ProgramCompileError::DisconnectedFiniteTargets); + } + } + Ok(()) +} + +fn map_observation_schema_compile_error(error: ObservationError) -> ProgramCompileError { + match error { + ObservationError::ResourceExhausted => ProgramCompileError::ResourceExhausted, + _ => ProgramCompileError::InternalInvariant, + } +} + +struct LoweredConstraint { + id: ConstraintId, + target: OccurrenceId, + mode: CompiledConstraintModeV1, + invocation: Invocation, +} + +fn compile_targets( + graph: &CompiledAppearanceGraph, + authored_targets: &mut [Target], + authored_selection: Option<&mut DeclaredJointSelectionV1>, +) -> Result< + ( + Box<[CompiledFiniteTargetV1]>, + Option, + ), + ProgramCompileError, +> { + struct CanonicalFiniteTargetV1<'a> { + id: TargetId, + binding: CompiledColorInputSlotV1, + candidates: &'a [TargetCandidateV1], + } + + let mut compiled = Vec::new(); + compiled + .try_reserve_exact(authored_targets.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for target in authored_targets { + let TargetDomainV1::Finite(candidates) = &mut target.domain else { + continue; + }; + if candidates.is_empty() { + return Err(ProgramCompileError::EmptyTargetDomain { target: target.id }); + } + let binding = graph + .bind_color_input(target_color_input_id(target.id)) + .ok_or(ProgramCompileError::InternalInvariant)?; + candidates.sort_unstable_by_key(|candidate| candidate.id); + if let Some(candidate) = candidates + .windows(2) + .find(|pair| pair[0].id == pair[1].id) + .map(|pair| pair[0].id) + { + return Err(ProgramCompileError::DuplicateTargetCandidate { + target: target.id, + candidate, + }); + } + let mut physical = Vec::new(); + physical + .try_reserve_exact(candidates.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + physical.extend( + candidates + .iter() + .map(|candidate| (candidate.signal, candidate.id)), + ); + physical.sort_unstable(); + if let Some(pair) = physical.windows(2).find(|pair| pair[0].0 == pair[1].0) { + return Err(ProgramCompileError::DuplicateTargetCandidateSignal { + target: target.id, + first: pair[0].1, + duplicate: pair[1].1, + signal: pair[0].0, + }); + } + compiled.push(CanonicalFiniteTargetV1 { + id: target.id, + binding, + candidates, + }); + } + + if compiled.is_empty() { + return match authored_selection { + None => Ok((Box::new([]), None)), + Some(_) => Err(ProgramCompileError::JointSelectionWithoutTargets), + }; + } + let Some(authored_selection) = authored_selection else { + return Err(ProgramCompileError::MissingJointSelection); + }; + + let mut authored_tuples = Vec::new(); + authored_tuples + .try_reserve_exact(authored_selection.states.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + 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) + .map(|pair| pair[0].target) + { + return Err(ProgramCompileError::JointStateDuplicateTarget { + state: state_index, + target, + }); + } + if let Some(choice) = authored_state.choices.iter().find(|choice| { + compiled + .binary_search_by_key(&choice.target, |target| target.id) + .is_err() + }) { + return Err(ProgramCompileError::JointStateUnknownTarget { + state: state_index, + target: choice.target, + }); + } + + let mut tuple = Vec::new(); + tuple + .try_reserve_exact(compiled.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for target in &compiled { + 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 = authored_state.choices[choice_index]; + let candidate_index = target + .candidates + .binary_search_by_key(&choice.candidate, |candidate| candidate.id) + .map_err(|_| ProgramCompileError::JointStateUnknownCandidate { + state: state_index, + target: target.id, + candidate: choice.candidate, + })?; + tuple.push(FiniteDomainOrdinalV1::new(candidate_index)); + } + authored_tuples.push(tuple); + } + + let mut domain_lengths = Vec::new(); + domain_lengths + .try_reserve_exact(compiled.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + domain_lengths.extend(compiled.iter().map(|target| target.candidates.len())); + let order = admit_finite_joint_order_v1(&domain_lengths, authored_tuples) + .map_err(ProgramCompileError::InvalidJointOrder)?; + let mut runtime_targets = Vec::new(); + runtime_targets + .try_reserve_exact(compiled.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for target in compiled { + let mut candidates = Vec::new(); + candidates + .try_reserve_exact(target.candidates.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + candidates.extend(target.candidates.iter().map(|candidate| candidate.signal())); + runtime_targets.push(CompiledFiniteTargetV1 { + binding: target.binding, + candidates: candidates.into_boxed_slice(), + }); + } + Ok(( + runtime_targets.into_boxed_slice(), + Some(CompiledJointSelectionV1 { order }), + )) +} + +fn compile_occurrence_contexts( + graph: &CompiledAppearanceGraph, + authored: &[Occurrence], +) -> Result, ProgramCompileError> { + let mut contexts = Vec::new(); + contexts + .try_reserve_exact(authored.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + contexts.extend( + authored + .iter() + .map(|occurrence| (occurrence.id(), occurrence.context())), + ); + contexts.sort_unstable_by_key(|(occurrence, _)| *occurrence); + + let mut compiled = Vec::new(); + compiled + .try_reserve_exact(authored.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for occurrence in graph.occurrence_ids() { + let index = contexts + .binary_search_by_key(&occurrence, |(declared, _)| *declared) + .map_err(|_| ProgramCompileError::InternalInvariant)?; + let target = graph + .bind_occurrence(occurrence) + .ok_or(ProgramCompileError::InternalInvariant)?; + compiled.push(CompiledOccurrenceContextV1 { + occurrence, + target, + context: contexts[index].1, + }); + } + if compiled.len() != authored.len() { + return Err(ProgramCompileError::InternalInvariant); + } + Ok(compiled.into_boxed_slice()) +} + +fn compile_constraints( + graph: &CompiledAppearanceGraph, + occurrence_contexts: &[CompiledOccurrenceContextV1], + authored: &ConstraintSet>, +) -> Result< + Box<[CompiledPointConstraint>]>, + ProgramCompileError, +> +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + let total = authored + .hard + .len() + .checked_add(authored.report_only.len()) + .ok_or(ProgramCompileError::ResourceExhausted)?; + let mut lowered = Vec::new(); + lowered + .try_reserve_exact(total) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + lowered.extend(authored.hard.iter().map(|constraint| LoweredConstraint { + id: constraint.id, + target: constraint.target, + mode: CompiledConstraintModeV1::Hard, + invocation: constraint.invocation, + })); + lowered.extend( + authored + .report_only + .iter() + .map(|constraint| LoweredConstraint { + id: constraint.id, + target: constraint.target, + mode: CompiledConstraintModeV1::ReportOnly, + invocation: constraint.invocation, + }), + ); + lowered.sort_unstable_by_key(|constraint| constraint.id); + if let Some(duplicate) = lowered + .windows(2) + .find(|pair| pair[0].id == pair[1].id) + .map(|pair| pair[0].id) + { + return Err(ProgramCompileError::DuplicateConstraint { + constraint: duplicate, + }); + } + for constraint in &lowered { + if graph.bind_occurrence(constraint.target).is_none() { + return Err(ProgramCompileError::MissingConstraintOccurrence { + constraint: constraint.id, + occurrence: constraint.target, + }); + } + } + + let mut compiled = Vec::new(); + compiled + .try_reserve_exact(total) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for constraint in lowered { + let target = graph + .bind_occurrence(constraint.target) + .ok_or(ProgramCompileError::InternalInvariant)?; + let modeled_occurrence_index = occurrence_contexts + .binary_search_by_key(&constraint.target, |binding| binding.occurrence) + .map_err(|_| ProgramCompileError::InternalInvariant)?; + if occurrence_contexts[modeled_occurrence_index].target != target { + return Err(ProgramCompileError::InternalInvariant); + } + compiled.push(CompiledPointConstraint { + id: constraint.id, + target_id: constraint.target, + target, + modeled_occurrence_index, + mode: constraint.mode, + invocation: constraint.invocation, + }); + } + Ok(compiled.into_boxed_slice()) +} + +fn compact_constraint_contexts( + all: &[CompiledOccurrenceContextV1], + constraints: &mut [CompiledPointConstraint], +) -> Result, ProgramCompileError> { + let mut targets = Vec::new(); + targets + .try_reserve_exact(constraints.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + targets.extend(constraints.iter().map(|constraint| constraint.target_id)); + targets.sort_unstable(); + targets.dedup(); + + let mut compact = Vec::new(); + compact + .try_reserve_exact(targets.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for occurrence in targets { + let index = all + .binary_search_by_key(&occurrence, |binding| binding.occurrence) + .map_err(|_| ProgramCompileError::InternalInvariant)?; + compact.push(all[index]); + } + + for constraint in constraints { + let index = compact + .binary_search_by_key(&constraint.target_id, |binding| binding.occurrence) + .map_err(|_| ProgramCompileError::InternalInvariant)?; + if compact[index].target != constraint.target { + return Err(ProgramCompileError::InternalInvariant); + } + constraint.modeled_occurrence_index = index; + } + Ok(compact.into_boxed_slice()) +} + +fn compile_outputs( + graph: &CompiledAppearanceGraph, + authored: &mut [OutputBinding], +) -> Result, ProgramCompileError> { + let len = authored.len(); + authored.sort_unstable_by_key(|output| output.output); + if let Some(duplicate) = authored + .windows(2) + .find(|pair| pair[0].output == pair[1].output) + .map(|pair| pair[0].output) + { + return Err(ProgramCompileError::DuplicateOutputSlot { output: duplicate }); + } + for output in authored.iter() { + if graph.bind_paint(output.paint).is_none() { + return Err(ProgramCompileError::MissingOutputPaint { + output: output.output, + paint: output.paint, + }); + } + } + + let mut compiled = Vec::new(); + compiled + .try_reserve_exact(len) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for output in authored.iter().copied() { + let paint = graph + .bind_paint(output.paint) + .ok_or(ProgramCompileError::InternalInvariant)?; + compiled.push(CompiledOutputBinding { + output: output.output, + paint_id: output.paint, + paint, + }); + } + Ok(compiled.into_boxed_slice()) +} + +pub(crate) fn check_render_node_count( + surface_count: usize, + occurrence_count: usize, +) -> Result<(), ProgramCompileError> { + surface_count + .checked_add(occurrence_count) + .ok_or(ProgramCompileError::ResourceExhausted) + .map(|_| ()) +} + +pub(crate) fn canonical_surface_input_port_sequence_matches( + actual: impl IntoIterator, + expected: &[SurfaceInputPortId], +) -> bool { + actual.into_iter().eq(expected.iter().copied()) +} + +const fn target_color_input_id(target: TargetId) -> ColorInputId { + ColorInputId::new(target.value()) +} + +fn try_collect_program( + exact_len: usize, + values: impl IntoIterator, +) -> Result, ProgramCompileError> { + let mut collected = Vec::new(); + collected + .try_reserve_exact(exact_len) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + collected.extend(values); + Ok(collected) +} + +fn lower_graph( + program: &Program, +) -> Result +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + let colors = try_collect_program( + program.targets.len(), + program + .targets + .iter() + .map(|target| target_color_input_id(target.id)), + )?; + let surface_inputs = try_collect_program( + program.observation_group.surface_input_ports.len(), + program + .observation_group + .surface_input_ports + .iter() + .copied(), + )?; + let opacities = try_collect_program( + program.opacities.len(), + program.opacities.iter().map(|input| input.id), + )?; + let paints = try_collect_program( + program.paints.len(), + program.paints.iter().map(|paint| match *paint { + Paint::Solid { id, target } => PaintSpec::Solid { + id, + color: target_color_input_id(target), + }, + Paint::Opacity { + id, + source, + opacity, + } => PaintSpec::Opacity { + id, + source, + opacity, + }, + }), + )?; + let surfaces = try_collect_program( + program.surfaces.len(), + program.surfaces.iter().map(|surface| match *surface { + Surface::Input { id, input } => SurfaceSpec::Input { id, port: input }, + Surface::FromOccurrence { id, occurrence } => { + SurfaceSpec::FromOccurrence { id, occurrence } + } + }), + )?; + let occurrences = try_collect_program( + program.occurrences.len(), + program.occurrences.iter().map(|occurrence| OccurrenceSpec { + id: occurrence.id, + subject: occurrence.subject, + against: occurrence.against, + profile: match occurrence.composition { + CompositionProfile::EncodedSrgb8SourceOverV1 => { + CompositionProfileV1::EncodedSrgb8SourceOverV1 + } + }, + }), + )?; + Ok(AppearanceGraphSpec::new( + colors, + surface_inputs, + opacities, + paints, + surfaces, + occurrences, + )) +} + +fn lower_bindings( + program: &Program, +) -> Result +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + let mut colors = Vec::new(); + colors + .try_reserve_exact(program.targets.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for target in &program.targets { + let source_index = program + .sources + .binary_search_by_key(&target.source, |source| source.id) + .map_err(|_| ProgramCompileError::InternalInvariant)?; + colors.push(( + target_color_input_id(target.id), + program.sources[source_index].signal.srgb8(), + )); + } + let surfaces = try_collect_program( + program.observation_group.surface_input_ports.len(), + program + .observation_group + .surface_input_ports + .iter() + .map(|input| (*input, Srgb8::new([0; 3]))), + )?; + let opacities = try_collect_program( + program.opacities.len(), + program + .opacities + .iter() + .map(|input| (input.id, input.value)), + )?; + Ok(AppearanceBindings::new(colors, surfaces, opacities)) +} + +fn map_compile_error(error: CompileError) -> ProgramCompileError { + match error { + CompileError::DuplicateColorInput { .. } => ProgramCompileError::InternalInvariant, + CompileError::DuplicateOpacityInput { input } => { + ProgramCompileError::DuplicateOpacityInput { input } + } + CompileError::DuplicateSurfaceInputPort { input } => { + ProgramCompileError::DuplicateSurfaceInputPort { input } + } + CompileError::DuplicatePaint { paint } => ProgramCompileError::DuplicatePaint { paint }, + CompileError::DuplicateSurface { surface } => { + ProgramCompileError::DuplicateSurface { surface } + } + CompileError::DuplicateOccurrence { occurrence } => { + ProgramCompileError::DuplicateOccurrence { occurrence } + } + CompileError::MissingPaintColorInput { .. } => ProgramCompileError::InternalInvariant, + CompileError::MissingPaintSource { paint, source } => { + ProgramCompileError::MissingPaintSource { paint, source } + } + CompileError::MissingPaintOpacityInput { paint, input } => { + ProgramCompileError::MissingPaintOpacityInput { paint, input } + } + CompileError::MissingSurfaceInputPort { surface, input } => { + ProgramCompileError::MissingSurfaceInputPort { surface, input } + } + CompileError::MissingSurfaceOccurrence { + surface, + occurrence, + } => ProgramCompileError::MissingSurfaceOccurrence { + surface, + occurrence, + }, + CompileError::MissingOccurrencePaint { occurrence, paint } => { + ProgramCompileError::MissingOccurrencePaint { occurrence, paint } + } + CompileError::MissingOccurrenceBackdrop { + occurrence, + surface, + } => ProgramCompileError::MissingOccurrenceBackdrop { + occurrence, + surface, + }, + CompileError::PaintCycle { paints } => ProgramCompileError::PaintCycle { paints }, + CompileError::RenderCycle { + surfaces, + occurrences, + } => ProgramCompileError::RenderCycle { + surfaces, + occurrences, + }, + } +} + +fn map_binding_compile_error(error: BindingError) -> ProgramCompileError { + match error { + BindingError::OpacityOutOfDomain { input, .. } => { + ProgramCompileError::OpacityOutOfDomain { input } + } + BindingError::ResourceExhausted => ProgramCompileError::ResourceExhausted, + _ => ProgramCompileError::InternalInvariant, + } +} diff --git a/crates/labcolors-core/src/program_session_tests.rs b/crates/labcolors-core/src/program_session_tests.rs new file mode 100644 index 00000000..bc933725 --- /dev/null +++ b/crates/labcolors-core/src/program_session_tests.rs @@ -0,0 +1,873 @@ +use crate::Srgb8; +use crate::appearance::{OccurrenceId, OpacityInputId, PaintId, SurfaceId, SurfaceInputPortId}; +use crate::constraints::{ExactSrgb8IdentityV1, ProgramPointEvaluatorV1, ProgramPointInvocation}; +use crate::lcs_occurrence::{ + AdaptingLuminanceCdM2, AppearanceContextId, AppearanceContextSchemaReleaseId, + BackgroundLuminanceRatio, ColorSignal, IEC_SRGB_D65_XYZ_FRAME_V1, SurroundProfileId, +}; +use crate::observation::{ + ObservationGroupId, ObservationHeadViewV1, ObservationPayloadInput, ObservationStreamId, + ObservationUpdateInput, ObservedScenarioSetInput, Revision, ScenarioId, ScenarioInput, + SurfaceInputBinding, +}; +use crate::program_session::{ + CompositionProfile, ConstraintId, ConstraintInvocation, ConstraintSet, ObservationGroup, + Occurrence, OpacityInput, OutputBinding, OutputSlotId, Paint, Program, ProgramCompileError, + Source, SourceId, Surface, Target, TargetId, canonical_surface_input_port_sequence_matches, + check_render_node_count, +}; +use crate::session::{SessionPlanV1, SessionState, SessionUpdateError}; + +const SOURCE: SourceId = SourceId::new(1); +const TARGET: TargetId = TargetId::new(1); +const SURFACE_PORT: SurfaceInputPortId = SurfaceInputPortId::new(2); +const OPACITY: OpacityInputId = OpacityInputId::new(3); +const SOLID: PaintId = PaintId::new(10); +const TRANSLUCENT: PaintId = PaintId::new(11); +const BACKDROP: SurfaceId = SurfaceId::new(20); +const VISIBLE_SURFACE: SurfaceId = SurfaceId::new(21); +const OCCURRENCE: OccurrenceId = OccurrenceId::new(30); +const OUTPUT: OutputSlotId = OutputSlotId::new(40); +const REQUIRED: ConstraintId = ConstraintId::new(50); +const GROUP: ObservationGroupId = ObservationGroupId::new(60); +const STREAM_A: ObservationStreamId = ObservationStreamId::new(70); +const STREAM_B: ObservationStreamId = ObservationStreamId::new(71); + +fn appearance_context() -> AppearanceContextId { + AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + IEC_SRGB_D65_XYZ_FRAME_V1, + AdaptingLuminanceCdM2::try_new(64.0).unwrap(), + BackgroundLuminanceRatio::try_new(0.2).unwrap(), + SurroundProfileId::AverageV1, + ) +} + +fn observation_group(surface_input_ports: Vec) -> ObservationGroup { + ObservationGroup::new(GROUP, surface_input_ports) +} + +fn base_program( + opacity: f64, + against: SurfaceId, + constraints: ConstraintSet>, + outputs: Vec, + evaluator: Evaluation, +) -> Program +where + Evaluation: ProgramPointEvaluatorV1, + ProgramPointInvocation: Copy, +{ + base_program_in_group(GROUP, opacity, against, constraints, outputs, evaluator) +} + +fn base_program_in_group( + group: ObservationGroupId, + opacity: f64, + against: SurfaceId, + constraints: ConstraintSet>, + outputs: Vec, + evaluator: Evaluation, +) -> Program +where + Evaluation: ProgramPointEvaluatorV1, + ProgramPointInvocation: Copy, +{ + Program::new( + vec![Source::new( + SOURCE, + ColorSignal::from_srgb8(Srgb8::new([0; 3])), + )], + vec![Target::fixed(TARGET, SOURCE)], + ObservationGroup::new(group, vec![SURFACE_PORT]), + vec![OpacityInput::new(OPACITY, opacity)], + vec![ + Paint::Solid { + id: SOLID, + target: TARGET, + }, + Paint::Opacity { + id: TRANSLUCENT, + source: SOLID, + opacity: OPACITY, + }, + ], + vec![ + Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }, + Surface::FromOccurrence { + id: VISIBLE_SURFACE, + occurrence: OCCURRENCE, + }, + ], + vec![Occurrence::new( + OCCURRENCE, + TRANSLUCENT, + against, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + )], + constraints, + outputs, + evaluator, + ) +} + +fn compile_error(program: Program) -> ProgramCompileError +where + Evaluation: ProgramPointEvaluatorV1, + ProgramPointInvocation: Copy, +{ + match program.compile() { + Ok(_) => panic!("invalid declaration compiled"), + Err(error) => error, + } +} + +fn observed_update( + stream: ObservationStreamId, + revision: u64, + scenarios: &[(u32, [u8; 3])], +) -> ObservationUpdateInput { + ObservationUpdateInput { + stream, + revision: Revision::new(revision), + payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { + scenarios: scenarios + .iter() + .map(|(scenario, backdrop)| ScenarioInput { + id: ScenarioId::new(*scenario), + bindings: vec![SurfaceInputBinding::new( + SURFACE_PORT, + ColorSignal::from_srgb8(Srgb8::new(*backdrop)), + )], + }) + .collect(), + }), + } +} + +fn exact_compiled( + constraints: ConstraintSet, +) -> crate::program_session::CompiledProgram { + base_program( + 0.5, + BACKDROP, + constraints, + vec![OutputBinding::new(OUTPUT, TRANSLUCENT)], + ExactSrgb8IdentityV1, + ) + .compile() + .unwrap() +} + +#[test] +fn authored_modes_are_marker_typed_and_values_preserve_exact_ids() { + let hard = ConstraintInvocation::hard(REQUIRED, OCCURRENCE, Srgb8::new([0x80; 3])); + let report = + ConstraintInvocation::report_only(ConstraintId::new(51), OCCURRENCE, Srgb8::new([0x81; 3])); + let set = ConstraintSet::new(vec![hard], vec![report]); + assert_eq!(set.hard()[0].id(), REQUIRED); + assert_eq!(set.hard()[0].target(), OCCURRENCE); + assert_eq!(*set.hard()[0].invocation(), Srgb8::new([0x80; 3])); + assert_eq!(set.report_only()[0].id(), ConstraintId::new(51)); + + let output = OutputBinding::new(OUTPUT, TRANSLUCENT); + assert_eq!(output.output(), OUTPUT); + assert_eq!(output.paint(), TRANSLUCENT); + assert_eq!(ConstraintId::new(7).value(), 7); + assert_eq!(OutputSlotId::new(8).value(), 8); + + let source = Source::new(SOURCE, ColorSignal::from_srgb8(Srgb8::new([1, 2, 3]))); + assert_eq!(SOURCE.value(), 1); + assert_eq!(source.id(), SOURCE); + assert_eq!( + source.signal(), + ColorSignal::from_srgb8(Srgb8::new([1, 2, 3])) + ); + let target = Target::fixed(TARGET, SOURCE); + assert_eq!(target.id(), TARGET); + assert_eq!(target.source(), SOURCE); + let opacity = OpacityInput::new(OPACITY, 0.375); + assert_eq!(opacity.id(), OPACITY); + assert_eq!(opacity.value(), 0.375); + + let occurrence = Occurrence::new( + OCCURRENCE, + TRANSLUCENT, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + ); + assert_eq!(occurrence.id(), OCCURRENCE); + assert_eq!(occurrence.subject(), TRANSLUCENT); + assert_eq!(occurrence.against(), BACKDROP); + assert_eq!( + occurrence.composition(), + CompositionProfile::EncodedSrgb8SourceOverV1 + ); + + let group = observation_group(vec![SURFACE_PORT]); + assert_eq!(group.id(), GROUP); + assert_eq!(group.surface_input_ports(), &[SURFACE_PORT]); +} + +#[test] +fn empty_domains_have_stable_precedence() { + let empty_surface = Program::new( + vec![Source::new( + SOURCE, + ColorSignal::from_srgb8(Srgb8::new([0; 3])), + )], + vec![Target::fixed(TARGET, SOURCE)], + observation_group(vec![]), + vec![], + vec![Paint::Solid { + id: SOLID, + target: TARGET, + }], + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + vec![Occurrence::new( + OCCURRENCE, + SOLID, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + )], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + OCCURRENCE, + Srgb8::new([0; 3]), + )], + vec![], + ), + vec![OutputBinding::new(OUTPUT, SOLID)], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(empty_surface), + ProgramCompileError::EmptyObservationGroup { group: GROUP } + ); + + let empty_occurrence = Program::new( + vec![Source::new( + SOURCE, + ColorSignal::from_srgb8(Srgb8::new([0; 3])), + )], + vec![Target::fixed(TARGET, SOURCE)], + observation_group(vec![SURFACE_PORT]), + vec![], + vec![Paint::Solid { + id: SOLID, + target: TARGET, + }], + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + vec![], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + OCCURRENCE, + Srgb8::new([0; 3]), + )], + vec![], + ), + vec![OutputBinding::new(OUTPUT, SOLID)], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(empty_occurrence), + ProgramCompileError::EmptyOccurrenceSet + ); + + let empty_constraints = base_program( + 0.5, + BACKDROP, + ConstraintSet::::new(vec![], vec![]), + vec![], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(empty_constraints), + ProgramCompileError::EmptyConstraintSet + ); + + let empty_outputs = base_program( + 0.5, + BACKDROP, + ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + vec![], + ), + vec![], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(empty_outputs), + ProgramCompileError::EmptyOutputSet + ); +} + +#[test] +fn physical_errors_precede_constraint_and_output_errors() { + let missing_surface = SurfaceId::new(999); + let duplicate = ConstraintId::new(77); + let program = base_program( + 0.5, + missing_surface, + ConstraintSet::new( + vec![ConstraintInvocation::hard( + duplicate, + OccurrenceId::new(998), + Srgb8::new([0; 3]), + )], + vec![ConstraintInvocation::report_only( + duplicate, + OccurrenceId::new(997), + Srgb8::new([0; 3]), + )], + ), + vec![ + OutputBinding::new(OUTPUT, PaintId::new(996)), + OutputBinding::new(OUTPUT, PaintId::new(995)), + ], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(program), + ProgramCompileError::MissingOccurrenceBackdrop { + occurrence: OCCURRENCE, + surface: missing_surface, + } + ); +} + +#[test] +fn constraint_and_output_error_precedence_is_canonical() { + let duplicate = ConstraintId::new(77); + let missing_occurrence = OccurrenceId::new(999); + let duplicate_constraints = base_program( + 0.5, + BACKDROP, + ConstraintSet::new( + vec![ConstraintInvocation::hard( + duplicate, + missing_occurrence, + Srgb8::new([0; 3]), + )], + vec![ConstraintInvocation::report_only( + duplicate, + OCCURRENCE, + Srgb8::new([0; 3]), + )], + ), + vec![ + OutputBinding::new(OUTPUT, PaintId::new(998)), + OutputBinding::new(OUTPUT, TRANSLUCENT), + ], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(duplicate_constraints), + ProgramCompileError::DuplicateConstraint { + constraint: duplicate, + } + ); + + let missing_constraint = base_program( + 0.5, + BACKDROP, + ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + missing_occurrence, + Srgb8::new([0; 3]), + )], + vec![], + ), + vec![ + OutputBinding::new(OUTPUT, TRANSLUCENT), + OutputBinding::new(OUTPUT, PaintId::new(998)), + ], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(missing_constraint), + ProgramCompileError::MissingConstraintOccurrence { + constraint: REQUIRED, + occurrence: missing_occurrence, + } + ); + + let duplicate_output = base_program( + 0.5, + BACKDROP, + ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + vec![], + ), + vec![ + OutputBinding::new(OUTPUT, PaintId::new(998)), + OutputBinding::new(OUTPUT, TRANSLUCENT), + ], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(duplicate_output), + ProgramCompileError::DuplicateOutputSlot { output: OUTPUT } + ); + + let missing_paint = PaintId::new(998); + let missing_output = base_program( + 0.5, + BACKDROP, + ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + vec![], + ), + vec![OutputBinding::new(OUTPUT, missing_paint)], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(missing_output), + ProgramCompileError::MissingOutputPaint { + output: OUTPUT, + paint: missing_paint, + } + ); +} + +#[test] +fn compile_canonicalizes_constraints_and_outputs_independent_of_mode_lists() { + let low = ConstraintId::new(1); + let high = ConstraintId::new(9); + let output_low = OutputSlotId::new(2); + let output_high = OutputSlotId::new(8); + let compiled = base_program( + 0.5, + BACKDROP, + ConstraintSet::new( + vec![ConstraintInvocation::hard( + high, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + vec![ConstraintInvocation::report_only( + low, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + ), + vec![ + OutputBinding::new(output_high, TRANSLUCENT), + OutputBinding::new(output_low, TRANSLUCENT), + ], + ExactSrgb8IdentityV1, + ) + .compile() + .unwrap(); + assert_eq!(compiled.observation_group_id(), GROUP); + assert_eq!(compiled.surface_input_ports(), &[SURFACE_PORT]); + assert_eq!( + compiled.constraint_ids().collect::>(), + vec![low, high] + ); + assert_eq!( + compiled.outputs().collect::>(), + vec![(output_low, TRANSLUCENT), (output_high, TRANSLUCENT)] + ); +} + +#[test] +fn canonical_helpers_and_checked_cardinality_fail_closed() { + assert_eq!(check_render_node_count(usize::MAX - 1, 1), Ok(())); + assert_eq!( + check_render_node_count(usize::MAX, 1), + Err(ProgramCompileError::ResourceExhausted) + ); + let canonical = [SurfaceInputPortId::new(1), SurfaceInputPortId::new(2)]; + assert!(canonical_surface_input_port_sequence_matches( + [SurfaceInputPortId::new(1), SurfaceInputPortId::new(2)], + &canonical, + )); + assert!(!canonical_surface_input_port_sequence_matches( + [SurfaceInputPortId::new(2), SurfaceInputPortId::new(1)], + &canonical, + )); + assert!(!canonical_surface_input_port_sequence_matches( + [SurfaceInputPortId::new(1)], + &canonical, + )); +} + +#[test] +fn independently_instantiated_streams_expire_with_their_compiled_owner_generation() { + let compiled = exact_compiled(ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + vec![], + )); + let mut first = compiled.instantiate(STREAM_A).unwrap(); + let mut second = compiled.instantiate(STREAM_B).unwrap(); + drop(compiled); + + assert!(matches!( + first.update(observed_update(STREAM_A, 1, &[(1, [0xFF; 3])])), + Err(SessionUpdateError::OwnerExpired), + )); + assert!(matches!( + second.update(observed_update(STREAM_B, 9, &[(2, [0xFF; 3])])), + Err(SessionUpdateError::OwnerExpired), + )); + assert!(matches!(first.state(), SessionState::Waiting)); + assert!(matches!(second.state(), SessionState::Waiting)); + assert_eq!(first.raw_head(), ObservationHeadViewV1::Empty); + assert_eq!(second.raw_head(), ObservationHeadViewV1::Empty); +} + +#[test] +fn program_sessions_reuse_the_owner_canonical_schema_handle() { + let compiled = exact_compiled(ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + vec![], + )); + + assert_eq!(compiled.observation_schema_strong_count_for_test(), 1); + + let mut first = compiled.instantiate(STREAM_A).unwrap(); + assert_eq!(compiled.observation_schema_strong_count_for_test(), 1); + let second = compiled.instantiate(STREAM_B).unwrap(); + assert_eq!(compiled.observation_schema_strong_count_for_test(), 1); + + drop(second); + assert_eq!(compiled.observation_schema_strong_count_for_test(), 1); + + let schema_ptr = { + let owner = first.plan().try_acquire_owner().unwrap(); + first + .plan() + .observation_schema(&owner) + .backing_ptr_for_test() + }; + let report_schema_ptr = match first + .update(observed_update(STREAM_A, 1, &[(1, [0xFF; 3])])) + .unwrap() + { + SessionState::Ready { current } => current.report().observation().schema_ptr_for_test(), + _ => panic!("the exact Program must verify"), + }; + assert_eq!(report_schema_ptr, schema_ptr); + let ObservationHeadViewV1::Observed(raw) = first.raw_head() else { + panic!("the raw head must retain the admitted observation"); + }; + assert_eq!(raw.schema_ptr_for_test(), schema_ptr); + assert_eq!(compiled.observation_schema_strong_count_for_test(), 2); + + first + .update(observed_update(STREAM_A, 1, &[(1, [0xFF; 3])])) + .unwrap(); + assert_eq!(compiled.observation_schema_strong_count_for_test(), 2); + + drop(first); + assert_eq!(compiled.observation_schema_strong_count_for_test(), 1); +} + +#[test] +fn multi_case_hard_failure_retains_the_full_matrix_without_outputs() { + let low = ConstraintId::new(1); + let high = ConstraintId::new(2); + let compiled = exact_compiled(ConstraintSet::new( + vec![ + ConstraintInvocation::hard(high, OCCURRENCE, Srgb8::new([0x00; 3])), + ConstraintInvocation::hard(low, OCCURRENCE, Srgb8::new([0x80; 3])), + ], + vec![], + )); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + let state = session + .update(observed_update( + STREAM_A, + 1, + &[(11, [0x00; 3]), (12, [0xFF; 3])], + )) + .unwrap(); + let SessionState::Failed { cause, previous } = state else { + panic!("each candidate target fails on one admitted physical case"); + }; + assert!(previous.is_none()); + let cells = cause.report().cells(); + assert_eq!( + cells.len(), + 4, + "two cases × two constraints must be complete" + ); + assert_eq!( + cells + .iter() + .map(|cell| (cell.case_index(), cell.constraint())) + .collect::>(), + vec![(0, low), (0, high), (1, low), (1, high)], + ); + assert!(cells.iter().all(|cell| cell.is_hard())); + assert!(cells.iter().all(|cell| cell.target() == OCCURRENCE)); + assert_eq!( + cells + .iter() + .filter(|cell| cell.result().is_violation()) + .count(), + 2, + ); + // `ProgramConflictV1` owns only the complete report; no output accessor or + // output storage exists on the failure type. +} + +#[test] +fn mixed_modes_retain_the_full_canonical_matrix_without_outputs_on_hard_failure() { + let diagnostic = ConstraintId::new(1); + let required = ConstraintId::new(2); + let compiled = exact_compiled(ConstraintSet::new( + vec![ConstraintInvocation::hard( + required, + OCCURRENCE, + Srgb8::new([0x00; 3]), + )], + vec![ConstraintInvocation::report_only( + diagnostic, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + )); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + let state = session + .update(observed_update( + STREAM_A, + 1, + &[(11, [0x00; 3]), (12, [0xFF; 3])], + )) + .unwrap(); + let SessionState::Failed { cause, previous } = state else { + panic!("the hard constraint must gate the otherwise complete mixed report"); + }; + assert!(previous.is_none()); + + let cells = cause.report().cells(); + assert_eq!(cells.len(), 4); + assert_eq!( + cells + .iter() + .map(|cell| (cell.case_index(), cell.constraint(), cell.is_hard())) + .collect::>(), + vec![ + (0, diagnostic, false), + (0, required, true), + (1, diagnostic, false), + (1, required, true), + ], + ); + assert_eq!( + cells + .iter() + .map(|cell| cell.result().is_violation()) + .collect::>(), + vec![true, false, false, true], + ); + assert!(cells.iter().all(|cell| cell.target() == OCCURRENCE)); + // `cause` is `ProgramConflictV1`: the failure surface exposes only this + // complete report, while Paint outputs exist only on `ProgramVerifiedV1`. +} + +#[test] +fn report_only_violations_do_not_block_program_scope_paint_outputs() { + let diagnostic = ConstraintId::new(7); + let compiled = exact_compiled(ConstraintSet::new( + vec![], + vec![ConstraintInvocation::report_only( + diagnostic, + OCCURRENCE, + Srgb8::new([0x7F; 3]), + )], + )); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + let state = session + .update(observed_update( + STREAM_A, + 1, + &[(1, [0x00; 3]), (2, [0xFF; 3])], + )) + .unwrap(); + let SessionState::Ready { current } = state else { + panic!("report-only violations must not gate outputs"); + }; + assert_eq!(current.report().cells().len(), 2); + assert!( + current + .report() + .cells() + .iter() + .all(|cell| !cell.is_hard() && cell.result().is_violation()), + ); + let [output] = current.outputs() else { + panic!("one canonical output must be emitted"); + }; + assert_eq!(output.output(), OUTPUT); + assert_eq!(output.paint().id(), TRANSLUCENT); + assert_eq!(output.paint().source(), Srgb8::new([0; 3])); + assert_eq!(output.paint().opacity_bits(), 0.5_f64.to_bits()); +} + +#[test] +fn nested_surface_uses_the_lower_occurrence_before_assessing_the_upper() { + const LOWER_SOURCE: SourceId = SourceId::new(101); + const UPPER_SOURCE: SourceId = SourceId::new(102); + const LOWER_TARGET: TargetId = TargetId::new(101); + const UPPER_TARGET: TargetId = TargetId::new(102); + const HALF: OpacityInputId = OpacityInputId::new(103); + const LOWER_PAINT: PaintId = PaintId::new(110); + const UPPER_SOLID: PaintId = PaintId::new(111); + const UPPER_PAINT: PaintId = PaintId::new(112); + const ROOT: SurfaceId = SurfaceId::new(120); + const DERIVED: SurfaceId = SurfaceId::new(121); + const LOWER: OccurrenceId = OccurrenceId::new(130); + const UPPER: OccurrenceId = OccurrenceId::new(131); + const NESTED_OUTPUT: OutputSlotId = OutputSlotId::new(140); + + let program = Program::new( + vec![ + Source::new(LOWER_SOURCE, ColorSignal::from_srgb8(Srgb8::new([0x80; 3]))), + Source::new(UPPER_SOURCE, ColorSignal::from_srgb8(Srgb8::new([0xFF; 3]))), + ], + vec![ + Target::fixed(LOWER_TARGET, LOWER_SOURCE), + Target::fixed(UPPER_TARGET, UPPER_SOURCE), + ], + observation_group(vec![SURFACE_PORT]), + vec![OpacityInput::new(HALF, 0.5)], + vec![ + Paint::Solid { + id: LOWER_PAINT, + target: LOWER_TARGET, + }, + Paint::Solid { + id: UPPER_SOLID, + target: UPPER_TARGET, + }, + Paint::Opacity { + id: UPPER_PAINT, + source: UPPER_SOLID, + opacity: HALF, + }, + ], + vec![ + Surface::Input { + id: ROOT, + input: SURFACE_PORT, + }, + Surface::FromOccurrence { + id: DERIVED, + occurrence: LOWER, + }, + ], + vec![ + Occurrence::new( + LOWER, + LOWER_PAINT, + ROOT, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + ), + Occurrence::new( + UPPER, + UPPER_PAINT, + DERIVED, + CompositionProfile::EncodedSrgb8SourceOverV1, + appearance_context(), + ), + ], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + UPPER, + Srgb8::new([0xC0; 3]), + )], + vec![], + ), + vec![OutputBinding::new(NESTED_OUTPUT, UPPER_PAINT)], + ExactSrgb8IdentityV1, + ); + let compiled = program.compile().unwrap(); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + let state = session + .update(observed_update(STREAM_A, 1, &[(1, [0x00; 3])])) + .unwrap(); + let SessionState::Ready { current } = state else { + panic!("upper occurrence must compose over the lower visible result"); + }; + assert!(!current.report().cells()[0].result().is_violation()); + let [output] = current.outputs() else { + panic!("nested program must emit its Paint, not visible composite"); + }; + assert_eq!(output.output(), NESTED_OUTPUT); + assert_eq!(output.paint().id(), UPPER_PAINT); + assert_eq!(output.paint().source(), Srgb8::new([0xFF; 3])); +} + +#[test] +fn raw_head_and_program_report_share_one_observation_backing() { + let compiled = exact_compiled(ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + vec![], + )); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + session + .update(observed_update(STREAM_A, 1, &[(9, [0xFF; 3])])) + .unwrap(); + let ObservationHeadViewV1::Observed(raw) = session.raw_head() else { + panic!("successful observed update must own a raw observed head"); + }; + let SessionState::Ready { current } = session.state() else { + panic!("fixture must verify"); + }; + let report = current.report().observation(); + assert_eq!(raw, report); + assert_eq!(raw.backing_ptr_for_test(), report.backing_ptr_for_test()); + assert_eq!(raw.schema_ptr_for_test(), report.schema_ptr_for_test()); + assert_eq!( + raw.physical_values(0).unwrap().as_ptr(), + report.physical_values(0).unwrap().as_ptr(), + ); + assert_eq!( + raw.provenance(0).unwrap().as_ptr(), + report.provenance(0).unwrap().as_ptr(), + ); +} diff --git a/crates/labcolors-core/src/release_registry.rs b/crates/labcolors-core/src/release_registry.rs new file mode 100644 index 00000000..9c3b4a24 --- /dev/null +++ b/crates/labcolors-core/src/release_registry.rs @@ -0,0 +1,613 @@ +//! Minimal machine-readable registry for the F0 color-model releases. +//! +//! Registry descriptors contain only facts fixed by the implemented code: +//! context consumption, admitted frame/domain, coordinate units, achromatic +//! law, formula/reference identity and typed release dependencies. The absence +//! of a difference calibration is an explicit keyless row, not a placeholder +//! calibration. No empirical applicability, validation, uncertainty or +//! observer-study data is inferred here. + +use crate::lcs_occurrence::{ + ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, AdmittedSrgb8TristimulusBindingV1, + AppearanceContextSchemaReleaseId, CAM16_UCS_VIEW_RELEASE_V1, CAM16_VIEW_RELEASE_V1, + Cam16UcsViewReleaseId, Cam16ViewReleaseId, ColorimetricFrameId, IEC_SRGB_D65_XYZ_FRAME_V1, + OKLAB_VIEW_RELEASE_V1, OklabViewReleaseId, +}; +use crate::output_projection::{ + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, CssOklchHueSerializationReleaseIdV1, + CssOklchNumberEncodingReleaseIdV1, DifferenceCalibrationReleaseIdV1, OKLCH_VIEW_RELEASE_V1, + OklchViewReleaseId, OutputGamutTreatmentV1, OutputProjectionReleaseIdV1, +}; + +pub(crate) const RELEASE_REGISTRY_SCHEMA_VERSION_V1: u16 = 1; + +// Registered rows append a fixed-width 15-byte descriptor after the release +// key. The byte order is documented by `RegisteredReleaseDescriptorV1` below. +const RELEASE_REGISTRY_CANONICAL_BYTES_V1: &[u8] = concat!( + "labcolors.release-registry.canonical-binary.v1\0", + "\0\x01\0\x06", + "\x01\x01\0\x26cam16-li-et-al-2017-cie-248-forward-v1", + "\x01\x02\x01\x01\x01\x02\x02\x02\0\0\0\0\0\0\0", + "\x01\x01\0\x26cam16-ucs-li-et-al-2017-rectangular-v1", + "\x01\x02\x01\x01\x04\x05\x05\x05\x03\x01\0\0\0\0\0", + "\x01\x01\0\x24oklab-ottosson-2021-01-25-xyz-d65-v1", + "\x01\x01\0\x01\x01\x01\x01\x01\0\0\0\0\0\0\0", + "\x01\x01\0\x27polar-from-ottosson-2021-01-25-oklab-v1", + "\x01\x01\0\x01\x02\x03\x03\x03\x01\0\x01\0\0\0\0", + "\x02\0\0\0", + "\x03\x01\0\x3acss-color-4-oklch-d65-from-modeled-iec61966-srgb8-solid-v1", + "\x01\x03\0\x01\x03\x04\x04\x04\x02\x01\x01\x01\x01\x01\x01", +) +.as_bytes(); + +const RELEASE_REGISTRY_FNV1A32_V1: u32 = 540_606_852; + +/// The disjoint kinds whose availability is reported by this registry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ReleaseRegistryClassV1 { + AppearanceView, + DifferenceCalibration, + OutputProjection, +} + +impl ReleaseRegistryClassV1 { + pub(crate) const fn canonical_tag(self) -> u8 { + match self { + Self::AppearanceView => 1, + Self::DifferenceCalibration => 2, + Self::OutputProjection => 3, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ReleaseRegistryAvailabilityV1 { + Unavailable, + Registered, +} + +impl ReleaseRegistryAvailabilityV1 { + pub(crate) const fn canonical_tag(self) -> u8 { + match self { + Self::Unavailable => 0, + Self::Registered => 1, + } + } +} + +/// A nominal link to one release implemented by the current F0 code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum RegisteredColorReleaseIdV1 { + Cam16View(Cam16ViewReleaseId), + Cam16UcsView(Cam16UcsViewReleaseId), + OklabView(OklabViewReleaseId), + OklchView(OklchViewReleaseId), + CssColor4OklchD65FromModeledSrgb8Solid(OutputProjectionReleaseIdV1), +} + +impl RegisteredColorReleaseIdV1 { + pub(crate) const fn class(self) -> ReleaseRegistryClassV1 { + match self { + Self::Cam16View(_) + | Self::Cam16UcsView(_) + | Self::OklabView(_) + | Self::OklchView(_) => ReleaseRegistryClassV1::AppearanceView, + Self::CssColor4OklchD65FromModeledSrgb8Solid(_) => { + ReleaseRegistryClassV1::OutputProjection + } + } + } + + /// Stable ASCII key for the exact formula/operation-order release. + pub(crate) const fn key(self) -> &'static str { + match self { + Self::Cam16View(Cam16ViewReleaseId::LiEtAl2017Cie248ForwardV1) => { + "cam16-li-et-al-2017-cie-248-forward-v1" + } + Self::Cam16UcsView(Cam16UcsViewReleaseId::LiEtAl2017Cam16UcsV1) => { + "cam16-ucs-li-et-al-2017-rectangular-v1" + } + Self::OklabView(OklabViewReleaseId::Ottosson20210125XyzD65V1) => { + "oklab-ottosson-2021-01-25-xyz-d65-v1" + } + Self::OklchView(OklchViewReleaseId::PolarFromOttosson20210125OklabV1) => { + "polar-from-ottosson-2021-01-25-oklab-v1" + } + Self::CssColor4OklchD65FromModeledSrgb8Solid(release) => release.key(), + } + } +} + +/// Whether and how a release consumes the occurrence's appearance context. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ReleaseContextRequirementV1 { + NoAppearanceContextConsumptionV1, + ConsumesAppearanceContextV1(AppearanceContextSchemaReleaseId), + RetainsOccurrenceContextWithoutGeometryConsumptionV1, +} + +impl ReleaseContextRequirementV1 { + pub(crate) const fn schema_release(self) -> Option { + match self { + Self::ConsumesAppearanceContextV1(schema) => Some(schema), + Self::NoAppearanceContextConsumptionV1 + | Self::RetainsOccurrenceContextWithoutGeometryConsumptionV1 => None, + } + } + + const fn canonical_tag(self) -> u8 { + match self { + Self::NoAppearanceContextConsumptionV1 => 1, + Self::ConsumesAppearanceContextV1( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + ) => 2, + Self::RetainsOccurrenceContextWithoutGeometryConsumptionV1 => 3, + } + } + + const fn schema_tag(self) -> u8 { + match self.schema_release() { + None => 0, + Some(AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1) => 1, + } + } +} + +/// Exact colorimetric frame admitted by every current registered release. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum RegistryAdmittedFrameV1 { + Cie1931TwoDegreeXyzIecD65RelativeY1V1, +} + +impl RegistryAdmittedFrameV1 { + pub(crate) const fn frame(self) -> ColorimetricFrameId { + match self { + Self::Cie1931TwoDegreeXyzIecD65RelativeY1V1 => IEC_SRGB_D65_XYZ_FRAME_V1, + } + } + + const fn canonical_tag(self) -> u8 { + match self { + Self::Cie1931TwoDegreeXyzIecD65RelativeY1V1 => 1, + } + } +} + +/// Code-admitted input domain; this carries no empirical applicability claim. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum RegistryAdmittedDomainV1 { + FiniteNonNegativeXyzStimulusV1, + FiniteOklabRectangularViewV1, + ModeledIec61966Srgb8OccurrenceV1, + FiniteCam16ViewV1, +} + +impl RegistryAdmittedDomainV1 { + const fn canonical_tag(self) -> u8 { + match self { + Self::FiniteNonNegativeXyzStimulusV1 => 1, + Self::FiniteOklabRectangularViewV1 => 2, + Self::ModeledIec61966Srgb8OccurrenceV1 => 3, + Self::FiniteCam16ViewV1 => 4, + } + } +} + +/// Coordinate/output units fixed by the registered code release. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum RegistryCoordinateUnitsV1 { + OklabCoordinatesUnitlessV1, + Cam16CorrelatesUnitlessHueDegreesV1, + OklchCoordinatesUnitlessHueDegreesV1, + CssColor4OklchPercentLightnessNumericChromaHueDegreesV1, + Cam16UcsJPrimeAPrimeBPrimeUnitlessV1, +} + +impl RegistryCoordinateUnitsV1 { + const fn canonical_tag(self) -> u8 { + match self { + Self::OklabCoordinatesUnitlessV1 => 1, + Self::Cam16CorrelatesUnitlessHueDegreesV1 => 2, + Self::OklchCoordinatesUnitlessHueDegreesV1 => 3, + Self::CssColor4OklchPercentLightnessNumericChromaHueDegreesV1 => 4, + Self::Cam16UcsJPrimeAPrimeBPrimeUnitlessV1 => 5, + } + } +} + +/// Exact hue/achromatic behavior implemented by a release. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum RegistryAchromaticLawV1 { + NoHueCoordinateV1, + HueUndefinedExactlyWhenCam16MIsZeroV1, + HueUndefinedExactlyWhenOklabAAndBAreZeroV1, + ExactSourceGreyOrRectangularOriginSerializesHueZeroV1, + RectangularChromaOriginExactlyWhenCam16MIsZeroV1, +} + +impl RegistryAchromaticLawV1 { + const fn canonical_tag(self) -> u8 { + match self { + Self::NoHueCoordinateV1 => 1, + Self::HueUndefinedExactlyWhenCam16MIsZeroV1 => 2, + Self::HueUndefinedExactlyWhenOklabAAndBAreZeroV1 => 3, + Self::ExactSourceGreyOrRectangularOriginSerializesHueZeroV1 => 4, + Self::RectangularChromaOriginExactlyWhenCam16MIsZeroV1 => 5, + } + } +} + +/// Formula/specification identity only, never empirical validation metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum RegistryReferenceIdentityV1 { + Ottosson20210125OklabXyzD65V1, + LiEtAl2017Cie248Cam16ForwardV1, + Ottosson20210125OklabPolarV1, + CssColor4OklchD65V1, + LiEtAl2017Cam16UcsCoordinatesV1, +} + +impl RegistryReferenceIdentityV1 { + const fn canonical_tag(self) -> u8 { + match self { + Self::Ottosson20210125OklabXyzD65V1 => 1, + Self::LiEtAl2017Cie248Cam16ForwardV1 => 2, + Self::Ottosson20210125OklabPolarV1 => 3, + Self::CssColor4OklchD65V1 => 4, + Self::LiEtAl2017Cam16UcsCoordinatesV1 => 5, + } + } +} + +/// Every typed edge executed by the current CSS output projection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct OutputProjectionDependencyGraphV1 { + modeled_source_binding: AdmittedSrgb8TristimulusBindingV1, + oklab_view: OklabViewReleaseId, + oklch_view: OklchViewReleaseId, + number_encoding: CssOklchNumberEncodingReleaseIdV1, + hue_serialization: CssOklchHueSerializationReleaseIdV1, + gamut_treatment: OutputGamutTreatmentV1, +} + +impl OutputProjectionDependencyGraphV1 { + const fn registered_v1() -> Self { + Self { + modeled_source_binding: ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, + oklab_view: OKLAB_VIEW_RELEASE_V1, + oklch_view: OKLCH_VIEW_RELEASE_V1, + number_encoding: CssOklchNumberEncodingReleaseIdV1::LPercent5C6Hue3V1, + hue_serialization: + CssOklchHueSerializationReleaseIdV1::ExactSourceGreyOrRectangularOriginToZeroV1, + gamut_treatment: OutputGamutTreatmentV1::NoExplicitProjectionGamutMapV1, + } + } + + pub(crate) const fn modeled_source_binding(self) -> AdmittedSrgb8TristimulusBindingV1 { + self.modeled_source_binding + } + + pub(crate) const fn oklab_view(self) -> OklabViewReleaseId { + self.oklab_view + } + + pub(crate) const fn oklch_view(self) -> OklchViewReleaseId { + self.oklch_view + } + + pub(crate) const fn number_encoding(self) -> CssOklchNumberEncodingReleaseIdV1 { + self.number_encoding + } + + pub(crate) const fn hue_serialization(self) -> CssOklchHueSerializationReleaseIdV1 { + self.hue_serialization + } + + pub(crate) const fn gamut_treatment(self) -> OutputGamutTreatmentV1 { + self.gamut_treatment + } +} + +/// Typed release-to-release prerequisites; no free-form dependency keys exist. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ReleaseDependencyGraphV1 { + DirectV1, + OklchPolarFromOklabV1(OklabViewReleaseId), + Cam16UcsRectangularFromCam16V1(Cam16ViewReleaseId), + CssColor4OklchD65V1(OutputProjectionDependencyGraphV1), +} + +impl ReleaseDependencyGraphV1 { + /// Fixed seven-byte tagged union. The graph tag determines its payload: + /// + /// - direct: six zero bytes; + /// - Oklch: `[0, Oklab, 0, 0, 0, 0]`; + /// - CSS output: `[source-binding, Oklab, Oklch, number, hue, gamut]`; + /// - CAM16-UCS: `[CAM16, 0, 0, 0, 0, 0]`. + const fn canonical_fields(self) -> [u8; 7] { + match self { + Self::DirectV1 => [0; 7], + Self::OklchPolarFromOklabV1(OklabViewReleaseId::Ottosson20210125XyzD65V1) => { + [1, 0, 1, 0, 0, 0, 0] + } + Self::Cam16UcsRectangularFromCam16V1( + Cam16ViewReleaseId::LiEtAl2017Cie248ForwardV1, + ) => [3, 1, 0, 0, 0, 0, 0], + Self::CssColor4OklchD65V1(graph) => [ + 2, + match graph.modeled_source_binding { + AdmittedSrgb8TristimulusBindingV1::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1 => 1, + }, + match graph.oklab_view { + OklabViewReleaseId::Ottosson20210125XyzD65V1 => 1, + }, + match graph.oklch_view { + OklchViewReleaseId::PolarFromOttosson20210125OklabV1 => 1, + }, + match graph.number_encoding { + CssOklchNumberEncodingReleaseIdV1::LPercent5C6Hue3V1 => 1, + }, + match graph.hue_serialization { + CssOklchHueSerializationReleaseIdV1::ExactSourceGreyOrRectangularOriginToZeroV1 => 1, + }, + match graph.gamut_treatment { + OutputGamutTreatmentV1::NoExplicitProjectionGamutMapV1 => 1, + }, + ], + } + } +} + +/// Complete code-truth descriptor for one registered release. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct RegisteredReleaseDescriptorV1 { + release: RegisteredColorReleaseIdV1, + context_requirement: ReleaseContextRequirementV1, + admitted_frame: RegistryAdmittedFrameV1, + admitted_domain: RegistryAdmittedDomainV1, + coordinate_units: RegistryCoordinateUnitsV1, + achromatic_law: RegistryAchromaticLawV1, + reference_identity: RegistryReferenceIdentityV1, + dependencies: ReleaseDependencyGraphV1, +} + +impl RegisteredReleaseDescriptorV1 { + pub(crate) const fn release(self) -> RegisteredColorReleaseIdV1 { + self.release + } + + pub(crate) const fn context_requirement(self) -> ReleaseContextRequirementV1 { + self.context_requirement + } + + pub(crate) const fn admitted_frame(self) -> RegistryAdmittedFrameV1 { + self.admitted_frame + } + + pub(crate) const fn admitted_domain(self) -> RegistryAdmittedDomainV1 { + self.admitted_domain + } + + pub(crate) const fn coordinate_units(self) -> RegistryCoordinateUnitsV1 { + self.coordinate_units + } + + pub(crate) const fn achromatic_law(self) -> RegistryAchromaticLawV1 { + self.achromatic_law + } + + pub(crate) const fn reference_identity(self) -> RegistryReferenceIdentityV1 { + self.reference_identity + } + + pub(crate) const fn dependencies(self) -> ReleaseDependencyGraphV1 { + self.dependencies + } + + /// Fixed-width descriptor bytes: + /// + /// `schema, context, context-schema, frame, domain, units, achromatic, + /// reference, dependency-graph, source-binding, Oklab, Oklch, number, + /// hue-policy, gamut-policy`. + pub(crate) const fn canonical_fields(self) -> [u8; 15] { + let dependencies = self.dependencies.canonical_fields(); + [ + 1, + self.context_requirement.canonical_tag(), + self.context_requirement.schema_tag(), + self.admitted_frame.canonical_tag(), + self.admitted_domain.canonical_tag(), + self.coordinate_units.canonical_tag(), + self.achromatic_law.canonical_tag(), + self.reference_identity.canonical_tag(), + dependencies[0], + dependencies[1], + dependencies[2], + dependencies[3], + dependencies[4], + dependencies[5], + dependencies[6], + ] + } +} + +const CAM16_VIEW_DESCRIPTOR_V1: RegisteredReleaseDescriptorV1 = RegisteredReleaseDescriptorV1 { + release: RegisteredColorReleaseIdV1::Cam16View(CAM16_VIEW_RELEASE_V1), + context_requirement: ReleaseContextRequirementV1::ConsumesAppearanceContextV1( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + ), + admitted_frame: RegistryAdmittedFrameV1::Cie1931TwoDegreeXyzIecD65RelativeY1V1, + admitted_domain: RegistryAdmittedDomainV1::FiniteNonNegativeXyzStimulusV1, + coordinate_units: RegistryCoordinateUnitsV1::Cam16CorrelatesUnitlessHueDegreesV1, + achromatic_law: RegistryAchromaticLawV1::HueUndefinedExactlyWhenCam16MIsZeroV1, + reference_identity: RegistryReferenceIdentityV1::LiEtAl2017Cie248Cam16ForwardV1, + dependencies: ReleaseDependencyGraphV1::DirectV1, +}; + +const CAM16_UCS_VIEW_DESCRIPTOR_V1: RegisteredReleaseDescriptorV1 = RegisteredReleaseDescriptorV1 { + release: RegisteredColorReleaseIdV1::Cam16UcsView(CAM16_UCS_VIEW_RELEASE_V1), + context_requirement: ReleaseContextRequirementV1::ConsumesAppearanceContextV1( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + ), + admitted_frame: RegistryAdmittedFrameV1::Cie1931TwoDegreeXyzIecD65RelativeY1V1, + admitted_domain: RegistryAdmittedDomainV1::FiniteCam16ViewV1, + coordinate_units: RegistryCoordinateUnitsV1::Cam16UcsJPrimeAPrimeBPrimeUnitlessV1, + achromatic_law: RegistryAchromaticLawV1::RectangularChromaOriginExactlyWhenCam16MIsZeroV1, + reference_identity: RegistryReferenceIdentityV1::LiEtAl2017Cam16UcsCoordinatesV1, + dependencies: ReleaseDependencyGraphV1::Cam16UcsRectangularFromCam16V1(CAM16_VIEW_RELEASE_V1), +}; + +const OKLAB_VIEW_DESCRIPTOR_V1: RegisteredReleaseDescriptorV1 = RegisteredReleaseDescriptorV1 { + release: RegisteredColorReleaseIdV1::OklabView(OKLAB_VIEW_RELEASE_V1), + context_requirement: ReleaseContextRequirementV1::NoAppearanceContextConsumptionV1, + admitted_frame: RegistryAdmittedFrameV1::Cie1931TwoDegreeXyzIecD65RelativeY1V1, + admitted_domain: RegistryAdmittedDomainV1::FiniteNonNegativeXyzStimulusV1, + coordinate_units: RegistryCoordinateUnitsV1::OklabCoordinatesUnitlessV1, + achromatic_law: RegistryAchromaticLawV1::NoHueCoordinateV1, + reference_identity: RegistryReferenceIdentityV1::Ottosson20210125OklabXyzD65V1, + dependencies: ReleaseDependencyGraphV1::DirectV1, +}; + +const OKLCH_VIEW_DESCRIPTOR_V1: RegisteredReleaseDescriptorV1 = RegisteredReleaseDescriptorV1 { + release: RegisteredColorReleaseIdV1::OklchView(OKLCH_VIEW_RELEASE_V1), + context_requirement: ReleaseContextRequirementV1::NoAppearanceContextConsumptionV1, + admitted_frame: RegistryAdmittedFrameV1::Cie1931TwoDegreeXyzIecD65RelativeY1V1, + admitted_domain: RegistryAdmittedDomainV1::FiniteOklabRectangularViewV1, + coordinate_units: RegistryCoordinateUnitsV1::OklchCoordinatesUnitlessHueDegreesV1, + achromatic_law: RegistryAchromaticLawV1::HueUndefinedExactlyWhenOklabAAndBAreZeroV1, + reference_identity: RegistryReferenceIdentityV1::Ottosson20210125OklabPolarV1, + dependencies: ReleaseDependencyGraphV1::OklchPolarFromOklabV1(OKLAB_VIEW_RELEASE_V1), +}; + +const OUTPUT_PROJECTION_DESCRIPTOR_V1: RegisteredReleaseDescriptorV1 = + RegisteredReleaseDescriptorV1 { + release: RegisteredColorReleaseIdV1::CssColor4OklchD65FromModeledSrgb8Solid( + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, + ), + context_requirement: + ReleaseContextRequirementV1::RetainsOccurrenceContextWithoutGeometryConsumptionV1, + admitted_frame: RegistryAdmittedFrameV1::Cie1931TwoDegreeXyzIecD65RelativeY1V1, + admitted_domain: RegistryAdmittedDomainV1::ModeledIec61966Srgb8OccurrenceV1, + coordinate_units: + RegistryCoordinateUnitsV1::CssColor4OklchPercentLightnessNumericChromaHueDegreesV1, + achromatic_law: + RegistryAchromaticLawV1::ExactSourceGreyOrRectangularOriginSerializesHueZeroV1, + reference_identity: RegistryReferenceIdentityV1::CssColor4OklchD65V1, + dependencies: ReleaseDependencyGraphV1::CssColor4OklchD65V1( + OutputProjectionDependencyGraphV1::registered_v1(), + ), + }; + +/// One closed registry row. +/// +/// The unavailable variant carries no descriptor or release identifier, so it +/// cannot be mistaken for an admitted difference formula. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ReleaseRegistryRecordV1 { + Registered(RegisteredReleaseDescriptorV1), + DifferenceCalibrationUnavailable, +} + +impl ReleaseRegistryRecordV1 { + pub(crate) const fn class(self) -> ReleaseRegistryClassV1 { + match self { + Self::Registered(descriptor) => descriptor.release().class(), + Self::DifferenceCalibrationUnavailable => ReleaseRegistryClassV1::DifferenceCalibration, + } + } + + pub(crate) const fn availability(self) -> ReleaseRegistryAvailabilityV1 { + match self { + Self::Registered(_) => ReleaseRegistryAvailabilityV1::Registered, + Self::DifferenceCalibrationUnavailable => ReleaseRegistryAvailabilityV1::Unavailable, + } + } + + pub(crate) const fn release(self) -> Option { + match self { + Self::Registered(descriptor) => Some(descriptor.release()), + Self::DifferenceCalibrationUnavailable => None, + } + } + + pub(crate) const fn descriptor(self) -> Option { + match self { + Self::Registered(descriptor) => Some(descriptor), + Self::DifferenceCalibrationUnavailable => None, + } + } +} + +// Canonical order is class tag, then release-key bytes. An unavailable class +// has exactly one keyless and descriptor-less row. +const RELEASE_REGISTRY_RECORDS_V1: [ReleaseRegistryRecordV1; 6] = [ + ReleaseRegistryRecordV1::Registered(CAM16_VIEW_DESCRIPTOR_V1), + ReleaseRegistryRecordV1::Registered(CAM16_UCS_VIEW_DESCRIPTOR_V1), + ReleaseRegistryRecordV1::Registered(OKLAB_VIEW_DESCRIPTOR_V1), + ReleaseRegistryRecordV1::Registered(OKLCH_VIEW_DESCRIPTOR_V1), + ReleaseRegistryRecordV1::DifferenceCalibrationUnavailable, + ReleaseRegistryRecordV1::Registered(OUTPUT_PROJECTION_DESCRIPTOR_V1), +]; + +pub(crate) const fn release_registry_records_v1() -> &'static [ReleaseRegistryRecordV1] { + &RELEASE_REGISTRY_RECORDS_V1 +} + +/// The existing difference-release type is uninhabited in this registry. +pub(crate) const fn impossible_difference_calibration_release_v1( + release: DifferenceCalibrationReleaseIdV1, +) -> ! { + match release {} +} + +/// Canonical binary encoding of the complete registry. +/// +/// Layout is `magic || schema:u16be || rows:u16be`, followed by rows in the +/// declared canonical order. Every row starts with +/// `class:u8 || availability:u8 || key_length:u16be || key:utf8`. A registered +/// row then carries the 15 descriptor bytes documented by +/// [`RegisteredReleaseDescriptorV1::canonical_fields`]. The unavailable +/// difference row has a zero key length and no descriptor bytes. +pub(crate) const fn release_registry_canonical_bytes_v1() -> &'static [u8] { + RELEASE_REGISTRY_CANONICAL_BYTES_V1 +} + +/// Explicit algorithm identity for the registry's non-cryptographic drift +/// sentinel. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ReleaseRegistryDigestAlgorithmV1 { + Fnv1a32V1, +} + +impl ReleaseRegistryDigestAlgorithmV1 { + pub(crate) const fn key(self) -> &'static str { + match self { + Self::Fnv1a32V1 => "fnv1a-32-v1", + } + } +} + +/// A deterministic drift sentinel, not cryptographic evidence or an +/// authenticity/content-identity claim. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct ReleaseRegistryDigestV1 { + algorithm: ReleaseRegistryDigestAlgorithmV1, + value: u32, +} + +impl ReleaseRegistryDigestV1 { + pub(crate) const fn algorithm(self) -> ReleaseRegistryDigestAlgorithmV1 { + self.algorithm + } + + pub(crate) const fn value(self) -> u32 { + self.value + } +} + +pub(crate) const fn release_registry_digest_v1() -> ReleaseRegistryDigestV1 { + ReleaseRegistryDigestV1 { + algorithm: ReleaseRegistryDigestAlgorithmV1::Fnv1a32V1, + value: RELEASE_REGISTRY_FNV1A32_V1, + } +} diff --git a/crates/labcolors-core/src/release_registry_tests.rs b/crates/labcolors-core/src/release_registry_tests.rs new file mode 100644 index 00000000..a89329d6 --- /dev/null +++ b/crates/labcolors-core/src/release_registry_tests.rs @@ -0,0 +1,348 @@ +use crate::lcs_occurrence::{ + ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, AppearanceContextSchemaReleaseId, + CAM16_UCS_VIEW_RELEASE_V1, CAM16_VIEW_RELEASE_V1, IEC_SRGB_D65_XYZ_FRAME_V1, + OKLAB_VIEW_RELEASE_V1, +}; +use crate::output_projection::{ + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, CssOklchHueSerializationReleaseIdV1, + CssOklchNumberEncodingReleaseIdV1, DifferenceCalibrationReleaseIdV1, OKLCH_VIEW_RELEASE_V1, + OutputGamutTreatmentV1, +}; +use crate::release_registry::{ + RELEASE_REGISTRY_SCHEMA_VERSION_V1, RegisteredColorReleaseIdV1, RegisteredReleaseDescriptorV1, + RegistryAchromaticLawV1, RegistryAdmittedDomainV1, RegistryAdmittedFrameV1, + RegistryCoordinateUnitsV1, RegistryReferenceIdentityV1, ReleaseContextRequirementV1, + ReleaseDependencyGraphV1, ReleaseRegistryAvailabilityV1, ReleaseRegistryClassV1, + ReleaseRegistryDigestAlgorithmV1, ReleaseRegistryRecordV1, + impossible_difference_calibration_release_v1, release_registry_canonical_bytes_v1, + release_registry_digest_v1, release_registry_records_v1, +}; + +fn descriptor(release: RegisteredColorReleaseIdV1) -> RegisteredReleaseDescriptorV1 { + release_registry_records_v1() + .iter() + .filter_map(|record| record.descriptor()) + .find(|descriptor| descriptor.release() == release) + .expect("registered release descriptor") +} + +#[test] +fn registry_contains_only_the_five_implemented_releases() { + let releases: Vec<_> = release_registry_records_v1() + .iter() + .filter_map(|record| record.release()) + .collect(); + assert_eq!( + releases, + [ + RegisteredColorReleaseIdV1::Cam16View(CAM16_VIEW_RELEASE_V1), + RegisteredColorReleaseIdV1::Cam16UcsView(CAM16_UCS_VIEW_RELEASE_V1), + RegisteredColorReleaseIdV1::OklabView(OKLAB_VIEW_RELEASE_V1), + RegisteredColorReleaseIdV1::OklchView(OKLCH_VIEW_RELEASE_V1), + RegisteredColorReleaseIdV1::CssColor4OklchD65FromModeledSrgb8Solid( + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, + ), + ], + ); +} + +#[test] +fn difference_registry_is_explicitly_unavailable_and_has_no_fake_descriptor() { + let row = release_registry_records_v1() + .iter() + .copied() + .find(|row| row.class() == ReleaseRegistryClassV1::DifferenceCalibration) + .expect("difference availability row"); + assert_eq!( + row, + ReleaseRegistryRecordV1::DifferenceCalibrationUnavailable + ); + assert_eq!( + row.availability(), + ReleaseRegistryAvailabilityV1::Unavailable + ); + assert_eq!(row.release(), None); + assert_eq!(row.descriptor(), None); + + let _: fn(DifferenceCalibrationReleaseIdV1) -> ! = impossible_difference_calibration_release_v1; +} + +#[test] +fn appearance_descriptors_pin_only_code_owned_domain_units_hue_and_reference_facts() { + let cam16 = descriptor(RegisteredColorReleaseIdV1::Cam16View(CAM16_VIEW_RELEASE_V1)); + assert_eq!( + cam16.context_requirement(), + ReleaseContextRequirementV1::ConsumesAppearanceContextV1( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + ), + ); + assert_eq!( + cam16.context_requirement().schema_release(), + Some(AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1), + ); + assert_eq!( + cam16.admitted_frame(), + RegistryAdmittedFrameV1::Cie1931TwoDegreeXyzIecD65RelativeY1V1, + ); + assert_eq!(cam16.admitted_frame().frame(), IEC_SRGB_D65_XYZ_FRAME_V1); + assert_eq!( + cam16.admitted_domain(), + RegistryAdmittedDomainV1::FiniteNonNegativeXyzStimulusV1, + ); + assert_eq!( + cam16.coordinate_units(), + RegistryCoordinateUnitsV1::Cam16CorrelatesUnitlessHueDegreesV1, + ); + assert_eq!( + cam16.achromatic_law(), + RegistryAchromaticLawV1::HueUndefinedExactlyWhenCam16MIsZeroV1, + ); + assert_eq!( + cam16.reference_identity(), + RegistryReferenceIdentityV1::LiEtAl2017Cie248Cam16ForwardV1, + ); + assert_eq!(cam16.dependencies(), ReleaseDependencyGraphV1::DirectV1); + + let cam16_ucs = descriptor(RegisteredColorReleaseIdV1::Cam16UcsView( + CAM16_UCS_VIEW_RELEASE_V1, + )); + assert_eq!( + cam16_ucs.context_requirement(), + ReleaseContextRequirementV1::ConsumesAppearanceContextV1( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + ), + ); + assert_eq!( + cam16_ucs.context_requirement().schema_release(), + Some(AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1), + ); + assert_eq!( + cam16_ucs.admitted_frame().frame(), + IEC_SRGB_D65_XYZ_FRAME_V1, + ); + assert_eq!( + cam16_ucs.admitted_domain(), + RegistryAdmittedDomainV1::FiniteCam16ViewV1, + ); + assert_eq!( + cam16_ucs.coordinate_units(), + RegistryCoordinateUnitsV1::Cam16UcsJPrimeAPrimeBPrimeUnitlessV1, + ); + assert_eq!( + cam16_ucs.achromatic_law(), + RegistryAchromaticLawV1::RectangularChromaOriginExactlyWhenCam16MIsZeroV1, + ); + assert_eq!( + cam16_ucs.reference_identity(), + RegistryReferenceIdentityV1::LiEtAl2017Cam16UcsCoordinatesV1, + ); + assert_eq!( + cam16_ucs.dependencies(), + ReleaseDependencyGraphV1::Cam16UcsRectangularFromCam16V1(CAM16_VIEW_RELEASE_V1), + ); + + let oklab = descriptor(RegisteredColorReleaseIdV1::OklabView(OKLAB_VIEW_RELEASE_V1)); + assert_eq!( + oklab.context_requirement(), + ReleaseContextRequirementV1::NoAppearanceContextConsumptionV1, + ); + assert_eq!(oklab.context_requirement().schema_release(), None); + assert_eq!(oklab.admitted_frame().frame(), IEC_SRGB_D65_XYZ_FRAME_V1); + assert_eq!( + oklab.admitted_domain(), + RegistryAdmittedDomainV1::FiniteNonNegativeXyzStimulusV1, + ); + assert_eq!( + oklab.coordinate_units(), + RegistryCoordinateUnitsV1::OklabCoordinatesUnitlessV1, + ); + assert_eq!( + oklab.achromatic_law(), + RegistryAchromaticLawV1::NoHueCoordinateV1, + ); + assert_eq!( + oklab.reference_identity(), + RegistryReferenceIdentityV1::Ottosson20210125OklabXyzD65V1, + ); + assert_eq!(oklab.dependencies(), ReleaseDependencyGraphV1::DirectV1); + + let oklch = descriptor(RegisteredColorReleaseIdV1::OklchView(OKLCH_VIEW_RELEASE_V1)); + assert_eq!( + oklch.context_requirement(), + ReleaseContextRequirementV1::NoAppearanceContextConsumptionV1, + ); + assert_eq!(oklch.admitted_frame().frame(), IEC_SRGB_D65_XYZ_FRAME_V1); + assert_eq!( + oklch.admitted_domain(), + RegistryAdmittedDomainV1::FiniteOklabRectangularViewV1, + ); + assert_eq!( + oklch.coordinate_units(), + RegistryCoordinateUnitsV1::OklchCoordinatesUnitlessHueDegreesV1, + ); + assert_eq!( + oklch.achromatic_law(), + RegistryAchromaticLawV1::HueUndefinedExactlyWhenOklabAAndBAreZeroV1, + ); + assert_eq!( + oklch.reference_identity(), + RegistryReferenceIdentityV1::Ottosson20210125OklabPolarV1, + ); + assert_eq!( + oklch.dependencies(), + ReleaseDependencyGraphV1::OklchPolarFromOklabV1(OKLAB_VIEW_RELEASE_V1), + ); +} + +#[test] +fn output_descriptor_binds_the_complete_typed_projection_dependency_chain() { + let output = descriptor( + RegisteredColorReleaseIdV1::CssColor4OklchD65FromModeledSrgb8Solid( + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, + ), + ); + assert_eq!( + output.context_requirement(), + ReleaseContextRequirementV1::RetainsOccurrenceContextWithoutGeometryConsumptionV1, + ); + assert_eq!(output.context_requirement().schema_release(), None); + assert_eq!(output.admitted_frame().frame(), IEC_SRGB_D65_XYZ_FRAME_V1); + assert_eq!( + output.admitted_domain(), + RegistryAdmittedDomainV1::ModeledIec61966Srgb8OccurrenceV1, + ); + assert_eq!( + output.coordinate_units(), + RegistryCoordinateUnitsV1::CssColor4OklchPercentLightnessNumericChromaHueDegreesV1, + ); + assert_eq!( + output.achromatic_law(), + RegistryAchromaticLawV1::ExactSourceGreyOrRectangularOriginSerializesHueZeroV1, + ); + assert_eq!( + output.reference_identity(), + RegistryReferenceIdentityV1::CssColor4OklchD65V1, + ); + + let ReleaseDependencyGraphV1::CssColor4OklchD65V1(dependencies) = output.dependencies() else { + panic!("output descriptor must carry its typed dependency graph"); + }; + assert_eq!( + dependencies.modeled_source_binding(), + ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, + ); + assert_eq!(dependencies.oklab_view(), OKLAB_VIEW_RELEASE_V1); + assert_eq!(dependencies.oklch_view(), OKLCH_VIEW_RELEASE_V1); + assert_eq!( + dependencies.number_encoding(), + CssOklchNumberEncodingReleaseIdV1::LPercent5C6Hue3V1, + ); + assert_eq!( + dependencies.hue_serialization(), + CssOklchHueSerializationReleaseIdV1::ExactSourceGreyOrRectangularOriginToZeroV1, + ); + assert_eq!( + dependencies.gamut_treatment(), + OutputGamutTreatmentV1::NoExplicitProjectionGamutMapV1, + ); +} + +#[test] +fn registered_rows_have_stable_exact_release_keys() { + let keys: Vec<_> = release_registry_records_v1() + .iter() + .filter_map(|row| row.release().map(RegisteredColorReleaseIdV1::key)) + .collect(); + assert_eq!( + keys, + [ + "cam16-li-et-al-2017-cie-248-forward-v1", + "cam16-ucs-li-et-al-2017-rectangular-v1", + "oklab-ottosson-2021-01-25-xyz-d65-v1", + "polar-from-ottosson-2021-01-25-oklab-v1", + "css-color-4-oklch-d65-from-modeled-iec61966-srgb8-solid-v1", + ], + ); +} + +#[test] +fn canonical_bytes_pin_schema_rows_descriptors_dependencies_and_order() { + assert_eq!(RELEASE_REGISTRY_SCHEMA_VERSION_V1, 1); + assert_eq!( + release_registry_canonical_bytes_v1(), + concat!( + "labcolors.release-registry.canonical-binary.v1\0", + "\0\x01\0\x06", + "\x01\x01\0\x26cam16-li-et-al-2017-cie-248-forward-v1", + "\x01\x02\x01\x01\x01\x02\x02\x02\0\0\0\0\0\0\0", + "\x01\x01\0\x26cam16-ucs-li-et-al-2017-rectangular-v1", + "\x01\x02\x01\x01\x04\x05\x05\x05\x03\x01\0\0\0\0\0", + "\x01\x01\0\x24oklab-ottosson-2021-01-25-xyz-d65-v1", + "\x01\x01\0\x01\x01\x01\x01\x01\0\0\0\0\0\0\0", + "\x01\x01\0\x27polar-from-ottosson-2021-01-25-oklab-v1", + "\x01\x01\0\x01\x02\x03\x03\x03\x01\0\x01\0\0\0\0", + "\x02\0\0\0", + "\x03\x01\0\x3acss-color-4-oklch-d65-from-modeled-iec61966-srgb8-solid-v1", + "\x01\x03\0\x01\x03\x04\x04\x04\x02\x01\x01\x01\x01\x01\x01", + ) + .as_bytes(), + ); +} + +#[test] +fn canonical_bytes_encode_every_typed_registry_row_and_descriptor_in_order() { + const MAGIC: &[u8] = b"labcolors.release-registry.canonical-binary.v1\0"; + + let bytes = release_registry_canonical_bytes_v1(); + assert_eq!(&bytes[..MAGIC.len()], MAGIC); + let mut cursor = MAGIC.len(); + let take_u16 = |cursor: &mut usize| { + let value = u16::from_be_bytes([bytes[*cursor], bytes[*cursor + 1]]); + *cursor += 2; + value + }; + + assert_eq!(take_u16(&mut cursor), RELEASE_REGISTRY_SCHEMA_VERSION_V1); + let records = release_registry_records_v1(); + assert_eq!(usize::from(take_u16(&mut cursor)), records.len()); + + for record in records { + assert_eq!(bytes[cursor], record.class().canonical_tag()); + cursor += 1; + assert_eq!(bytes[cursor], record.availability().canonical_tag()); + cursor += 1; + + let key_length = usize::from(take_u16(&mut cursor)); + let expected_key = record + .release() + .map(RegisteredColorReleaseIdV1::key) + .unwrap_or("") + .as_bytes(); + assert_eq!(key_length, expected_key.len()); + assert_eq!(&bytes[cursor..cursor + key_length], expected_key); + cursor += key_length; + + if let Some(descriptor) = record.descriptor() { + let fields = descriptor.canonical_fields(); + assert_eq!(&bytes[cursor..cursor + fields.len()], fields); + cursor += fields.len(); + } + } + + assert_eq!(cursor, bytes.len()); +} + +#[test] +fn digest_names_its_non_cryptographic_algorithm_and_covers_descriptor_bytes() { + let digest = release_registry_digest_v1(); + assert_eq!( + digest.algorithm(), + ReleaseRegistryDigestAlgorithmV1::Fnv1a32V1, + ); + assert_eq!(digest.algorithm().key(), "fnv1a-32-v1"); + assert_eq!( + digest.value(), + crate::fnv1a_32(release_registry_canonical_bytes_v1()), + ); + assert_eq!(digest.value(), 540_606_852); +} diff --git a/crates/labcolors-core/src/scale.rs b/crates/labcolors-core/src/scale.rs index 6e2b3fb6..db7f042c 100644 --- a/crates/labcolors-core/src/scale.rs +++ b/crates/labcolors-core/src/scale.rs @@ -752,16 +752,10 @@ fn quadratic_roots(d: f64, c: f64, b: f64) -> ([f64; 3], usize) { (roots, 2) } -/// Стена гамута **Display P3** при `(L, h)` — та же бисекция, что -/// [`max_chroma_bisect`], но валидность кандидата проверяется в ЛИНЕЙНОМ P3 -/// (Oklab → линейный sRGB → XYZ → линейный P3; первые два шага — линейная -/// алгебра, корректная и за пределами sRGB-куба). -/// -/// Этап 1 gamut-aware солвера (2026-07-03): геометрия стен и решётка эмиссии; -/// перевод `Solved`/эмиссии на P3-кандидаты — этап 2. Чистая гамут-геометрия -/// CSS Color 4 матриц — нуля подгонки (класс M-13 инвентаря). -// Прод-потребитель — этап 2 (P3-кандидаты солвера); до него читается тестами. -#[cfg_attr(not(test), allow(dead_code))] +/// Test-only physical Display P3 gamut boundary. This is geometry, not a +/// released output capability: no production selector, encoder, or verifier +/// consumes it yet. +#[cfg(test)] pub(crate) fn max_chroma_p3_bisect(l_ok: f64, h_ok_deg: f64) -> f64 { let h_ok = h_ok_deg.to_radians(); let cos_h = h_ok.cos(); diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index 00ad0bad..d899d933 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -8,8 +8,8 @@ //! текущего фона и viewing conditions; сериализация принадлежит биндингу. //! //! Граница набора атомарна. Доказанная недостижимость и незавершённый bounded -//! search остаются типизированными исходами отдельных ролей; rejected, -//! unsupported и internal закрывают вызов через [`ResolveSetError`] без +//! search остаются типизированными исходами отдельных ролей; rejected и +//! internal закрывают вызов через [`ResolveSetError`] без //! частичного успешного вектора. Нулевое значение представлено явно через //! [`Resolved::None`]. //! @@ -546,29 +546,6 @@ pub enum RoleSpec { /// path. Wire-ключ — его boundary-проекция, а не декоративный metadata. mode: crate::numerical_plan::NumericalExecutionModeV1, }, - /// Frozen PairFill frontend до C7c. Источник становится opaque Paint; - /// единственный source-over occurrence создаёт фактически emitted Surface. - /// Отдельной Pair-эвристики, собственного compositor-а и выбора стороны нет. - PairFill { - /// Пер-темный кодированный якорь источника. - tint: LadderTint, - }, - /// Frozen PairLabel frontend до C7c. Кандидаты label Paint проверяются на - /// фактически emitted opaque PairFill Surface общим joint evaluator/recheck. - /// Поля alpha сохраняют замороженную форму публичного RoleSpec, но после P1 - /// обязаны быть opaque: представление не выводится из client role names. - PairLabel { - /// Пер-темный кодированный якорь источника fill и hue-направления label. - tint: LadderTint, - /// Доля максимума контраста PairFill Surface `(0, 1]`. - fraction: f64, - /// Hard floor против emitted PairFill Surface. - floor: Floor, - /// Замороженное поле representation light; P1 принимает только `1.0`. - surface_alpha_light: f64, - /// Замороженное поле representation dark; P1 принимает только `1.0`. - surface_alpha_dark: f64, - }, Ladder { /// Пер-темный кодированный тинт (якорь источника). tint: LadderTint, @@ -1358,8 +1335,6 @@ impl std::error::Error for RoleFailure { pub enum ResolveSetErrorKind { /// Значение запроса вышло за объявленный домен. Rejected, - /// Запрос требует capability, которую этот резолвер не реализует. - Unsupported, /// Состояние, произведённое ядром, нарушило внутренний постинвариант. Internal, } @@ -1369,7 +1344,6 @@ impl ResolveSetErrorKind { pub const fn as_str(self) -> &'static str { match self { Self::Rejected => SolveFailureCategory::Rejected.as_str(), - Self::Unsupported => SolveFailureCategory::Unsupported.as_str(), Self::Internal => "internal", } } @@ -1378,16 +1352,15 @@ impl ResolveSetErrorKind { #[derive(Debug, Clone, PartialEq)] enum ResolveSetErrorState { Rejected(BoundaryFailure), - Unsupported(BoundaryFailure), Internal(SolveFailure), } /// Отказ всего набора из [`resolve_named_set`]. /// -/// Rejected-запросы, неподдержанные capability и внутренний дрейф закрывают -/// весь вызов. Конструкторы приватны; допуск делит [`SolveFailure::boundary`] -/// с [`RoleFailure`], а [`Self::kind`] и [`Self::code`] остаются -/// авторитетной whole-call-классификацией. +/// Rejected-запросы и внутренний дрейф закрывают весь вызов. Конструкторы +/// приватны; допуск делит [`SolveFailure::boundary`] с [`RoleFailure`], а +/// [`Self::kind`] и [`Self::code`] остаются авторитетной whole-call- +/// классификацией. #[derive(Debug, Clone, PartialEq)] pub struct ResolveSetError { state: ResolveSetErrorState, @@ -1398,17 +1371,15 @@ impl ResolveSetError { pub const fn kind(&self) -> ResolveSetErrorKind { match &self.state { ResolveSetErrorState::Rejected(_) => ResolveSetErrorKind::Rejected, - ResolveSetErrorState::Unsupported(_) => ResolveSetErrorKind::Unsupported, ResolveSetErrorState::Internal(_) => ResolveSetErrorKind::Internal, } } - /// Стабильный машинный код ядра для rejected/unsupported-отказов. + /// Стабильный машинный код ядра для rejected-отказов. /// У внутреннего дрейфа намеренно нет публичного solver-кода. pub const fn code(&self) -> Option<&'static str> { match &self.state { - ResolveSetErrorState::Rejected(evidence) - | ResolveSetErrorState::Unsupported(evidence) => Some(evidence.boundary.code()), + ResolveSetErrorState::Rejected(evidence) => Some(evidence.boundary.code()), ResolveSetErrorState::Internal(_) => None, } } @@ -1416,8 +1387,7 @@ impl ResolveSetError { /// Структурированный исходный отказ — диагностика и точные evidence-поля. pub const fn reason(&self) -> &SolveFailure { match &self.state { - ResolveSetErrorState::Rejected(evidence) - | ResolveSetErrorState::Unsupported(evidence) => &evidence.reason, + ResolveSetErrorState::Rejected(evidence) => &evidence.reason, ResolveSetErrorState::Internal(reason) => reason, } } @@ -1441,10 +1411,9 @@ type PendingResolution = Result; /// Исход резолва одной допущенной роли: решённый цвет, честный ноль, /// типизированная численная неопределённость или локальный отказ роли. /// -/// Доказанная недостижимость и незавершённый bounded search отдаются -/// пер-ролью и не маскируются. Rejected/unsupported/internal провенанс в этом -/// типе жить не может: он закрывает [`resolve_named_set`] через -/// [`ResolveSetError`]. +/// Доказанная недостижимость и незавершённый bounded search отдаются пер-ролью +/// и не маскируются. Rejected/internal провенанс в этом типе жить не может: он +/// закрывает [`resolve_named_set`] через [`ResolveSetError`]. #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub enum Resolved { @@ -1502,9 +1471,6 @@ fn classify_role_failure(reason: SolveFailure) -> Result Err(ResolveSetError { state: ResolveSetErrorState::Rejected(BoundaryFailure { reason, boundary }), }), - SolveFailureCategory::Unsupported => Err(ResolveSetError { - state: ResolveSetErrorState::Unsupported(BoundaryFailure { reason, boundary }), - }), }, None => Err(ResolveSetError { state: ResolveSetErrorState::Internal(reason), @@ -2153,26 +2119,6 @@ fn resolve_spec_in( }); } RoleSpec::Decorative { magnitude } => ctx.decorative_contract(magnitude), - RoleSpec::PairFill { tint } => { - return lower_pair_fill_frontend(bg, tint, vc); - } - RoleSpec::PairLabel { - tint, - fraction, - floor, - surface_alpha_light, - surface_alpha_dark, - } => { - return lower_pair_label_frontend( - bg, - tint, - fraction, - floor, - surface_alpha_light, - surface_alpha_dark, - vc, - ); - } RoleSpec::Ladder { tint, alpha_light, @@ -2590,196 +2536,6 @@ fn resolve_solid_with_ui_floor( } } -/// Exact page background as the root Surface of the Pair point graph. -fn pair_root_surface(bg: &BgInput) -> Result { - crate::alpha::encoded_to_srgb8(bg.encoded_display(), "pair page background") - .map(Srgb8::new) - .map_err(|error| { - SolveFailure::InternalInvariant(format!( - "validated Pair background left encoded-sRGB8 domain: {error}" - )) - }) -} - -/// Frozen Pair representation is opaque. The old PairSide/Oklab adjustment and -/// the hidden FillPrimary opacity are both absent: representation no longer -/// depends on a client vocabulary term. -fn lower_pair_fill_occurrence( - bg: &BgInput, - tint: LadderTint, - vc: &ViewingConditions, -) -> Result { - let source = tint.srgb8_for_vc(vc); - let backdrop = pair_root_surface(bg)?; - Ok(crate::pair::lower_fill( - source, - crate::composition::AdmittedOpacityV1::OPAQUE, - backdrop, - )) -} - -fn lower_pair_fill_frontend( - bg: &BgInput, - tint: LadderTint, - vc: &ViewingConditions, -) -> PendingResolution { - let fill = lower_pair_fill_occurrence(bg, tint, vc)?; - finish_rgba_from_certificate( - fill.paint().source(), - crate::composition::AdmittedOpacityV1::OPAQUE.value(), - fill.occurrence().certificate(), - vc, - false, - false, - ) -} - -fn pair_requirement(floor: Floor) -> crate::pair::PairLabelRequirementV1 { - match floor { - Floor::AaText => crate::pair::PairLabelRequirementV1::Wcag22( - crate::wcag22::Wcag22CriterionV1::Sc143TextDefault, - ), - Floor::AaUi => crate::pair::PairLabelRequirementV1::Wcag22( - crate::wcag22::Wcag22CriterionV1::Sc1411UiComponentOrState, - ), - Floor::None => crate::pair::PairLabelRequirementV1::None, - } -} - -fn resolved_label_bytes(resolved: &Resolved) -> Result { - let Resolved::Color { solved, .. } = resolved else { - return Err(SolveFailure::InternalInvariant( - "Pair label proposal did not produce a Color".into(), - )); - }; - crate::srgb8::hex_bytes(solved.hex()) - .map(Srgb8::new) - .map_err(|error| { - SolveFailure::InternalInvariant(format!( - "Pair label solver emitted invalid sRGB8: {error}" - )) - }) -} - -fn pair_candidate( - surface_bg: &BgInput, - fraction: f64, - floor: Floor, - source: Srgb8, - vc: &ViewingConditions, - surface_ctx: &ResolveContext, -) -> Result<(Resolved, Srgb8), SolveFailure> { - let resolved = resolve_hued_anchor_from_srgb8( - surface_bg, - TextAnchor::new(fraction, floor)?, - source, - vc, - surface_ctx, - )?; - let bytes = resolved_label_bytes(&resolved)?; - Ok((resolved, bytes)) -} - -/// PairLabel proposes the exact authored fraction first, then a floor-aware -/// fallback. The common joint engine—not the proposal stage—forms the feasible -/// set, applies the declared order and performs fresh final-occurrence recheck. -#[allow(clippy::too_many_arguments)] -fn lower_pair_label_frontend( - bg: &BgInput, - tint: LadderTint, - fraction: f64, - floor: Floor, - surface_alpha_light: f64, - surface_alpha_dark: f64, - vc: &ViewingConditions, -) -> PendingResolution { - if surface_alpha_light.to_bits() != 1.0_f64.to_bits() - || surface_alpha_dark.to_bits() != 1.0_f64.to_bits() - { - return Err(SolveFailure::InvalidInput( - "PairLabel surface representation must be opaque after P1".into(), - )); - } - - let source = tint.srgb8_for_vc(vc); - let backdrop = pair_root_surface(bg)?; - let fill = lower_pair_fill_occurrence(bg, tint, vc)?; - let surface = fill.visible(); - let surface_bg = BgInput::solid(&surface.to_hex()).map_err(|error| { - SolveFailure::InternalInvariant(format!("generated PairFill Surface was rejected: {error}")) - })?; - let surface_ctx = ResolveContext::new(&surface_bg, vc); - - let mut resolved_candidates = Vec::new(); - let mut physical_candidates = Vec::new(); - let mut order = Vec::new(); - - match pair_candidate(&surface_bg, fraction, Floor::None, source, vc, &surface_ctx) { - Ok((resolved, bytes)) => { - let ordinal = crate::joint::CandidateOrdinalV1::new(1); - resolved_candidates.push((ordinal, resolved)); - physical_candidates.push(crate::pair::PairLabelCandidateV1::new(ordinal, bytes)); - order.push(ordinal); - } - Err(error) if matches!(floor, Floor::None) => return Err(error), - Err(error) if error.boundary().is_none() => return Err(error), - Err(_) => {} - } - - if !matches!(floor, Floor::None) { - let (resolved, bytes) = - pair_candidate(&surface_bg, fraction, floor, source, vc, &surface_ctx)?; - if physical_candidates - .iter() - .all(|candidate| candidate.source() != bytes) - { - let ordinal = crate::joint::CandidateOrdinalV1::new(2); - resolved_candidates.push((ordinal, resolved)); - physical_candidates.push(crate::pair::PairLabelCandidateV1::new(ordinal, bytes)); - order.push(ordinal); - } - } - - if physical_candidates.is_empty() { - return Err(SolveFailure::InternalInvariant( - "Pair frontend produced an empty candidate domain".into(), - )); - } - - let verified = crate::pair::select_label_candidates( - source, - crate::composition::AdmittedOpacityV1::OPAQUE, - physical_candidates, - order, - backdrop, - pair_requirement(floor), - ) - .map_err(|error| { - SolveFailure::InternalInvariant(format!( - "Pair joint selection failed after typed proposal admission: {error:?}" - )) - })?; - - if verified.fill_occurrence() != fill.occurrence() - || verified.fill_paint() != fill.paint() - || verified.label_occurrence().visible() != verified.label_paint().source().bytes() - { - return Err(SolveFailure::InternalInvariant( - "Pair joint evidence drifted from the selected physical chain".into(), - )); - } - - resolved_candidates - .into_iter() - .find(|(ordinal, _)| *ordinal == verified.ordinal()) - .map(|(_, resolved)| resolved) - .ok_or_else(|| { - SolveFailure::InternalInvariant( - "Pair selection returned an ordinal outside the proposal domain".into(), - ) - }) -} - /// Альфа-аналог: солид-цель `solid` (кодированный, по теме) на фоне резолва /// инвертируется в `(tint, фактическая α)`. Перед инверсией цель квантуется до /// эмитируемой sRGB8-сетки; production-композитор обязан побайтно вернуть её. @@ -3308,10 +3064,7 @@ impl RoleSpec { }; match self { - RoleSpec::Anchor(_) - | RoleSpec::Glow { .. } - | RoleSpec::PairFill { .. } - | RoleSpec::Zero => Ok(()), + RoleSpec::Anchor(_) | RoleSpec::Glow { .. } | RoleSpec::Zero => Ok(()), RoleSpec::DecorativeDj { magnitude_dj } => { positive("decorative dJ light magnitude", magnitude_dj.light())?; positive("decorative dJ dark magnitude", magnitude_dj.dark()) @@ -3325,26 +3078,6 @@ impl RoleSpec { )) } } - RoleSpec::PairLabel { - fraction, - surface_alpha_light, - surface_alpha_dark, - .. - } => { - if !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0 { - return Err(format!( - "pair-label fraction must be finite and inside (0, 1], got {fraction}" - )); - } - alpha("pair-label light surface alpha", surface_alpha_light)?; - alpha("pair-label dark surface alpha", surface_alpha_dark)?; - if surface_alpha_light.to_bits() != 1.0_f64.to_bits() - || surface_alpha_dark.to_bits() != 1.0_f64.to_bits() - { - return Err("pair-label surface representation must be opaque after P1".into()); - } - Ok(()) - } RoleSpec::Ladder { alpha_light, alpha_dark, @@ -3409,10 +3142,6 @@ impl RoleSpec { pub fn legal_floor(&self) -> Option { match self { RoleSpec::Anchor(anchor) => anchor.conformance().min_ratio(), - // Лейбл тинт-бейджа несёт свой пол против тинт-поверхности — семантика - // контракта, как у текст/UI-якоря (иерархия-пасс его не трогает: он - // singleton, не ступень лестницы). - RoleSpec::PairLabel { floor, .. } => floor.min_ratio(), _ => None, } } @@ -4026,11 +3755,9 @@ fn demotion_outcome( Err(failure) => match failure.boundary().map(|boundary| boundary.category()) { Some(SolveFailureCategory::Unreachable) => Ok(None), Some(SolveFailureCategory::Unresolved) | None => Err(failure), - Some(SolveFailureCategory::Rejected | SolveFailureCategory::Unsupported) => { - Err(SolveFailure::InternalInvariant(format!( - "validated sRGB hierarchy demotion produced {failure}" - ))) - } + Some(SolveFailureCategory::Rejected) => Err(SolveFailure::InternalInvariant(format!( + "validated sRGB hierarchy demotion produced {failure}" + ))), }, } } @@ -4250,11 +3977,6 @@ mod tests { ResolveSetErrorKind::Rejected, Some("invalid_input"), ), - ( - SolveFailure::GamutUnsupported, - ResolveSetErrorKind::Unsupported, - Some("gamut_unsupported"), - ), ( SolveFailure::InternalInvariant("injected drift".into()), ResolveSetErrorKind::Internal, @@ -4518,7 +4240,6 @@ mod tests { floor: 4.5, max_ratio: 3.0, }, - SolveFailure::GamutUnsupported, SolveFailure::InvalidInput("generated probe".into()), ] { assert!( @@ -4579,15 +4300,11 @@ mod tests { ] { assert_eq!(demotion_outcome(Err(failure), 20.0), Ok(None)); } - for failure in [ - SolveFailure::GamutUnsupported, - SolveFailure::InvalidInput("generated request".into()), - ] { - assert!(matches!( - demotion_outcome(Err(failure), 20.0), - Err(SolveFailure::InternalInvariant(_)) - )); - } + let failure = SolveFailure::InvalidInput("generated request".into()); + assert!(matches!( + demotion_outcome(Err(failure), 20.0), + Err(SolveFailure::InternalInvariant(_)) + )); } #[test] @@ -4627,13 +4344,6 @@ mod tests { #[test] fn named_table_validates_every_raw_role_field_before_resolution() { let tint = LadderTint::new([[0.25, 0.5, 0.75]; 4]).unwrap(); - let pair = |fraction, light, dark| RoleSpec::PairLabel { - tint, - fraction, - floor: Floor::AaText, - surface_alpha_light: light, - surface_alpha_dark: dark, - }; let ladder = |light, dark| RoleSpec::Ladder { tint, alpha_light: light, @@ -4658,10 +4368,6 @@ mod tests { RoleSpec::DecorativeDj { magnitude_dj: DjMagnitude::new(1.0, 0.0), }, - pair(f64::NAN, 0.5, 0.5), - pair(0.5, 0.0, 0.5), - pair(0.5, 0.5, f64::INFINITY), - pair(0.5, 0.25, 1.0), ladder(0.0, 0.5), ladder(0.5, f64::NAN), ladder_with_floor(1.0, 1.0, Some(Floor::None)), @@ -4689,7 +4395,6 @@ mod tests { RoleSpec::DecorativeDj { magnitude_dj: DjMagnitude::new(1.0, 2.0), }, - pair(0.5, 1.0, 1.0), ladder(0.25, 1.0), ladder_with_floor(1.0, 1.0, Some(Floor::AaUi)), RoleSpec::AlphaAnalog { diff --git a/crates/labcolors-core/src/session.rs b/crates/labcolors-core/src/session.rs index e10d8003..9b8d9be6 100644 --- a/crates/labcolors-core/src/session.rs +++ b/crates/labcolors-core/src/session.rs @@ -1,26 +1,31 @@ -//! Single lifecycle and observation owner for private F2/C8d full support. +//! Sole revision-bound runtime lifecycle for compiled point programs. //! -//! The closed state below owns the one current raw payload through either its -//! revision-bound report or its current `Unknown`. A separate raw head does not -//! exist; [`ObservationHeadViewV1`] is derived by borrow. At most one previous -//! verified report is retained and no transition builds a history chain. +//! [`Session`] owns the one concrete raw observation head and the one +//! evaluator lifecycle. A plan supplies only its canonical observation schema +//! and consuming evaluation; it cannot admit updates or commit lifecycle +//! state. The plan type is sealed and statically dispatched, so sharing this +//! lifecycle across compiled plans adds neither a runtime tag nor a trait +//! object. use std::mem; -use crate::composition::CompositionProfileV1; use crate::observation::{ - ObservationError, ObservationHeadViewV1, ObservationOwnerV1, ObservationSchemaMismatchV1, - ObservationStreamId, ObservationUpdateInput, PreparedObservationUpdateV1, - RevisionBoundUnknownV1, prepare_observation, -}; -use crate::point_support::{ - BoundPointSupportRecheckV1, CompiledPointSupportRecheckV1, PointSupportDecisionV1, - PointSupportEvaluationErrorV1, PointSupportViolationV1, VerifiedPointSupportV1, + CanonicalObservationSchemaV1, ObservationError, ObservationHeadViewV1, ObservationOwnerV1, + ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, + PreparedObservationUpdateV1, Revision, RevisionBoundObservationV1, RevisionBoundUnknownV1, + SchemaOrderedScenarioSourceV1, UnknownReasonId, prepare_observation, + prepare_schema_ordered_observation, }; -/// Linear authority to consume and revision-bind an observation. The type is -/// visible to the evaluator only as a parameter; its private field and private -/// constructor make safe construction exclusive to this Session module. +/// Crate-private sealing prevents an additional runtime owner from being +/// smuggled in through a public extension point. +pub(crate) mod private { + pub(crate) trait PlanSealed {} + pub(crate) trait EvidenceSealed {} +} + +/// Linear authority to revision-bind one admitted observation to evaluator +/// evidence. Safe construction remains exclusive to this module. pub(crate) struct SessionObservationBindingPermitV1 { _private: (), } @@ -36,370 +41,281 @@ impl SessionObservationBindingPermitV1 { } } -#[derive(Debug, PartialEq)] -#[cfg_attr(test, derive(Clone))] -pub(crate) enum PointSupportSessionStateV1 { - /// Initial state or a current Unknown without any previous verified report. - Waiting { - current_unknown: Option, - }, +/// Complete result of evaluating one admitted observation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SessionDecision { + Verified(Verified), + Violation(Violation), +} + +pub(crate) trait SessionEvidenceV1: private::EvidenceSealed { + fn observation(&self) -> &RevisionBoundObservationV1; +} + +impl SessionDecision +where + Verified: SessionEvidenceV1, + Violation: SessionEvidenceV1, +{ + fn observation(&self) -> &RevisionBoundObservationV1 { + match self { + Self::Verified(evidence) => evidence.observation(), + Self::Violation(evidence) => evidence.observation(), + } + } +} + +/// A compiled, statically dispatched evaluator used by the sole [`Session`] +/// lifecycle. Implementations own their per-Session scratch directly. +pub(crate) trait SessionPlanV1: private::PlanSealed { + /// One owned lease over the exact compiled owner generation used by an + /// update. Acquiring it is the first operation in the transaction, so an + /// expired plan cannot admit raw state or reach physical execution. + type OwnerLease; + type Verified: SessionEvidenceV1; + type Violation: SessionEvidenceV1; + type Error; + + fn try_acquire_owner(&self) -> Option; + + /// Return the canonical schema reached through the same owner lease that + /// will authorize evaluation. Self-owned plans may return their own schema; + /// weakly bound plans must derive it from the pinned generation. + fn observation_schema<'a>( + &'a self, + owner: &'a Self::OwnerLease, + ) -> &'a CanonicalObservationSchemaV1; + + fn evaluate( + &mut self, + owner: &Self::OwnerLease, + observation: RevisionBoundObservationV1, + permit: SessionObservationBindingPermitV1, + ) -> Result, Self::Error>; +} + +/// Evaluator lifecycle. The current raw payload is deliberately not embedded +/// here: `Unknown` carries no evidence, while `Stale` retains at most one +/// previous verified witness. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SessionState { + Waiting, Ready { - current: VerifiedPointSupportV1, + current: Verified, }, Stale { - previous: VerifiedPointSupportV1, - current_unknown: RevisionBoundUnknownV1, + previous: Verified, }, Failed { - cause: PointSupportViolationV1, - previous: Option, + cause: Violation, + previous: Option, }, } -impl PointSupportSessionStateV1 { - pub(crate) fn last_verified(&self) -> Option<&VerifiedPointSupportV1> { +impl SessionState { + pub(crate) fn last_verified(&self) -> Option<&Verified> { match self { - Self::Waiting { .. } => None, + Self::Waiting => None, Self::Ready { current } => Some(current), - Self::Stale { previous, .. } => Some(previous), + Self::Stale { previous } => Some(previous), Self::Failed { previous, .. } => previous.as_ref(), } } } -impl ObservationOwnerV1 for PointSupportSessionStateV1 { +#[derive(Debug, Clone, PartialEq, Eq)] +enum SessionObservationHeadV1 { + Empty, + Unknown(RevisionBoundUnknownV1), + Observed(RevisionBoundObservationV1), +} + +impl ObservationOwnerV1 for SessionObservationHeadV1 { fn observation_head(&self) -> ObservationHeadViewV1<'_> { match self { - Self::Waiting { - current_unknown: None, - } => ObservationHeadViewV1::Empty, - Self::Waiting { - current_unknown: Some(unknown), - } - | Self::Stale { - current_unknown: unknown, - .. - } => ObservationHeadViewV1::Unknown(unknown), - Self::Ready { current } => { - ObservationHeadViewV1::Observed(current.report().observation()) - } - Self::Failed { cause, .. } => { - ObservationHeadViewV1::Observed(cause.report().observation()) - } + Self::Empty => ObservationHeadViewV1::Empty, + Self::Unknown(unknown) => ObservationHeadViewV1::Unknown(unknown), + Self::Observed(observation) => ObservationHeadViewV1::Observed(observation), } } } +/// An update failed before either raw-head or lifecycle commit. #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum PointSupportSessionUpdateErrorV1 { +pub(crate) enum SessionUpdateError { + OwnerExpired, Observation(ObservationError), - ObservationSchemaMismatch(ObservationSchemaMismatchV1), - ResourceExhausted, - InternalInvariant, + Plan(PlanError), + EvidenceBindingInvariant, } -#[derive(Debug, PartialEq)] -#[cfg_attr(test, derive(Clone))] -pub(crate) struct PointSupportSessionV1 { +type SessionUpdateResult<'session, Plan> = Result< + &'session SessionState<::Verified, ::Violation>, + SessionUpdateError<::Error>, +>; + +/// The only production owner of revision admission and evaluator lifecycle. +/// `Plan` is monomorphized; there is no plan enum, dynamic dispatch or adapter. +/// A plan may keep only a weak reference to its compiled owner generation; +/// every update pins that exact generation before admission and releases it +/// after commit or rollback. +#[derive(Debug)] +pub(crate) struct Session { stream: ObservationStreamId, - recheck: BoundPointSupportRecheckV1, - state: PointSupportSessionStateV1, - #[cfg(test)] - force_resource_failure: bool, + plan: Plan, + raw_head: SessionObservationHeadV1, + state: SessionState, } -impl PointSupportSessionV1 { - /// The compiled recheck owns the only canonical schema and is moved into - /// the Session. No second schema or replacement Paint can enter updates. - pub(crate) fn new( - stream: ObservationStreamId, - compiled: CompiledPointSupportRecheckV1, - ) -> Self { +impl Session { + pub(crate) fn new(stream: ObservationStreamId, plan: Plan) -> Self { Self { stream, - recheck: compiled.into_session_recheck(), - state: PointSupportSessionStateV1::Waiting { - current_unknown: None, - }, - #[cfg(test)] - force_resource_failure: false, + plan, + raw_head: SessionObservationHeadV1::Empty, + state: SessionState::Waiting, } } - pub(crate) const fn state(&self) -> &PointSupportSessionStateV1 { + pub(crate) const fn state(&self) -> &SessionState { &self.state } pub(crate) fn raw_head(&self) -> ObservationHeadViewV1<'_> { - self.state.observation_head() - } - - pub(crate) const fn composition_profile(&self) -> CompositionProfileV1 { - self.recheck.composition_profile() + self.raw_head.observation_head() } - #[cfg(test)] - pub(crate) fn force_next_resource_failure(&mut self) { - self.force_resource_failure = true; + pub(crate) const fn plan(&self) -> &Plan { + &self.plan } - /// One transaction: prepare/canonicalize without mutation, transfer the - /// exact observation under a Session-only permit to the consuming - /// evaluator, then replace the closed owner with infallible moves only. + /// Prepare, evaluate and commit one update transaction. Admission and plan + /// errors leave both the concrete raw head and lifecycle state untouched. + /// Plan-local scratch may have been overwritten by a failed evaluation, + /// but it is not observable lifecycle state and every evaluation must + /// completely initialize the scratch it consumes. pub(crate) fn update( &mut self, update: ObservationUpdateInput, - ) -> Result<&PointSupportSessionStateV1, PointSupportSessionUpdateErrorV1> { - let prepared = prepare_observation( - &mut self.state, - self.stream, - self.recheck.surface_schema(), - update, - ) - .map_err(PointSupportSessionUpdateErrorV1::Observation)?; - - match prepared { - PreparedObservationUpdateV1::Idempotent(prepared) => Ok(prepared.into_owner()), - PreparedObservationUpdateV1::Unknown(prepared) => { - let (state, unknown) = prepared.into_parts(); - let previous = take_last_verified(state); - *state = match previous { - Some(previous) => PointSupportSessionStateV1::Stale { - previous, - current_unknown: unknown, - }, - None => PointSupportSessionStateV1::Waiting { - current_unknown: Some(unknown), - }, - }; - Ok(state) - } - PreparedObservationUpdateV1::Observed(prepared) => { - #[cfg(test)] - if mem::take(&mut self.force_resource_failure) { - return Err(PointSupportSessionUpdateErrorV1::ResourceExhausted); - } - - // Evaluation consumes the exact admitted observation and can - // return only an already revision-bound decision. The mutable - // owner is retained unchanged until that fallible work succeeds. - let (state, observation) = prepared.into_parts(); - let decision = self - .recheck - .evaluate(observation, SessionObservationBindingPermitV1::mint()) - .map_err(map_evaluation_error)?; - let previous = take_last_verified(state); - *state = match decision { - PointSupportDecisionV1::Verified(current) => { - PointSupportSessionStateV1::Ready { current } - } - PointSupportDecisionV1::Violation(cause) => { - PointSupportSessionStateV1::Failed { cause, previous } - } - }; - Ok(state) - } - } - } -} + ) -> SessionUpdateResult<'_, Plan> { + let owner = self + .plan + .try_acquire_owner() + .ok_or(SessionUpdateError::OwnerExpired)?; + let schema = self.plan.observation_schema(&owner); + let prepared = prepare_observation(&mut self.raw_head, self.stream, schema, update) + .map_err(SessionUpdateError::Observation)?; -fn map_evaluation_error(error: PointSupportEvaluationErrorV1) -> PointSupportSessionUpdateErrorV1 { - match error { - PointSupportEvaluationErrorV1::ObservationSchemaMismatch(mismatch) => { - PointSupportSessionUpdateErrorV1::ObservationSchemaMismatch(mismatch) - } - PointSupportEvaluationErrorV1::ResourceExhausted => { - PointSupportSessionUpdateErrorV1::ResourceExhausted - } - PointSupportEvaluationErrorV1::CompiledPlanInvariant - | PointSupportEvaluationErrorV1::Wcag22Invariant - | PointSupportEvaluationErrorV1::StabilityArithmeticInvariant => { - PointSupportSessionUpdateErrorV1::InternalInvariant - } + apply_prepared_update(&mut self.plan, &mut self.state, &owner, prepared) } -} -/// Move exactly one retained verified witness out of the old closed owner. -fn take_last_verified(state: &mut PointSupportSessionStateV1) -> Option { - match mem::replace( - state, - PointSupportSessionStateV1::Waiting { - current_unknown: None, - }, - ) { - PointSupportSessionStateV1::Waiting { .. } => None, - PointSupportSessionStateV1::Ready { current } => Some(current), - PointSupportSessionStateV1::Stale { previous, .. } => Some(previous), - PointSupportSessionStateV1::Failed { previous, .. } => previous, - } -} - -#[cfg(test)] -mod structural_tests { - use super::SessionObservationBindingPermitV1; - use crate::Srgb8; - use crate::appearance::{EncodedPointPaintV1, OccurrenceId, PaintId, SurfaceInputPortId}; - use crate::composition::{AdmittedOpacityV1, CompositionProfileV1}; - use crate::observation::{ - ObservationHeadViewV1, ObservationOwnerV1, ObservationPayloadInput, - ObservationSchemaMismatchV1, ObservationStreamId, ObservationUpdateInput, - ObservedScenarioSetInput, PreparedObservationUpdateV1, Revision, - RevisionBoundObservationV1, ScenarioId, ScenarioInput, SurfaceInputBinding, - prepare_observation, - }; - use crate::point_support::{ - CompiledPointSupportRecheckV1, PointSupportCriterionRequirementV1, - PointSupportEvaluationErrorV1, PointSupportOccurrenceRequirementV1, - PointSupportStabilityPolicyV1, - }; - - const STREAM: ObservationStreamId = ObservationStreamId::new(700); - const REQUIRED_SURFACE: SurfaceInputPortId = SurfaceInputPortId::new(10); - const WRONG_SURFACE: SurfaceInputPortId = SurfaceInputPortId::new(20); - - struct EmptyOwner; - - impl ObservationOwnerV1 for EmptyOwner { - fn observation_head(&self) -> ObservationHeadViewV1<'_> { - ObservationHeadViewV1::Empty - } + /// Stream-affine `Unknown` admission without re-exporting or duplicating + /// the Session-owned stream identity at a package boundary. + pub(crate) fn update_unknown( + &mut self, + revision: Revision, + reason: UnknownReasonId, + ) -> SessionUpdateResult<'_, Plan> { + self.update(ObservationUpdateInput { + stream: self.stream, + revision, + payload: ObservationPayloadInput::Unknown(reason), + }) } - fn wrong_schema_observation() -> RevisionBoundObservationV1 { - let mut owner = EmptyOwner; - let prepared = prepare_observation( - &mut owner, - STREAM, - &[WRONG_SURFACE], - ObservationUpdateInput { - stream: STREAM, - revision: Revision::new(1), - payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { - scenarios: vec![ScenarioInput { - id: ScenarioId::new(1), - bindings: vec![SurfaceInputBinding::new( - WRONG_SURFACE, - Srgb8::new([255; 3]), - )], - }], - }), - }, + /// Package hot path for already schema-ordered point-sRGB8 scenarios. + /// It shares the exact lifecycle transaction below without constructing + /// keyed surface bindings or a second raw observation owner. + pub(crate) fn update_schema_ordered( + &mut self, + revision: Revision, + source: &Source, + order_scratch: &mut Vec, + ) -> SessionUpdateResult<'_, Plan> { + let owner = self + .plan + .try_acquire_owner() + .ok_or(SessionUpdateError::OwnerExpired)?; + let schema = self.plan.observation_schema(&owner); + let prepared = prepare_schema_ordered_observation( + &mut self.raw_head, + self.stream, + schema, + revision, + source, + order_scratch, ) - .unwrap(); - let PreparedObservationUpdateV1::Observed(prepared) = prepared else { - panic!("fresh observed update must prepare an observation"); - }; - let (_owner, observation) = prepared.into_parts(); - observation - } + .map_err(SessionUpdateError::Observation)?; - fn narrow_schema_observation() -> RevisionBoundObservationV1 { - let mut owner = EmptyOwner; - let prepared = prepare_observation( - &mut owner, - STREAM, - &[REQUIRED_SURFACE], - ObservationUpdateInput { - stream: STREAM, - revision: Revision::new(1), - payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { - scenarios: vec![ScenarioInput { - id: ScenarioId::new(1), - bindings: vec![SurfaceInputBinding::new( - REQUIRED_SURFACE, - Srgb8::new([255; 3]), - )], - }], - }), - }, - ) - .unwrap(); - let PreparedObservationUpdateV1::Observed(prepared) = prepared else { - panic!("fresh observed update must prepare an observation"); - }; - let (_owner, observation) = prepared.into_parts(); - observation + apply_prepared_update(&mut self.plan, &mut self.state, &owner, prepared) } +} - #[test] - fn consuming_evaluator_rejects_wrong_keyed_schema_before_composition() { - let paint = EncodedPointPaintV1::from_admitted( - PaintId::new(1), - Srgb8::new([0; 3]), - AdmittedOpacityV1::new(1.0).unwrap(), - ); - let compiled = CompiledPointSupportRecheckV1::new( - CompositionProfileV1::EncodedSrgb8SourceOverV1, - vec![PointSupportOccurrenceRequirementV1::new( - OccurrenceId::new(1), - REQUIRED_SURFACE, - paint, - Some(Srgb8::new([0; 3])), - PointSupportCriterionRequirementV1::NotRequested, - PointSupportStabilityPolicyV1::Disabled, - )], - ) - .unwrap(); - let recheck = compiled.into_session_recheck(); - - crate::composition::reset_source_over_evaluation_count(); - assert_eq!( - recheck +fn apply_prepared_update<'session, Plan: SessionPlanV1>( + plan: &mut Plan, + state: &'session mut SessionState, + owner: &Plan::OwnerLease, + prepared: PreparedObservationUpdateV1<'_, SessionObservationHeadV1>, +) -> SessionUpdateResult<'session, Plan> { + match prepared { + PreparedObservationUpdateV1::Idempotent(prepared) => { + let _raw_head = prepared.into_owner(); + Ok(state) + } + PreparedObservationUpdateV1::Unknown(prepared) => { + let (raw_head, unknown) = prepared.into_parts(); + let next_state = match take_last_verified(state) { + Some(previous) => SessionState::Stale { previous }, + None => SessionState::Waiting, + }; + *raw_head = SessionObservationHeadV1::Unknown(unknown); + *state = next_state; + Ok(state) + } + PreparedObservationUpdateV1::Observed(prepared) => { + // Clone only the small Rc-backed observation handle. Both the + // committed raw head and returned evidence then share the exact + // immutable observation backing. + let (raw_head, observation) = prepared.into_parts(); + let next_raw_head = SessionObservationHeadV1::Observed(observation.clone()); + let decision = plan .evaluate( - wrong_schema_observation(), + owner, + observation, SessionObservationBindingPermitV1::mint(), ) - .unwrap_err(), - PointSupportEvaluationErrorV1::ObservationSchemaMismatch( - ObservationSchemaMismatchV1::new(0, 0, Some(REQUIRED_SURFACE), Some(WRONG_SURFACE),), - ) - ); - assert_eq!(crate::composition::source_over_evaluation_count(), 0); - } + .map_err(SessionUpdateError::Plan)?; + let SessionObservationHeadV1::Observed(expected_observation) = &next_raw_head else { + unreachable!("the pending raw head was constructed as Observed") + }; + if !decision + .observation() + .is_same_binding_as(expected_observation) + { + return Err(SessionUpdateError::EvidenceBindingInvariant); + } - #[test] - fn consuming_evaluator_rejects_narrow_schema_without_indexing_panic() { - let paint = EncodedPointPaintV1::from_admitted( - PaintId::new(1), - Srgb8::new([0; 3]), - AdmittedOpacityV1::new(1.0).unwrap(), - ); - let compiled = CompiledPointSupportRecheckV1::new( - CompositionProfileV1::EncodedSrgb8SourceOverV1, - vec![ - PointSupportOccurrenceRequirementV1::new( - OccurrenceId::new(1), - REQUIRED_SURFACE, - paint, - Some(Srgb8::new([0; 3])), - PointSupportCriterionRequirementV1::NotRequested, - PointSupportStabilityPolicyV1::Disabled, - ), - PointSupportOccurrenceRequirementV1::new( - OccurrenceId::new(2), - WRONG_SURFACE, - paint, - Some(Srgb8::new([0; 3])), - PointSupportCriterionRequirementV1::NotRequested, - PointSupportStabilityPolicyV1::Disabled, - ), - ], - ) - .unwrap(); - let recheck = compiled.into_session_recheck(); + // All fallible work is complete. Commit with moves only. + let previous = take_last_verified(state); + let next_state = match decision { + SessionDecision::Verified(current) => SessionState::Ready { current }, + SessionDecision::Violation(cause) => SessionState::Failed { cause, previous }, + }; + *raw_head = next_raw_head; + *state = next_state; + Ok(state) + } + } +} - crate::composition::reset_source_over_evaluation_count(); - assert_eq!( - recheck - .evaluate( - narrow_schema_observation(), - SessionObservationBindingPermitV1::mint(), - ) - .unwrap_err(), - PointSupportEvaluationErrorV1::ObservationSchemaMismatch( - ObservationSchemaMismatchV1::new(0, 1, Some(WRONG_SURFACE), None), - ) - ); - assert_eq!(crate::composition::source_over_evaluation_count(), 0); +/// Move exactly one retained verified witness out of the old closed owner. +fn take_last_verified( + state: &mut SessionState, +) -> Option { + match mem::replace(state, SessionState::Waiting) { + SessionState::Waiting => None, + SessionState::Ready { current } => Some(current), + SessionState::Stale { previous } => Some(previous), + SessionState::Failed { previous, .. } => previous, } } diff --git a/crates/labcolors-core/src/session_tests.rs b/crates/labcolors-core/src/session_tests.rs index 6574981f..76ad9d8d 100644 --- a/crates/labcolors-core/src/session_tests.rs +++ b/crates/labcolors-core/src/session_tests.rs @@ -1,65 +1,261 @@ -use proptest::prelude::*; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; use crate::Srgb8; -use crate::appearance::{EncodedPointPaintV1, OccurrenceId, PaintId, SurfaceInputPortId}; -use crate::composition::{AdmittedOpacityV1, CompositionProfileV1}; +use crate::appearance::SurfaceInputPortId; +use crate::lcs_occurrence::ColorSignal; use crate::observation::{ - ObservationError, ObservationHeadViewV1, ObservationPayloadInput, ObservationStreamId, - ObservationUpdateInput, ObservedScenarioSetInput, Revision, ScenarioId, ScenarioInput, - SurfaceInputBinding, UnknownReasonId, -}; -use crate::point_support::{ - CompiledPointSupportRecheckV1, PointSupportCriterionRequirementV1, - PointSupportOccurrenceRequirementV1, PointSupportStabilityPolicyV1, + CanonicalObservationSchemaV1, ObservationError, ObservationHeadViewV1, ObservationPayloadInput, + ObservationStreamId, ObservationUpdateInput, ObservedScenarioSetInput, Revision, + RevisionBoundObservationV1, ScenarioId, ScenarioInput, SchemaOrderedScenarioSourceV1, + SurfaceInputBinding, UnknownReasonId, canonicalize_observation_schema, }; use crate::session::{ - PointSupportSessionStateV1, PointSupportSessionUpdateErrorV1, PointSupportSessionV1, + Session, SessionDecision, SessionEvidenceV1, SessionObservationBindingPermitV1, SessionPlanV1, + SessionState, SessionUpdateError, private as session_private, }; -const PAINT: PaintId = PaintId::new(7); -const OCCURRENCE: OccurrenceId = OccurrenceId::new(11); -const SURFACE: SurfaceInputPortId = SurfaceInputPortId::new(21); const STREAM: ObservationStreamId = ObservationStreamId::new(31); -const TARGET: [u8; 3] = [128; 3]; +const FOREIGN_STREAM: ObservationStreamId = ObservationStreamId::new(32); +const SURFACE: SurfaceInputPortId = SurfaceInputPortId::new(21); -fn candidate() -> EncodedPointPaintV1 { - EncodedPointPaintV1::from_admitted( - PAINT, - Srgb8::new([0; 3]), - AdmittedOpacityV1::new(0.5).unwrap(), - ) +#[derive(Debug, Clone, PartialEq, Eq)] +struct SentinelVerified { + observation: RevisionBoundObservationV1, } -fn requirement() -> CompiledPointSupportRecheckV1 { - CompiledPointSupportRecheckV1::new( - CompositionProfileV1::EncodedSrgb8SourceOverV1, - vec![PointSupportOccurrenceRequirementV1::new( - OCCURRENCE, - SURFACE, - candidate(), - Some(Srgb8::new(TARGET)), - PointSupportCriterionRequirementV1::NotRequested, - PointSupportStabilityPolicyV1::Disabled, - )], - ) - .unwrap() +#[derive(Debug, Clone, PartialEq, Eq)] +struct SentinelViolation { + observation: RevisionBoundObservationV1, +} + +impl session_private::EvidenceSealed for SentinelVerified {} + +impl SessionEvidenceV1 for SentinelVerified { + fn observation(&self) -> &RevisionBoundObservationV1 { + &self.observation + } +} + +impl session_private::EvidenceSealed for SentinelViolation {} + +impl SessionEvidenceV1 for SentinelViolation { + fn observation(&self) -> &RevisionBoundObservationV1 { + &self.observation + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SentinelError { + Forced, + SchemaBackingMismatch, + EmptyObservation, +} + +#[derive(Debug, Clone)] +struct SentinelControl { + evaluations: Rc>, + fail_next: Rc>, + substitute_next: Rc>>, +} + +impl SentinelControl { + fn evaluation_count(&self) -> usize { + self.evaluations.get() + } + + fn fail_next(&self) { + self.fail_next.set(true); + } + + fn substitute_next_with(&self, observation: RevisionBoundObservationV1) { + *self.substitute_next.borrow_mut() = Some(observation); + } +} + +#[derive(Debug)] +struct SentinelPlan { + schema: CanonicalObservationSchemaV1, + control: SentinelControl, +} + +impl session_private::PlanSealed for SentinelPlan {} + +impl SessionPlanV1 for SentinelPlan { + type OwnerLease = (); + type Verified = SentinelVerified; + type Violation = SentinelViolation; + type Error = SentinelError; + + fn try_acquire_owner(&self) -> Option { + Some(()) + } + + fn observation_schema<'a>( + &'a self, + _owner: &'a Self::OwnerLease, + ) -> &'a CanonicalObservationSchemaV1 { + &self.schema + } + + fn evaluate( + &mut self, + _owner: &Self::OwnerLease, + observation: RevisionBoundObservationV1, + _permit: SessionObservationBindingPermitV1, + ) -> Result, Self::Error> { + self.control + .evaluations + .set(self.control.evaluations.get() + 1); + if self.control.fail_next.replace(false) { + return Err(SentinelError::Forced); + } + if !observation.shares_schema_backing_with(&self.schema) { + return Err(SentinelError::SchemaBackingMismatch); + } + let first = observation + .physical_values(0) + .and_then(|values| values.first()) + .copied() + .map(ColorSignal::srgb8) + .ok_or(SentinelError::EmptyObservation)?; + let observation = self + .control + .substitute_next + .borrow_mut() + .take() + .unwrap_or(observation); + if first == Srgb8::new([255; 3]) { + Ok(SessionDecision::Verified(SentinelVerified { observation })) + } else { + Ok(SessionDecision::Violation(SentinelViolation { + observation, + })) + } + } +} + +#[derive(Debug)] +struct ReplacingOwnerGeneration { + schema: CanonicalObservationSchemaV1, +} + +#[derive(Debug)] +struct ReplacingOwnerPlan { + generation: std::rc::Weak, + owner_slot: Rc>>>, + evaluations: Rc>, +} + +impl session_private::PlanSealed for ReplacingOwnerPlan {} + +impl SessionPlanV1 for ReplacingOwnerPlan { + type OwnerLease = Rc; + type Verified = SentinelVerified; + type Violation = SentinelViolation; + type Error = SentinelError; + + fn try_acquire_owner(&self) -> Option { + self.generation.upgrade() + } + + fn observation_schema<'a>( + &'a self, + owner: &'a Self::OwnerLease, + ) -> &'a CanonicalObservationSchemaV1 { + &owner.schema + } + + fn evaluate( + &mut self, + owner: &Self::OwnerLease, + observation: RevisionBoundObservationV1, + _permit: SessionObservationBindingPermitV1, + ) -> Result, Self::Error> { + self.evaluations.set(self.evaluations.get() + 1); + assert!( + observation.shares_schema_backing_with(&owner.schema), + "admission and evaluation must use the same pinned generation" + ); + let old_generation = self + .owner_slot + .borrow_mut() + .replace(Rc::new(ReplacingOwnerGeneration { + schema: canonicalize_observation_schema(vec![SURFACE]).unwrap(), + })) + .expect("the first owner generation must still be installed"); + assert!(Rc::ptr_eq(owner, &old_generation)); + drop(old_generation); + assert!( + self.generation.upgrade().is_some(), + "the transaction lease must pin its starting owner generation" + ); + Ok(SessionDecision::Verified(SentinelVerified { observation })) + } +} + +struct OneOrderedScenario { + id: ScenarioId, + value: Srgb8, +} + +impl SchemaOrderedScenarioSourceV1 for OneOrderedScenario { + fn scenario_count(&self) -> usize { + 1 + } + + fn scenario_id(&self, scenario_index: usize) -> ScenarioId { + assert_eq!(scenario_index, 0); + self.id + } + + fn value_count(&self, scenario_index: usize) -> usize { + assert_eq!(scenario_index, 0); + 1 + } + + fn value(&self, scenario_index: usize, binding_index: usize) -> Srgb8 { + assert_eq!((scenario_index, binding_index), (0, 0)); + self.value + } } -fn session() -> PointSupportSessionV1 { - PointSupportSessionV1::new(STREAM, requirement()) +fn session() -> ( + Session, + SentinelControl, + *const SurfaceInputPortId, +) { + let schema = canonicalize_observation_schema(vec![SURFACE]).unwrap(); + let schema_ptr = schema.backing_ptr_for_test(); + let control = SentinelControl { + evaluations: Rc::new(Cell::new(0)), + fail_next: Rc::new(Cell::new(false)), + substitute_next: Rc::new(RefCell::new(None)), + }; + ( + Session::new( + STREAM, + SentinelPlan { + schema, + control: control.clone(), + }, + ), + control, + schema_ptr, + ) } -fn observed_update(revision: u64, backdrop: [u8; 3]) -> ObservationUpdateInput { +fn observed_update(revision: u64, value: [u8; 3]) -> ObservationUpdateInput { ObservationUpdateInput { stream: STREAM, revision: Revision::new(revision), payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { scenarios: vec![ScenarioInput { id: ScenarioId::new(1), - bindings: vec![SurfaceInputBinding { - port: SURFACE, - value: Srgb8::new(backdrop), - }], + bindings: vec![SurfaceInputBinding::new( + SURFACE, + ColorSignal::from_srgb8(Srgb8::new(value)), + )], }], }), } @@ -80,265 +276,332 @@ fn malformed_update(revision: u64) -> ObservationUpdateInput { payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { scenarios: vec![ScenarioInput { id: ScenarioId::new(1), - bindings: vec![], + bindings: Vec::new(), }], }), } } -fn verified_revision(state: &PointSupportSessionStateV1) -> Option { +fn raw_observed(session: &Session) -> &RevisionBoundObservationV1 { + let ObservationHeadViewV1::Observed(observation) = session.raw_head() else { + panic!("raw head must be Observed"); + }; + observation +} + +fn verified_revision( + state: &SessionState, +) -> Option { state .last_verified() - .map(|verified| verified.report().observation().revision()) + .map(|verified| verified.observation.revision()) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum StateKind { - Waiting, - Ready, - Stale, - Failed, -} - -fn state_kind(state: &PointSupportSessionStateV1) -> StateKind { - match state { - PointSupportSessionStateV1::Waiting { .. } => StateKind::Waiting, - PointSupportSessionStateV1::Ready { .. } => StateKind::Ready, - PointSupportSessionStateV1::Stale { .. } => StateKind::Stale, - PointSupportSessionStateV1::Failed { .. } => StateKind::Failed, - } +fn assert_shared_observation( + raw: &RevisionBoundObservationV1, + evidence: &RevisionBoundObservationV1, +) { + assert_eq!(raw, evidence); + assert_eq!(raw.backing_ptr_for_test(), evidence.backing_ptr_for_test()); + assert_eq!(raw.schema_ptr_for_test(), evidence.schema_ptr_for_test()); +} + +#[test] +fn construction_is_waiting_and_owns_no_raw_evidence() { + let (session, control, _) = session(); + assert!(matches!(session.state(), SessionState::Waiting)); + assert_eq!(session.raw_head(), ObservationHeadViewV1::Empty); + assert_eq!(control.evaluation_count(), 0); +} + +#[test] +fn ready_failed_unknown_retains_exactly_one_verified_witness() { + let (mut session, control, schema_ptr) = session(); + + let current_observation = match session.update(observed_update(1, [255; 3])).unwrap() { + SessionState::Ready { current } => current.observation.clone(), + _ => panic!("white sentinel input must verify"), + }; + assert_eq!(current_observation.revision(), Revision::new(1)); + assert_eq!(current_observation.schema_ptr_for_test(), schema_ptr); + assert_shared_observation(raw_observed(&session), ¤t_observation); + + let (cause_observation, previous_revision) = + match session.update(observed_update(2, [0; 3])).unwrap() { + SessionState::Failed { cause, previous } => ( + cause.observation.clone(), + previous.as_ref().unwrap().observation.revision(), + ), + _ => panic!("black sentinel input must violate"), + }; + assert_eq!(cause_observation.revision(), Revision::new(2)); + assert_eq!(previous_revision, Revision::new(1)); + assert_shared_observation(raw_observed(&session), &cause_observation); + + let previous_revision = match session.update(unknown_update(3, 9)).unwrap() { + SessionState::Stale { previous } => previous.observation.revision(), + _ => panic!("Unknown after a verified result must become Stale"), + }; + assert_eq!(previous_revision, Revision::new(1)); + let ObservationHeadViewV1::Unknown(unknown) = session.raw_head() else { + panic!("Unknown belongs to the separate raw head"); + }; + assert_eq!(unknown.stream(), STREAM); + assert_eq!(unknown.revision(), Revision::new(3)); + assert_eq!(unknown.reason(), UnknownReasonId::new(9)); + assert_eq!(control.evaluation_count(), 2); } #[test] -fn construction_uses_only_the_compiled_schema_and_profile() { - let session = session(); +fn violation_without_verified_then_unknown_returns_to_waiting() { + let (mut session, control, _) = session(); assert!(matches!( - session.state(), - PointSupportSessionStateV1::Waiting { - current_unknown: None, - } + session.update(observed_update(1, [0; 3])).unwrap(), + SessionState::Failed { previous: None, .. } )); - assert_eq!(session.raw_head(), ObservationHeadViewV1::Empty); - assert_eq!( - session.composition_profile(), - CompositionProfileV1::EncodedSrgb8SourceOverV1 - ); + assert!(matches!( + session.update(unknown_update(2, 7)).unwrap(), + SessionState::Waiting + )); + assert_eq!(verified_revision(session.state()), None); + assert_eq!(control.evaluation_count(), 1); } #[test] -fn ready_violation_unknown_preserves_exactly_one_verified_witness() { - let mut session = session(); - let PointSupportSessionStateV1::Ready { current } = - session.update(observed_update(1, [255; 3])).unwrap() - else { - panic!("white backdrop must verify #808080 target"); +fn exact_replay_is_idempotent_and_never_invokes_the_plan() { + let (mut session, control, _) = session(); + session.update(observed_update(1, [255; 3])).unwrap(); + let raw_backing = raw_observed(&session).backing_ptr_for_test(); + assert_eq!(control.evaluation_count(), 1); + + session.update(observed_update(1, [255; 3])).unwrap(); + assert_eq!(control.evaluation_count(), 1); + assert_eq!(raw_observed(&session).backing_ptr_for_test(), raw_backing); + let SessionState::Ready { current } = session.state() else { + panic!("exact replay must retain Ready"); }; - assert_eq!(current.report().observation().revision(), Revision::new(1)); + assert_shared_observation(raw_observed(&session), ¤t.observation); + + session.update(unknown_update(2, 11)).unwrap(); + session.update(unknown_update(2, 11)).unwrap(); + assert_eq!(control.evaluation_count(), 1); + assert_eq!(session.raw_head().revision(), Some(Revision::new(2))); +} + +#[test] +fn schema_ordered_admission_shares_only_the_plan_schema_handle() { + let (mut session, control, schema_ptr) = session(); assert_eq!( - current.report().cells().next().unwrap().provenance(), - &[ScenarioId::new(1)] + session + .plan() + .observation_schema(&()) + .strong_count_for_test(), + 1, ); + let source = OneOrderedScenario { + id: ScenarioId::new(1), + value: Srgb8::new([255; 3]), + }; + let mut order_scratch = Vec::new(); - let PointSupportSessionStateV1::Failed { cause, previous } = - session.update(observed_update(2, [0; 3])).unwrap() + let SessionState::Ready { current } = session + .update_schema_ordered(Revision::new(1), &source, &mut order_scratch) + .unwrap() else { - panic!("black backdrop must violate #808080 target"); + panic!("white sentinel input must verify"); }; - assert_eq!(cause.report().observation().revision(), Revision::new(2)); + assert_eq!(current.observation.schema_ptr_for_test(), schema_ptr); assert_eq!( - previous.as_ref().unwrap().report().observation().revision(), - Revision::new(1) + session + .plan() + .observation_schema(&()) + .strong_count_for_test(), + 2, ); + assert_eq!(control.evaluation_count(), 1); - let PointSupportSessionStateV1::Stale { - previous, - current_unknown, - } = session.update(unknown_update(3, 9)).unwrap() - else { - panic!("unknown after a verified result must become Stale"); - }; - let expected_unknown = *current_unknown; - assert_eq!(previous.report().observation().revision(), Revision::new(1)); - assert_eq!(current_unknown.stream(), STREAM); - assert_eq!(current_unknown.revision(), Revision::new(3)); - assert_eq!(current_unknown.reason(), UnknownReasonId::new(9)); + session + .update_schema_ordered(Revision::new(1), &source, &mut order_scratch) + .unwrap(); assert_eq!( - session.raw_head(), - ObservationHeadViewV1::Unknown(&expected_unknown) + session + .plan() + .observation_schema(&()) + .strong_count_for_test(), + 2, ); + assert_eq!(control.evaluation_count(), 1); } #[test] -fn violation_without_prior_then_unknown_is_waiting() { - let mut session = session(); - assert!(matches!( - session.update(observed_update(1, [0; 3])).unwrap(), - PointSupportSessionStateV1::Failed { previous: None, .. } - )); - let PointSupportSessionStateV1::Waiting { - current_unknown: Some(current_unknown), - } = session.update(unknown_update(2, 1)).unwrap() - else { - panic!("unknown without a verified result must be retained by Waiting"); +fn equal_content_at_a_higher_revision_rebinds_fresh_evidence() { + let (mut session, control, _) = session(); + session.update(observed_update(1, [255; 3])).unwrap(); + let first_backing = raw_observed(&session).backing_ptr_for_test(); + + session.update(observed_update(2, [255; 3])).unwrap(); + assert_eq!(control.evaluation_count(), 2); + assert_ne!(raw_observed(&session).backing_ptr_for_test(), first_backing); + let SessionState::Ready { current } = session.state() else { + panic!("higher revision must produce fresh Ready evidence"); }; - let expected_unknown = *current_unknown; - assert_eq!(current_unknown.stream(), STREAM); - assert_eq!(current_unknown.revision(), Revision::new(2)); - assert_eq!(current_unknown.reason(), UnknownReasonId::new(1)); - assert_eq!( - session.raw_head(), - ObservationHeadViewV1::Unknown(&expected_unknown) - ); - assert_eq!(verified_revision(session.state()), None); + assert_eq!(current.observation.revision(), Revision::new(2)); + assert_shared_observation(raw_observed(&session), ¤t.observation); } #[test] -fn stale_and_failed_transitions_move_one_previous_without_history() { - let mut session = session(); +fn rejected_admission_neither_invokes_plan_nor_mutates_closed_state() { + let (mut session, control, _) = session(); session.update(observed_update(1, [255; 3])).unwrap(); - session.update(unknown_update(2, 1)).unwrap(); - assert_eq!(verified_revision(session.state()), Some(Revision::new(1))); - assert_eq!(session.raw_head().revision(), Some(Revision::new(2))); + let raw_backing = raw_observed(&session).backing_ptr_for_test(); - session.update(observed_update(3, [0; 3])).unwrap(); - assert_eq!(state_kind(session.state()), StateKind::Failed); - assert_eq!(verified_revision(session.state()), Some(Revision::new(1))); - assert_eq!(session.raw_head().revision(), Some(Revision::new(3))); + let mut foreign = observed_update(2, [0; 3]); + foreign.stream = FOREIGN_STREAM; + assert_eq!( + session.update(foreign), + Err(SessionUpdateError::Observation( + ObservationError::StreamMismatch { + expected: STREAM, + actual: FOREIGN_STREAM, + } + )) + ); + assert_eq!( + session.update(malformed_update(2)), + Err(SessionUpdateError::Observation( + ObservationError::MissingSurfaceInputBinding { + scenario: ScenarioId::new(1), + input: SURFACE, + } + )) + ); + assert_eq!( + session.update(observed_update(0, [255; 3])), + Err(SessionUpdateError::Observation( + ObservationError::RevisionOutOfOrder { + current: Revision::new(1), + incoming: Revision::new(0), + } + )) + ); + assert_eq!( + session.update(observed_update(1, [0; 3])), + Err(SessionUpdateError::Observation( + ObservationError::RevisionConflict { + revision: Revision::new(1), + } + )) + ); - session.update(unknown_update(4, 2)).unwrap(); - assert_eq!(state_kind(session.state()), StateKind::Stale); + assert_eq!(control.evaluation_count(), 1); + assert_eq!(raw_observed(&session).backing_ptr_for_test(), raw_backing); assert_eq!(verified_revision(session.state()), Some(Revision::new(1))); - assert_eq!(session.raw_head().revision(), Some(Revision::new(4))); - - session.update(observed_update(5, [255; 3])).unwrap(); - assert_eq!(state_kind(session.state()), StateKind::Ready); - assert_eq!(verified_revision(session.state()), Some(Revision::new(5))); - assert_eq!(session.raw_head().revision(), Some(Revision::new(5))); } #[test] -fn unknown_idempotent_and_rejected_updates_never_evaluate() { - let mut session = session(); - crate::composition::reset_source_over_evaluation_count(); - let unknown = unknown_update(1, 1); - session.update(unknown.clone()).unwrap(); - assert_eq!(crate::composition::source_over_evaluation_count(), 0); - let before = session.clone(); - session.update(unknown).unwrap(); - assert_eq!(crate::composition::source_over_evaluation_count(), 0); - assert_eq!(session, before); - - let update = observed_update(2, [255; 3]); - session.update(update.clone()).unwrap(); - assert_eq!(crate::composition::source_over_evaluation_count(), 1); - let before = session.clone(); - session.update(update).unwrap(); - assert_eq!(crate::composition::source_over_evaluation_count(), 1); - assert_eq!(session, before); - - let before = session.clone(); - crate::composition::reset_source_over_evaluation_count(); +fn plan_failure_commits_neither_raw_head_nor_lifecycle_and_retry_is_fresh() { + let (mut session, control, _) = session(); + session.update(observed_update(1, [255; 3])).unwrap(); + let raw_backing = raw_observed(&session).backing_ptr_for_test(); + control.fail_next(); + assert_eq!( - session.update(malformed_update(1)), - Err(PointSupportSessionUpdateErrorV1::Observation( - ObservationError::RevisionOutOfOrder { - current: Revision::new(2), - incoming: Revision::new(1), - }, - )) + session.update(observed_update(2, [0; 3])), + Err(SessionUpdateError::Plan(SentinelError::Forced)) ); - assert_eq!(crate::composition::source_over_evaluation_count(), 0); - assert_eq!(session, before); - - for rejected in [ - malformed_update(3), - ObservationUpdateInput { - stream: ObservationStreamId::new(99), - revision: Revision::new(3), - payload: ObservationPayloadInput::Unknown(UnknownReasonId::new(1)), - }, - observed_update(2, [0; 3]), - ] { - let before = session.clone(); - crate::composition::reset_source_over_evaluation_count(); - assert!(session.update(rejected).is_err()); - assert_eq!(crate::composition::source_over_evaluation_count(), 0); - assert_eq!(session, before); - } + assert_eq!(control.evaluation_count(), 2); + assert_eq!(raw_observed(&session).backing_ptr_for_test(), raw_backing); + assert_eq!(session.raw_head().revision(), Some(Revision::new(1))); + assert_eq!(verified_revision(session.state()), Some(Revision::new(1))); + + let (cause_observation, previous_revision) = + match session.update(observed_update(2, [0; 3])).unwrap() { + SessionState::Failed { cause, previous } => ( + cause.observation.clone(), + previous.as_ref().unwrap().observation.revision(), + ), + _ => panic!("retry must re-prepare, re-evaluate and commit"), + }; + assert_eq!(control.evaluation_count(), 3); + assert_eq!(cause_observation.revision(), Revision::new(2)); + assert_eq!(previous_revision, Revision::new(1)); + assert_shared_observation(raw_observed(&session), &cause_observation); } #[test] -fn resource_preflight_failure_is_atomic_and_retryable() { - let mut session = session(); +fn detached_plan_evidence_is_rejected_before_raw_or_lifecycle_commit() { + let (mut session, control, _) = session(); session.update(observed_update(1, [255; 3])).unwrap(); - let before = session.clone(); - session.force_next_resource_failure(); - crate::composition::reset_source_over_evaluation_count(); + let first_observation = raw_observed(&session).clone(); + control.substitute_next_with(first_observation); + assert_eq!( session.update(observed_update(2, [255; 3])), - Err(PointSupportSessionUpdateErrorV1::ResourceExhausted) + Err(SessionUpdateError::EvidenceBindingInvariant) ); - assert_eq!(crate::composition::source_over_evaluation_count(), 0); - assert_eq!(session, before); - assert_eq!(session.state(), before.state()); - assert_eq!(session.raw_head(), before.raw_head()); - assert_eq!(session.composition_profile(), before.composition_profile()); + assert_eq!(control.evaluation_count(), 2); + assert_eq!(session.raw_head().revision(), Some(Revision::new(1))); + assert_eq!(verified_revision(session.state()), Some(Revision::new(1))); - session.update(observed_update(2, [255; 3])).unwrap(); - assert_eq!(verified_revision(session.state()), Some(Revision::new(2))); -} - -proptest! { - #[test] - fn lifecycle_matches_pure_last_verified_model(ops in prop::collection::vec(0u8..5, 1..60)) { - let mut session = session(); - session.update(observed_update(1, [255; 3])).unwrap(); - let mut raw_revision = 1u64; - let mut expected_kind = StateKind::Ready; - let mut expected_verified = Some(Revision::new(1)); - let mut last_applied = observed_update(1, [255; 3]); - - for (next_revision, op) in (2u64..).zip(ops) { - let before = session.clone(); - match op { - 0 => { - let update = observed_update(next_revision, [255; 3]); - session.update(update.clone()).unwrap(); - raw_revision = next_revision; - expected_kind = StateKind::Ready; - expected_verified = Some(Revision::new(next_revision)); - last_applied = update; - } - 1 => { - let update = observed_update(next_revision, [0; 3]); - session.update(update.clone()).unwrap(); - raw_revision = next_revision; - expected_kind = StateKind::Failed; - last_applied = update; - } - 2 => { - let update = unknown_update(next_revision, u32::from(op)); - session.update(update.clone()).unwrap(); - raw_revision = next_revision; - expected_kind = if expected_verified.is_some() { - StateKind::Stale - } else { - StateKind::Waiting - }; - last_applied = update; - } - 3 => { - session.update(last_applied.clone()).unwrap(); - prop_assert_eq!(&session, &before); - } - _ => { - let rejected = observed_update(raw_revision, [17; 3]); - prop_assert!(session.update(rejected).is_err()); - prop_assert_eq!(&session, &before); - } - } - prop_assert_eq!(state_kind(session.state()), expected_kind); - prop_assert_eq!(verified_revision(session.state()), expected_verified); - } + let current_observation = match session.update(observed_update(2, [255; 3])).unwrap() { + SessionState::Ready { current } => current.observation.clone(), + _ => panic!("a fresh retry must bind evidence from the current observation"), + }; + assert_eq!(control.evaluation_count(), 3); + assert_eq!(current_observation.revision(), Revision::new(2)); + assert_shared_observation(raw_observed(&session), ¤t_observation); +} + +#[test] +fn reentrant_owner_replacement_finishes_on_its_pinned_generation_then_expires() { + let first_generation = Rc::new(ReplacingOwnerGeneration { + schema: canonicalize_observation_schema(vec![SURFACE]).unwrap(), + }); + let owner_slot = Rc::new(RefCell::new(Some(Rc::clone(&first_generation)))); + let evaluations = Rc::new(Cell::new(0)); + let mut session = Session::new( + STREAM, + ReplacingOwnerPlan { + generation: Rc::downgrade(&first_generation), + owner_slot: Rc::clone(&owner_slot), + evaluations: Rc::clone(&evaluations), + }, + ); + drop(first_generation); + + let SessionState::Ready { current } = session.update(observed_update(1, [255; 3])).unwrap() + else { + panic!("the transaction pinned before replacement must commit"); + }; + assert_eq!(current.observation.revision(), Revision::new(1)); + assert_eq!(evaluations.get(), 1); + assert!(owner_slot.borrow().is_some()); + + assert_eq!( + session.update(observed_update(2, [0; 3])), + Err(SessionUpdateError::OwnerExpired), + ); + assert_eq!(evaluations.get(), 1); + assert_eq!(session.raw_head().revision(), Some(Revision::new(1))); + assert_eq!(verified_revision(session.state()), Some(Revision::new(1))); +} + +#[test] +fn session_source_contains_one_generic_update_owner_and_no_legacy_runtime() { + let source = include_str!("session.rs"); + assert_eq!(source.matches("pub(crate) fn update(").count(), 1); + for forbidden in [ + "PointSupportSessionV1", + "PointSupportSessionStateV1", + "PointSupportSessionUpdateErrorV1", + "ProgramExpired", + "ObservationStreamBinding", + "SurfaceUpdate", + "Box &[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/crates/labcolors-core/src/solve.rs b/crates/labcolors-core/src/solve.rs index ceaf0b2f..66472972 100644 --- a/crates/labcolors-core/src/solve.rs +++ b/crates/labcolors-core/src/solve.rs @@ -73,16 +73,29 @@ pub enum ChromaPolicy { Relative(f64), } -/// Output colour gamut. The solver produces colours inside this gamut. +/// Output colour gamut. The public surface lists only executable output paths. +/// +/// A reserved Display P3 selector is intentionally not a public capability: +/// +/// ```compile_fail +/// let _ = labcolors_core::Gamut::DisplayP3; +/// ``` +/// +/// Re-encoding an already sRGB-bounded hex value is not a P3 output path, so +/// those former helpers are intentionally absent too: +/// +/// ```compile_fail +/// use labcolors_core::p3_from_hex; +/// ``` +/// +/// ```compile_fail +/// use labcolors_core::p3_css_from_hex; +/// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub enum Gamut { /// Standard sRGB. Srgb, - /// Display P3. Reserved: the wider-gamut chroma boundary lands in a later - /// chapter, so v1 returns [`SolveFailure::GamutUnsupported`] rather than - /// silently solving in sRGB. - DisplayP3, } /// The WCAG 2.1 AA legal contrast floor a contract must clear. @@ -339,8 +352,8 @@ impl Solved { /// Why a solve did not return a colour. The variant and its /// [`SolveFailureCategory`] distinguish proof of unreachability from an -/// exhausted algorithm, a rejected request, an unsupported capability, and an -/// internal invariant. Bindings must fail closed on [`Self::InternalInvariant`]. +/// exhausted algorithm, a rejected request, and an internal invariant. Bindings +/// must fail closed on [`Self::InternalInvariant`]. #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub enum SolveFailure { @@ -363,8 +376,6 @@ pub enum SolveFailure { /// light-on-dark). `max_ratio` is the most contrast this background can /// supply in that polarity; `floor` is the ratio the contract required. FloorUnreachable { floor: f64, max_ratio: f64 }, - /// The requested gamut is not supported yet (Display P3 arrives later). - GamutUnsupported, /// Malformed input, such as an invalid hex colour or a non-finite target. InvalidInput(String), /// A value produced and validated by the core later violated an internal @@ -386,8 +397,6 @@ pub enum SolveFailureCategory { Unresolved, /// The request is malformed or inconsistent with the declared domain. Rejected, - /// The request is valid, but the requested capability is not implemented. - Unsupported, } impl SolveFailureCategory { @@ -397,7 +406,6 @@ impl SolveFailureCategory { Self::Unreachable => "unreachable", Self::Unresolved => "unresolved", Self::Rejected => "rejected", - Self::Unsupported => "unsupported", } } } @@ -439,7 +447,6 @@ impl SolveFailure { Self::FloorUnreachable { .. } => { (SolveFailureCategory::Unreachable, "floor_unreachable") } - Self::GamutUnsupported => (SolveFailureCategory::Unsupported, "gamut_unsupported"), Self::InvalidInput(_) => (SolveFailureCategory::Rejected, "invalid_input"), Self::InternalInvariant(_) => return None, }; @@ -472,12 +479,6 @@ impl core::fmt::Display for SolveFailure { f, "WCAG floor {floor:.1}:1 is unreachable on this background (max {max_ratio:.2}:1)" ), - Self::GamutUnsupported => { - write!( - f, - "requested gamut is not supported yet (Display P3 is future work)" - ) - } Self::InvalidInput(msg) => write!(f, "invalid input: {msg}"), Self::InternalInvariant(msg) => write!(f, "internal invariant failure: {msg}"), } @@ -507,10 +508,7 @@ pub fn solve( vc: &ViewingConditions, gamut: Gamut, ) -> Result { - // The Display P3 chroma boundary is future work (chapter 5); fail loudly. - if gamut != Gamut::Srgb { - return Err(SolveFailure::GamutUnsupported); - } + let Gamut::Srgb = gamut; validate_job(contract, hue, chroma_policy)?; // Compute the background's quantised display-luminance interval once and // hand it to [`solve_in`]; batch/set entry points reuse the same value for @@ -539,17 +537,14 @@ pub struct SolveJob { /// quantised display-luminance interval is computed once for the whole slice. /// The returned vector is positional: entry `i` is the result for `jobs[i]`, /// each carrying its own `Result` so one failed request never fails the batch. -/// A whole-batch failure (unsupported gamut, or a background that cannot be -/// reduced) is the outer `Err`. +/// A background that cannot be reduced is the outer `Err`. pub fn solve_many( bg: BgInput, jobs: &[SolveJob], vc: &ViewingConditions, gamut: Gamut, ) -> Result>, SolveFailure> { - if gamut != Gamut::Srgb { - return Err(SolveFailure::GamutUnsupported); - } + let Gamut::Srgb = gamut; // Background side: one forward for the whole batch (see [`solve`]). let interval = bg.luma_interval(vc)?; Ok(jobs @@ -1423,11 +1418,6 @@ mod tests { SolveFailureCategory::Unreachable, "floor_unreachable", ), - ( - SolveFailure::GamutUnsupported, - SolveFailureCategory::Unsupported, - "gamut_unsupported", - ), ( SolveFailure::InvalidInput("x".into()), SolveFailureCategory::Rejected, @@ -1468,7 +1458,6 @@ mod tests { floor: 4.5, max_ratio: 2.0, }, - SolveFailure::GamutUnsupported, SolveFailure::InvalidInput("x".to_string()), SolveFailure::InternalInvariant("x".to_string()), ]; @@ -1481,7 +1470,6 @@ mod tests { | SolveFailure::ExceedsRange { .. } | SolveFailure::BoundedSearchExhausted { .. } | SolveFailure::FloorUnreachable { .. } - | SolveFailure::GamutUnsupported | SolveFailure::InvalidInput(_) | SolveFailure::InternalInvariant(_) => {} } @@ -2040,24 +2028,6 @@ mod tests { ); } - #[test] - fn display_p3_gamut_is_reserved_not_implemented() { - // SEAM (c): the P3 variant exists in the type but returns a real error, - // never a panic and never a silent sRGB fallback. - let vc = ViewingConditions::srgb(); - let bg = BgInput::solid("#FFFFFF").unwrap(); - let err = solve( - bg, - Contract::text(60.0), - Hue::deg(0.0), - ChromaPolicy::Neutral, - &vc, - Gamut::DisplayP3, - ) - .unwrap_err(); - assert_eq!(err, SolveFailure::GamutUnsupported); - } - #[test] fn degenerate_range_matches_explicit_target() { // SEAM (b): a degenerate range [t, t] solves identically to text(t). diff --git a/crates/labcolors-core/src/spaces/cam16.rs b/crates/labcolors-core/src/spaces/cam16.rs index fbfcc99c..54d5aace 100644 --- a/crates/labcolors-core/src/spaces/cam16.rs +++ b/crates/labcolors-core/src/spaces/cam16.rs @@ -285,9 +285,7 @@ pub(crate) fn forward(xyz: [f64; 3], vc: &ViewingConditions) -> (f64, f64, f64) }) { return hit; } - #[cfg(test)] - FORWARD_CALLS.with(|c| c.set(c.get() + 1)); - let result = forward_compute(xyz, vc); + let result = forward_cache_free_v1(xyz, vc); FORWARD_CACHE.with(|c| { let mut c = c.borrow_mut(); if c.active { @@ -297,6 +295,52 @@ pub(crate) fn forward(xyz: [f64; 3], vc: &ViewingConditions) -> (f64, f64, f64) result } +/// Execute one CAM16 forward pass without consulting the legacy per-set cache. +/// +/// The legacy cache is deliberately keyed only by XYZ because its +/// `resolve_set` owner holds one viewing condition for the entire guard scope. +/// An F0 appearance state instead carries its own immutable context, so it must +/// never inherit that ambient single-context assumption. Both paths still use +/// the same numeric owner below; this boundary changes caching only, not math or +/// operation order. +#[inline] +fn forward_cache_free_v1(xyz: [f64; 3], vc: &ViewingConditions) -> (f64, f64, f64) { + #[cfg(test)] + FORWARD_CALLS.with(|c| c.set(c.get() + 1)); + forward_compute(xyz, vc) +} + +/// Complete correlates of the registered CAM16 forward view. +/// +/// This is an internal numeric carrier, not an editable colour value or a +/// difference metric. Hue absence is classified by the occurrence layer after +/// all coordinates have passed its finite-domain admission. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Cam16CorrelatesV1 { + pub(crate) j: f64, + pub(crate) q: f64, + pub(crate) c: f64, + pub(crate) m: f64, + pub(crate) s: f64, + pub(crate) h: f64, +} + +/// Derive the full CAM16 correlate set from the same cache-free `J/M/h` +/// operation-order owner used on a cache miss by [`forward`]. +/// +/// `C`, `Q` and `s` are the published CAM16 correlates. The explicit `J = 0` +/// branch gives mathematical black `(C, M, Q, s) = 0` without evaluating the +/// otherwise indeterminate `C / sqrt(J / 100)` ratio. +pub(crate) fn forward_correlates_v1(xyz: [f64; 3], vc: &ViewingConditions) -> Cam16CorrelatesV1 { + let (j, m, h) = forward_cache_free_v1(xyz, vc); + let root_j = (j / 100.0).sqrt(); + let c = m / vc.fl_pow_025; + let q = (4.0 / vc.c) * root_j * (vc.aw + 4.0) * vc.fl_pow_025; + let alpha = if root_j == 0.0 { 0.0 } else { c / root_j }; + let s = 50.0 * (vc.c * alpha / (vc.aw + 4.0)).sqrt(); + Cam16CorrelatesV1 { j, q, c, m, s, h } +} + /// The CIECAM16 forward math itself (cache-free); see [`forward`]. fn forward_compute(xyz: [f64; 3], vc: &ViewingConditions) -> (f64, f64, f64) { let xyz = [xyz[0] * 100.0, xyz[1] * 100.0, xyz[2] * 100.0]; diff --git a/crates/labcolors-core/src/spaces/mod.rs b/crates/labcolors-core/src/spaces/mod.rs index f3c2e20d..2445d3cd 100644 --- a/crates/labcolors-core/src/spaces/mod.rs +++ b/crates/labcolors-core/src/spaces/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod cam16; pub(crate) mod cat16; pub(crate) mod oklab; pub(crate) mod oklch; +#[cfg(test)] pub(crate) mod p3; pub(crate) mod srgb; pub(crate) mod vc; diff --git a/crates/labcolors-core/src/spaces/oklab.rs b/crates/labcolors-core/src/spaces/oklab.rs index 2d02a326..6f23b42d 100644 --- a/crates/labcolors-core/src/spaces/oklab.rs +++ b/crates/labcolors-core/src/spaces/oklab.rs @@ -1,4 +1,4 @@ -//! Oklab perceptual colour space (sRGB path). +//! Oklab perceptual colour-space kernels for direct XYZ(D65) and sRGB paths. //! //! Source: Björn Ottosson, "A perceptual color space for image processing" //! (2020), . The matrices below are @@ -25,6 +25,18 @@ const LMS_TO_OKLAB: [[f64; 3]; 3] = [ [ 0.0259040371, 0.7827717662, -0.8086757660], ]; +// Direct XYZ(D65) -> LMS projection used by the registered F0 Oklab view. +// These are the CSS Color 4 / 2021 Oklab coefficients. Keeping this projection +// direct is important: an XYZ stimulus is not an encoded or linear-sRGB value, +// and routing it through the inverse sRGB matrix would make an output space an +// accidental part of stimulus geometry. +#[rustfmt::skip] +const XYZ_D65_TO_LMS_OKLAB_20210125: [[f64; 3]; 3] = [ + [0.819_022_437_996_703, 0.3619062600528904, -0.1288737815209879], + [0.0329836539323885, 0.9292868615863434, 0.0361446663506424], + [0.0481771893596242, 0.2642395317527308, 0.6335478284694309], +]; + /// Canonical degree domain of a hue angle. pub(crate) const HUE_DEG_MIN_INCLUSIVE: f64 = 0.0; pub(crate) const HUE_DEG_MAX_EXCLUSIVE: f64 = 360.0; @@ -57,6 +69,18 @@ pub(crate) fn srgb_linear_to_oklab(rgb: [f64; 3]) -> [f64; 3] { mat_vec_mul(LMS_TO_OKLAB, lms_) } +/// Direct 2021 Oklab projection of one relative XYZ(D65) stimulus. +/// +/// The caller owns frame admission. This kernel does not perform chromatic +/// adaptation, infer an output profile, clamp coordinates or provide an +/// inverse/editing operation. +#[inline] +pub(crate) fn xyz_d65_to_oklab_v1(xyz: [f64; 3]) -> [f64; 3] { + let lms = mat_vec_mul(XYZ_D65_TO_LMS_OKLAB_20210125, xyz); + let lms_root = [lms[0].cbrt(), lms[1].cbrt(), lms[2].cbrt()]; + mat_vec_mul(LMS_TO_OKLAB, lms_root) +} + pub(crate) fn oklab_to_srgb_linear(lab: [f64; 3]) -> [f64; 3] { let lms_ = mat_vec_mul(OKLAB_TO_LMS, lab); let lms = [lms_[0].powi(3), lms_[1].powi(3), lms_[2].powi(3)]; diff --git a/crates/labcolors-core/src/spaces/oklch.rs b/crates/labcolors-core/src/spaces/oklch.rs index 83874159..e7e7fe29 100644 --- a/crates/labcolors-core/src/spaces/oklch.rs +++ b/crates/labcolors-core/src/spaces/oklch.rs @@ -4,7 +4,7 @@ //! …в идеале бы выводить окончательно oklch». Солид и полупрозрачная роль //! отличаются ФИЗИКОЙ (α), но не синтаксисом: `oklch(L% C H)` и //! `oklch(L% C H / A)` — один парсер, одна форма, явно именованные -//! компоненты, готовность к широкому гамуту (P3 — этап gamut-aware солвера). +//! компоненты. //! //! Значения остаются решёнными в sRGB-гамуте: oklch здесь — система координат //! записи, не расширение гамута. Точность цифр подобрана под БАЙТ-ТОЧНЫЙ @@ -55,7 +55,7 @@ pub fn oklch_css_from_hex(hex: &str, alpha: Option) -> Result, значения -//! эталонной реализации colorjs.io). Передаточная функция Display P3 идентична -//! sRGB (IEC 61966-2-1 § 6.4) — переиспользуются [`super::srgb::srgb_gamma`] / -//! [`super::srgb::srgb_gamma_inv`], вторых копий кривой нет. -//! -//! Точность цифр подобрана под БАЙТ-ТОЧНЫЙ round-trip: `p3_css_from_hex` → -//! парс → XYZ → sRGB даёт исходные 8-битные байты на решётке с шагом 5 по -//! каждому каналу (около 140 тысяч цветов, включая края; тест -//! `round_trip_is_byte_exact_on_lattice`). Это не полный перебор куба. - -use super::srgb::{srgb_from_hex, srgb_gamma, srgb_to_xyz}; +//! This module is test-only until a complete output-profile release supplies +//! its own candidate domain, encoder, and final encoded verifier. The sole +//! retained operation is the physical XYZ(D65) to linear Display P3 transform +//! used by the private gamut-boundary regression. It is not an output +//! capability and exposes no public selector or CSS serializer. -// ------------------------------------------------------------------ -// linear Display P3 → XYZ(D65) -// ------------------------------------------------------------------ -#[rustfmt::skip] -const P3_TO_XYZ_D65: [[f64; 3]; 3] = [ - [ 0.486_570_948_648_216_15, 0.265_667_693_169_093_06, 0.198_217_285_234_362_5 ], - [ 0.228_974_564_069_748_78, 0.691_738_521_836_506_4, 0.079_286_914_093_745 ], - [ 0.0, 0.045_113_381_858_902_64, 1.043_944_368_900_976 ], -]; - -// ------------------------------------------------------------------ -// XYZ(D65) → linear Display P3 -// ------------------------------------------------------------------ #[rustfmt::skip] const XYZ_D65_TO_P3: [[f64; 3]; 3] = [ [ 2.493_496_911_941_425, -0.931_383_617_919_123_9, -0.402_710_784_450_716_84 ], @@ -54,367 +13,36 @@ const XYZ_D65_TO_P3: [[f64; 3]; 3] = [ [ 0.035_845_830_243_784_47, -0.076_172_389_268_041_82, 0.956_884_524_007_687_2 ], ]; -fn mat_vec_mul(m: [[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] { - [ - m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], - m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], - m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2], - ] -} - -/// XYZ(D65, Y∈[0,1]) → линейный Display P3. +/// XYZ(D65, Y in `[0, 1]`) to linear Display P3. pub(crate) fn xyz_to_p3_linear(xyz: [f64; 3]) -> [f64; 3] { - mat_vec_mul(XYZ_D65_TO_P3, xyz) -} - -/// Линейный Display P3 → XYZ(D65, Y∈[0,1]). -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) fn p3_linear_to_xyz(rgb: [f64; 3]) -> [f64; 3] { - mat_vec_mul(P3_TO_XYZ_D65, rgb) -} - -/// Гашение вычислительного шума у краёв [0, 1]. -/// -/// Для sRGB-входа компоненты P3 математически лежат в [0, 1] (sRGB ⊂ P3); -/// за края может выйти только f64-шум цепочки матриц (≲1e-12, у белой точки — -/// две независимые деривации D65). Шум гасится, реальный выход за гамут — -/// честная ошибка вызывающего (сюда такие значения не приходят, пока солвер -/// работает в sRGB-гамуте; гард — на будущий gamut-aware этап). -const GAMUT_NOISE: f64 = 1e-9; - -fn clamp_gamut_noise(v: f64) -> Result { - if !(-GAMUT_NOISE..=1.0 + GAMUT_NOISE).contains(&v) { - return Err(format!("компонента P3 вне гамута: {v}")); - } - Ok(v.clamp(0.0, 1.0)) -} - -/// Гамма-кодированные компоненты Display P3 `[r, g, b]` (каждая в [0, 1]) -/// из sRGB-hex-солида. -/// -/// Путь: hex → линейный sRGB → XYZ(D65) → линейный P3 → передаточная кривая -/// (общая с sRGB, IEC 61966-2-1 § 6.4). -/// -/// # Errors -/// -/// `Err` — невалидный hex (пробрасывается из парсера) либо компонента вне -/// гамута сверх шумового эпсилона (недостижимо для валидного sRGB-входа). -pub fn p3_from_hex(hex: &str) -> Result<[f64; 3], String> { - let xyz = srgb_to_xyz(srgb_from_hex(hex)?); - let lin = xyz_to_p3_linear(xyz); - Ok([ - srgb_gamma(clamp_gamut_noise(lin[0])?), - srgb_gamma(clamp_gamut_noise(lin[1])?), - srgb_gamma(clamp_gamut_noise(lin[2])?), - ]) -} - -/// CSS-строка `color(display-p3 R G B)` / `color(display-p3 R G B / A)` из -/// sRGB-hex-солида и опциональной альфы. -/// -/// Точность: 6 знаков на компоненту — ошибка квантования печати ≤ 5·10⁻⁷ при -/// полушаге 8-битного канала ≈ 2·10⁻³, запас > 3 порядков; байт-точность -/// round-trip проверена тестом на решётке с шагом 5. Политика альфы — единая -/// (`super::oklch::css_alpha_suffix`): та же, что у oklch-эмиссии. -/// -/// # Errors -/// -/// `Err` — невалидный hex, неконечная альфа либо альфа вне `[0, 1]`. -pub fn p3_css_from_hex(hex: &str, alpha: Option) -> Result { - let [r, g, b] = p3_from_hex(hex)?; - let suffix = super::oklch::css_alpha_suffix(alpha)?; - Ok(format!("color(display-p3 {r:.6} {g:.6} {b:.6}{suffix})")) -} - -// ------------------------------------------------------------------ -// 8-битная решётка эмиссии P3 (этап 1 gamut-aware солвера, 2026-07-03). -// МЁРТВАЯ В ПРОД — только тесты до подключения этапом 2 (см. модульный -// раздел «Потребление»); каждая функция несёт allow(dead_code) для non-test. -// ------------------------------------------------------------------ - -/// 8-битное квантование линейного P3: передаточная кривая (общая с sRGB) → -/// байты. Решётка кандидатов будущего P3-солвера — зеркало sRGB-пути -/// (quantise + измерение на отданном значении). -/// -/// # Errors -/// -/// `Err` — компонента вне гамута P3 сверх шумового эпсилона: квантовать -/// такое значение как цвет нельзя. -#[cfg_attr(not(test), allow(dead_code))] // прод-потребитель — этап 2 -pub(crate) fn p3_bytes_from_linear(lin: [f64; 3]) -> Result<[u8; 3], String> { - let mut out = [0_u8; 3]; - for (i, &v) in lin.iter().enumerate() { - let encoded = srgb_gamma(clamp_gamut_noise(v)?); - out[i] = (encoded * 255.0).round() as u8; - } - Ok(out) -} - -/// Линейный P3 из 8-битных байтов решётки (обратный путь квантования). -#[cfg_attr(not(test), allow(dead_code))] // прод-потребитель — этап 2 -pub(crate) fn p3_linear_from_bytes(bytes: [u8; 3]) -> [f64; 3] { [ - super::srgb::srgb_gamma_inv(f64::from(bytes[0]) / 255.0), - super::srgb::srgb_gamma_inv(f64::from(bytes[1]) / 255.0), - super::srgb::srgb_gamma_inv(f64::from(bytes[2]) / 255.0), + XYZ_D65_TO_P3[0][0] * xyz[0] + XYZ_D65_TO_P3[0][1] * xyz[1] + XYZ_D65_TO_P3[0][2] * xyz[2], + XYZ_D65_TO_P3[1][0] * xyz[0] + XYZ_D65_TO_P3[1][1] * xyz[1] + XYZ_D65_TO_P3[1][2] * xyz[2], + XYZ_D65_TO_P3[2][0] * xyz[0] + XYZ_D65_TO_P3[2][1] * xyz[1] + XYZ_D65_TO_P3[2][2] * xyz[2], ] } -/// CSS-строка `color(display-p3 R G B [/ A])` из 8-битных байтов решётки. -/// Точность печати и политика альфы — те же, что у [`p3_css_from_hex`] -/// (6 знаков: полушаг канала ≈ 2·10⁻³, запас > 3 порядков; байт-точность -/// round-trip доказана тестом на решётке). -#[cfg_attr(not(test), allow(dead_code))] // прод-потребитель — этап 2 -pub(crate) fn p3_css_from_bytes(bytes: [u8; 3], alpha: Option) -> Result { - let r = f64::from(bytes[0]) / 255.0; - let g = f64::from(bytes[1]) / 255.0; - let b = f64::from(bytes[2]) / 255.0; - let suffix = super::oklch::css_alpha_suffix(alpha)?; - Ok(format!("color(display-p3 {r:.6} {g:.6} {b:.6}{suffix})")) -} - #[cfg(test)] mod tests { - use super::*; - use crate::spaces::srgb::{hex_from_srgb, srgb_gamma_inv, xyz_to_srgb}; - - /// Парсер эмитированной строки — эталонная реконструкция потребителя: - /// браузер декодирует компоненты той же передаточной кривой и тем же - /// матричным путём P3 → XYZ → sRGB. - fn parse_emitted(css: &str) -> (String, Option) { - let inner = css - .strip_prefix("color(display-p3 ") - .and_then(|s| s.strip_suffix(')')) - .expect("форма color(display-p3 ...)"); - let (rgb_str, alpha) = match inner.split_once(" / ") { - Some((rgb, a)) => (rgb, Some(a.parse::().expect("альфа — число"))), - None => (inner, None), - }; - let parts: Vec = rgb_str - .split_whitespace() - .map(|p| { - p.parse::() - .unwrap_or_else(|_| panic!("компонента не число: {p} в {css}")) - }) - .collect(); - assert_eq!(parts.len(), 3, "ровно R G B: {css}"); - let lin_p3 = [ - srgb_gamma_inv(parts[0]), - srgb_gamma_inv(parts[1]), - srgb_gamma_inv(parts[2]), - ]; - let xyz = p3_linear_to_xyz(lin_p3); - (hex_from_srgb(xyz_to_srgb(xyz)), alpha) - } - - /// Матрицы — взаимные обратные: P3 → XYZ → P3 тождественно до f64-шума. - #[test] - fn matrices_are_mutual_inverses() { - for rgb in [ - [1.0, 0.0, 0.0], - [0.0, 1.0, 0.0], - [0.0, 0.0, 1.0], - [1.0, 1.0, 1.0], - [0.25, 0.5, 0.75], - ] { - let back = xyz_to_p3_linear(p3_linear_to_xyz(rgb)); - for i in 0..3 { - assert!( - (back[i] - rgb[i]).abs() < 1e-12, - "P3 roundtrip канал {i}: {} vs {}", - back[i], - rgb[i] - ); - } - } - } - - /// Белые точки согласованы: sRGB-белый → P3 (1, 1, 1) до шума двух - /// независимых D65-дериваций (обе цепочки CSS Color 4). #[test] - fn srgb_white_maps_to_p3_white() { - let [r, g, b] = p3_from_hex("#FFFFFF").unwrap(); - for (ch, v) in [("r", r), ("g", g), ("b", b)] { - assert!((v - 1.0).abs() < 1e-6, "белый канал {ch}: {v}"); - } - } - - /// sRGB ⊂ P3: каждая точка решётки куба конвертируется без выхода за - /// гамут (кламп только шумового эпсилона — иначе p3_from_hex вернул бы Err). - /// Шаг 17 взаимно прост с 255 — решётка не выровнена по «удобным» байтам. - #[test] - fn srgb_cube_is_inside_p3_gamut() { - let steps: Vec = (0u16..=255).step_by(17).map(|v| v as u8).collect(); - for &r in &steps { - for &g in &steps { - for &b in &steps { - let hex = format!("#{r:02X}{g:02X}{b:02X}"); - p3_from_hex(&hex).unwrap_or_else(|e| panic!("{hex} вне P3: {e}")); - } - } - } - } - - /// Байт-точность round-trip на решётке 8-битного куба с шагом 5 (включая - /// края 0 и 255): формат → парс → P3 → XYZ → sRGB → те же байты. - #[test] - fn round_trip_is_byte_exact_on_lattice() { - let steps: Vec = (0u16..=255).step_by(5).map(|v| v as u8).collect(); - assert!(steps.contains(&0) && steps.contains(&255)); - for &r in &steps { - for &g in &steps { - for &b in &steps { - let hex = format!("#{r:02X}{g:02X}{b:02X}"); - let css = p3_css_from_hex(&hex, None).unwrap(); - let (back, alpha) = parse_emitted(&css); - assert_eq!(back, hex, "round-trip разошёлся: {css}"); - assert_eq!(alpha, None); - } - } - } - } - - /// Серые с альфой: полный грей-рамп байт-точен, альфа проходит как данные. - #[test] - fn round_trip_is_byte_exact_on_greys_with_alpha() { - for v in 0u16..=255 { - let v = v as u8; - let hex = format!("#{v:02X}{v:02X}{v:02X}"); - let css = p3_css_from_hex(&hex, Some(0.361)).unwrap(); - let (back, alpha) = parse_emitted(&css); - assert_eq!(back, hex, "grey round-trip разошёлся: {css}"); - assert_eq!(alpha, Some(0.361)); - } - } - - /// Политика alpha едина с oklch-эмиссией: любое недоменное значение — - /// ошибка. Даже малый clamp изменил бы публичное число и мог бы выдать - /// соседний конечный композит за сертифицированный. - #[test] - fn alpha_guard_shared_with_oklch() { - assert!(p3_css_from_hex("#101012", Some(f64::NAN)).is_err()); - assert!(p3_css_from_hex("#101012", Some(-10.0)).is_err()); - assert!(p3_css_from_hex("#101012", Some(2.0)).is_err()); - assert!(p3_css_from_hex("#101012", Some(-1e-7)).is_err()); - assert!(p3_css_from_hex("#101012", Some(1.0 + 1e-9)).is_err()); - assert!( - p3_css_from_hex("#101012", Some(0.0)) - .unwrap() - .ends_with(" / 0)") - ); - assert!( - p3_css_from_hex("#101012", Some(1.0)) - .unwrap() - .ends_with(" / 1)") - ); - } - - /// Форма строки — контракт потребителя: `color(display-p3 R G B [/ A])`, - /// компоненты в [0, 1], без знакового нуля. - #[test] - fn css_shape_is_the_contract() { - let solid = p3_css_from_hex("#3E87FF", None).unwrap(); - assert!(solid.starts_with("color(display-p3 ") && solid.ends_with(')')); - assert!(!solid.contains('/')); - assert!(!solid.contains("-0."), "signed zero запрещён: {solid}"); - let translucent = p3_css_from_hex("#101012", Some(0.122)).unwrap(); - assert!(translucent.contains(" / 0.122)")); - // Чистый sRGB-красный внутри P3 — менее насыщен, чем P3-красный: - // r < 1, g/b > 0 (иначе матрицы перепутаны). - let [r, g, b] = p3_from_hex("#FF0000").unwrap(); - assert!(r > 0.9 && r < 1.0, "P3 r красного: {r}"); - assert!(g > 0.0 && b > 0.0, "P3 g/b красного: {g}/{b}"); - } - - /// Этап 1 gamut-aware: стена P3 не уже sRGB-стены НИГДЕ (sRGB ⊂ P3) и - /// СТРОГО шире на насыщенных срединных светлотах (зелёная зона P3 — - /// самое сильное расширение). Сетка L × h покрывает обе ветки бисекции. - #[test] - fn p3_wall_dominates_srgb_wall() { + fn physical_p3_wall_contains_the_srgb_wall() { let mut strictly_wider_somewhere = false; for l10 in 2..=9 { - let l = f64::from(l10) / 10.0; - for h in (0..360).step_by(15) { - let h = f64::from(h); - let srgb = crate::scale::max_chroma_bisect(l, h); - let p3 = crate::scale::max_chroma_p3_bisect(l, h); + let lightness = f64::from(l10) / 10.0; + for hue in (0..360).step_by(15) { + let hue = f64::from(hue); + let srgb = crate::scale::max_chroma_bisect(lightness, hue); + let p3 = crate::scale::max_chroma_p3_bisect(lightness, hue); assert!( p3 >= srgb - 1e-9, - "P3-стена уже sRGB при L={l}, h={h}: {p3} < {srgb}" + "P3 wall is narrower than sRGB at L={lightness}, h={hue}: {p3} < {srgb}" ); - if p3 > srgb * 1.05 { - strictly_wider_somewhere = true; - } + strictly_wider_somewhere |= p3 > srgb * 1.05; } } assert!( strictly_wider_somewhere, - "P3 обязан быть строго шире sRGB хоть где-то (иначе матрицы выродились)" - ); - } - - /// Достижимость за sRGB-стеной: цвет с хромой между стенами (вне sRGB, - /// внутри P3) представим на 8-битной P3-решётке И ПЕРЕЖИВАЕТ квантование — - /// перечитанный с решётки цвет остаётся за sRGB-стеной. Это ровно то, - /// что этап 2 отдаст наружу. - #[test] - fn beyond_srgb_chroma_survives_the_p3_lattice() { - use crate::spaces::oklab::{oklab_to_srgb_linear, srgb_linear_to_oklab}; - // Зелёная срединная зона — максимальный разрыв стен. - let (l, h) = (0.75, 145.0); - let srgb_wall = crate::scale::max_chroma_bisect(l, h); - let p3_wall = crate::scale::max_chroma_p3_bisect(l, h); - assert!( - p3_wall > srgb_wall * 1.1, - "в зелёной зоне разрыв стен обязан быть ощутимым: {p3_wall} vs {srgb_wall}" + "the P3 matrix must produce a genuinely wider gamut somewhere" ); - let c = (srgb_wall + p3_wall) / 2.0; - let h_rad = h.to_radians(); - let lab = [l, c * h_rad.cos(), c * h_rad.sin()]; - let lin_p3 = xyz_to_p3_linear(srgb_to_xyz(oklab_to_srgb_linear(lab))); - let bytes = p3_bytes_from_linear(lin_p3).expect("между стенами — внутри P3"); - // Перечитываем с решётки и меряем хрому честно (на отданном значении). - let back = p3_linear_to_xyz(p3_linear_from_bytes(bytes)); - let back_lab = srgb_linear_to_oklab(crate::spaces::srgb::xyz_to_srgb(back)); - let back_c = (back_lab[1] * back_lab[1] + back_lab[2] * back_lab[2]).sqrt(); - assert!( - back_c > srgb_wall, - "квантование не должно ронять хрому обратно в sRGB: {back_c} <= {srgb_wall}" - ); - } - - /// Байт-точный round-trip решётки: байты → css-строка → парс компонент → - /// байты. Шаг 7 взаимно прост с 255 — решётка пробегает все классы вычетов. - #[test] - fn p3_lattice_css_round_trip_is_byte_exact() { - for r in (0..=255).step_by(7) { - for g in (0..=255).step_by(51) { - for b in (0..=255).step_by(51) { - let bytes = [r as u8, g as u8, b as u8]; - let css = p3_css_from_bytes(bytes, None).expect("байты валидны"); - let inner = css - .strip_prefix("color(display-p3 ") - .and_then(|s| s.strip_suffix(')')) - .expect("форма color(display-p3 ...)"); - let parts: Vec = inner - .split_whitespace() - .map(|p| p.parse::().expect("компонента — число")) - .collect(); - let parsed = [ - (parts[0] * 255.0).round() as u8, - (parts[1] * 255.0).round() as u8, - (parts[2] * 255.0).round() as u8, - ]; - assert_eq!(parsed, bytes, "byte round-trip сломан: {css}"); - } - } - } - } - - /// Гард решётки: не-цвет (за гамутом P3) не квантуется молча. - #[test] - fn out_of_gamut_linear_is_an_error_not_a_clamp() { - assert!(p3_bytes_from_linear([1.2, 0.5, 0.5]).is_err()); - assert!(p3_bytes_from_linear([-0.2, 0.5, 0.5]).is_err()); } } diff --git a/crates/labcolors-core/src/spaces/srgb.rs b/crates/labcolors-core/src/spaces/srgb.rs index 79ad447f..c5392669 100644 --- a/crates/labcolors-core/src/spaces/srgb.rs +++ b/crates/labcolors-core/src/spaces/srgb.rs @@ -147,6 +147,17 @@ pub(crate) fn srgb_linear_from_srgb8(rgb: Srgb8) -> [f64; 3] { [decode_8bit(r), decode_8bit(g), decode_8bit(b)] } +/// Derive one CIE 1931 XYZ(D65, relative Y = 1) point from exact encoded sRGB8. +/// +/// This is the single operation-order owner for the registered finite-input +/// colourimetric transform. It is a deterministic model of a declared encoded +/// point signal; it does not claim that a renderer emitted or an +/// observer measured the resulting tristimulus. +#[inline] +pub(crate) fn xyz_d65_from_srgb8_v1(rgb: Srgb8) -> [f64; 3] { + srgb_to_xyz(srgb_linear_from_srgb8(rgb)) +} + /// Parse `#RRGGBB` → `(linear, display)` in one pass over the bytes: `linear` /// is the exact 8-bit decode (as [`srgb_from_hex`]), `display` is the /// **gamma-encoded** value WCAG measures — `[r/255, g/255, b/255]` — obtained diff --git a/crates/labcolors-core/src/spaces/vc.rs b/crates/labcolors-core/src/spaces/vc.rs index 6079c6b5..5bf159ed 100644 --- a/crates/labcolors-core/src/spaces/vc.rs +++ b/crates/labcolors-core/src/spaces/vc.rs @@ -17,6 +17,17 @@ use std::sync::OnceLock; use super::{cam16::adapt, cat16::xyz_to_cone}; +/// Closed CIECAM16 surround tuple admitted by the F0 occurrence context. +/// +/// Keeping the triplets behind variants prevents callers from independently +/// combining `F`, `c` and `N_c` into a context the release never registered. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Cam16SurroundV1 { + Average, + Dim, + Dark, +} + /// Условия просмотра для модели цветового восприятия CIECAM16. /// /// Значения по умолчанию соответствуют sRGB: D65, серый фон 20 %, среднее @@ -75,6 +86,31 @@ impl Default for ViewingConditions { } impl ViewingConditions { + /// Build CAM16 derived constants from immutable semantic context inputs. + /// + /// `background_luminance_ratio` is `Y_b / Y_w`, not a percentage. The + /// existing builder consumes percent, so the conversion remains here at + /// the legacy-kernel boundary rather than leaking into the occurrence + /// domain. Admission of the numeric inputs is owned by `AppearanceContext`. + pub(crate) fn from_semantic_inputs_v1( + adapting_luminance_cd_m2: f64, + background_luminance_ratio: f64, + surround: Cam16SurroundV1, + ) -> Self { + let (f, c, nc) = match surround { + Cam16SurroundV1::Average => (1.0, 0.69, 1.0), + Cam16SurroundV1::Dim => (0.9, 0.59, 0.9), + Cam16SurroundV1::Dark => (0.8, 0.525, 0.8), + }; + Self::build( + adapting_luminance_cd_m2, + background_luminance_ratio * 100.0, + f, + c, + nc, + ) + } + /// Коэффициент фоновой яркости CAM16: `n = Y_b / Y_w`. pub fn n(&self) -> f64 { self.n diff --git a/crates/labcolors-core/tests/data/labui_emission_golden.txt b/crates/labcolors-core/tests/data/labui_emission_golden.txt index 30d0a3c3..183c13ff 100644 --- a/crates/labcolors-core/tests/data/labui_emission_golden.txt +++ b/crates/labcolors-core/tests/data/labui_emission_golden.txt @@ -91,18 +91,9 @@ srgb|#FFFFFF|label-danger=rgba(#FF3B30,1) srgb|#FFFFFF|border-accent=rgba(#007AFF,0.2) srgb|#FFFFFF|border-danger=rgba(#FF3B30,0.2) srgb|#FFFFFF|border-focus=rgba(#007AFF,1) -srgb|#FFFFFF|badge-fill-brand=rgba(#007AFF,1) -srgb|#FFFFFF|badge-fill-danger=rgba(#FF3B30,1) -srgb|#FFFFFF|badge-fill-warning=rgba(#FFA100,1) -srgb|#FFFFFF|badge-fill-success=rgba(#34C759,1) -srgb|#FFFFFF|badge-fill-info=rgba(#3E87FF,1) -srgb|#FFFFFF|badge-fill-static-dark=rgba(#101012,1) -srgb|#FFFFFF|badge-fill-static-light=rgba(#FFFFFF,1) srgb|#FFFFFF|fill-neutral-tinted->fill-primary srgb|#FFFFFF|border-neutral->border-base srgb|#FFFFFF|fx-skeleton-base->fill-quaternary -srgb|#FFFFFF|fill-accent->badge-fill-brand -srgb|#FFFFFF|fill-danger->badge-fill-danger srgb|#FFFFFF|icon->label-tertiary srgb|#FFFFFF|border-ghost->border-none srgb|#F2F2F7|bg-tone-2=#EBEBF5 @@ -198,18 +189,9 @@ srgb|#F2F2F7|label-danger=rgba(#FF3B30,1) srgb|#F2F2F7|border-accent=rgba(#007AFF,0.2) srgb|#F2F2F7|border-danger=rgba(#FF3B30,0.2) srgb|#F2F2F7|border-focus=rgba(#007AFF,1) -srgb|#F2F2F7|badge-fill-brand=rgba(#007AFF,1) -srgb|#F2F2F7|badge-fill-danger=rgba(#FF3B30,1) -srgb|#F2F2F7|badge-fill-warning=rgba(#FFA100,1) -srgb|#F2F2F7|badge-fill-success=rgba(#34C759,1) -srgb|#F2F2F7|badge-fill-info=rgba(#3E87FF,1) -srgb|#F2F2F7|badge-fill-static-dark=rgba(#101012,1) -srgb|#F2F2F7|badge-fill-static-light=rgba(#FFFFFF,1) srgb|#F2F2F7|fill-neutral-tinted->fill-primary srgb|#F2F2F7|border-neutral->border-base srgb|#F2F2F7|fx-skeleton-base->fill-quaternary -srgb|#F2F2F7|fill-accent->badge-fill-brand -srgb|#F2F2F7|fill-danger->badge-fill-danger srgb|#F2F2F7|icon->label-tertiary srgb|#F2F2F7|border-ghost->border-none srgb|#7F7F7F|bg-tone-2=#797981 @@ -305,18 +287,9 @@ srgb|#7F7F7F|label-danger=rgba(#FF3B30,1) srgb|#7F7F7F|border-accent=rgba(#007AFF,0.2) srgb|#7F7F7F|border-danger=rgba(#FF3B30,0.2) srgb|#7F7F7F|border-focus=rgba(#007AFF,1) -srgb|#7F7F7F|badge-fill-brand=rgba(#007AFF,1) -srgb|#7F7F7F|badge-fill-danger=rgba(#FF3B30,1) -srgb|#7F7F7F|badge-fill-warning=rgba(#FFA100,1) -srgb|#7F7F7F|badge-fill-success=rgba(#34C759,1) -srgb|#7F7F7F|badge-fill-info=rgba(#3E87FF,1) -srgb|#7F7F7F|badge-fill-static-dark=rgba(#101012,1) -srgb|#7F7F7F|badge-fill-static-light=rgba(#FFFFFF,1) srgb|#7F7F7F|fill-neutral-tinted->fill-primary srgb|#7F7F7F|border-neutral->border-base srgb|#7F7F7F|fx-skeleton-base->fill-quaternary -srgb|#7F7F7F|fill-accent->badge-fill-brand -srgb|#7F7F7F|fill-danger->badge-fill-danger srgb|#7F7F7F|icon->label-tertiary srgb|#7F7F7F|border-ghost->border-none srgb|#1C1C1E|bg-tone-2=#202028 @@ -412,18 +385,9 @@ srgb|#1C1C1E|label-danger=rgba(#FF3B30,1) srgb|#1C1C1E|border-accent=rgba(#007AFF,0.2) srgb|#1C1C1E|border-danger=rgba(#FF3B30,0.2) srgb|#1C1C1E|border-focus=rgba(#007AFF,1) -srgb|#1C1C1E|badge-fill-brand=rgba(#007AFF,1) -srgb|#1C1C1E|badge-fill-danger=rgba(#FF3B30,1) -srgb|#1C1C1E|badge-fill-warning=rgba(#FFA100,1) -srgb|#1C1C1E|badge-fill-success=rgba(#34C759,1) -srgb|#1C1C1E|badge-fill-info=rgba(#3E87FF,1) -srgb|#1C1C1E|badge-fill-static-dark=rgba(#101012,1) -srgb|#1C1C1E|badge-fill-static-light=rgba(#FFFFFF,1) srgb|#1C1C1E|fill-neutral-tinted->fill-primary srgb|#1C1C1E|border-neutral->border-base srgb|#1C1C1E|fx-skeleton-base->fill-quaternary -srgb|#1C1C1E|fill-accent->badge-fill-brand -srgb|#1C1C1E|fill-danger->badge-fill-danger srgb|#1C1C1E|icon->label-tertiary srgb|#1C1C1E|border-ghost->border-none srgb|#101012|bg-tone-2=#15151B @@ -519,18 +483,9 @@ srgb|#101012|label-danger=rgba(#FF3B30,1) srgb|#101012|border-accent=rgba(#007AFF,0.2) srgb|#101012|border-danger=rgba(#FF3B30,0.2) srgb|#101012|border-focus=rgba(#007AFF,1) -srgb|#101012|badge-fill-brand=rgba(#007AFF,1) -srgb|#101012|badge-fill-danger=rgba(#FF3B30,1) -srgb|#101012|badge-fill-warning=rgba(#FFA100,1) -srgb|#101012|badge-fill-success=rgba(#34C759,1) -srgb|#101012|badge-fill-info=rgba(#3E87FF,1) -srgb|#101012|badge-fill-static-dark=rgba(#101012,1) -srgb|#101012|badge-fill-static-light=rgba(#FFFFFF,1) srgb|#101012|fill-neutral-tinted->fill-primary srgb|#101012|border-neutral->border-base srgb|#101012|fx-skeleton-base->fill-quaternary -srgb|#101012|fill-accent->badge-fill-brand -srgb|#101012|fill-danger->badge-fill-danger srgb|#101012|icon->label-tertiary srgb|#101012|border-ghost->border-none srgb|#3478F6|bg-tone-2=#797981 @@ -626,18 +581,9 @@ srgb|#3478F6|label-danger=rgba(#FF3B30,1) srgb|#3478F6|border-accent=rgba(#007AFF,0.2) srgb|#3478F6|border-danger=rgba(#FF3B30,0.2) srgb|#3478F6|border-focus=rgba(#007AFF,1) -srgb|#3478F6|badge-fill-brand=rgba(#007AFF,1) -srgb|#3478F6|badge-fill-danger=rgba(#FF3B30,1) -srgb|#3478F6|badge-fill-warning=rgba(#FFA100,1) -srgb|#3478F6|badge-fill-success=rgba(#34C759,1) -srgb|#3478F6|badge-fill-info=rgba(#3E87FF,1) -srgb|#3478F6|badge-fill-static-dark=rgba(#101012,1) -srgb|#3478F6|badge-fill-static-light=rgba(#FFFFFF,1) srgb|#3478F6|fill-neutral-tinted->fill-primary srgb|#3478F6|border-neutral->border-base srgb|#3478F6|fx-skeleton-base->fill-quaternary -srgb|#3478F6|fill-accent->badge-fill-brand -srgb|#3478F6|fill-danger->badge-fill-danger srgb|#3478F6|icon->label-tertiary srgb|#3478F6|border-ghost->border-none dim|#FFFFFF|bg-tone-2=#E7E7F1 @@ -733,18 +679,9 @@ dim|#FFFFFF|label-danger=rgba(#FF3A3A,1) dim|#FFFFFF|border-accent=rgba(#4A8FFF,0.2) dim|#FFFFFF|border-danger=rgba(#FF3A3A,0.2) dim|#FFFFFF|border-focus=rgba(#4A8FFF,1) -dim|#FFFFFF|badge-fill-brand=rgba(#4A8FFF,1) -dim|#FFFFFF|badge-fill-danger=rgba(#FF3A3A,1) -dim|#FFFFFF|badge-fill-warning=rgba(#FF9008,1) -dim|#FFFFFF|badge-fill-success=rgba(#30D158,1) -dim|#FFFFFF|badge-fill-info=rgba(#5696FF,1) -dim|#FFFFFF|badge-fill-static-dark=rgba(#101012,1) -dim|#FFFFFF|badge-fill-static-light=rgba(#FFFFFF,1) dim|#FFFFFF|fill-neutral-tinted->fill-primary dim|#FFFFFF|border-neutral->border-base dim|#FFFFFF|fx-skeleton-base->fill-quaternary -dim|#FFFFFF|fill-accent->badge-fill-brand -dim|#FFFFFF|fill-danger->badge-fill-danger dim|#FFFFFF|icon->label-tertiary dim|#FFFFFF|border-ghost->border-none dim|#F2F2F7|bg-tone-2=#DBDBE5 @@ -840,18 +777,9 @@ dim|#F2F2F7|label-danger=rgba(#FF3A3A,1) dim|#F2F2F7|border-accent=rgba(#4A8FFF,0.2) dim|#F2F2F7|border-danger=rgba(#FF3A3A,0.2) dim|#F2F2F7|border-focus=rgba(#4A8FFF,1) -dim|#F2F2F7|badge-fill-brand=rgba(#4A8FFF,1) -dim|#F2F2F7|badge-fill-danger=rgba(#FF3A3A,1) -dim|#F2F2F7|badge-fill-warning=rgba(#FF9008,1) -dim|#F2F2F7|badge-fill-success=rgba(#30D158,1) -dim|#F2F2F7|badge-fill-info=rgba(#5696FF,1) -dim|#F2F2F7|badge-fill-static-dark=rgba(#101012,1) -dim|#F2F2F7|badge-fill-static-light=rgba(#FFFFFF,1) dim|#F2F2F7|fill-neutral-tinted->fill-primary dim|#F2F2F7|border-neutral->border-base dim|#F2F2F7|fx-skeleton-base->fill-quaternary -dim|#F2F2F7|fill-accent->badge-fill-brand -dim|#F2F2F7|fill-danger->badge-fill-danger dim|#F2F2F7|icon->label-tertiary dim|#F2F2F7|border-ghost->border-none dim|#7F7F7F|bg-tone-2=#6F6F77 @@ -947,18 +875,9 @@ dim|#7F7F7F|label-danger=rgba(#FF3A3A,1) dim|#7F7F7F|border-accent=rgba(#4A8FFF,0.2) dim|#7F7F7F|border-danger=rgba(#FF3A3A,0.2) dim|#7F7F7F|border-focus=rgba(#4A8FFF,1) -dim|#7F7F7F|badge-fill-brand=rgba(#4A8FFF,1) -dim|#7F7F7F|badge-fill-danger=rgba(#FF3A3A,1) -dim|#7F7F7F|badge-fill-warning=rgba(#FF9008,1) -dim|#7F7F7F|badge-fill-success=rgba(#30D158,1) -dim|#7F7F7F|badge-fill-info=rgba(#5696FF,1) -dim|#7F7F7F|badge-fill-static-dark=rgba(#101012,1) -dim|#7F7F7F|badge-fill-static-light=rgba(#FFFFFF,1) dim|#7F7F7F|fill-neutral-tinted->fill-primary dim|#7F7F7F|border-neutral->border-base dim|#7F7F7F|fx-skeleton-base->fill-quaternary -dim|#7F7F7F|fill-accent->badge-fill-brand -dim|#7F7F7F|fill-danger->badge-fill-danger dim|#7F7F7F|icon->label-tertiary dim|#7F7F7F|border-ghost->border-none dim|#1C1C1E|bg-tone-2=#28282E @@ -1054,18 +973,9 @@ dim|#1C1C1E|label-danger=rgba(#FF3A3A,1) dim|#1C1C1E|border-accent=rgba(#4A8FFF,0.2) dim|#1C1C1E|border-danger=rgba(#FF3A3A,0.2) dim|#1C1C1E|border-focus=rgba(#4A8FFF,1) -dim|#1C1C1E|badge-fill-brand=rgba(#4A8FFF,1) -dim|#1C1C1E|badge-fill-danger=rgba(#FF3A3A,1) -dim|#1C1C1E|badge-fill-warning=rgba(#FF9008,1) -dim|#1C1C1E|badge-fill-success=rgba(#30D158,1) -dim|#1C1C1E|badge-fill-info=rgba(#5696FF,1) -dim|#1C1C1E|badge-fill-static-dark=rgba(#101012,1) -dim|#1C1C1E|badge-fill-static-light=rgba(#FFFFFF,1) dim|#1C1C1E|fill-neutral-tinted->fill-primary dim|#1C1C1E|border-neutral->border-base dim|#1C1C1E|fx-skeleton-base->fill-quaternary -dim|#1C1C1E|fill-accent->badge-fill-brand -dim|#1C1C1E|fill-danger->badge-fill-danger dim|#1C1C1E|icon->label-tertiary dim|#1C1C1E|border-ghost->border-none dim|#101012|bg-tone-2=#1C1C22 @@ -1161,18 +1071,9 @@ dim|#101012|label-danger=rgba(#FF3A3A,1) dim|#101012|border-accent=rgba(#4A8FFF,0.2) dim|#101012|border-danger=rgba(#FF3A3A,0.2) dim|#101012|border-focus=rgba(#4A8FFF,1) -dim|#101012|badge-fill-brand=rgba(#4A8FFF,1) -dim|#101012|badge-fill-danger=rgba(#FF3A3A,1) -dim|#101012|badge-fill-warning=rgba(#FF9008,1) -dim|#101012|badge-fill-success=rgba(#30D158,1) -dim|#101012|badge-fill-info=rgba(#5696FF,1) -dim|#101012|badge-fill-static-dark=rgba(#101012,1) -dim|#101012|badge-fill-static-light=rgba(#FFFFFF,1) dim|#101012|fill-neutral-tinted->fill-primary dim|#101012|border-neutral->border-base dim|#101012|fx-skeleton-base->fill-quaternary -dim|#101012|fill-accent->badge-fill-brand -dim|#101012|fill-danger->badge-fill-danger dim|#101012|icon->label-tertiary dim|#101012|border-ghost->border-none dim|#3478F6|bg-tone-2=#6E6E77 @@ -1268,17 +1169,8 @@ dim|#3478F6|label-danger=rgba(#FF3A3A,1) dim|#3478F6|border-accent=rgba(#4A8FFF,0.2) dim|#3478F6|border-danger=rgba(#FF3A3A,0.2) dim|#3478F6|border-focus=rgba(#4A8FFF,1) -dim|#3478F6|badge-fill-brand=rgba(#4A8FFF,1) -dim|#3478F6|badge-fill-danger=rgba(#FF3A3A,1) -dim|#3478F6|badge-fill-warning=rgba(#FF9008,1) -dim|#3478F6|badge-fill-success=rgba(#30D158,1) -dim|#3478F6|badge-fill-info=rgba(#5696FF,1) -dim|#3478F6|badge-fill-static-dark=rgba(#101012,1) -dim|#3478F6|badge-fill-static-light=rgba(#FFFFFF,1) dim|#3478F6|fill-neutral-tinted->fill-primary dim|#3478F6|border-neutral->border-base dim|#3478F6|fx-skeleton-base->fill-quaternary -dim|#3478F6|fill-accent->badge-fill-brand -dim|#3478F6|fill-danger->badge-fill-danger dim|#3478F6|icon->label-tertiary dim|#3478F6|border-ghost->border-none diff --git a/crates/labcolors-core/tests/empirical_inventory.rs b/crates/labcolors-core/tests/empirical_inventory.rs index 9ea9deba..91142c5a 100644 --- a/crates/labcolors-core/tests/empirical_inventory.rs +++ b/crates/labcolors-core/tests/empirical_inventory.rs @@ -57,9 +57,7 @@ const PERCEPTUAL_MODULES: [&str; 7] = [ // // Why a SUBSET: the two modules below are POLICY modules — their magnitudes are // tunable perceptual policy (role fractions and neutral thresholds). The remaining -// modules are STANDARD-MODEL or bounded numeric-search transforms. Pair after P1 -// contains only typed topology/lowering and no perceptual policy constants, so it -// is intentionally outside both inventory scan surfaces. +// modules are STANDARD-MODEL or bounded numeric-search transforms. const POLICY_LITERAL_MODULES: &[&str] = &["semantic.rs", "neutral.rs"]; /// Bare float literal VALUES that are NOT tunable perceptual policy — universal @@ -171,9 +169,6 @@ const STRUCTURAL_NONPOLICY_ALLOWLIST: &[&str] = &[ "NEIGHBOR_STEPS", "DJ_NEIGHBOR_STEPS", "MAX_PROBES", - // Bisection iteration count in the pair-fill minimal-nudge search — an - // iteration budget, not a perceptual magnitude (pair.rs). - "BISECTION_STEPS", ]; // ───────────────────────────────────────────────────────────────────────────── diff --git a/crates/labcolors-core/tests/property_invariants.rs b/crates/labcolors-core/tests/property_invariants.rs index 9a7b13e7..8b19c7f8 100644 --- a/crates/labcolors-core/tests/property_invariants.rs +++ b/crates/labcolors-core/tests/property_invariants.rs @@ -23,7 +23,7 @@ use labcolors_core::{ BgInput, Brand, Floor, GlowDecisionProfileV1, LadderPosition, LadderSource, NeutralAnchors, NeutralConfig, NeutralPick, NeutralTint, PaletteFamily, Resolved, RoleFailure, RoleRecipe, ThemeAnchors, ThemeConfig, ThemesConfig, VcPreset, ViewingConditions, oklch_from_hex, - p3_from_hex, resolve_named_set, srgb_encoded_from_hex, + resolve_named_set, srgb_encoded_from_hex, }; use proptest::prelude::*; use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner}; @@ -593,7 +593,7 @@ fn hued_brand_label_stays_in_family_coordinate_band_when_chromatic() { // СВОЙСТВО 6 — fuzz-устойчивость парсеров цвета (ноль паник на мусоре) // // КЛАСС: «парсер hex падает/паникует на враждебном/битом входе». Любая строка на -// входе `oklch_from_hex` / `srgb_encoded_from_hex` / `p3_from_hex` / `BgInput::solid` +// входе `oklch_from_hex` / `srgb_encoded_from_hex` / `BgInput::solid` // → аккуратный `Result` (Ok или Err), НИКОГДА не паника (паника развернула бы стек // и уронила бы тест). Класс-закрыватель для парсеров, читающих внешний ввод. // БЬЁТ НА МУТАЦИИ: замена валидации на `unwrap`/индексацию без границ → паника → RED. @@ -615,10 +615,9 @@ fn hex_like() -> impl Strategy { #[test] fn color_parsers_never_panic_on_arbitrary_input() { check(2000, hex_like(), |s| { - // Все четыре парсера обязаны вернуть Result, не паниковать. + // Все три парсера обязаны вернуть Result, не паниковать. let _ = oklch_from_hex(&s); let _ = srgb_encoded_from_hex(&s); - let _ = p3_from_hex(&s); let _ = BgInput::solid(&s); Ok(()) }); diff --git a/crates/labcolors-core/tests/solve_characterization.rs b/crates/labcolors-core/tests/solve_characterization.rs index ba2a2f34..ef7e8d30 100644 --- a/crates/labcolors-core/tests/solve_characterization.rs +++ b/crates/labcolors-core/tests/solve_characterization.rs @@ -262,7 +262,6 @@ fn outcome_line(result: &Result) -> String { bits(*floor), bits(*max_ratio) ), - Err(SolveFailure::GamutUnsupported) => "err gamut_unsupported".to_string(), Err(SolveFailure::InvalidInput(message)) => { format!("err invalid_input message={message:?}") } @@ -287,20 +286,6 @@ fn run_case(case: &CaseSpec) -> Result { fn observed_map() -> BTreeMap { let mut observed = BTreeMap::new(); - // Одна GamutUnsupported-строка поверх матрицы (у solve это внешний гейт). - let gamut_case = solve( - BgInput::solid("#FFFFFF").expect("literal background"), - Contract::text(60.0), - Hue::deg(0.0), - ChromaPolicy::Neutral, - &ViewingConditions::srgb(), - Gamut::DisplayP3, - ); - observed.insert( - "bg=#FFFFFF contract=text(60) floor=default hue=0 chroma=neutral gamut=display-p3" - .to_string(), - outcome_line(&gamut_case), - ); for case in matrix() { let previous = observed.insert(case_key(&case), outcome_line(&run_case(&case))); assert!( @@ -410,7 +395,6 @@ fn characterization_counters_are_non_vacuous() { "exceeds_range" => "exceeds_range", "bounded_search_exhausted" => "bounded_search_exhausted", "floor_unreachable" => "floor_unreachable", - "gamut_unsupported" => "gamut_unsupported", "invalid_input" => "invalid_input", "internal_invariant" => "internal_invariant", other => panic!("unknown error class {other}"), @@ -426,7 +410,6 @@ fn characterization_counters_are_non_vacuous() { "below_contrast_floor", "exceeds_range", "floor_unreachable", - "gamut_unsupported", "invalid_input", ] { assert!( @@ -441,10 +424,9 @@ fn characterization_counters_are_non_vacuous() { // квантования; walk в 2 distinct-шага пересекает всё остальное (фикс #44). // Эмпирически: сканы публичного API на миллионы вызовов (solid-фоны обеих // полярностей, серые и хроматические, hue-сетка, Neutral/Relative вплоть до - // 1.0, Floor::None/AaText/AaUi, |Lc| 7.3..112, srgb и dim surround; wide - // gamut не участвует — DisplayP3 умирает на внешнем гейте) не производят - // ни одного. Правда самого варианта (`closest_examined` локален, не глобален) - // запинена на его собственном шве: + // 1.0, Floor::None/AaText/AaUi, |Lc| 7.3..112, srgb и dim surround) не + // производят ни одного. Правда самого варианта (`closest_examined` локален, + // не глобален) запинена на его собственном шве: // `solve::tests::bounded_search_exhausted_is_local_not_global_counterexample`. // Появление исхода из этой матрицы = изменение поведения поиска, не «новый кейс». assert_eq!( @@ -464,7 +446,7 @@ fn characterization_counters_are_non_vacuous() { /// `solve_many(bg, jobs) == jobs.map(solve)` позиционно: успехи, каждый класс /// per-job ошибки, пустой вход, дубликаты и смешанные валидные/невалидные -/// задания; внешняя gamut-ошибка остаётся внешней и не сдвигает позиции. +/// задания. #[test] fn solve_many_is_positionally_identical_to_sequential_solve() { let vc = ViewingConditions::srgb(); @@ -600,13 +582,6 @@ fn solve_many_is_positionally_identical_to_sequential_solve() { .expect("empty batch") .is_empty() ); - - // Внешняя ошибка гамута — внешняя: Err всей партии, позиций нет. - let bg = BgInput::solid("#FFFFFF").expect("literal background"); - assert!(matches!( - solve_many(bg, &jobs, &vc, Gamut::DisplayP3), - Err(SolveFailure::GamutUnsupported) - )); } /// Позитивная характеризация JND-полосы против `recheck_against` — публичного @@ -701,7 +676,7 @@ fn jnd_band_resolves_within_budget_with_tolerant_acceptance() { /// Linux-x64 расходится РОВНО хвост ulp одного поля — Oklab-hue коррелята /// `h_ok` — в двух хроматических кейсах матрицы. Всё остальное (hex-байты, `lc`, /// `wcag_ratio`, `floor_override`, `jp`, `s`, все payload'ы ошибок) — -/// бит-идентично на всех 77 кейсах. Оба кейса — libm-разница +/// бит-идентично на всех 76 кейсах. Оба кейса — libm-разница /// (atan2/cbrt, 5 ulp на хроматике). У точного sRGB-серого `h_ok = 0` по /// определению, поэтому его прежний atan2-шум больше не является частью /// платформенного контракта. Рост этого множества — изменение численного @@ -728,7 +703,7 @@ fn platform_fixtures_agree_except_documented_hue_ulp_drift() { }; let mac = load(FIXTURE_MACOS_AARCH64); let linux = load(FIXTURE_LINUX_X64); - assert_eq!(mac.len(), 77, "macOS fixture cardinality"); + assert_eq!(mac.len(), 76, "macOS fixture cardinality"); assert_eq!( mac.keys().collect::>(), linux.keys().collect::>(), diff --git a/crates/labcolors-ffi/src/lib.rs b/crates/labcolors-ffi/src/lib.rs index 76a2c33d..ae5136f1 100644 --- a/crates/labcolors-ffi/src/lib.rs +++ b/crates/labcolors-ffi/src/lib.rs @@ -352,8 +352,6 @@ pub enum FailureCategory { Unresolved, /// Запрос не принадлежит объявленному domain. Rejected, - /// Запрос валиден, но capability не реализована. - Unsupported, } impl FailureCategory { @@ -362,7 +360,6 @@ impl FailureCategory { labcolors_core::SolveFailureCategory::Unreachable => Self::Unreachable, labcolors_core::SolveFailureCategory::Unresolved => Self::Unresolved, labcolors_core::SolveFailureCategory::Rejected => Self::Rejected, - labcolors_core::SolveFailureCategory::Unsupported => Self::Unsupported, } } } @@ -389,7 +386,7 @@ pub enum ColorError { key: String, }, /// Resolver не вернул цвет. Категория отделяет доказанную недостижимость - /// от unresolved, rejected и unsupported исходов. + /// от unresolved и rejected исходов. #[error("solve failure {category:?}/{code}")] Failure { /// Стабильная семантическая категория core failure. @@ -997,11 +994,6 @@ mod tests { FailureCategory::Unreachable, "floor_unreachable", ), - ( - U::GamutUnsupported, - FailureCategory::Unsupported, - "gamut_unsupported", - ), ( U::InvalidInput("fixture".into()), FailureCategory::Rejected, diff --git a/crates/labcolors-wasm/src/config_dto.rs b/crates/labcolors-wasm/src/config_dto.rs index 0e6abaa7..55f8fa93 100644 --- a/crates/labcolors-wasm/src/config_dto.rs +++ b/crates/labcolors-wasm/src/config_dto.rs @@ -155,14 +155,6 @@ pub enum RoleRecipeDto { step: String, decision_profile: String, }, - PairFill { - source: LadderSourceDto, - }, - PairLabel { - source: LadderSourceDto, - fraction: f64, - floor: FloorDto, - }, AlphaAnalog { of: LadderSourceDto, alpha: f64, @@ -354,18 +346,6 @@ impl TryFrom for RoleRecipe { position: position_from_key(&position)?, floor: floor.map(Floor::from), }, - RoleRecipeDto::PairFill { source } => RoleRecipe::PairFill { - source: source.into(), - }, - RoleRecipeDto::PairLabel { - source, - fraction, - floor, - } => RoleRecipe::PairLabel { - source: source.into(), - fraction, - floor: floor.into(), - }, RoleRecipeDto::AlphaAnalog { of, alpha } => RoleRecipe::AlphaAnalog { of: of.into(), alpha, @@ -515,18 +495,6 @@ impl TryFrom<&RoleRecipe> for RoleRecipeDto { position: position.key().to_string(), floor: floor.map(floor_to_dto).transpose()?, }, - RoleRecipe::PairFill { source } => RoleRecipeDto::PairFill { - source: source.try_into()?, - }, - RoleRecipe::PairLabel { - source, - fraction, - floor, - } => RoleRecipeDto::PairLabel { - source: source.try_into()?, - fraction: *fraction, - floor: floor_to_dto(*floor)?, - }, RoleRecipe::AlphaAnalog { of, alpha } => RoleRecipeDto::AlphaAnalog { of: of.try_into()?, alpha: *alpha, @@ -663,31 +631,6 @@ mod tests { .expect("восстановленный конфиг компилируется"); } - /// Recipe-адаптер `pair-label` гоняется через JSON без - /// потерь: kebab-тег `pair-label`, источник/доля/пол целы туда-обратно. - /// Это только доказательство DTO round-trip, а не наличия pair-label - /// в целевом graph API. - #[test] - fn pair_label_recipe_round_trips_through_json() { - use labcolors_core::solve::Floor; - let json = r#"{"kind":"pair-label","source":{"kind":"family","key":"warning"},"fraction":0.461,"floor":"aa-ui"}"#; - let dto: RoleRecipeDto = serde_json::from_str(json).expect("pair-label парсится"); - let core = RoleRecipe::try_from(dto).expect("DTO → RoleRecipe"); - assert!( - matches!( - &core, - RoleRecipe::PairLabel { fraction, floor: Floor::AaUi, .. } - if (*fraction - 0.461).abs() < 1e-12 - ), - "pair-label конвертируется в ядро с целыми полями" - ); - let back = RoleRecipeDto::try_from(&core).expect("RoleRecipe → DTO"); - let re = serde_json::to_string(&back).expect("сериализуем"); - assert!(re.contains(r#""kind":"pair-label""#), "kebab-тег цел: {re}"); - assert!(re.contains(r#""floor":"aa-ui""#), "пол цел: {re}"); - assert!(re.contains(r#""key":"warning""#), "источник цел: {re}"); - } - /// C6 RED: удалённая специальная sentiment-схема обязана стать неизвестной, /// а не тихо игнорироваться serde после удаления поля/варианта. #[test] @@ -876,7 +819,7 @@ mod tests { let full = labui_dto(); assert_eq!( format!("{:016x}", fingerprint(&full)), - "1adb2876102d77f3", + "bce14f09e43c705a", "пин паспорта main; при легитимной смене паспорта обнови это число" ); } diff --git a/crates/labcolors-wasm/src/lib.rs b/crates/labcolors-wasm/src/lib.rs index 45c562a1..38b0a313 100644 --- a/crates/labcolors-wasm/src/lib.rs +++ b/crates/labcolors-wasm/src/lib.rs @@ -417,8 +417,6 @@ export type RoleRecipe = step: "subtle" | "base" | "bloom"; decision_profile: GlowDecisionProfileV1; } - | { kind: "pair-fill"; source: LadderSource } - | { kind: "pair-label"; source: LadderSource; fraction: number; floor: "aa-text" | "aa-ui" | "none" } | { kind: "alpha-analog"; of: LadderSource; alpha: number } | { kind: "material"; source: LadderSource; tone_light: number; tone_dark: number; floor: "aa-text" | "aa-ui" } | { kind: "zero" }; @@ -659,7 +657,7 @@ impl LabColors { /// /// Возвращает полный `ResolvedTheme`. Локальный `unresolved` остаётся /// типизированным исходом роли; ordinary `unreachable` отклоняет весь вызов - /// как `OutputConflictError`. Rejected/unsupported/internal также + /// как `OutputConflictError`. Rejected/internal также /// отклоняются атомарно: частичной темы или CSS не бывает. /// Ошибки границы — структурная форма `": "`, Rust-паника /// в JavaScript не разматывается. diff --git a/crates/labcolors-wasm/tests/data/labui.config.json b/crates/labcolors-wasm/tests/data/labui.config.json index 69fc367b..8c6b9b17 100644 --- a/crates/labcolors-wasm/tests/data/labui.config.json +++ b/crates/labcolors-wasm/tests/data/labui.config.json @@ -1138,76 +1138,8 @@ }, "position": "focus-ring" } - }, - { - "name": "badge-fill-brand", - "recipe": { - "kind": "pair-fill", - "source": { - "kind": "brand" - } - } - }, - { - "name": "badge-fill-danger", - "recipe": { - "kind": "pair-fill", - "source": { - "kind": "family", - "key": "red" - } - } - }, - { - "name": "badge-fill-warning", - "recipe": { - "kind": "pair-fill", - "source": { - "kind": "family", - "key": "orange" - } - } - }, - { - "name": "badge-fill-success", - "recipe": { - "kind": "pair-fill", - "source": { - "kind": "family", - "key": "green" - } - } - }, - { - "name": "badge-fill-info", - "recipe": { - "kind": "pair-fill", - "source": { - "kind": "family", - "key": "blue" - } - } - }, - { - "name": "badge-fill-static-dark", - "recipe": { - "kind": "pair-fill", - "source": { - "kind": "neutral", - "pick": "dark" - } - } - }, - { - "name": "badge-fill-static-light", - "recipe": { - "kind": "pair-fill", - "source": { - "kind": "neutral", - "pick": "light" - } - } } + ], "aliases": [ { @@ -1222,14 +1154,6 @@ "alias": "fx-skeleton-base", "target": "fill-quaternary" }, - { - "alias": "fill-accent", - "target": "badge-fill-brand" - }, - { - "alias": "fill-danger", - "target": "badge-fill-danger" - }, { "alias": "icon", "target": "label-tertiary" diff --git a/crates/labcolors-wasm/tests/data/labui.config.prod.json b/crates/labcolors-wasm/tests/data/labui.config.prod.json index 24c4ddea..e147e4ae 100644 --- a/crates/labcolors-wasm/tests/data/labui.config.prod.json +++ b/crates/labcolors-wasm/tests/data/labui.config.prod.json @@ -1109,76 +1109,8 @@ }, "position": "focus-ring" } - }, - { - "name": "badge-fill-brand", - "recipe": { - "kind": "pair-fill", - "source": { - "kind": "brand" - } - } - }, - { - "name": "badge-fill-danger", - "recipe": { - "kind": "pair-fill", - "source": { - "kind": "family", - "key": "red" - } - } - }, - { - "name": "badge-fill-warning", - "recipe": { - "kind": "pair-fill", - "source": { - "kind": "family", - "key": "orange" - } - } - }, - { - "name": "badge-fill-success", - "recipe": { - "kind": "pair-fill", - "source": { - "kind": "family", - "key": "green" - } - } - }, - { - "name": "badge-fill-info", - "recipe": { - "kind": "pair-fill", - "source": { - "kind": "family", - "key": "blue" - } - } - }, - { - "name": "badge-fill-static-dark", - "recipe": { - "kind": "pair-fill", - "source": { - "kind": "neutral", - "pick": "dark" - } - } - }, - { - "name": "badge-fill-static-light", - "recipe": { - "kind": "pair-fill", - "source": { - "kind": "neutral", - "pick": "light" - } - } } + ], "aliases": [ { @@ -1193,14 +1125,6 @@ "alias": "fx-skeleton-base", "target": "fill-quaternary" }, - { - "alias": "fill-accent", - "target": "badge-fill-brand" - }, - { - "alias": "fill-danger", - "target": "badge-fill-danger" - }, { "alias": "icon", "target": "label-tertiary" diff --git a/docs/whitepaper.md b/docs/whitepaper.md index 63eeedd3..6fb80095 100644 --- a/docs/whitepaper.md +++ b/docs/whitepaper.md @@ -79,8 +79,6 @@ fingerprint считается по распарсенной поддержив | `DecorativeLc` | декоративная контрастная величина без нормативного текстового смысла | | `Ladder` | якорь источника при alpha закрытой позиции | | `AlphaAnalog` | прозрачная форма объявленной solid-цели на локальном фоне | -| `PairFill` | frozen frontend: exact authored source как opaque Paint/Occurrence | -| `PairLabel` | finite label candidate domain против фактически emitted PairFill Surface | | `Material` | точечная двухслойная композиция с выведенной alpha | | `Glow` | точечные screen-слои с явным numerical profile | | `Zero` | явное отсутствие цветового значения | @@ -108,11 +106,10 @@ point-пути scratch принадлежит caller-у и размещён на compiler-а проверяется тестом. Compiler принадлежит proof-поверхности и отсутствует в production-артефакте. -`PairLabel` использует фактически emitted `PairFill` occurrence как derived -Surface. Frontend формирует конечный label candidate domain; общий joint engine -исполняет `fill → surfaceFrom → label`, строит полный hard-report, выбирает по -явному total order и повторяет fresh recheck. Имена клиентских токенов и -`FillPrimary` в physical lowering не участвуют. +Связь foreground с производной Surface задаёт client-owned Program. Core +лоуверит её в общие occurrence и joint-constraint примитивы, строит полный +hard-report и выполняет fresh recheck. Закрытых UI-рецептов для конкретного +компонента в физическом графе нет. Публичный API не принимает произвольный client-authored graph. `NamedRoleTable` остаётся boundary-представлением и не служит extension point для новых доменных diff --git a/packages/colors/bench/wasm.json b/packages/colors/bench/wasm.json index 812941ff..a6747cef 100644 --- a/packages/colors/bench/wasm.json +++ b/packages/colors/bench/wasm.json @@ -19,13 +19,13 @@ "command": "CARGO_ENCODED_RUSTFLAGS= wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked" }, "measurement": { - "source": "github-actions-run-29875865333", + "source": "github-actions-run-30214988060", "platform": "linux-x64", - "rawBytes": 421398 + "rawBytes": 376707 }, "policy": { - "maxRawBytes": 421398, - "basis": "c8d-revision-bound-point-support", + "maxRawBytes": 376707, + "basis": "program-content-identity", "gzip": "diagnostic-only" } } diff --git a/packages/colors/smoke.consumer.ts b/packages/colors/smoke.consumer.ts index 64f6c003..59e591f7 100644 --- a/packages/colors/smoke.consumer.ts +++ b/packages/colors/smoke.consumer.ts @@ -66,15 +66,6 @@ requireFailure({ code: "invalid_input", message: "x", }); -requireFailure({ - kind: "failure", - cssVar: "--lab-example", - // @ts-expect-error unsupported closes the whole resolve and cannot be role data. - category: "unsupported", - code: "gamut_unsupported", - message: "x", -}); - const admittedFailureCategory: FailureCategory = "unresolved"; void admittedFailureCategory; diff --git a/packages/colors/test/release-contract.test.mjs b/packages/colors/test/release-contract.test.mjs index b2ff182b..b8c22103 100644 --- a/packages/colors/test/release-contract.test.mjs +++ b/packages/colors/test/release-contract.test.mjs @@ -1012,7 +1012,6 @@ test("publish artifact validator executes and rejects identity or byte drift", ( "stable-cam16-glow-target-or-maximum-selection", "renderer-or-output-pipeline-equivalence", "spatial-glow-field", - "display-p3", ], artifacts: { tarball: { @@ -1228,10 +1227,9 @@ test("release checker rejects solve failure wire drift", () => { ["unreachable", "exceeds_range"], ["unresolved", "bounded_search_exhausted"], ["unreachable", "floor_unreachable"], - ["unsupported", "gamut_unsupported"], ["rejected", "invalid_input"], ]; - assert.equal(boundaryRows.length, 6, "public core failure dictionary changed"); + assert.equal(boundaryRows.length, 5, "public core failure dictionary changed"); for (const [category, code] of boundaryRows) { assert.doesNotThrow(() => validateSolveFailurePair(category, code)); const wrongCategory = category === "unreachable" ? "rejected" : "unreachable"; diff --git a/packages/colors/test/wasm-boundary.golden.json b/packages/colors/test/wasm-boundary.golden.json index 40188b7d..ae76d7fb 100644 --- a/packages/colors/test/wasm-boundary.golden.json +++ b/packages/colors/test/wasm-boundary.golden.json @@ -1 +1 @@ -{"_meta":{"purpose":"Байт-точный golden границы JS↔WASM: recheckContrast/_recheckContrastMulti и полный snapshot resolveTheme.vars.","regenerated":"2026-07-10","regen_reason":"ADR-0004: point-glow решается по конечным encoded-sRGB8 screen-state; alpha эмитируется кратчайшей decimal-записью без повторного округления.","drift_scope":"Относительно origin/main изменены ровно 24 листа resolveVars: только --lab-fx-glow-{brand,danger,warning,neutral}-alpha на 6 фонах. P1 (2026-07-21) дополнительно изменил 30 fill-leaves: fill-accent, fill-danger, badge-fill-{brand,danger,info} на 6 light-фонах — старый pair_fill притемняющий nudge удалён, эмиссия fill теперь identity-якорь бренда; recheck по-прежнему 0/2752 drift (замерено).","recheck_unchanged":"Секция recheck: 2752/2752 числовых значений, то есть 1376/1376 пар (lc, wcag), байт-идентичны origin/main; ни одно значение recheckContrast не изменено.","numeric_provenance":{"glow_alpha":"Старый fixed-48 bisection + 4-decimal alpha заменён перебором конечных sRGB8-state, внутренней alpha выбранного интервала и shortest-roundtrip binary64 CSS.","scope_check":"Фактический структурный diff вычислен по каждому листу resolveVars; 4 alpha-ключа × 6 записей = 24."},"wcag_floors_not_weakened":"Glow — декоративная роль без WCAG-пола. Отдельно подтверждено: все 1376 пар (2752 числовых значения) recheck неизменны, поэтому измеренный контраст ни одной проверяемой роли не ослаблен.","isolated_verification_required":"Регенерация принимается только после независимой проверки scope, recheck-инварианта и полного Rust/npm/browser gate.","partition_correction":{"regenerated":"2026-07-11","reason":"Коррекция канонической binary64 alpha по фактическому midpoint encoded-sRGB8 partition без дополнительного округления.","drift_scope":"Ровно 17 alpha leaves: light/#000000 danger и четыре Glow alpha на каждом из #808080, #3A3A3C, #007AFF, #FF3B30; остальные resolveVars-листья неизменны.","recheck_unchanged":"2752/2752 числовых значений, то есть 1376/1376 пар (lc, wcag), побитно неизменны.","numeric_provenance":{"halo":"#FF3B30","background":"#000000","state":"#030101","lower_bits":"0x3f85555555555555","upper_bits":"0x3f8c1c1c1c1c1c1c","midpoint_bits":"0x3f88b8b8b8b8b8b8","midpoint":"0.012071078431372548","previous_bits":"0x3f88b8b8b8b8b8b9","previous_relation":"1 ULP выше, внутри той же partition"},"exhaustive_proof":"65 536 channel partitions и seam: state/composite outputs неизменны.","performance":"Full-range → bracketed exact search: base 19.7→4.37 µs, bloom 74.1→16.2 µs, long 656→139 µs."},"history":{"previous_regenerated":"2026-07-10","previous_regen_reason":"Глава #64 (ADR-0003): ось читаемости активирована в домене Ys — реактивный recheck (measure_contrast/recheck_against) и solver-вывод считаются в Ys. recheck flat и resolveVars регенерированы против пересобранного движка; inputs (theme/bg/fgs) сохранены. semver-major по ADR.","previous_drift_scope":"resolveVars: exactly 24 leaves changed, all --lab-fx-glow-{brand,danger,warning,neutral}-core (glow-centre colour, background-independent → 4 distinct values). No other var moved. glow base/alpha, labels, borders, fills, backgrounds all byte-identical.","previous_recheck_unchanged":"Исторический regen также не менял recheck; прежний текст ошибочно называл 2752 числа парами. Фактический объём: 1376 пар (lc, wcag).","previous_numeric_provenance":{"glow-brand-core":"oklch(78.84894% 0.062240 265.472) -> oklch(78.76611% 0.108476 257.256): chroma rises to the blue gamut wall at the functional lightness (fixed-fraction under-saturated it); L ±0.08pp, hue shifts to the cusp.","glow-danger-core":"oklch(81.62208% 0.103545 27.533) -> oklch(81.77103% 0.102764 29.025): near-identical (red already sat near its wall).","glow-warning-core":"oklch(88.80612% 0.081204 68.866) -> oklch(88.88181% 0.082838 68.777): near-identical.","glow-neutral-core":"oklch(99.97226% 0.001314 106.423) -> oklch(100.00000% 0.000000 89.876): neutral hue is achromatic → gamut wall chroma is 0 (was fixed-fraction residue noise); exact achromatic source stays on the neutral ray."},"previous_wave1_scope":"resolveVars: 69 info-* leaves changed across 6 theme×bg entries. Zero non-info drift (scope-checked). recheck untouched."},"endpoint_recovery":{"regenerated":"2026-07-17","reason":"Acceptance is checked before low-contrast dead-zone and physical-endpoint rejection, so previously false-failed opaque border endpoints now resolve.","drift_scope":"Exactly 9 resolveVars leaves added: border-{brand,danger,info}-strong on light #808080, #007AFF and #FF3B30. No existing leaf changed or disappeared.","recheck_unchanged":"Every committed recheck value remains bit-identical; the parity test compares all 2752 numbers before resolveVars.","proof":"agnostic_gates::accepted_endpoint_recovery_keeps_previously_false_failed_borders_legal plus full Rust workspace and WASM boundary parity."}},"recheck":[{"theme":"light","bg":"#FFFFFF","fgs":["#F7F7FF","#F7F7FF","#0A0A10","#71717A","#94949E","#BDBDC7","#94949E","#0A0A10","#00030E","#0057BB","#037BFF","#78AFFF","#150000","#BE0005","#FF070E","#FF9A8C","#1B0D00","#A06300","#D28300","#FFAA3A","#000B01","#007C2D","#00A73F","#00D452","#020013","#4D00F9","#6D6EFF","#9FA9FF"],"flat":[0,1.0658201232723763,0,1.0658201232723763,104.65963437608701,19.74046779866239,70.1623092547118,4.832895561154151,53.96706418364319,3.004779237664955,33.34341874861831,1.8637664731545371,53.96706418364319,3.004779237664955,104.65963437608701,19.74046779866239,105.59876563358553,20.60103636973117,79.91552226990729,6.816302360996974,63.95198968935827,3.97647793086433,41.89234722606997,2.2400949113548063,105.32470288110206,20.35108757719671,78.97384099389187,6.579727828852003,63.90360859563009,3.970705427078051,37.83221395673913,2.048194921340976,103.87315757816087,19.018778265876662,70.62528347734393,4.90716956443272,53.97808064225952,3.0056474129326713,34.232350404909745,1.8982213760304238,104.97702200816887,20.032297013031588,73.2226464627747,5.355840815772706,56.123729301186145,3.182173571701684,36.54497093010938,1.9925549350335838,105.76444562516849,20.751302844974063,83.48658341134008,7.825697912025839,63.67798680778349,3.9439477616440737,40.86459333479343,2.1890112231357013]},{"theme":"light","bg":"#000000","fgs":["#010103","#010103","#F1F1FC","#B3B3BC","#909099","#65656D","#909099","#F1F1FC","#E6F0FF","#60A1FF","#0074F3","#004CA4","#FFF4F2","#FF8273","#F40009","#A80004","#FFF7EE","#F79C00","#C97E00","#8F5800","#C2FFC9","#00C84D","#00A03C","#006E26","#EEF0FF","#9099FF","#6662FF","#4300DB"],"flat":[0,1.0069471255994658,0,1.0069471255994658,-99.67218963360757,18.727928195985914,-63.80513707672789,10.090949031745104,-45.863656882792206,6.635143500688325,-26.33477890397734,3.635637824948841,-45.863656882792206,6.635143500688325,-99.67218963360757,18.727928195985914,-97.97941453819666,18.27263411531394,-53.55180732861014,8.039306941172763,-34.54692380627581,4.792369408161928,-17.418806999279397,2.56984376276454,-102.41236835704379,19.474434420712562,-56.92703132210305,8.692613650917824,-34.93266406694178,4.850563950189369,-18.308962222075785,2.6667193472488013,-103.5613239139708,19.790915044333452,-61.97487030298502,9.71021814570805,-44.901407836051874,6.467843223430967,-25.783665577412222,3.5638173443979557,-98.60837652742211,18.44127602271432,-60.30793382928855,9.368875012646658,-42.707239087904505,6.093571182387199,-23.370874926227668,3.2583602660985775,-98.9883689300502,18.54346384276384,-54.32153113615954,8.18635152260471,-34.304902287955215,4.756029101219059,-14.435475219756622,2.261552965854495]},{"theme":"light","bg":"#808080","fgs":["#7A7A82","#7A7A82","#010103","#18171E","#37363E","#5B5B64","#37363E","#010103","#000002","#00163A","#003272","#004494","#040000","#3A0000","#760002","#990003","#0B0400","#261300","#512F00","#845100","#000200","#001D05","#004114","#006322","#000002","#160059","#2B0097","#3B00C5"],"flat":[0,1.0777393702545288,0,1.0777393702545288,40.18994509287661,5.280525528202377,36.81932911616927,4.505560397043893,27.93898600302036,3.0217055248761144,13.845788611978202,1.7013528597992067,27.93898600302036,3.0217055248761144,40.18994509287661,5.280525528202377,40.3306726916817,5.312553092992316,36.89312592554248,4.521969910173767,28.754774555141132,3.1292028976206594,22.090354756391577,2.35851620111828,40.23106479600237,5.289901483963394,36.823385380889306,4.5064610721321205,27.786796188195932,3.002060282138028,21.02583740042693,2.2570951143110336,39.64148780057383,5.154352713354357,36.89828467875601,4.523118840482662,27.981450040156208,3.027209986332121,13.567318350507929,1.6836851754208233,40.150141663678234,5.271436537678366,36.85731353296706,4.514000513189092,27.852320260403722,3.0105025419148825,16.643886294769857,1.8937047658181707,40.3306726916817,5.312553092992316,36.84988795298528,4.512349526507044,30.916517468784825,3.4313626667845316,25.033605894158644,2.6690076358452903]},{"theme":"light","bg":"#3A3A3C","fgs":["#3E3E45","#3E3E45","#F2F2FD","#BBBBC5","#9E9EA7","#7A7A82","#9E9EA7","#F2F2FD","#E7F1FF","#73ADFF","#2985FF","#0060CB","#FFF5F4","#FF9687","#FF4336","#CD0006","#FFF8F0","#FFA62C","#DC8A00","#AC6B00","#C7FFCC","#00D251","#00B043","#008631","#EFF1FF","#9CA6FF","#767AFF","#532BFF"],"flat":[0,1.0699484563148034,0,1.0699484563148034,-89.06520303275792,10.211241554507897,-56.964055924386784,5.959497869408446,-41.60773958742316,4.2707825803649335,-24.25691832785775,2.6663016206717356,-41.60773958742316,4.2707825803649335,-89.06520303275792,10.211241554507897,-87.32090210121694,9.956824779623638,-48.0046310941281,4.945145596338778,-30.285195552724065,3.1849116126306156,-14.342034884792934,1.9106934427040745,-91.71639880150975,10.602849175762582,-51.97724928251108,5.385053682831725,-31.570923815646545,3.301015045785777,-14.82740446463819,1.94471301206827,-92.86717481243348,10.774663089384916,-55.64998948221256,5.805759082702303,-40.41406674606042,4.149700467279057,-23.7537806706175,2.6249801387527425,-87.99158651504085,10.054343074329001,-53.75183524673264,5.586666992924044,-38.313874917805634,3.940371955014153,-21.037647044419426,2.407284189061088,-88.33220875070053,10.104016013712561,-48.80525652513833,5.032519885671211,-30.915189659062865,3.2415642790980215,-11.323318014385267,1.7063261265170042]},{"theme":"light","bg":"#007AFF","fgs":["#797981","#797981","#020205","#16151C","#36353D","#72727A","#36353D","#020205","#000103","#001536","#003374","#0058BC","#070000","#360000","#730002","#BE0005","#0F0500","#241200","#4F2F00","#A16400","#000400","#001B05","#003F13","#007C2D","#000005","#140055","#3300AC","#4E00FA"],"flat":[0,1.0745907693903731,0,1.0745907693903731,39.47891404105317,5.158402202047827,36.63906407938327,4.513939148043301,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.47891404105317,5.158402202047827,39.65687576920386,5.198408498908002,36.59714090951085,4.504660010775197,27.83078380746869,3.029349552004155,13.31049770996219,1.6744770124926043,39.57933742278427,5.181007308853048,36.66186584356392,4.51899176883356,27.77510172821011,3.0221297438766705,12.721590912296357,1.6379804580418844,38.8564182220264,5.016999835671664,36.579572970042136,4.500775701626332,27.65113132417139,3.006116231224861,0,1.205042331050678,39.39108202991847,5.138573889881769,36.624715730830154,4.510761781161014,27.902773646365304,3.038708829709556,0,1.3333017445114326,39.737197591996,5.216381929144518,36.59037871155487,4.5031645842195545,27.68585404738298,3.010592990868115,17.038043883395044,1.933101474636693]},{"theme":"light","bg":"#FF3B30","fgs":["#8A8A92","#8A8A92","#030306","#212129","#3E3D45","#76767F","#3E3D45","#030306","#000104","#00204D","#003B83","#005CC4","#080000","#4C0001","#830002","#C70006","#0F0600","#331C00","#5B3600","#A76800","#000400","#002809","#004917","#00822F","#000005","#1F0075","#3A00C3","#511AFF"],"flat":[0,1.0356140935636031,0,1.0356140935636031,43.57584669970237,5.806877937534237,38.3004661979112,4.504747008392574,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.57584669970237,5.806877937534237,43.87966672163739,5.884401548408216,38.30767260595269,4.50632138120243,29.16726657041677,3.0308664461202253,15.823096179531001,1.7789458759332724,43.782795788734404,5.8597651770218455,38.402450836988166,4.527103297945568,29.016037258963053,3.011424325654164,14.9864556348382,1.7254955647131072,42.99821792375369,5.657954629703501,38.41622407485322,4.5301350068404425,28.93769973409828,3.0014083479801514,0,1.28054929135364,43.62397616937584,5.819206103341516,38.406807106466914,4.528061863682665,29.022563392947294,3.012260432085235,8.92751562582044,1.3982348229239339,43.970091731453365,5.907320242919889,38.28628402517786,4.501651045763603,28.99214635836292,3.0083657301901288,19.11088367642911,2.0125842767555064]},{"theme":"light","bg":"#101012","fgs":["#15151B","#15151B","#F2F2FC","#B4B4BE","#93939C","#696972","#93939C","#F2F2FC","#E6F0FF","#64A4FF","#0077F9","#0050AC","#FFF4F3","#FF8777","#F9000A","#B00005","#FFF7EF","#FA9E00","#CD8000","#955C00","#C3FFC9","#00CA4E","#00A33D","#007329","#EEF0FF","#939CFF","#6967FF","#4600E6"],"flat":[0,1.045340264415452,0,1.045340264415452,-98.5838326466521,17.08955684747884,-62.72698384194396,9.243246994613658,-45.67498009680545,6.239717532288448,-26.395025771311026,3.4974414103185016,-45.67498009680545,6.239717532288448,-98.5838326466521,17.08955684747884,-96.3221346483494,16.53807546575917,-53.23458352949761,7.5085332653696355,-34.299151794570456,4.531377660721796,-17.34040000814869,2.4827755446650244,-100.79897840003042,17.636700891614694,-56.73966900010428,8.131153426700068,-34.4661942600266,4.554628420782135,-18.26813498707333,2.5778416349364046,-101.94670279716274,17.922901715066683,-61.50032492399525,9.010497025774457,-44.48245067627942,6.049054371037485,-25.983086445749187,3.447217107352778,-97.04991927160123,16.714741751700544,-59.578454823729174,8.650894122604043,-42.30409033566848,5.707629679225331,-23.50941826359813,3.1535587190594883,-97.33108904020293,16.783196253636362,-53.9780650339559,7.638815670143083,-34.15649094558268,4.511565332868746,-14.178333309149869,2.174938899863266]},{"theme":"light","bg":"#F6F8FA","fgs":["#F0F0FA","#F0F0FA","#0A0910","#707079","#8F8F99","#BCBCC5","#8F8F99","#0A0910","#00030D","#0057B9","#0079FD","#74ADFF","#150000","#BC0005","#FD000A","#FF9788","#1B0D00","#9F6200","#CB7F00","#FFA730","#000B01","#007B2C","#00A63E","#00D251","#020013","#4C00F7","#6C6CFF","#9DA6FF"],"flat":[0,1.0634215248159875,0,1.0634215248159875,100.62201386979996,18.61882254505944,66.47667620763235,4.605948460093869,52.23653324893716,3.0084294463712395,29.780700684603257,1.7710808257265196,52.23653324893716,3.0084294463712395,100.62201386979996,18.61882254505944,101.48497819102282,19.361251289328656,75.9364891028185,6.438321694919453,60.636932359482174,3.8278153397206016,38.76699274290234,2.152541489746932,101.19913515757631,19.11640864166299,75.28055256994783,6.281220843840182,60.46094873358925,3.807428346232839,34.70184973776556,1.9658801332164664,99.74758985463511,17.864929125608796,66.9127104243937,4.673002507305443,52.13428535623209,3.0001216660302674,31.16352525448906,1.8227359456035261,100.85145428464311,18.816958763489637,69.48045983543288,5.0977335979175304,52.421867335088024,3.0235758695027473,33.32486172802245,1.9082022515525083,101.63887790164272,19.492343272893066,79.67393134344084,7.442688338927282,60.17031912503092,3.7741072518393395,37.967753096619255,2.113772626279229]},{"theme":"light","bg":"#1C1C1E","fgs":["#202028","#202028","#F1F1FD","#B6B6BF","#95959E","#6D6C75","#95959E","#F1F1FD","#E6F1FF","#67A5FF","#0079FD","#0053B2","#FFF5F3","#FF8A7A","#FD000A","#B60005","#FFF7EF","#FC9F00","#D08200","#9A5F00","#C4FFCA","#00CB4E","#00A53E","#00772A","#EEF1FF","#949EFF","#6B6BFF","#4900EE"],"flat":[0,1.0522076023272475,0,1.0522076023272475,-96.27140987076255,15.184146579953019,-61.97466067394124,8.452688508186561,-44.87094637123084,5.728792540137694,-25.977188885961883,3.283145241842075,-44.87094637123084,5.728792540137694,-96.27140987076255,15.184146579953019,-94.97224440690107,14.900628115594163,-51.99577035710509,6.808135431331698,-33.457001357615106,4.175350120738554,-16.755883343489252,2.3335896778079332,-99.44736446714371,15.886233758087014,-55.869159717403925,7.428487313030341,-33.63398343987836,4.1977071628099685,-17.709012021143003,2.4235530906296177,-100.15616825242344,16.04465607216319,-60.39258141645385,8.181935837052826,-43.77686148964125,5.57032618168592,-25.67861999448382,3.2497240951569792,-95.39392806862337,14.992417327878547,-58.246405974587134,7.820609198173194,-41.3596549106396,5.227261702394524,-23.16505433719768,2.9752806858720673,-95.97861011098902,15.120061263203354,-52.9765437482588,6.963016139185695,-33.57839540296167,4.190679013723035,-13.485138200601865,2.040069034387161]},{"theme":"light","bg":"#7C3AED","fgs":["#707078","#707078","#F4F4FE","#E3E4ED","#BDBDC6","#A6A6B0","#BDBDC6","#F4F4FE","#EBF3FF","#D5E6FF","#92BFFF","#4291FF","#FFF8F7","#FFDDD8","#FFA597","#FF6253","#FFFAF5","#FFE0BE","#FFAA39","#E79100","#D0FFD4","#7DFF93","#00DA54","#00B947","#F1F4FF","#DFE4FF","#AEB8FF","#8087FF"],"flat":[0,1.1608373681184396,0,1.1608373681184396,-73.98396246220521,5.215032461901156,-64.0643931275358,4.500912896664839,-41.75099800228171,3.055051474145459,-29.486682699107686,2.3616035737730514,-41.75099800228171,3.055051474145459,-73.98396246220521,5.215032461901156,-72.43421936609613,5.100687932363686,-64.08586870464498,4.502412953317652,-41.11294430990105,3.017135229848019,-18.888486914529935,1.8251241221961068,-76.90365537296871,5.4332059719499135,-64.06334225320066,4.50083949888721,-40.940708789235714,3.0069340578770496,-21.122834074579163,1.9331720160112165,-77.70409895709017,5.493642615376888,-64.34147182620212,4.520282197700913,-40.849183913847796,3.0015190917179395,-28.18413111501551,2.292444746213784,-72.9506558335481,5.138679113149092,-64.10668565953954,4.5038671985064,-41.2870784084647,3.027463512408998,-26.013778316259767,2.1791858289450103,-73.64084437140687,5.1896286230945075,-64.42349258712335,4.5260223420377566,-41.02780633656069,3.0120908713744514,-19.464187683477977,1.8526993919522168]},{"theme":"light","bg":"#123456","fgs":["#36363D","#36363D","#F2F2FD","#BEBEC8","#A2A2AC","#808089","#A2A2AC","#F2F2FD","#E8F1FF","#79B0FF","#368BFF","#0065D6","#FFF6F4","#FF9B8D","#FF5446","#D80007","#FFF8F1","#FFAB3F","#E18E00","#B47000","#C8FFCD","#00D552","#00B545","#008D34","#EFF1FF","#A0AAFF","#7B81FF","#573FFF"],"flat":[0,1.0609289970892934,0,1.0609289970892934,-91.39519035537737,11.4423466076867,-60.94367397154904,6.896920022598847,-46.020165423777584,5.026435795944436,-29.36949737122932,3.250019698594834,-46.020165423777584,5.026435795944436,-91.39519035537737,11.4423466076867,-89.77514075602531,11.177468536043195,-51.89752791553575,5.733156384550998,-34.996215684604906,3.8114980409485155,-18.92394323577027,2.320895932067837,-94.48648764812391,11.954651961610136,-56.02375667309079,6.252768618847473,-36.81873931987068,4.002056146272223,-19.591172380553846,2.375625103310219,-95.24016771148194,12.080915286992608,-59.85145182180837,6.751641124414219,-44.82390104707481,4.887508677146329,-28.683175933367217,3.184369254481423,-90.45906258458693,11.288985892577246,-57.499124974387314,6.443154991463433,-42.83495757582209,4.660276446547384,-26.099142978345885,2.9428672820648862,-90.66219607331998,11.322193559065964,-52.9772743130274,5.867283737103295,-35.76790053375578,3.8916727925995533,-16.863337707955058,2.1560372721390864]},{"theme":"light","bg":"#ABCDEF","fgs":["#C2C2CB","#C2C2CB","#06060C","#55545D","#706F78","#A1A1AA","#706F78","#06060C","#000209","#00479B","#0065D5","#3289FF","#100000","#9F0004","#D70007","#FF4F41","#170A00","#7A4A00","#9F6200","#DF8C00","#000801","#006322","#00822F","#00B344","#01000D","#3E00CE","#573EFF","#797EFF"],"flat":[0,1.0705446339746656,0,1.0705446339746656,75.64582413051765,12.232204997602986,52.76667497081496,4.519371986307913,41.423484798334584,3.0020546798073307,18.127484103049046,1.5512322351556214,41.423484798334584,3.0020546798073307,75.64582413051765,12.232204997602986,76.22533906008685,12.552007092013115,56.99670466281028,5.352680994694784,44.43662180236468,3.3253647200326193,29.122033451491582,2.0641033245604916,76.01516726706762,12.436514137733148,55.97147975339624,5.133613537650522,43.91010121645754,3.2654000659595037,27.461071919951625,1.9716236837030474,74.81611736383091,11.770648316029503,52.816193854903005,4.528105409994289,41.51373908005361,3.011071841176318,19.679891349505557,1.6112041351931181,75.72958089661125,12.278654436624837,52.80881394476793,4.526802423347816,41.42233250009274,3.001939806510638,21.536768417458934,1.687644517337887,76.3513076320574,12.62087594492386,59.99704797613187,6.063965240585196,46.62694808670478,3.592268428661898,28.6381619838115,2.036506690369533]},{"theme":"light","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"dark","bg":"#FFFFFF","fgs":["#E7E7F1","#D9D9E2","#0D0D12","#707079","#94949D","#BCBCC6","#94949D","#0D0D12","#000414","#0053C4","#1F78FF","#79ACFF","#190000","#BC0016","#FD0021","#FF978D","#1D0B00","#A65B00","#DA7A00","#FFA658","#000E02","#007C2A","#00A63C","#00D24E","#04001A","#5900F2","#7867FF","#A4A4FF"],"flat":[10.564452802176923,1.2281904700369508,18.372353597432387,1.402460683906872,104.27090588423802,19.38311547953439,70.6022439311581,4.903434648448188,54.00450766949271,3.0077315832596954,33.86624309585679,1.8839141891835494,54.00450766949271,3.0077315832596954,104.27090588423802,19.38311547953439,105.42046624663428,20.43859328774187,80.31672424485741,6.9206271592755195,64.53598724808649,4.047139863973963,42.92136358171458,2.2930773168482466,105.12312344447312,20.1664284240786,79.32640956847692,6.666965706057784,64.47466861031803,4.0396342482899685,38.694434835096864,2.0867987099188317,103.94997559222902,19.08898290736351,71.78028267942507,5.099770465607366,55.19920557284124,3.104259347966041,35.17783363962355,1.9359471784913758,104.66191026229393,19.742561272328775,73.25578885512941,5.361938351796974,56.57179613789485,3.2209892436744982,37.49012931672191,2.03317973864909,105.58294145619523,20.58664787402852,83.26627791326895,7.757864847967472,64.57504887916579,4.051931818616954,42.090577540190225,2.2501559682241092]},{"theme":"dark","bg":"#000000","fgs":["#030205","#08080D","#F0F0FA","#B1B1BB","#8F8F98","#64646D","#8F8F98","#F0F0FA","#E3EEFF","#639EFF","#006DFE","#0048AD","#FFF4F3","#FF7F74","#F1001F","#A80012","#FFF7F1","#FF921B","#D17400","#955100","#B8FFBE","#00C649","#009F39","#006E25","#EDEEFF","#9794FF","#725BFF","#4E00D6"],"flat":[0,1.0147465549687367,0,1.0508696650285665,-99.01083964835996,18.549513785944853,-62.774763175831374,9.875849762659984,-45.37711439886134,6.5503119047070335,-25.95111170367181,3.5855589616463144,-45.37711439886134,6.5503119047070335,-99.01083964835996,18.549513785944853,-96.73192161780433,17.93999294470544,-52.63286661912575,7.865273702162647,-33.381528921151094,4.618607106385301,-17.0501576858861,2.5303719690800346,-102.4562582898777,19.486486384763445,-56.148341062851905,8.53993960492931,-34.330992536827104,4.759940239487347,-18.37234103122508,2.6737006902768288,-103.68997976483836,19.82648045930809,-60.35961607275431,9.379380118591195,-43.39157032733722,6.209218370112115,-24.93643207012505,3.4548840566407524,-97.27916136850891,18.085610527348415,-59.34389647192141,9.173841233004914,-42.25887199411116,6.018336366435839,-23.360562325895305,3.2570864547421428,-97.9872053947921,18.274719246988955,-53.323021296304695,7.99582368531923,-33.63351308594995,4.6559156780457185,-14.771084781300106,2.2949481607054216]},{"theme":"dark","bg":"#808080","fgs":["#707078","#66666E","#030305","#18171D","#37363E","#5D5D64","#37363E","#030305","#000104","#00153F","#00317C","#00429F","#070000","#3A0002","#750009","#9B0010","#0C0300","#291200","#562D00","#8B4B00","#000300","#001D05","#004112","#006521","#010005","#1A0057","#350098","#4700C4"],"flat":[0,1.24296889833444,9.274501031402123,1.4407196981948422,39.91548097777799,5.217615096019717,36.836632862345205,4.5094036578339445,27.93898600302036,3.0217055248761144,13.086665786299626,1.6537770693375544,27.93898600302036,3.0217055248761144,39.91548097777799,5.217615096019717,40.20955594591163,5.284998875760269,36.83828085440759,4.509769828130583,28.330761142327287,3.0728704517581886,21.819504217645104,2.3321848000622474,40.142120786515875,5.269603471071473,36.80831529061993,4.503115584720142,27.930310922781615,3.0205822411426944,20.559717681283935,2.214401964672886,39.70511745091868,5.169077378914306,36.80795164205375,4.503034882134433,27.78871707797606,3.002307432697674,14.025581970431295,1.7128949100718707,40.051433392362526,5.248844071123953,36.85731353296706,4.514000513189092,27.870384117529113,3.0128341396537346,15.95953035602916,1.844055168824021,40.26997047853993,5.298759395894901,36.795231110735635,4.500212645278562,30.09285126953815,3.3133648365325183,24.185949678819867,2.5748913454036786]},{"theme":"dark","bg":"#3A3A3C","fgs":["#46464E","#4F4F57","#F1F1FA","#BABAC4","#9D9DA6","#797982","#9D9DA6","#F1F1FA","#E4EFFF","#75AAFF","#3482FF","#005AD5","#FFF6F4","#FF9389","#FF3C3B","#CC0019","#FFF8F2","#FFA350","#E58000","#B36300","#BCFFC2","#00D04D","#00AF3F","#00862E","#EEEFFF","#A2A1FF","#7F74FF","#601DFF"],"flat":[0,1.2140718035663858,0,1.3988859584097761,-88.35603271482154,10.107493939375463,-56.41682578047019,5.8952705955406675,-41.10063170437542,4.219157833086703,-23.840055313660137,2.6320437618590202,-41.10063170437542,4.219157833086703,-88.35603271482154,10.107493939375463,-86.07057705487603,9.776047393674569,-47.005198509651805,4.836992692850337,-29.617188814986996,3.125340410912816,-13.465221764046625,1.8500449052652255,-92.15650032550448,10.66842694645063,-51.122226750198095,5.289032001141636,-30.69564720892069,3.221769980092269,-14.697188723997265,1.9355550836437716,-92.95341233620333,10.787583067801071,-55.01595924446716,5.732183607436098,-39.01368610071904,4.00959458617392,-22.717127042375882,2.5408179478715844,-86.56463892830851,9.847321829129962,-52.766100540364825,5.474290510931042,-37.83315401910713,3.893128667535661,-21.00389596511428,2.404636707585164,-87.32881842479753,9.957973594755346,-47.6452882768299,4.906141960838558,-29.99057738527554,3.1585747205458907,-10.990631160230787,1.6845762464445797]},{"theme":"dark","bg":"#007AFF","fgs":["#6F6F77","#65656D","#040408","#16151B","#36353D","#72727A","#36353D","#040408","#000107","#00133B","#00317C","#0054C7","#0B0000","#360002","#730009","#BF0016","#100500","#261000","#542C00","#A85D00","#000501","#001B04","#004012","#007D2B","#010009","#180052","#3B00A6","#5A00F5"],"flat":[0,1.2399763057879079,9.137153276370956,1.4379357071162235,39.199487305374596,5.095159290353486,36.655643103420836,4.517612479062309,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.199487305374596,5.095159290353486,39.616537893860624,5.189361828546554,36.671589094740156,4.521147554384925,27.767977778595686,3.0212072610618272,13.560368041515986,1.6902988819780091,39.46146930028483,5.1544680971312,36.646426655727396,4.515570174173857,27.733751845040576,3.016779185756929,12.426352071081123,1.6200937148513173,38.82022467282525,5.008731246970903,36.66757146424993,4.520256699490143,27.6755983348751,3.009270049666561,0,1.2356833222737893,39.28492308543262,5.114543979353608,36.632422880871374,4.512468289727634,27.610461383700073,3.00088106913278,0,1.3173837917708568,39.666598999480925,5.200587158978297,36.5946359240296,4.504106003407952,27.684199915070796,3.0103795763779906,16.582944142840912,1.898781610023956]},{"theme":"dark","bg":"#FF3B30","fgs":["#7F7F87","#74747D","#040408","#212128","#3E3D45","#76767F","#3E3D45","#040408","#000107","#001E53","#00398D","#0058CF","#0C0000","#4C0004","#83000C","#C60018","#110500","#371A00","#603300","#AE6000","#000601","#002808","#004916","#00822D","#01000A","#250070","#4400BC","#5E01FF"],"flat":[0,1.119564506502328,0,1.304916965157494,43.43238144483196,5.770041002680692,38.322111022898454,4.5094781825179275,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.43238144483196,5.770041002680692,43.84943203331798,5.876721182230661,38.357196991852035,4.517162998132957,28.969046969390032,3.0054117916551304,16.078397205708793,1.7957132708136676,43.66281217184597,5.829141814712315,38.38176722933373,4.522556047220382,28.96308732518828,3.004650205577566,15.121014183458136,1.7339400568635355,43.01534021323247,5.66238735865634,38.36369120360285,4.518587529333456,28.956857085057415,3.003854272405285,0,1.32359073643957,43.42248485866928,5.7674953973596095,38.41370496023468,4.529580289565808,29.031977011275746,3.0134669332394415,8.949958172317846,1.3992774406692907,43.88937786124038,5.8868666342953615,38.32550423955951,4.510220548286027,28.94413025668513,3.002229117722787,19.264807559936084,2.024522036885816]},{"theme":"dark","bg":"#101012","fgs":["#1C1C22","#24242A","#F0F0FA","#B3B3BD","#92929B","#686871","#92929B","#F0F0FA","#E3EEFF","#66A0FF","#0E71FF","#004CB5","#FFF5F3","#FF8378","#F60020","#AF0013","#FFF7F1","#FF962A","#D57700","#9B5500","#B9FFBF","#00C849","#00A23A","#007226","#EDEEFF","#9997FF","#7460FF","#5200DF"],"flat":[0,1.1210017368042908,0,1.2316217101091165,-97.35355975851269,16.788671896406843,-62.18912907483276,9.1408846014785,-45.183954583215964,6.160892481462449,-25.971291980892453,3.445784610130369,-45.183954583215964,6.160892481462449,-97.35355975851269,16.788671896406843,-95.07464172795706,16.23701078357789,-51.883866319617056,7.27432706128743,-33.10437599843017,4.366728841176053,-16.962076325331164,2.444617555127284,-101.23789901188303,17.745933910216884,-55.660852739640006,7.937252290960222,-33.86013630190819,4.470540437936136,-18.127983335051407,2.5633448249431416,-102.03269987499108,17.94442049171427,-60.01549050114797,8.732123022916136,-43.24478677485694,5.853976070416037,-25.11442835418257,3.342537791539289,-95.74776769254933,16.399183837627458,-58.596724506079966,8.469603286006345,-41.852118066322895,5.637910373772879,-23.122294138224145,3.108849028833141,-96.32992550494482,16.539962663017093,-52.86610846158114,7.444323350911882,-33.336065162067605,4.398429825394501,-14.383943910910213,2.194178799785328]},{"theme":"dark","bg":"#F6F8FA","fgs":["#E0E0EA","#D2D2DC","#0D0D12","#717179","#8F8F98","#BCBCC6","#8F8F98","#0D0D12","#000414","#0053C4","#1F78FF","#79ACFF","#190000","#BC0016","#FD0021","#FF978D","#1D0B00","#A65B00","#D67800","#FFA658","#000E02","#007C2A","#00A63C","#00D24E","#04001A","#5900F2","#7867FF","#A4A4FF"],"flat":[10.34292311627503,1.2313464424988367,18.024461551245693,1.4097592283337514,100.14533816071224,18.207162386274927,66.07133001527934,4.544848448940367,52.273633182943,3.011452358576948,29.740675372331037,1.769619110018133,52.273633182943,3.011452358576948,100.14533816071224,18.207162386274927,101.29489852310851,19.198605473400605,76.19115652133168,6.500760037097026,60.41041952456072,3.801604173546957,38.795795858188825,2.153958743950203,100.99755572094737,18.94295256384705,75.20084184495119,6.262487955668726,60.349100886792264,3.7945539156195487,34.56886711157111,1.9601948416949677,99.82440786870325,17.93087452582946,67.65471495589931,4.7903727911067815,52.29898260017819,3.0135204434714913,31.0522659160978,1.8184953129572932,100.53634253876818,18.544800983402446,69.13022113160365,5.036635229225181,52.44622841436909,3.025575236651027,33.364561593196164,1.9098288766402804,101.45737373266948,19.337677744697757,79.14071018974319,7.287203401685083,60.44948115564005,3.8061054053754897,37.96500981666448,2.1136413881018767]},{"theme":"dark","bg":"#1C1C1E","fgs":["#28282E","#303036","#F0F1FA","#B5B5BE","#94949D","#6C6C74","#94949D","#F0F1FA","#E3EEFF","#69A2FF","#1975FF","#004FBB","#FFF5F3","#FF877C","#FA0021","#B50014","#FFF7F1","#FF9833","#D87900","#A05800","#B9FFC0","#00CA4A","#00A43B","#007628","#EDEEFF","#9B99FF","#7664FF","#5500E7"],"flat":[0,1.1612081374032734,0,1.2977584057597347,-96.00102363023473,15.124963081358866,-61.434200333395395,8.359778481952823,-44.37695589123639,5.656999216809453,-25.852207004995158,3.269134017520218,-44.37695589123639,5.656999216809453,-96.00102363023473,15.124963081358866,-93.28410718321774,14.535439506623602,-51.011442228336165,6.654206660130301,-32.65955270222981,4.075313081844305,-16.370466566638967,2.297770413224217,-99.44736446714371,15.886233758087014,-55.07262883280597,7.299026812522581,-33.02825162884881,4.121422426501604,-17.569909707299868,2.4103015359642868,-100.24216533025177,16.063919770413232,-58.9183675206033,7.932997007393653,-42.52780974275247,5.391835753982905,-24.79898612463087,3.1522681596337323,-93.99035989085725,14.687777164248876,-57.733604751916815,7.735299184328377,-40.905085629416114,5.1638402024196015,-22.782862127638978,2.93464773079297,-94.53939096020551,14.80664328764592,-51.95162400089297,6.801199303451522,-32.707141977036414,4.081250761977603,-13.69412377461175,2.0581050526656677]},{"theme":"dark","bg":"#7C3AED","fgs":["#7A7A83","#85858D","#F2F2FD","#E3E4ED","#BBBCC5","#A6A6AF","#BBBCC5","#F2F2FD","#E8F1FF","#D5E6FF","#95BEFF","#498FFF","#FFF9F8","#FFDDD8","#FFA59B","#FF5F57","#FFFAF6","#FFDFC5","#FFA95E","#F18700","#C6FFCB","#7DFF91","#00DA51","#00B943","#F0F1FF","#E1E3FF","#B4B6FF","#8982FF"],"flat":[-8.022975593833932,1.3402714370624624,-13.06659651622355,1.5567826841029107,-72.79588351600884,5.127281558149197,-64.0643931275358,4.500912896664839,-41.08533982136385,3.0154992993085026,-29.446612137967147,2.3594628663401775,-41.08533982136385,3.0154992993085026,-72.79588351600884,5.127281558149197,-71.17583391665679,5.008590480308264,-64.08586870464498,4.502412953317652,-40.969901639859465,3.0086620775128243,-18.492326685553408,1.8062558438836862,-77.39176152066469,5.470028106981068,-64.06334225320066,4.50083949888721,-41.06186552000793,3.0141084284175577,-20.58109958398818,1.9067218663179524,-77.74802214310357,5.496966722090798,-64.16882972322054,4.508209630901898,-40.97688137217631,3.00907529195687,-26.93048349706687,2.2267208299060117,-71.5855645088526,5.038503428518723,-64.0583201718401,4.500488740880354,-41.24499881268402,3.0249663274797034,-25.961518214862377,2.176489332337808,-72.19208007215988,5.082914152992658,-64.24580556994597,4.513590807561596,-40.91738125342747,3.005553523356943,-18.74578210381434,1.818317299241165]},{"theme":"dark","bg":"#123456","fgs":["#3E3E45","#47474E","#F0F0FC","#BDBDC6","#A1A1AA","#7F7F88","#A1A1AA","#F0F0FC","#E5EFFF","#7AADFF","#3D87FF","#005FDF","#FFF6F5","#FF988E","#FF4D48","#D5001A","#FFF8F3","#FFA75A","#EA8300","#BA6700","#BDFFC3","#00D34E","#00B341","#008C31","#EEEFFF","#A5A5FF","#837AFF","#6433FF"],"flat":[0,1.1989453999458657,0,1.3802668927515667,-90.21171279539291,11.248602775503162,-60.350304841650235,6.81783465078813,-45.467647083484636,4.962060105853706,-28.90763677945104,3.2057711392885686,-45.467647083484636,4.962060105853706,-90.21171279539291,11.248602775503162,-88.52359832003891,10.974551054175622,-50.82334067294272,5.601029591424501,-33.90019185783247,3.6989242542195706,-17.94937633357589,2.2421371448335408,-94.53061684959366,11.962030249162733,-55.11693338695639,6.136943021617957,-35.65662848796355,3.880065704029081,-19.010500723408704,2.3279588656978594,-95.32685818617963,12.095472485308715,-58.832789738116276,6.617312123916825,-43.099834270009964,4.690265818580579,-27.202021451205145,3.0448373808729228,-89.02366089613628,11.055448870118658,-56.507119659393844,6.314879036055816,-41.91393002442956,4.556652813501584,-25.675484951698337,2.9041378624661793,-89.65880574741696,11.158543725868578,-51.69548126800453,5.708204299589472,-34.40239467659594,3.750316223165759,-16.078998268001875,2.0949612264666513]},{"theme":"dark","bg":"#ABCDEF","fgs":["#B4B4BE","#A8A8B1","#09090F","#55545C","#706F78","#A1A1AB","#706F78","#09090F","#00020F","#0045A5","#0060E2","#3F88FF","#140000","#A10011","#D7001B","#FF4F49","#190900","#814600","#A85D00","#EA8300","#000A01","#006320","#00822D","#00B341","#030014","#4A00CC","#6537FF","#847BFF"],"flat":[8.489900665165298,1.2445786124510558,14.633674840112363,1.428005718026211,75.26124671135368,12.018395958758797,52.798813706484495,4.5250375920814765,41.423484798334584,3.0020546798073307,18.08917030758312,1.5497949541740348,41.423484798334584,3.0020546798073307,75.26124671135368,12.018395958758797,76.15878035868923,12.51550617725489,56.792209524954714,5.308053794065892,44.82231091099692,3.370285744389143,29.010809014184062,2.057710850183084,75.84645644595844,12.343365988484052,55.500674792931,5.036843578382145,43.81499067450534,3.25473208522667,27.348178994525146,1.9655632934834584,74.80511978941351,11.764533211175078,52.793708756474935,4.52413702940673,41.44647230556496,3.0043476386355414,20.422200521634597,1.6411239547419576,75.54552640875083,12.176514522007174,52.82929323710673,4.530419449281277,41.44477504659015,3.004178254346042,21.57431352051131,1.6892460110571705,76.19495675605003,12.535354496536943,59.24767070343283,5.876074797996776,46.58341111523108,3.5866744685580465,28.54022915013225,2.030988108287992]},{"theme":"dark","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"light-ic","bg":"#FFFFFF","fgs":["#F7F7FF","#F7F7FF","#0A0A10","#71717A","#94949E","#BDBDC7","#94949E","#0A0A10","#000110","#0042E2","#3B78FF","#85AEFF","#150000","#BD0011","#FF001B","#FF998D","#1A0500","#B04A00","#E96400","#FFA174","#000B01","#007C2D","#00A73F","#00D452","#00040B","#00639C","#0089D6","#4DB3FF"],"flat":[0,1.0658201232723763,0,1.0658201232723763,104.65963437608701,19.74046779866239,70.1623092547118,4.832895561154151,53.96706418364319,3.004779237664955,33.34341874861831,1.8637664731545371,53.96706418364319,3.004779237664955,104.65963437608701,19.74046779866239,105.76809306682351,20.754602558566376,81.70242596755779,7.298044196933449,63.72862579130605,3.9499301799077116,41.321899652634976,2.211520376313395,105.32470288110206,20.35108757719671,79.14032751842986,6.6207260543602775,64.03544238362204,3.986463961054262,38.10332212672181,2.0602150617562085,104.62285904737456,19.706640245810163,73.93672186564804,5.489431142391079,57.895853978119064,3.3399091341000195,36.26437665881752,1.9807324696579875,104.97702200816887,20.032297013031588,73.2226464627747,5.355840815772706,56.123729301186145,3.182173571701684,36.54497093010938,1.9925549350335838,105.53601527934143,20.543945015271454,78.36297683006073,6.432254688155311,62.2832355663668,3.784280156272109,42.68471757482252,2.28072562681704]},{"theme":"light-ic","bg":"#000000","fgs":["#010103","#010103","#F1F1FC","#B3B3BC","#909099","#65656D","#909099","#F1F1FC","#E8F0FF","#709FFF","#2D6DFF","#0038C6","#FFF4F3","#FF8275","#F30019","#A7000E","#FFF6F1","#FF8C52","#DF5F00","#9D4100","#C2FFC9","#00C84D","#009F3C","#006E27","#DFF0FF","#10A5FF","#0082CB","#005689"],"flat":[0,1.0069471255994658,0,1.0069471255994658,-99.67218963360757,18.727928195985914,-63.80513707672789,10.090949031745104,-45.863656882792206,6.635143500688325,-26.33477890397734,3.635637824948841,-45.863656882792206,6.635143500688325,-99.67218963360757,18.727928195985914,-98.22794037003116,18.339196272629252,-53.8293996799751,8.092203727319841,-34.21813658812841,4.7430333536498335,-15.622162303180753,2.381112763636304,-102.4562582898777,19.486486384763445,-56.97432424245902,8.701923574139988,-34.76332175899368,4.824974728357804,-18.151652148372637,2.6494391947645526,-103.24826961310278,19.70448161329645,-59.10147589327934,9.125072654774526,-40.787696351505694,5.7744786453226835,-22.81212855995232,3.1897383862050708,-98.60837652742211,18.44127602271432,-60.30793382928855,9.368875012646658,-42.29572232514961,6.0245037012352585,-23.381466511664765,3.259668821930569,-97.12909045511768,18.04563033112125,-52.54162933208091,7.848085216280267,-36.273216344700636,5.055412083856902,-18.54110013749204,2.69234396770907]},{"theme":"light-ic","bg":"#808080","fgs":["#7A7A82","#7A7A82","#010103","#18171E","#37363E","#5B5B64","#37363E","#010103","#000002","#00104D","#002488","#0032B2","#040000","#3A0002","#750007","#98000B","#080100","#2E0E00","#602500","#8F3B00","#000200","#001D05","#004114","#006322","#000102","#00192E","#003A5F","#004D7C"],"flat":[0,1.0777393702545288,0,1.0777393702545288,40.18994509287661,5.280525528202377,36.81932911616927,4.505560397043893,27.93898600302036,3.0217055248761144,13.845788611978202,1.7013528597992067,27.93898600302036,3.0217055248761144,40.18994509287661,5.280525528202377,40.3306726916817,5.312553092992316,36.799792142283025,4.501224406456354,30.04772679653348,3.3069997994324063,23.797727835383583,2.533076638038498,40.23106479600237,5.289901483963394,36.80831529061993,4.503115584720142,27.941932493369514,3.022087143525339,21.190215752118235,2.2723970292907865,40.01441400964806,5.240352767492206,36.83929336929951,4.509994812829007,27.908315541084118,3.0177360720288915,16.77490328531677,1.9034128352995776,40.150141663678234,5.271436537678366,36.85731353296706,4.514000513189092,27.852320260403722,3.0105025419148825,16.643886294769857,1.8937047658181707,40.22977489962569,5.289607582090012,36.84755783504637,4.511831555904809,27.796281383025352,3.0032808914145437,21.058698283639867,2.260143784790762]},{"theme":"light-ic","bg":"#3A3A3C","fgs":["#3E3E45","#3E3E45","#F2F2FD","#BBBBC5","#9E9EA7","#7A7A82","#9E9EA7","#F2F2FD","#EAF1FF","#81ABFF","#4982FF","#0048F5","#FFF5F4","#FF9589","#FF403B","#CC0013","#FFF7F3","#FF9E6E","#F46900","#BD5000","#C6FFCC","#00D251","#00B043","#008631","#E1F1FF","#45B0FF","#0090E1","#006CA9"],"flat":[0,1.0699484563148034,0,1.0699484563148034,-89.06520303275792,10.211241554507897,-56.964055924386784,5.959497869408446,-41.60773958742316,4.2707825803649335,-24.25691832785775,2.6663016206717356,-41.60773958742316,4.2707825803649335,-89.06520303275792,10.211241554507897,-87.69551010135778,10.011246151186945,-48.263751486481056,4.97335266465752,-30.44825366943375,3.199530942583609,-12.043546933756078,1.753943193668956,-91.71639880150975,10.602849175762582,-51.72679347557047,5.3568510977778265,-31.21621892784593,3.268795397886287,-14.661523244642579,1.9330507439017046,-92.55409442667087,10.727809933310253,-53.89547122979923,5.603122076267749,-35.95806640240085,3.7112655792375078,-20.04367187639212,2.329915503818876,-87.89039906251095,10.039605852107476,-53.75183524673264,5.586666992924044,-38.313874917805634,3.940371955014153,-21.037647044419426,2.407284189061088,-86.58838125370931,9.850752189993974,-46.78872683576137,4.81370216625391,-31.383244337404896,3.2839492580871354,-15.736559790538532,2.009284883552937]},{"theme":"light-ic","bg":"#007AFF","fgs":["#797981","#797981","#020205","#16151C","#36353D","#72727A","#36353D","#020205","#000004","#000E48","#002895","#0042E3","#070000","#360001","#730006","#BE0011","#0C0200","#2B0D00","#5F2400","#B04A00","#000400","#001B05","#003F13","#007C2D","#000103","#00172B","#00395C","#00639D"],"flat":[0,1.0745907693903731,0,1.0745907693903731,39.47891404105317,5.158402202047827,36.63906407938327,4.513939148043301,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.47891404105317,5.158402202047827,39.74741500641279,5.218664231374835,36.670023859840576,4.5208004701163915,27.672031623266886,3.008810094200282,15.351341296852958,1.8098737923818007,39.57933742278427,5.181007308853048,36.654144364688555,4.517280323585742,27.751464091733354,3.019069960427185,12.671992881770905,1.6349566993987985,39.23716411822194,5.10371254671437,36.601471319299684,4.505617849545331,27.652865692456807,3.006339685224649,7.684471784052528,1.3665581876464832,39.39108202991847,5.138573889881769,36.624715730830154,4.510761781161014,27.902773646365304,3.038708829709556,0,1.3333017445114326,39.65687576920386,5.198408498908002,36.65150522712613,4.5166954713046765,27.64129393913477,3.0048491045091428,12.054359140378313,1.5979376774972005]},{"theme":"light-ic","bg":"#FF3B30","fgs":["#8A8A92","#8A8A92","#030306","#212129","#3E3D45","#76767F","#3E3D45","#030306","#000004","#001864","#002FA8","#0045ED","#080000","#4C0003","#830008","#C60012","#0D0200","#3E1500","#6C2A00","#B84D00","#000401","#002809","#004917","#00822F","#000103","#00233D","#00416A","#0068A4"],"flat":[0,1.0356140935636031,0,1.0356140935636031,43.57584669970237,5.806877937534237,38.3004661979112,4.504747008392574,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.57584669970237,5.806877937534237,43.98030914587015,5.909904848562026,38.293478372508915,4.503221171740978,28.94317626341717,3.002107337321695,17.95394033246701,1.9258322867444517,43.782795788734404,5.8597651770218455,38.38866051296957,4.524070782124866,28.984608537472,3.0074014381148784,15.156957289606964,1.7362054736619685,43.437607341847624,5.771384990646562,38.33399448678559,4.512078832150148,28.981628167432756,3.007020263830111,9.892674202630067,1.4441010369850493,43.614187977218386,5.816700203526642,38.406807106466914,4.528061863682665,29.022563392947294,3.012260432085235,8.92751562582044,1.3982348229239339,43.88976990866122,5.886966133555759,38.401524303038336,4.526899458852529,29.03631728859714,3.0140233900654434,14.306925700285792,1.683714708917422]},{"theme":"light-ic","bg":"#101012","fgs":["#15151B","#15151B","#F2F2FC","#B4B4BE","#93939C","#696972","#93939C","#F2F2FC","#E9F0FF","#74A2FF","#3371FF","#003CD0","#FFF4F3","#FF8679","#F8001A","#AF000F","#FFF6F2","#FF9058","#E36100","#A44400","#C3FFC9","#00CA4E","#00A33D","#007329","#E0F0FF","#21A8FF","#0085D0","#005B90"],"flat":[0,1.045340264415452,0,1.045340264415452,-98.5838326466521,17.08955684747884,-62.72698384194396,9.243246994613658,-45.67498009680545,6.239717532288448,-26.395025771311026,3.4974414103185016,-45.67498009680545,6.239717532288448,-98.5838326466521,17.08955684747884,-96.69585202312288,16.62869936569993,-53.53835262595417,7.561646720391368,-33.90853669724825,4.477228400656394,-15.643424374972522,2.314414627530853,-100.79897840003042,17.636700891614694,-56.5097869254783,8.089667628698802,-34.29633291417122,4.530985783372177,-18.10601775891745,2.56107712884258,-101.63443561648342,17.844851007069916,-58.71630179001587,8.4915976550775,-40.34850051574898,5.4087697654311055,-22.889197327723014,3.0820931228283226,-97.04991927160123,16.714741751700544,-59.578454823729174,8.650894122604043,-42.30409033566848,5.707629679225331,-23.50941826359813,3.1535587190594883,-95.5914257275877,16.361459308701495,-52.26386479821355,7.339891411251783,-35.96637326948896,4.765958094131511,-18.710865986535588,2.6239504003951875]},{"theme":"light-ic","bg":"#F6F8FA","fgs":["#F0F0FA","#F0F0FA","#0A0910","#707079","#8F8F99","#BCBCC5","#8F8F99","#0A0910","#000110","#0041DF","#3876FF","#82ACFF","#140000","#BB0011","#FC001B","#FF968A","#1A0500","#AE4900","#E76300","#FF9F70","#000B01","#007B2C","#00A63F","#00D252","#00040B","#00629B","#0088D4","#47B1FF"],"flat":[0,1.0634215248159875,0,1.0634215248159875,100.62201386979996,18.61882254505944,66.47667620763235,4.605948460093869,52.23653324893716,3.0084294463712395,29.780700684603257,1.7710808257265196,52.23653324893716,3.0084294463712395,100.62201386979996,18.61882254505944,101.64252534329776,19.49544279635548,78.08171358923367,6.990900026896573,60.31711036263554,3.7908833425501425,38.14751646036453,2.122399526754989,101.24542779029852,19.156162076239593,75.44574141529571,6.3202869230281635,60.62673294996029,3.82662941345977,34.94277926170305,1.9762445240799382,100.4972913238488,18.51105924752946,70.36281029021609,5.256397052662421,54.37780741212484,3.1906001595018556,32.840214506002376,1.8885138214456323,100.85145428464311,18.816958763489637,69.48045983543288,5.0977335979175304,52.4093478352768,3.0225491335598114,33.31123457222728,1.9076443919396007,101.41044755581565,19.297565623167603,74.60712675826916,6.125340559948453,58.63177587043786,3.604653750954651,39.55009522975671,2.1915837971666887]},{"theme":"light-ic","bg":"#1C1C1E","fgs":["#202028","#202028","#F1F1FD","#B6B6BF","#95959E","#6D6C75","#95959E","#F1F1FD","#E9F1FF","#76A4FF","#3875FF","#003ED7","#FFF4F3","#FF897C","#FC001B","#B50010","#FFF6F2","#FF935D","#E66300","#A94600","#C4FFCA","#00CB4F","#00A53F","#00772B","#E0F0FF","#2BA9FF","#0087D3","#005E95"],"flat":[0,1.0522076023272475,0,1.0522076023272475,-96.27140987076255,15.184146579953019,-61.97466067394124,8.452688508186561,-44.87094637123084,5.728792540137694,-25.977188885961883,3.283145241842075,-44.87094637123084,5.728792540137694,-96.27140987076255,15.184146579953019,-95.34499825068502,14.98175500729861,-52.621417231568934,6.906762415988252,-33.47849770408375,4.178062617945301,-14.893749254597887,2.163552488849563,-99.00844385529109,15.788447906049281,-55.63494162352738,7.390318911682041,-33.467257033822236,4.176644120449875,-17.547592326855693,2.408179342981014,-99.84390107174409,15.974784754119627,-57.911424929350886,7.764836527735112,-39.59013024218227,4.982347876116714,-22.33006111099907,2.8868875311347497,-95.39392806862337,14.992417327878547,-58.26045217149408,7.822951516486187,-41.372488477704024,5.229057297458628,-23.176065198031452,2.976455642863797,-93.8008911828484,14.646846343308852,-51.05892490110174,6.66159703590636,-35.06283530237813,4.380256350040106,-18.07990151348513,2.4590883821528062]},{"theme":"light-ic","bg":"#7C3AED","fgs":["#707078","#707078","#F4F4FE","#E3E4ED","#BDBDC6","#A6A6B0","#BDBDC6","#F4F4FE","#EDF3FF","#D8E6FF","#9ABDFF","#598FFF","#FFF8F7","#FFDDD8","#FFA59A","#FF6156","#FFF9F7","#FFDECE","#FFA77C","#FF7112","#D0FFD4","#7DFF93","#00D955","#00B947","#E5F3FF","#CEE8FF","#79C3FF","#0099EE"],"flat":[0,1.1608373681184396,0,1.1608373681184396,-73.98396246220521,5.215032461901156,-64.0643931275358,4.500912896664839,-41.75099800228171,3.055051474145459,-29.486682699107686,2.3616035737730514,-41.75099800228171,3.055051474145459,-73.98396246220521,5.215032461901156,-72.68698719753608,5.119268434409956,-64.43727527190525,4.526987198998707,-41.01457995095163,3.0113075347043563,-19.296469937711088,1.8446469211142276,-76.90365537296871,5.4332059719499135,-64.06334225320066,4.50083949888721,-41.0312066106476,3.012292267380192,-20.969079339398604,1.925648566411713,-77.34727635116013,5.466668081501757,-64.08510797117285,4.502359813071005,-40.89305028080999,3.004113887137535,-23.84545366268376,2.068528569757355,-72.9506558335481,5.138679113149092,-64.10668565953954,4.5038671985064,-40.827825177303865,3.0002560129496776,-26.013778316259767,2.1791858289450103,-71.69069599862902,5.046190228559761,-64.15987367011111,4.5075837056249375,-40.85863800181002,3.0020782433474276,-19.258832894479415,1.8428420367238694]},{"theme":"light-ic","bg":"#123456","fgs":["#36363D","#36363D","#F2F2FD","#BEBEC8","#A2A2AC","#808089","#A2A2AC","#F2F2FD","#EAF1FF","#86AEFF","#5189FF","#0650FF","#FFF6F4","#FF9B8F","#FF5349","#D70015","#FFF7F3","#FFA376","#FA6C00","#C75400","#C8FFCD","#00D553","#00B445","#008D34","#E2F1FF","#50B4FF","#0094E7","#0072B3"],"flat":[0,1.0609289970892934,0,1.0609289970892934,-91.39519035537737,11.4423466076867,-60.94367397154904,6.896920022598847,-46.020165423777584,5.026435795944436,-29.36949737122932,3.250019698594834,-46.020165423777584,5.026435795944436,-91.39519035537737,11.4423466076867,-90.02549742397721,11.218239018758677,-52.117590678997175,5.760385990810306,-35.453068631322715,3.8588722418561026,-17.16315496591595,2.1796295803105563,-94.48648764812391,11.954651961610136,-56.08049309972586,6.260045669338659,-36.68653001054492,3.9880923379680597,-19.42498133620683,2.3619323117143227,-94.88408174929032,12.021194381023134,-58.024655972390946,6.511549338355004,-40.19653971715428,4.366167533936967,-25.014063203920518,2.8441658803090872,-90.45906258458693,11.288985892577246,-57.51342916294683,6.44501257640493,-42.39985031117064,4.611196053798779,-26.099142978345885,2.9428672820648862,-89.03890865199516,11.05791933389335,-51.142776043878534,5.640183677550314,-35.55629387962385,3.86961291577043,-20.57349953874105,2.4573829383449617]},{"theme":"light-ic","bg":"#ABCDEF","fgs":["#C2C2CB","#C2C2CB","#06060C","#55545D","#706F78","#A1A1AA","#706F78","#06060C","#00010B","#0035BB","#044EFF","#4E86FF","#0F0000","#9F000C","#D60015","#FF4D45","#150400","#903B00","#BB4F00","#F86B00","#000801","#006322","#008230","#00B344","#000307","#005182","#0071B2","#0093E5"],"flat":[0,1.0705446339746656,0,1.0705446339746656,75.64582413051765,12.232204997602986,52.76667497081496,4.519371986307913,41.423484798334584,3.0020546798073307,18.127484103049046,1.5512322351556214,41.423484798334584,3.0020546798073307,75.64582413051765,12.232204997602986,76.30385323493446,12.594966222228084,58.55230095328762,5.70794052541694,46.62204712410211,3.5916381056717506,29.005214449343097,2.0573900879913625,76.05323451380515,12.457481581934719,55.93606418979168,5.126251708978212,44.077718465236906,3.284322001440957,27.730078776096413,1.986177453897667,75.43283879959024,12.113874624281086,52.77586207835023,4.520990578468311,41.44187963136357,3.003889327255304,23.868980516769618,1.7916338607346896,75.72958089661125,12.278654436624837,52.80881394476793,4.526802423347816,41.41072061989075,3.0007825637465393,21.536768417458934,1.687644517337887,76.14729893560808,12.509202438867769,55.739334564053664,5.085602713702602,43.07798550292857,3.173723442083943,28.271676351762963,2.015968674447834]},{"theme":"light-ic","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"dark-ic","bg":"#FFFFFF","fgs":["#E7E7F1","#D9D9E2","#0D0D12","#707079","#94949D","#BCBCC6","#94949D","#0D0D12","#000511","#005DAD","#0080EB","#67AEFF","#180001","#BB0023","#FB0032","#FF9690","#1D0D00","#A16200","#D38200","#FFA93F","#000E02","#007C2A","#00A63B","#00D24D","#060018","#6E00E2","#8F5BFF","#B49FFF"],"flat":[10.564452802176923,1.2281904700369508,18.372353597432387,1.402460683906872,104.27090588423802,19.38311547953439,70.6022439311581,4.903434648448188,54.00450766949271,3.0077315832596954,33.86624309585679,1.8839141891835494,54.00450766949271,3.0077315832596954,104.27090588423802,19.38311547953439,105.36973966240839,20.39226096069594,79.16304803673168,6.626348275559002,63.912632944603466,3.971781216419874,43.318131952880584,2.3140155027501375,105.16667360757414,20.20636839933495,79.43361118518992,6.693805381831162,64.79709340122825,4.079330310903514,38.90470830731088,2.096381256670562,103.76723276112338,18.92212884965907,70.7453629185683,4.926701923645705,54.109273202798526,3.0160152686700097,34.512224892089854,1.9092708836328856,104.66191026229393,19.742561272328775,73.25578885512941,5.361938351796974,56.58363991444959,3.2220248289870597,37.502971296697396,2.0337403797475795,105.5639998705614,20.569417077144905,82.76169093861434,7.605398027541734,65.05029978451972,4.11090668779679,42.03743585537794,2.2474520733771812]},{"theme":"dark-ic","bg":"#000000","fgs":["#030205","#08080D","#F0F0FA","#B1B1BB","#8F8F98","#64646D","#8F8F98","#F0F0FA","#DFEEFF","#4AA0FF","#007AE0","#005198","#FFF4F3","#FF7D79","#EF0030","#A6001E","#FFF7EF","#F99A00","#CA7D00","#915800","#B8FFBE","#00C648","#009F38","#006E24","#F0EDFF","#A98DFF","#894CFF","#6000C7"],"flat":[0,1.0147465549687367,0,1.0508696650285665,-99.01083964835996,18.549513785944853,-62.774763175831374,9.875849762659984,-45.37711439886134,6.5503119047070335,-25.95111170367181,3.5855589616463144,-45.37711439886134,6.5503119047070335,-99.01083964835996,18.549513785944853,-96.24720863865953,17.81140932471069,-52.09132740819334,7.763491481432673,-34.99617079306692,4.860177125212619,-17.977340963301042,2.6303715448994067,-102.4562582898777,19.486486384763445,-55.7461567674855,8.461541475204797,-34.016004840410574,4.712824540535038,-18.066789785866806,2.6401456993132153,-103.60398268701002,19.802704734429785,-61.68353878496926,9.650187857341214,-44.76775468944945,6.444757556916112,-26.060791619056427,3.599837857825526,-97.27916136850891,18.085610527348415,-59.33082759509665,9.171209294707863,-42.24705692654848,6.016359585841944,-23.350526713793172,3.2558471191579894,-97.94239544297146,18.262727694350296,-53.03355221198167,7.94095381214551,-32.857248309508414,4.541449578831631,-15.041171256747345,2.322063533056465]},{"theme":"dark-ic","bg":"#808080","fgs":["#707078","#66666E","#030305","#18171D","#37363E","#5D5D64","#37363E","#030305","#000103","#001834","#00376B","#004A8C","#070000","#390005","#750012","#99001B","#0D0400","#261300","#522F00","#865100","#000300","#001D05","#004112","#006521","#010005","#22004F","#43008F","#5800B7"],"flat":[0,1.24296889833444,9.274501031402123,1.4407196981948422,39.91548097777799,5.217615096019717,36.836632862345205,4.5094036578339445,27.93898600302036,3.0217055248761144,13.086665786299626,1.6537770693375544,27.93898600302036,3.0217055248761144,39.91548097777799,5.217615096019717,40.21965913293546,5.287302224624218,36.81767378273908,4.505192880903515,27.950777846769416,3.023233051937069,21.000772856623527,2.25477321245894,40.142120786515875,5.269603471071473,36.88792093057972,4.5208109211231795,27.866909320024153,3.0123854877509646,20.876190217747006,2.243276608438576,39.57947058767665,5.139984882718895,36.89828467875601,4.523118840482662,27.8635036155662,3.011945822214043,13.292640012092916,1.6665035199669143,40.051433392362526,5.248844071123953,36.85731353296706,4.514000513189092,27.870384117529113,3.0128341396537346,15.95953035602916,1.844055168824021,40.26997047853993,5.298759395894901,36.80600977189143,4.502603953746083,29.651273965169533,3.2515347547731954,23.802145579236715,2.533547958530533]},{"theme":"dark-ic","bg":"#3A3A3C","fgs":["#46464E","#4F4F57","#F1F1FA","#BABAC4","#9D9DA6","#797982","#9D9DA6","#F1F1FA","#E1EFFF","#62ACFF","#0087F7","#0065BB","#FFF5F4","#FF928C","#FF3644","#CA0027","#FFF8F1","#FFA533","#DD8900","#AD6A00","#BCFFC2","#00D04C","#00AF3F","#00862E","#F1EFFF","#B29CFF","#956AFF","#7700F5"],"flat":[0,1.2140718035663858,0,1.3988859584097761,-88.35603271482154,10.107493939375463,-56.41682578047019,5.8952705955406675,-41.10063170437542,4.219157833086703,-23.840055313660137,2.6320437618590202,-41.10063170437542,4.219157833086703,-88.35603271482154,10.107493939375463,-85.70519954500536,9.72347127561709,-46.57313915269262,4.790554592637207,-29.772868283167547,3.1391774083572677,-14.678197720495364,1.9342213705917548,-91.71639880150975,10.602849175762582,-50.907190680193835,5.264997878479995,-30.103011927821978,3.1686137953283313,-14.381529067368087,1.9134497328938256,-92.9101803888625,10.781105347058572,-55.36632323767275,5.772792653136124,-40.27550194757439,4.135743433221995,-23.629833528615176,2.614848030728214,-86.56463892830851,9.847321829129962,-52.75275099572737,5.472775232316544,-37.83315401910713,3.893128667535661,-21.00389596511428,2.404636707585164,-87.71836703917482,10.014570561299498,-47.715877818006604,4.91379345402092,-29.619990398118716,3.125589171245837,-10.872649839108671,1.6769004806806627]},{"theme":"dark-ic","bg":"#007AFF","fgs":["#6F6F77","#65656D","#040408","#16151B","#36353D","#72727A","#36353D","#040408","#000106","#001632","#00366A","#005EAF","#0B0000","#360004","#730011","#BD0023","#100600","#241200","#502E00","#A36300","#000501","#001B04","#004012","#007D2B","#020009","#20004A","#48009A","#6F00E5"],"flat":[0,1.2399763057879079,9.137153276370956,1.4379357071162235,39.199487305374596,5.095159290353486,36.655643103420836,4.517612479062309,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.199487305374596,5.095159290353486,39.62660370456575,5.191620542906115,36.592103069401674,4.503545882983851,27.695057695096033,3.0117807086970667,12.482735608358766,1.623488823202914,39.46146930028483,5.1544680971312,36.631002505660305,4.512153756975423,27.677944212691237,3.0095726068531357,12.751768680957815,1.6398240151911772,38.729462058672325,4.987982714010286,36.579572970042136,4.500775701626332,27.747483785710887,3.0185550274007946,0,1.2039987886628472,39.28492308543262,5.114543979353608,36.632422880871374,4.512468289727634,27.610461383700073,3.00088106913278,0,1.3173837917708568,39.63684952401951,5.1939188146581685,36.601297620325695,4.505579426399357,27.609531401873983,3.0007614645527125,16.085712499624492,1.86220122646402]},{"theme":"dark-ic","bg":"#FF3B30","fgs":["#7F7F87","#74747D","#040408","#212128","#3E3D45","#76767F","#3E3D45","#040408","#000206","#002245","#003E78","#0062B6","#0B0000","#4C0008","#820015","#C50025","#110600","#341C00","#5B3600","#A96700","#000601","#002808","#004916","#00822D","#02000A","#2E0067","#5300AD","#7400EE"],"flat":[0,1.119564506502328,0,1.304916965157494,43.43238144483196,5.770041002680692,38.322111022898454,4.5094781825179275,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.43238144483196,5.770041002680692,43.7603252928922,5.854038847974277,38.29235457140463,4.502975854531618,29.083956028514045,3.020138635078335,15.054852030501879,1.729780743830801,43.694363439742204,5.837205585263101,38.354206470247746,4.516507237210082,29.087182234169767,3.020553277003921,15.230546565278194,1.740856512459411,42.92491991360228,5.63897133999608,38.33365423714702,4.512004338988724,28.93769973409828,3.0014083479801514,0,1.2794758858630608,43.42248485866928,5.7674953973596095,38.41370496023468,4.529580289565808,29.031977011275746,3.0134669332394415,8.949958172317846,1.3992774406692907,43.85966524740055,5.879321607935794,38.335090707081115,4.512318847459295,28.952846517354917,3.003342035233188,18.7969313728254,1.9885281036480773]},{"theme":"dark-ic","bg":"#101012","fgs":["#1C1C22","#24242A","#F0F0FA","#B3B3BD","#92929B","#686871","#92929B","#F0F0FA","#E0EEFF","#4FA3FF","#007DE4","#00559F","#FFF4F3","#FF817D","#F40031","#AD001F","#FFF7EF","#FB9C00","#CE7F00","#965B00","#B9FFBF","#00C849","#00A239","#007226","#F0EEFF","#AA90FF","#8C52FF","#6500D0"],"flat":[0,1.1210017368042908,0,1.2316217101091165,-97.35355975851269,16.788671896406843,-62.18912907483276,9.1408846014785,-45.183954583215964,6.160892481462449,-25.971291980892453,3.445784610130369,-45.183954583215964,6.160892481462449,-97.35355975851269,16.788671896406843,-94.7101738760577,16.149472101206463,-51.76844025191021,7.254462182627787,-34.605057818633654,4.573999892517157,-17.84521283217997,2.534242228411278,-100.79897840003042,17.636700891614694,-55.24750846744172,7.863491890193123,-33.54190885081095,4.42668652015819,-17.815136446883052,2.5311583129351276,-101.94670279716274,17.922901715066683,-61.02785966423507,8.921519321161206,-44.34904450398409,6.02788949767693,-25.86724540779468,3.4331609270477186,-95.74776769254933,16.399183837627458,-58.596724506079966,8.469603286006345,-41.840254848173814,5.636085625741139,-23.122294138224145,3.108849028833141,-96.71860922522349,16.634224259107555,-52.4354046324141,7.369572153296626,-32.71416296796099,4.3135863728502315,-14.766203746026353,2.2302399315399106]},{"theme":"dark-ic","bg":"#F6F8FA","fgs":["#E0E0EA","#D2D2DC","#0D0D12","#717179","#8F8F98","#BCBCC6","#8F8F98","#0D0D12","#000511","#005DAD","#0080EB","#67AEFF","#180001","#BB0023","#FB0032","#FF9690","#1D0D00","#A16200","#CC7E00","#FFA93F","#000E02","#007C2A","#00A63B","#00D24D","#060018","#6E00E1","#8F5BFF","#B49FFF"],"flat":[10.34292311627503,1.2313464424988367,18.024461551245693,1.4097592283337514,100.14533816071224,18.207162386274927,66.07133001527934,4.544848448940367,52.273633182943,3.011452358576948,29.740675372331037,1.769619110018133,52.273633182943,3.011452358576948,100.14533816071224,18.207162386274927,101.24417193888262,19.155084079579783,75.03748031320593,6.224334741672483,59.78706522107772,3.730817455350078,39.19256422935483,2.1736266322828217,101.04110588404839,18.980469423093126,75.30804346166417,6.287699296730837,60.6715256777025,3.8318416601446055,34.77914058378512,1.9691960254812915,99.64166503759763,17.774143327140518,66.61979519504256,4.627804134340593,52.265155427331,3.010761189364384,30.386657168564103,1.79343744066295,100.53634253876818,18.544800983402446,69.13022113160365,5.036635229225181,52.45807219092383,3.026547994099155,33.37740357317164,1.9103555042369325,101.43843214703563,19.321492322017065,78.73738474892319,7.17242701812758,60.92473206099399,3.861502331684902,37.91186813185219,2.111101535692422]},{"theme":"dark-ic","bg":"#1C1C1E","fgs":["#28282E","#303036","#F0F1FA","#B5B5BE","#94949D","#6C6C74","#94949D","#F0F1FA","#E0EEFF","#53A4FF","#007FE8","#0058A5","#FFF4F3","#FF8580","#F80032","#B30021","#FFF7F0","#FD9D00","#D18100","#9B5E00","#B9FFC0","#00CA4A","#00A43A","#007628","#F0EEFF","#AC93FF","#8D57FF","#6800D8"],"flat":[0,1.1612081374032734,0,1.2977584057597347,-96.00102363023473,15.124963081358866,-61.434200333395395,8.359778481952823,-44.37695589123639,5.656999216809453,-25.852207004995158,3.269134017520218,-44.37695589123639,5.656999216809453,-96.00102363023473,15.124963081358866,-92.91963933131836,14.457074514504098,-50.53751898728166,6.580638229837842,-33.762466759799054,4.213972880586457,-17.265259153354464,2.3814244063048684,-99.00844385529109,15.788447906049281,-54.621704682580784,7.2261694596451385,-32.708249696736004,4.081389020198301,-17.26088719592284,2.381011454094406,-100.19905346524207,16.054261379481122,-59.919533209261445,8.101702037099825,-43.64240726631874,5.55098822531801,-25.561703474916055,3.236683846808585,-93.99035989085725,14.687777164248876,-57.733604751916815,7.735299184328377,-40.893113879964474,5.162174617584753,-22.782862127638978,2.93464773079297,-94.92807468048419,14.891026660055605,-51.83505302329671,6.7828987244211,-32.07221654016657,4.002369709649521,-14.066989001346808,2.090531498955086]},{"theme":"dark-ic","bg":"#7C3AED","fgs":["#7A7A83","#85858D","#F2F2FD","#E3E4ED","#BBBCC5","#A6A6AF","#BBBCC5","#F2F2FD","#E5F1FF","#D2E7FF","#8AC0FF","#2091FF","#FFF9F8","#FFDDDA","#FFA49E","#FF5C5D","#FFFAF5","#FFE0C0","#FFAA42","#E99000","#C6FFCB","#7DFF91","#00DA50","#00B943","#F3F1FF","#E6E2FF","#C1B2FF","#9D7AFF"],"flat":[-8.022975593833932,1.3402714370624624,-13.06659651622355,1.5567826841029107,-72.79588351600884,5.127281558149197,-64.0643931275358,4.500912896664839,-41.08533982136385,3.0154992993085026,-29.446612137967147,2.3594628663401775,-41.08533982136385,3.0154992993085026,-72.79588351600884,5.127281558149197,-70.80493468751378,4.981574213000164,-64.1741844582375,4.5085838821814495,-40.86541522684489,3.0024791016401666,-17.915271666932362,1.7789289638170434,-77.39176152066469,5.470028106981068,-64.14420274941838,4.506488572747314,-40.82594869447877,3.0001450551263997,-20.102672577261966,1.8834970356793017,-77.70409895709017,5.493642615376888,-64.40976920815079,4.525061719384059,-40.93902681557826,3.00683450902807,-28.2154352545988,2.2940964528512438,-71.5855645088526,5.038503428518723,-64.0583201718401,4.500488740880354,-41.23137463469248,3.02415799426954,-25.961518214862377,2.176489332337808,-72.5833510184408,5.111647048756453,-64.45521953810703,4.528243512077896,-40.9348520371047,3.006587427640115,-18.521762858381592,1.807654812879132]},{"theme":"dark-ic","bg":"#123456","fgs":["#3E3E45","#47474E","#F0F0FC","#BDBDC6","#A1A1AA","#7F7F88","#A1A1AA","#F0F0FC","#E1EFFF","#68AEFF","#008BFD","#006AC4","#FFF6F5","#FF9791","#FF494F","#D30029","#FFF8F1","#FFA941","#E28C00","#B46E00","#BEFFC3","#00D34D","#00B340","#008C31","#F1EFFF","#B49FFF","#9971FF","#7D09FF"],"flat":[0,1.1989453999458657,0,1.3802668927515667,-90.21171279539291,11.248602775503162,-60.350304841650235,6.81783465078813,-45.467647083484636,4.962060105853706,-28.90763677945104,3.2057711392885686,-45.467647083484636,4.962060105853706,-90.21171279539291,11.248602775503162,-88.0351868676248,10.895768939711282,-50.01919534416472,5.502978516969682,-33.92042169291382,3.7009882121251625,-19.118481017052428,2.3367853131295435,-94.53061684959366,11.962030249162733,-54.89593536437227,6.108854061876529,-35.13675470036395,3.8260433618990066,-18.695569008603144,2.3023140000481574,-95.24016771148194,12.080915286992608,-59.18458546592085,6.6635751999987605,-44.37309985295556,4.8355935478331284,-28.125674388660414,3.1315039953558483,-89.11957165172129,11.070992163190612,-56.49372778683512,6.313154752438012,-41.901500594929324,4.555261373045625,-25.675484951698337,2.9041378624661793,-90.04835436179425,11.221964232051386,-51.2922333885473,5.658542947646642,-34.21126239038315,3.7307191421666435,-15.254686874750304,2.03178289437105]},{"theme":"dark-ic","bg":"#ABCDEF","fgs":["#B4B4BE","#A8A8B1","#09090F","#55545C","#706F78","#A1A1AB","#706F78","#09090F","#00030D","#004D92","#006CC7","#008BFE","#130001","#9F001C","#D60029","#FF4B50","#190B00","#7B4A00","#A06100","#E38C00","#000A01","#006320","#00822D","#00B341","#040013","#5B00BE","#7E16FF","#9971FF"],"flat":[8.489900665165298,1.2445786124510558,14.633674840112363,1.428005718026211,75.26124671135368,12.018395958758797,52.798813706484495,4.5250375920814765,41.423484798334584,3.0020546798073307,18.08917030758312,1.5497949541740348,41.423484798334584,3.0020546798073307,75.26124671135368,12.018395958758797,76.08600684668272,12.475516218126787,55.96473752983635,5.13221099097022,43.3369682989835,3.2018590596167122,29.318326289650187,2.0754572365320043,75.88156299830736,12.362776535253154,55.82217638546274,5.102669267083783,43.91931922104849,3.266436635421996,27.87409429449102,1.9940348577564484,74.63415754799017,11.66954886455628,52.68236753448497,4.504554642142053,41.632593181405234,3.023005829909607,19.021862742942993,1.5853635699211195,75.54552640875083,12.176514522007174,52.82929323710673,4.530419449281277,41.44477504659015,3.004178254346042,21.57431352051131,1.6892460110571705,76.18160530883091,12.528031661692317,58.96992438550627,5.808209180198148,47.36214126331064,3.68860336114861,29.106756402593504,2.0632235490419992]},{"theme":"dark-ic","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]}],"resolveVars":[{"theme":"light","bg":"#FFFFFF","vars":{"--lab-bg-tone-2":"oklch(97.84197% 0.010603 286.202)","--lab-bg-tone-3":"oklch(97.84197% 0.010603 286.202)","--lab-label-primary":"oklch(19.12257% 0.014100 291.556)","--lab-label-secondary":"oklch(56.54196% 0.013721 285.953)","--lab-label-tertiary":"oklch(66.97448% 0.014606 285.999)","--lab-label-quaternary":"oklch(81.39504% 0.013897 286.087)","--lab-border-strong":"oklch(19.12257% 0.014100 291.556)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(19.39430% 0.069681 257.297)","--lab-label-brand-secondary":"oklch(57.33316% 0.205939 257.291)","--lab-label-brand-tertiary":"oklch(67.12978% 0.175419 257.690)","--lab-label-brand-quaternary":"oklch(81.31388% 0.094313 257.967)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(20.16318% 0.082740 29.234)","--lab-label-danger-secondary":"oklch(59.62536% 0.244212 28.655)","--lab-label-danger-tertiary":"oklch(69.12968% 0.198650 28.574)","--lab-label-danger-quaternary":"oklch(82.55135% 0.097676 29.389)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(19.32631% 0.042728 66.944)","--lab-label-warning-secondary":"oklch(57.27465% 0.125021 68.799)","--lab-label-warning-tertiary":"oklch(67.73500% 0.148484 68.161)","--lab-label-warning-quaternary":"oklch(82.04043% 0.139600 68.374)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(67.73500% 0.148484 68.161 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(18.59864% 0.054816 147.311)","--lab-label-success-secondary":"oklch(54.91588% 0.161394 147.418)","--lab-label-success-tertiary":"oklch(64.88263% 0.190555 147.443)","--lab-label-success-quaternary":"oklch(79.02491% 0.232159 147.432)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(64.96409% 0.172888 147.450 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(19.54489% 0.077696 259.509)","--lab-label-info-secondary":"oklch(57.68997% 0.232691 259.838)","--lab-label-info-tertiary":"oklch(67.26428% 0.173598 259.980)","--lab-label-info-quaternary":"oklch(81.49851% 0.092914 259.576)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.5","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.5","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.5","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.5","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fill-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-icon":"oklch(66.97448% 0.014606 285.999)"}},{"theme":"light","bg":"#000000","vars":{"--lab-bg-tone-2":"oklch(7.11623% 0.013707 282.350)","--lab-bg-tone-3":"oklch(7.11623% 0.013707 282.350)","--lab-label-primary":"oklch(98.65156% 0.006606 286.278)","--lab-label-secondary":"oklch(80.14049% 0.013952 286.081)","--lab-label-tertiary":"oklch(69.23260% 0.013029 286.055)","--lab-label-quaternary":"oklch(55.16643% 0.013808 285.938)","--lab-border-strong":"oklch(98.65156% 0.006606 286.278)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(98.69528% 0.006207 255.474)","--lab-label-brand-secondary":"oklch(80.06451% 0.101154 257.822)","--lab-label-brand-tertiary":"oklch(69.35433% 0.162198 257.610)","--lab-label-brand-quaternary":"oklch(55.96293% 0.201872 257.393)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(98.64532% 0.006567 28.833)","--lab-label-danger-secondary":"oklch(81.38408% 0.105319 28.863)","--lab-label-danger-tertiary":"oklch(71.19739% 0.181099 28.350)","--lab-label-danger-quaternary":"oklch(58.10538% 0.238004 28.677)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(98.72985% 0.009577 72.664)","--lab-label-warning-secondary":"oklch(80.98123% 0.149940 68.750)","--lab-label-warning-tertiary":"oklch(70.11346% 0.152989 68.857)","--lab-label-warning-quaternary":"oklch(55.86543% 0.122417 68.219)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(98.31403% 0.027558 147.033)","--lab-label-success-secondary":"oklch(77.69544% 0.228159 147.448)","--lab-label-success-tertiary":"oklch(67.10085% 0.197388 147.383)","--lab-label-success-quaternary":"oklch(53.45797% 0.157009 147.442)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(98.69528% 0.006207 255.474)","--lab-label-info-secondary":"oklch(80.30515% 0.099317 259.806)","--lab-label-info-tertiary":"oklch(69.49027% 0.160387 260.058)","--lab-label-info-quaternary":"oklch(56.23879% 0.227606 259.853)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.013010286081645773","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.012071078431372548","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.009560345877481427","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.00784313725490196","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fill-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-icon":"oklch(69.23260% 0.013029 286.055)"}},{"theme":"light","bg":"#808080","vars":{"--lab-bg-tone-2":"oklch(58.21536% 0.012099 286.018)","--lab-bg-tone-3":"oklch(58.21536% 0.012099 286.018)","--lab-label-primary":"oklch(13.77177% 0.013406 284.503)","--lab-label-secondary":"oklch(20.89710% 0.013787 291.711)","--lab-label-tertiary":"oklch(33.70211% 0.013945 291.371)","--lab-label-quaternary":"oklch(49.18380% 0.012632 285.926)","--lab-border-strong":"oklch(13.77177% 0.013406 284.503)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(13.49058% 0.050205 258.119)","--lab-label-brand-secondary":"oklch(20.89968% 0.076454 257.725)","--lab-label-brand-tertiary":"oklch(34.17043% 0.122830 257.309)","--lab-label-brand-quaternary":"oklch(49.70427% 0.178878 257.337)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.22004% 0.058352 29.234)","--lab-label-danger-secondary":"oklch(21.88171% 0.089792 29.234)","--lab-label-danger-tertiary":"oklch(35.54757% 0.145582 28.627)","--lab-label-danger-quaternary":"oklch(51.73054% 0.211869 28.643)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(13.56514% 0.029751 68.088)","--lab-label-warning-secondary":"oklch(20.90753% 0.046395 66.438)","--lab-label-warning-tertiary":"oklch(34.00266% 0.074904 67.452)","--lab-label-warning-quaternary":"oklch(49.79045% 0.108745 68.715)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(91.20808% 0.064865 69.118 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.01439% 0.039836 145.937)","--lab-label-success-secondary":"oklch(20.10741% 0.059351 147.255)","--lab-label-success-tertiary":"oklch(32.71649% 0.095649 147.616)","--lab-label-success-quaternary":"oklch(47.52181% 0.139141 147.559)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(88.67895% 0.198079 147.416 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(13.83602% 0.055501 259.684)","--lab-label-info-secondary":"oklch(21.26627% 0.087439 260.146)","--lab-label-info-tertiary":"oklch(34.39884% 0.140838 260.069)","--lab-label-info-quaternary":"oklch(50.11242% 0.202893 259.861)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.11057506131405687","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.11023622047244083","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.07169755954418727","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.04724409448818886","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fill-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-icon":"oklch(33.70211% 0.013945 291.371)","--lab-border-brand-strong":"oklch(34.17043% 0.122830 257.309 / 1)","--lab-border-danger-strong":"oklch(35.54757% 0.145582 28.627 / 1)","--lab-border-info-strong":"oklch(34.39884% 0.140838 260.069 / 1)"}},{"theme":"light","bg":"#3A3A3C","vars":{"--lab-bg-tone-2":"oklch(36.65084% 0.011912 285.797)","--lab-bg-tone-3":"oklch(36.65084% 0.011912 285.797)","--lab-label-primary":"oklch(98.92134% 0.005280 286.303)","--lab-label-secondary":"oklch(82.53513% 0.012530 281.039)","--lab-label-tertiary":"oklch(72.82755% 0.014297 286.039)","--lab-label-quaternary":"oklch(61.25907% 0.011943 286.042)","--lab-border-strong":"oklch(98.92134% 0.005280 286.303)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(98.77036% 0.005722 264.533)","--lab-label-brand-secondary":"oklch(82.44675% 0.088464 257.008)","--lab-label-brand-tertiary":"oklch(73.08383% 0.140495 257.442)","--lab-label-brand-quaternary":"oklch(61.77245% 0.208353 257.413)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(61.75781% 0.208491 257.338 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(98.86605% 0.005516 31.054)","--lab-label-danger-secondary":"oklch(83.38591% 0.092088 28.223)","--lab-label-danger-tertiary":"oklch(74.63167% 0.153784 28.842)","--lab-label-danger-quaternary":"oklch(64.10554% 0.244701 28.706)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(98.75683% 0.008520 67.727)","--lab-label-warning-secondary":"oklch(83.08426% 0.131366 68.787)","--lab-label-warning-tertiary":"oklch(73.86147% 0.161425 68.614)","--lab-label-warning-quaternary":"oklch(61.96370% 0.135496 68.532)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(98.51065% 0.024195 147.300)","--lab-label-success-secondary":"oklch(80.08887% 0.235054 147.469)","--lab-label-success-tertiary":"oklch(70.67977% 0.207519 147.455)","--lab-label-success-quaternary":"oklch(59.24524% 0.173588 147.533)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(98.77036% 0.005722 264.533)","--lab-label-info-secondary":"oklch(82.42821% 0.087905 259.666)","--lab-label-info-tertiary":"oklch(73.17409% 0.139071 259.898)","--lab-label-info-quaternary":"oklch(61.97090% 0.205878 259.740)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.06909778454881221","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.060913705583756306","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.040609137055837526","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.028061954965508236","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fill-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-icon":"oklch(72.82755% 0.014297 286.039)"}},{"theme":"light","bg":"#007AFF","vars":{"--lab-bg-tone-2":"oklch(57.87475% 0.012117 286.015)","--lab-bg-tone-3":"oklch(57.87475% 0.012117 286.015)","--lab-label-primary":"oklch(13.41536% 0.016099 291.497)","--lab-label-secondary":"oklch(20.01464% 0.013939 291.638)","--lab-label-tertiary":"oklch(33.31157% 0.013986 291.359)","--lab-label-quaternary":"oklch(48.82850% 0.012656 285.922)","--lab-border-strong":"oklch(13.41536% 0.016099 291.497)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(13.49058% 0.050205 258.119)","--lab-label-brand-secondary":"oklch(20.23588% 0.071518 256.892)","--lab-label-brand-tertiary":"oklch(33.52307% 0.122654 257.729)","--lab-label-brand-quaternary":"oklch(49.33014% 0.177503 257.333)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.22004% 0.058352 29.234)","--lab-label-danger-secondary":"oklch(20.90400% 0.085780 29.234)","--lab-label-danger-tertiary":"oklch(34.90075% 0.142917 28.593)","--lab-label-danger-quaternary":"oklch(51.33845% 0.210254 28.629)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(13.56514% 0.029751 68.088)","--lab-label-warning-secondary":"oklch(20.32691% 0.044866 67.175)","--lab-label-warning-tertiary":"oklch(33.70499% 0.073308 69.356)","--lab-label-warning-quaternary":"oklch(49.32505% 0.107532 68.994)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(90.50991% 0.070001 68.839 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.01439% 0.039836 145.937)","--lab-label-success-secondary":"oklch(19.36939% 0.056254 147.869)","--lab-label-success-tertiary":"oklch(32.05045% 0.093836 147.561)","--lab-label-success-quaternary":"oklch(47.21271% 0.138736 147.423)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(87.96697% 0.214930 147.350 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(13.83602% 0.055501 259.684)","--lab-label-info-secondary":"oklch(20.33878% 0.084139 260.257)","--lab-label-info-tertiary":"oklch(33.98919% 0.139378 260.097)","--lab-label-info-quaternary":"oklch(49.73960% 0.201561 259.877)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.1414396647356093","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.1805418331471749","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.09073588245366788","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.06430782839451565","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fill-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-icon":"oklch(33.31157% 0.013986 291.359)","--lab-border-brand-strong":"oklch(33.52307% 0.122654 257.729 / 1)","--lab-border-danger-strong":"oklch(34.90075% 0.142917 28.593 / 1)","--lab-border-info-strong":"oklch(33.98919% 0.139378 260.097 / 1)"}},{"theme":"light","bg":"#FF3B30","vars":{"--lab-bg-tone-2":"oklch(63.60048% 0.011829 286.059)","--lab-bg-tone-3":"oklch(63.60048% 0.011829 286.059)","--lab-label-primary":"oklch(13.83495% 0.015576 284.180)","--lab-label-secondary":"oklch(25.12484% 0.015017 285.269)","--lab-label-tertiary":"oklch(36.40556% 0.013673 291.446)","--lab-label-quaternary":"oklch(51.30243% 0.012497 285.951)","--lab-border-strong":"oklch(13.83495% 0.015576 284.180)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(14.05977% 0.051776 257.879)","--lab-label-brand-secondary":"oklch(25.33060% 0.091490 257.423)","--lab-label-brand-tertiary":"oklch(36.69778% 0.133197 257.540)","--lab-label-brand-quaternary":"oklch(51.93456% 0.187074 257.359)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.49010% 0.059461 29.234)","--lab-label-danger-secondary":"oklch(26.17120% 0.107130 28.474)","--lab-label-danger-tertiary":"oklch(38.31782% 0.156989 28.748)","--lab-label-danger-quaternary":"oklch(53.87750% 0.220648 28.624)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(14.09840% 0.030367 70.955)","--lab-label-warning-secondary":"oklch(25.16306% 0.055216 68.014)","--lab-label-warning-tertiary":"oklch(36.95716% 0.080989 68.209)","--lab-label-warning-quaternary":"oklch(51.83499% 0.113346 68.533)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(94.55166% 0.039779 69.746 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.47470% 0.038038 148.984)","--lab-label-success-secondary":"oklch(24.13011% 0.070822 147.468)","--lab-label-success-tertiary":"oklch(35.33425% 0.103721 147.463)","--lab-label-success-quaternary":"oklch(49.62199% 0.145290 147.559)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(93.08890% 0.111383 147.112 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(14.07469% 0.058967 260.480)","--lab-label-info-secondary":"oklch(25.53067% 0.104920 260.137)","--lab-label-info-tertiary":"oklch(37.14168% 0.149591 259.762)","--lab-label-info-quaternary":"oklch(52.13660% 0.212021 259.943)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.1835748792270531","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.4395613333149519","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.1777791862086449","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.1016772651089421","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fill-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-icon":"oklch(36.40556% 0.013673 291.446)","--lab-border-brand-strong":"oklch(36.69778% 0.133197 257.540 / 1)","--lab-border-danger-strong":"oklch(38.31782% 0.156989 28.748 / 1)","--lab-border-info-strong":"oklch(37.14168% 0.149591 259.762 / 1)"}}]} +{"_meta":{"purpose":"Байт-точный golden границы JS↔WASM: recheckContrast/_recheckContrastMulti и полный snapshot resolveTheme.vars.","regenerated":"2026-07-22","regen_reason":"Hard deletion of retired component-specific recipe variants and their generated role leaves.","drift_scope":"Exactly 54 obsolete resolveVars leaves removed (9 keys × 6 theme/background cases); all remaining vars and every recheck value are unchanged.","recheck_unchanged":"Секция recheck: 2752/2752 числовых значений, то есть 1376/1376 пар (lc, wcag), байт-идентичны origin/main; ни одно значение recheckContrast не изменено.","numeric_provenance":{"glow_alpha":"Старый fixed-48 bisection + 4-decimal alpha заменён перебором конечных sRGB8-state, внутренней alpha выбранного интервала и shortest-roundtrip binary64 CSS.","scope_check":"Фактический структурный diff вычислен по каждому листу resolveVars; 4 alpha-ключа × 6 записей = 24."},"wcag_floors_not_weakened":"Glow — декоративная роль без WCAG-пола. Отдельно подтверждено: все 1376 пар (2752 числовых значения) recheck неизменны, поэтому измеренный контраст ни одной проверяемой роли не ослаблен.","isolated_verification_required":"Регенерация принимается только после независимой проверки scope, recheck-инварианта и полного Rust/npm/browser gate.","partition_correction":{"regenerated":"2026-07-11","reason":"Коррекция канонической binary64 alpha по фактическому midpoint encoded-sRGB8 partition без дополнительного округления.","drift_scope":"Ровно 17 alpha leaves: light/#000000 danger и четыре Glow alpha на каждом из #808080, #3A3A3C, #007AFF, #FF3B30; остальные resolveVars-листья неизменны.","recheck_unchanged":"2752/2752 числовых значений, то есть 1376/1376 пар (lc, wcag), побитно неизменны.","numeric_provenance":{"halo":"#FF3B30","background":"#000000","state":"#030101","lower_bits":"0x3f85555555555555","upper_bits":"0x3f8c1c1c1c1c1c1c","midpoint_bits":"0x3f88b8b8b8b8b8b8","midpoint":"0.012071078431372548","previous_bits":"0x3f88b8b8b8b8b8b9","previous_relation":"1 ULP выше, внутри той же partition"},"exhaustive_proof":"65 536 channel partitions и seam: state/composite outputs неизменны.","performance":"Full-range → bracketed exact search: base 19.7→4.37 µs, bloom 74.1→16.2 µs, long 656→139 µs."},"history":{"previous_regenerated":"2026-07-10","previous_regen_reason":"Глава #64 (ADR-0003): ось читаемости активирована в домене Ys — реактивный recheck (measure_contrast/recheck_against) и solver-вывод считаются в Ys. recheck flat и resolveVars регенерированы против пересобранного движка; inputs (theme/bg/fgs) сохранены. semver-major по ADR.","previous_drift_scope":"resolveVars: exactly 24 leaves changed, all --lab-fx-glow-{brand,danger,warning,neutral}-core (glow-centre colour, background-independent → 4 distinct values). No other var moved. glow base/alpha, labels, borders, fills, backgrounds all byte-identical.","previous_recheck_unchanged":"Исторический regen также не менял recheck; прежний текст ошибочно называл 2752 числа парами. Фактический объём: 1376 пар (lc, wcag).","previous_numeric_provenance":{"glow-brand-core":"oklch(78.84894% 0.062240 265.472) -> oklch(78.76611% 0.108476 257.256): chroma rises to the blue gamut wall at the functional lightness (fixed-fraction under-saturated it); L ±0.08pp, hue shifts to the cusp.","glow-danger-core":"oklch(81.62208% 0.103545 27.533) -> oklch(81.77103% 0.102764 29.025): near-identical (red already sat near its wall).","glow-warning-core":"oklch(88.80612% 0.081204 68.866) -> oklch(88.88181% 0.082838 68.777): near-identical.","glow-neutral-core":"oklch(99.97226% 0.001314 106.423) -> oklch(100.00000% 0.000000 89.876): neutral hue is achromatic → gamut wall chroma is 0 (was fixed-fraction residue noise); exact achromatic source stays on the neutral ray."},"previous_wave1_scope":"resolveVars: 69 info-* leaves changed across 6 theme×bg entries. Zero non-info drift (scope-checked). recheck untouched."},"endpoint_recovery":{"regenerated":"2026-07-17","reason":"Acceptance is checked before low-contrast dead-zone and physical-endpoint rejection, so previously false-failed opaque border endpoints now resolve.","drift_scope":"Exactly 9 resolveVars leaves added: border-{brand,danger,info}-strong on light #808080, #007AFF and #FF3B30. No existing leaf changed or disappeared.","recheck_unchanged":"Every committed recheck value remains bit-identical; the parity test compares all 2752 numbers before resolveVars.","proof":"agnostic_gates::accepted_endpoint_recovery_keeps_previously_false_failed_borders_legal plus full Rust workspace and WASM boundary parity."}},"recheck":[{"theme":"light","bg":"#FFFFFF","fgs":["#F7F7FF","#F7F7FF","#0A0A10","#71717A","#94949E","#BDBDC7","#94949E","#0A0A10","#00030E","#0057BB","#037BFF","#78AFFF","#150000","#BE0005","#FF070E","#FF9A8C","#1B0D00","#A06300","#D28300","#FFAA3A","#000B01","#007C2D","#00A73F","#00D452","#020013","#4D00F9","#6D6EFF","#9FA9FF"],"flat":[0,1.0658201232723763,0,1.0658201232723763,104.65963437608701,19.74046779866239,70.1623092547118,4.832895561154151,53.96706418364319,3.004779237664955,33.34341874861831,1.8637664731545371,53.96706418364319,3.004779237664955,104.65963437608701,19.74046779866239,105.59876563358553,20.60103636973117,79.91552226990729,6.816302360996974,63.95198968935827,3.97647793086433,41.89234722606997,2.2400949113548063,105.32470288110206,20.35108757719671,78.97384099389187,6.579727828852003,63.90360859563009,3.970705427078051,37.83221395673913,2.048194921340976,103.87315757816087,19.018778265876662,70.62528347734393,4.90716956443272,53.97808064225952,3.0056474129326713,34.232350404909745,1.8982213760304238,104.97702200816887,20.032297013031588,73.2226464627747,5.355840815772706,56.123729301186145,3.182173571701684,36.54497093010938,1.9925549350335838,105.76444562516849,20.751302844974063,83.48658341134008,7.825697912025839,63.67798680778349,3.9439477616440737,40.86459333479343,2.1890112231357013]},{"theme":"light","bg":"#000000","fgs":["#010103","#010103","#F1F1FC","#B3B3BC","#909099","#65656D","#909099","#F1F1FC","#E6F0FF","#60A1FF","#0074F3","#004CA4","#FFF4F2","#FF8273","#F40009","#A80004","#FFF7EE","#F79C00","#C97E00","#8F5800","#C2FFC9","#00C84D","#00A03C","#006E26","#EEF0FF","#9099FF","#6662FF","#4300DB"],"flat":[0,1.0069471255994658,0,1.0069471255994658,-99.67218963360757,18.727928195985914,-63.80513707672789,10.090949031745104,-45.863656882792206,6.635143500688325,-26.33477890397734,3.635637824948841,-45.863656882792206,6.635143500688325,-99.67218963360757,18.727928195985914,-97.97941453819666,18.27263411531394,-53.55180732861014,8.039306941172763,-34.54692380627581,4.792369408161928,-17.418806999279397,2.56984376276454,-102.41236835704379,19.474434420712562,-56.92703132210305,8.692613650917824,-34.93266406694178,4.850563950189369,-18.308962222075785,2.6667193472488013,-103.5613239139708,19.790915044333452,-61.97487030298502,9.71021814570805,-44.901407836051874,6.467843223430967,-25.783665577412222,3.5638173443979557,-98.60837652742211,18.44127602271432,-60.30793382928855,9.368875012646658,-42.707239087904505,6.093571182387199,-23.370874926227668,3.2583602660985775,-98.9883689300502,18.54346384276384,-54.32153113615954,8.18635152260471,-34.304902287955215,4.756029101219059,-14.435475219756622,2.261552965854495]},{"theme":"light","bg":"#808080","fgs":["#7A7A82","#7A7A82","#010103","#18171E","#37363E","#5B5B64","#37363E","#010103","#000002","#00163A","#003272","#004494","#040000","#3A0000","#760002","#990003","#0B0400","#261300","#512F00","#845100","#000200","#001D05","#004114","#006322","#000002","#160059","#2B0097","#3B00C5"],"flat":[0,1.0777393702545288,0,1.0777393702545288,40.18994509287661,5.280525528202377,36.81932911616927,4.505560397043893,27.93898600302036,3.0217055248761144,13.845788611978202,1.7013528597992067,27.93898600302036,3.0217055248761144,40.18994509287661,5.280525528202377,40.3306726916817,5.312553092992316,36.89312592554248,4.521969910173767,28.754774555141132,3.1292028976206594,22.090354756391577,2.35851620111828,40.23106479600237,5.289901483963394,36.823385380889306,4.5064610721321205,27.786796188195932,3.002060282138028,21.02583740042693,2.2570951143110336,39.64148780057383,5.154352713354357,36.89828467875601,4.523118840482662,27.981450040156208,3.027209986332121,13.567318350507929,1.6836851754208233,40.150141663678234,5.271436537678366,36.85731353296706,4.514000513189092,27.852320260403722,3.0105025419148825,16.643886294769857,1.8937047658181707,40.3306726916817,5.312553092992316,36.84988795298528,4.512349526507044,30.916517468784825,3.4313626667845316,25.033605894158644,2.6690076358452903]},{"theme":"light","bg":"#3A3A3C","fgs":["#3E3E45","#3E3E45","#F2F2FD","#BBBBC5","#9E9EA7","#7A7A82","#9E9EA7","#F2F2FD","#E7F1FF","#73ADFF","#2985FF","#0060CB","#FFF5F4","#FF9687","#FF4336","#CD0006","#FFF8F0","#FFA62C","#DC8A00","#AC6B00","#C7FFCC","#00D251","#00B043","#008631","#EFF1FF","#9CA6FF","#767AFF","#532BFF"],"flat":[0,1.0699484563148034,0,1.0699484563148034,-89.06520303275792,10.211241554507897,-56.964055924386784,5.959497869408446,-41.60773958742316,4.2707825803649335,-24.25691832785775,2.6663016206717356,-41.60773958742316,4.2707825803649335,-89.06520303275792,10.211241554507897,-87.32090210121694,9.956824779623638,-48.0046310941281,4.945145596338778,-30.285195552724065,3.1849116126306156,-14.342034884792934,1.9106934427040745,-91.71639880150975,10.602849175762582,-51.97724928251108,5.385053682831725,-31.570923815646545,3.301015045785777,-14.82740446463819,1.94471301206827,-92.86717481243348,10.774663089384916,-55.64998948221256,5.805759082702303,-40.41406674606042,4.149700467279057,-23.7537806706175,2.6249801387527425,-87.99158651504085,10.054343074329001,-53.75183524673264,5.586666992924044,-38.313874917805634,3.940371955014153,-21.037647044419426,2.407284189061088,-88.33220875070053,10.104016013712561,-48.80525652513833,5.032519885671211,-30.915189659062865,3.2415642790980215,-11.323318014385267,1.7063261265170042]},{"theme":"light","bg":"#007AFF","fgs":["#797981","#797981","#020205","#16151C","#36353D","#72727A","#36353D","#020205","#000103","#001536","#003374","#0058BC","#070000","#360000","#730002","#BE0005","#0F0500","#241200","#4F2F00","#A16400","#000400","#001B05","#003F13","#007C2D","#000005","#140055","#3300AC","#4E00FA"],"flat":[0,1.0745907693903731,0,1.0745907693903731,39.47891404105317,5.158402202047827,36.63906407938327,4.513939148043301,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.47891404105317,5.158402202047827,39.65687576920386,5.198408498908002,36.59714090951085,4.504660010775197,27.83078380746869,3.029349552004155,13.31049770996219,1.6744770124926043,39.57933742278427,5.181007308853048,36.66186584356392,4.51899176883356,27.77510172821011,3.0221297438766705,12.721590912296357,1.6379804580418844,38.8564182220264,5.016999835671664,36.579572970042136,4.500775701626332,27.65113132417139,3.006116231224861,0,1.205042331050678,39.39108202991847,5.138573889881769,36.624715730830154,4.510761781161014,27.902773646365304,3.038708829709556,0,1.3333017445114326,39.737197591996,5.216381929144518,36.59037871155487,4.5031645842195545,27.68585404738298,3.010592990868115,17.038043883395044,1.933101474636693]},{"theme":"light","bg":"#FF3B30","fgs":["#8A8A92","#8A8A92","#030306","#212129","#3E3D45","#76767F","#3E3D45","#030306","#000104","#00204D","#003B83","#005CC4","#080000","#4C0001","#830002","#C70006","#0F0600","#331C00","#5B3600","#A76800","#000400","#002809","#004917","#00822F","#000005","#1F0075","#3A00C3","#511AFF"],"flat":[0,1.0356140935636031,0,1.0356140935636031,43.57584669970237,5.806877937534237,38.3004661979112,4.504747008392574,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.57584669970237,5.806877937534237,43.87966672163739,5.884401548408216,38.30767260595269,4.50632138120243,29.16726657041677,3.0308664461202253,15.823096179531001,1.7789458759332724,43.782795788734404,5.8597651770218455,38.402450836988166,4.527103297945568,29.016037258963053,3.011424325654164,14.9864556348382,1.7254955647131072,42.99821792375369,5.657954629703501,38.41622407485322,4.5301350068404425,28.93769973409828,3.0014083479801514,0,1.28054929135364,43.62397616937584,5.819206103341516,38.406807106466914,4.528061863682665,29.022563392947294,3.012260432085235,8.92751562582044,1.3982348229239339,43.970091731453365,5.907320242919889,38.28628402517786,4.501651045763603,28.99214635836292,3.0083657301901288,19.11088367642911,2.0125842767555064]},{"theme":"light","bg":"#101012","fgs":["#15151B","#15151B","#F2F2FC","#B4B4BE","#93939C","#696972","#93939C","#F2F2FC","#E6F0FF","#64A4FF","#0077F9","#0050AC","#FFF4F3","#FF8777","#F9000A","#B00005","#FFF7EF","#FA9E00","#CD8000","#955C00","#C3FFC9","#00CA4E","#00A33D","#007329","#EEF0FF","#939CFF","#6967FF","#4600E6"],"flat":[0,1.045340264415452,0,1.045340264415452,-98.5838326466521,17.08955684747884,-62.72698384194396,9.243246994613658,-45.67498009680545,6.239717532288448,-26.395025771311026,3.4974414103185016,-45.67498009680545,6.239717532288448,-98.5838326466521,17.08955684747884,-96.3221346483494,16.53807546575917,-53.23458352949761,7.5085332653696355,-34.299151794570456,4.531377660721796,-17.34040000814869,2.4827755446650244,-100.79897840003042,17.636700891614694,-56.73966900010428,8.131153426700068,-34.4661942600266,4.554628420782135,-18.26813498707333,2.5778416349364046,-101.94670279716274,17.922901715066683,-61.50032492399525,9.010497025774457,-44.48245067627942,6.049054371037485,-25.983086445749187,3.447217107352778,-97.04991927160123,16.714741751700544,-59.578454823729174,8.650894122604043,-42.30409033566848,5.707629679225331,-23.50941826359813,3.1535587190594883,-97.33108904020293,16.783196253636362,-53.9780650339559,7.638815670143083,-34.15649094558268,4.511565332868746,-14.178333309149869,2.174938899863266]},{"theme":"light","bg":"#F6F8FA","fgs":["#F0F0FA","#F0F0FA","#0A0910","#707079","#8F8F99","#BCBCC5","#8F8F99","#0A0910","#00030D","#0057B9","#0079FD","#74ADFF","#150000","#BC0005","#FD000A","#FF9788","#1B0D00","#9F6200","#CB7F00","#FFA730","#000B01","#007B2C","#00A63E","#00D251","#020013","#4C00F7","#6C6CFF","#9DA6FF"],"flat":[0,1.0634215248159875,0,1.0634215248159875,100.62201386979996,18.61882254505944,66.47667620763235,4.605948460093869,52.23653324893716,3.0084294463712395,29.780700684603257,1.7710808257265196,52.23653324893716,3.0084294463712395,100.62201386979996,18.61882254505944,101.48497819102282,19.361251289328656,75.9364891028185,6.438321694919453,60.636932359482174,3.8278153397206016,38.76699274290234,2.152541489746932,101.19913515757631,19.11640864166299,75.28055256994783,6.281220843840182,60.46094873358925,3.807428346232839,34.70184973776556,1.9658801332164664,99.74758985463511,17.864929125608796,66.9127104243937,4.673002507305443,52.13428535623209,3.0001216660302674,31.16352525448906,1.8227359456035261,100.85145428464311,18.816958763489637,69.48045983543288,5.0977335979175304,52.421867335088024,3.0235758695027473,33.32486172802245,1.9082022515525083,101.63887790164272,19.492343272893066,79.67393134344084,7.442688338927282,60.17031912503092,3.7741072518393395,37.967753096619255,2.113772626279229]},{"theme":"light","bg":"#1C1C1E","fgs":["#202028","#202028","#F1F1FD","#B6B6BF","#95959E","#6D6C75","#95959E","#F1F1FD","#E6F1FF","#67A5FF","#0079FD","#0053B2","#FFF5F3","#FF8A7A","#FD000A","#B60005","#FFF7EF","#FC9F00","#D08200","#9A5F00","#C4FFCA","#00CB4E","#00A53E","#00772A","#EEF1FF","#949EFF","#6B6BFF","#4900EE"],"flat":[0,1.0522076023272475,0,1.0522076023272475,-96.27140987076255,15.184146579953019,-61.97466067394124,8.452688508186561,-44.87094637123084,5.728792540137694,-25.977188885961883,3.283145241842075,-44.87094637123084,5.728792540137694,-96.27140987076255,15.184146579953019,-94.97224440690107,14.900628115594163,-51.99577035710509,6.808135431331698,-33.457001357615106,4.175350120738554,-16.755883343489252,2.3335896778079332,-99.44736446714371,15.886233758087014,-55.869159717403925,7.428487313030341,-33.63398343987836,4.1977071628099685,-17.709012021143003,2.4235530906296177,-100.15616825242344,16.04465607216319,-60.39258141645385,8.181935837052826,-43.77686148964125,5.57032618168592,-25.67861999448382,3.2497240951569792,-95.39392806862337,14.992417327878547,-58.246405974587134,7.820609198173194,-41.3596549106396,5.227261702394524,-23.16505433719768,2.9752806858720673,-95.97861011098902,15.120061263203354,-52.9765437482588,6.963016139185695,-33.57839540296167,4.190679013723035,-13.485138200601865,2.040069034387161]},{"theme":"light","bg":"#7C3AED","fgs":["#707078","#707078","#F4F4FE","#E3E4ED","#BDBDC6","#A6A6B0","#BDBDC6","#F4F4FE","#EBF3FF","#D5E6FF","#92BFFF","#4291FF","#FFF8F7","#FFDDD8","#FFA597","#FF6253","#FFFAF5","#FFE0BE","#FFAA39","#E79100","#D0FFD4","#7DFF93","#00DA54","#00B947","#F1F4FF","#DFE4FF","#AEB8FF","#8087FF"],"flat":[0,1.1608373681184396,0,1.1608373681184396,-73.98396246220521,5.215032461901156,-64.0643931275358,4.500912896664839,-41.75099800228171,3.055051474145459,-29.486682699107686,2.3616035737730514,-41.75099800228171,3.055051474145459,-73.98396246220521,5.215032461901156,-72.43421936609613,5.100687932363686,-64.08586870464498,4.502412953317652,-41.11294430990105,3.017135229848019,-18.888486914529935,1.8251241221961068,-76.90365537296871,5.4332059719499135,-64.06334225320066,4.50083949888721,-40.940708789235714,3.0069340578770496,-21.122834074579163,1.9331720160112165,-77.70409895709017,5.493642615376888,-64.34147182620212,4.520282197700913,-40.849183913847796,3.0015190917179395,-28.18413111501551,2.292444746213784,-72.9506558335481,5.138679113149092,-64.10668565953954,4.5038671985064,-41.2870784084647,3.027463512408998,-26.013778316259767,2.1791858289450103,-73.64084437140687,5.1896286230945075,-64.42349258712335,4.5260223420377566,-41.02780633656069,3.0120908713744514,-19.464187683477977,1.8526993919522168]},{"theme":"light","bg":"#123456","fgs":["#36363D","#36363D","#F2F2FD","#BEBEC8","#A2A2AC","#808089","#A2A2AC","#F2F2FD","#E8F1FF","#79B0FF","#368BFF","#0065D6","#FFF6F4","#FF9B8D","#FF5446","#D80007","#FFF8F1","#FFAB3F","#E18E00","#B47000","#C8FFCD","#00D552","#00B545","#008D34","#EFF1FF","#A0AAFF","#7B81FF","#573FFF"],"flat":[0,1.0609289970892934,0,1.0609289970892934,-91.39519035537737,11.4423466076867,-60.94367397154904,6.896920022598847,-46.020165423777584,5.026435795944436,-29.36949737122932,3.250019698594834,-46.020165423777584,5.026435795944436,-91.39519035537737,11.4423466076867,-89.77514075602531,11.177468536043195,-51.89752791553575,5.733156384550998,-34.996215684604906,3.8114980409485155,-18.92394323577027,2.320895932067837,-94.48648764812391,11.954651961610136,-56.02375667309079,6.252768618847473,-36.81873931987068,4.002056146272223,-19.591172380553846,2.375625103310219,-95.24016771148194,12.080915286992608,-59.85145182180837,6.751641124414219,-44.82390104707481,4.887508677146329,-28.683175933367217,3.184369254481423,-90.45906258458693,11.288985892577246,-57.499124974387314,6.443154991463433,-42.83495757582209,4.660276446547384,-26.099142978345885,2.9428672820648862,-90.66219607331998,11.322193559065964,-52.9772743130274,5.867283737103295,-35.76790053375578,3.8916727925995533,-16.863337707955058,2.1560372721390864]},{"theme":"light","bg":"#ABCDEF","fgs":["#C2C2CB","#C2C2CB","#06060C","#55545D","#706F78","#A1A1AA","#706F78","#06060C","#000209","#00479B","#0065D5","#3289FF","#100000","#9F0004","#D70007","#FF4F41","#170A00","#7A4A00","#9F6200","#DF8C00","#000801","#006322","#00822F","#00B344","#01000D","#3E00CE","#573EFF","#797EFF"],"flat":[0,1.0705446339746656,0,1.0705446339746656,75.64582413051765,12.232204997602986,52.76667497081496,4.519371986307913,41.423484798334584,3.0020546798073307,18.127484103049046,1.5512322351556214,41.423484798334584,3.0020546798073307,75.64582413051765,12.232204997602986,76.22533906008685,12.552007092013115,56.99670466281028,5.352680994694784,44.43662180236468,3.3253647200326193,29.122033451491582,2.0641033245604916,76.01516726706762,12.436514137733148,55.97147975339624,5.133613537650522,43.91010121645754,3.2654000659595037,27.461071919951625,1.9716236837030474,74.81611736383091,11.770648316029503,52.816193854903005,4.528105409994289,41.51373908005361,3.011071841176318,19.679891349505557,1.6112041351931181,75.72958089661125,12.278654436624837,52.80881394476793,4.526802423347816,41.42233250009274,3.001939806510638,21.536768417458934,1.687644517337887,76.3513076320574,12.62087594492386,59.99704797613187,6.063965240585196,46.62694808670478,3.592268428661898,28.6381619838115,2.036506690369533]},{"theme":"light","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"dark","bg":"#FFFFFF","fgs":["#E7E7F1","#D9D9E2","#0D0D12","#707079","#94949D","#BCBCC6","#94949D","#0D0D12","#000414","#0053C4","#1F78FF","#79ACFF","#190000","#BC0016","#FD0021","#FF978D","#1D0B00","#A65B00","#DA7A00","#FFA658","#000E02","#007C2A","#00A63C","#00D24E","#04001A","#5900F2","#7867FF","#A4A4FF"],"flat":[10.564452802176923,1.2281904700369508,18.372353597432387,1.402460683906872,104.27090588423802,19.38311547953439,70.6022439311581,4.903434648448188,54.00450766949271,3.0077315832596954,33.86624309585679,1.8839141891835494,54.00450766949271,3.0077315832596954,104.27090588423802,19.38311547953439,105.42046624663428,20.43859328774187,80.31672424485741,6.9206271592755195,64.53598724808649,4.047139863973963,42.92136358171458,2.2930773168482466,105.12312344447312,20.1664284240786,79.32640956847692,6.666965706057784,64.47466861031803,4.0396342482899685,38.694434835096864,2.0867987099188317,103.94997559222902,19.08898290736351,71.78028267942507,5.099770465607366,55.19920557284124,3.104259347966041,35.17783363962355,1.9359471784913758,104.66191026229393,19.742561272328775,73.25578885512941,5.361938351796974,56.57179613789485,3.2209892436744982,37.49012931672191,2.03317973864909,105.58294145619523,20.58664787402852,83.26627791326895,7.757864847967472,64.57504887916579,4.051931818616954,42.090577540190225,2.2501559682241092]},{"theme":"dark","bg":"#000000","fgs":["#030205","#08080D","#F0F0FA","#B1B1BB","#8F8F98","#64646D","#8F8F98","#F0F0FA","#E3EEFF","#639EFF","#006DFE","#0048AD","#FFF4F3","#FF7F74","#F1001F","#A80012","#FFF7F1","#FF921B","#D17400","#955100","#B8FFBE","#00C649","#009F39","#006E25","#EDEEFF","#9794FF","#725BFF","#4E00D6"],"flat":[0,1.0147465549687367,0,1.0508696650285665,-99.01083964835996,18.549513785944853,-62.774763175831374,9.875849762659984,-45.37711439886134,6.5503119047070335,-25.95111170367181,3.5855589616463144,-45.37711439886134,6.5503119047070335,-99.01083964835996,18.549513785944853,-96.73192161780433,17.93999294470544,-52.63286661912575,7.865273702162647,-33.381528921151094,4.618607106385301,-17.0501576858861,2.5303719690800346,-102.4562582898777,19.486486384763445,-56.148341062851905,8.53993960492931,-34.330992536827104,4.759940239487347,-18.37234103122508,2.6737006902768288,-103.68997976483836,19.82648045930809,-60.35961607275431,9.379380118591195,-43.39157032733722,6.209218370112115,-24.93643207012505,3.4548840566407524,-97.27916136850891,18.085610527348415,-59.34389647192141,9.173841233004914,-42.25887199411116,6.018336366435839,-23.360562325895305,3.2570864547421428,-97.9872053947921,18.274719246988955,-53.323021296304695,7.99582368531923,-33.63351308594995,4.6559156780457185,-14.771084781300106,2.2949481607054216]},{"theme":"dark","bg":"#808080","fgs":["#707078","#66666E","#030305","#18171D","#37363E","#5D5D64","#37363E","#030305","#000104","#00153F","#00317C","#00429F","#070000","#3A0002","#750009","#9B0010","#0C0300","#291200","#562D00","#8B4B00","#000300","#001D05","#004112","#006521","#010005","#1A0057","#350098","#4700C4"],"flat":[0,1.24296889833444,9.274501031402123,1.4407196981948422,39.91548097777799,5.217615096019717,36.836632862345205,4.5094036578339445,27.93898600302036,3.0217055248761144,13.086665786299626,1.6537770693375544,27.93898600302036,3.0217055248761144,39.91548097777799,5.217615096019717,40.20955594591163,5.284998875760269,36.83828085440759,4.509769828130583,28.330761142327287,3.0728704517581886,21.819504217645104,2.3321848000622474,40.142120786515875,5.269603471071473,36.80831529061993,4.503115584720142,27.930310922781615,3.0205822411426944,20.559717681283935,2.214401964672886,39.70511745091868,5.169077378914306,36.80795164205375,4.503034882134433,27.78871707797606,3.002307432697674,14.025581970431295,1.7128949100718707,40.051433392362526,5.248844071123953,36.85731353296706,4.514000513189092,27.870384117529113,3.0128341396537346,15.95953035602916,1.844055168824021,40.26997047853993,5.298759395894901,36.795231110735635,4.500212645278562,30.09285126953815,3.3133648365325183,24.185949678819867,2.5748913454036786]},{"theme":"dark","bg":"#3A3A3C","fgs":["#46464E","#4F4F57","#F1F1FA","#BABAC4","#9D9DA6","#797982","#9D9DA6","#F1F1FA","#E4EFFF","#75AAFF","#3482FF","#005AD5","#FFF6F4","#FF9389","#FF3C3B","#CC0019","#FFF8F2","#FFA350","#E58000","#B36300","#BCFFC2","#00D04D","#00AF3F","#00862E","#EEEFFF","#A2A1FF","#7F74FF","#601DFF"],"flat":[0,1.2140718035663858,0,1.3988859584097761,-88.35603271482154,10.107493939375463,-56.41682578047019,5.8952705955406675,-41.10063170437542,4.219157833086703,-23.840055313660137,2.6320437618590202,-41.10063170437542,4.219157833086703,-88.35603271482154,10.107493939375463,-86.07057705487603,9.776047393674569,-47.005198509651805,4.836992692850337,-29.617188814986996,3.125340410912816,-13.465221764046625,1.8500449052652255,-92.15650032550448,10.66842694645063,-51.122226750198095,5.289032001141636,-30.69564720892069,3.221769980092269,-14.697188723997265,1.9355550836437716,-92.95341233620333,10.787583067801071,-55.01595924446716,5.732183607436098,-39.01368610071904,4.00959458617392,-22.717127042375882,2.5408179478715844,-86.56463892830851,9.847321829129962,-52.766100540364825,5.474290510931042,-37.83315401910713,3.893128667535661,-21.00389596511428,2.404636707585164,-87.32881842479753,9.957973594755346,-47.6452882768299,4.906141960838558,-29.99057738527554,3.1585747205458907,-10.990631160230787,1.6845762464445797]},{"theme":"dark","bg":"#007AFF","fgs":["#6F6F77","#65656D","#040408","#16151B","#36353D","#72727A","#36353D","#040408","#000107","#00133B","#00317C","#0054C7","#0B0000","#360002","#730009","#BF0016","#100500","#261000","#542C00","#A85D00","#000501","#001B04","#004012","#007D2B","#010009","#180052","#3B00A6","#5A00F5"],"flat":[0,1.2399763057879079,9.137153276370956,1.4379357071162235,39.199487305374596,5.095159290353486,36.655643103420836,4.517612479062309,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.199487305374596,5.095159290353486,39.616537893860624,5.189361828546554,36.671589094740156,4.521147554384925,27.767977778595686,3.0212072610618272,13.560368041515986,1.6902988819780091,39.46146930028483,5.1544680971312,36.646426655727396,4.515570174173857,27.733751845040576,3.016779185756929,12.426352071081123,1.6200937148513173,38.82022467282525,5.008731246970903,36.66757146424993,4.520256699490143,27.6755983348751,3.009270049666561,0,1.2356833222737893,39.28492308543262,5.114543979353608,36.632422880871374,4.512468289727634,27.610461383700073,3.00088106913278,0,1.3173837917708568,39.666598999480925,5.200587158978297,36.5946359240296,4.504106003407952,27.684199915070796,3.0103795763779906,16.582944142840912,1.898781610023956]},{"theme":"dark","bg":"#FF3B30","fgs":["#7F7F87","#74747D","#040408","#212128","#3E3D45","#76767F","#3E3D45","#040408","#000107","#001E53","#00398D","#0058CF","#0C0000","#4C0004","#83000C","#C60018","#110500","#371A00","#603300","#AE6000","#000601","#002808","#004916","#00822D","#01000A","#250070","#4400BC","#5E01FF"],"flat":[0,1.119564506502328,0,1.304916965157494,43.43238144483196,5.770041002680692,38.322111022898454,4.5094781825179275,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.43238144483196,5.770041002680692,43.84943203331798,5.876721182230661,38.357196991852035,4.517162998132957,28.969046969390032,3.0054117916551304,16.078397205708793,1.7957132708136676,43.66281217184597,5.829141814712315,38.38176722933373,4.522556047220382,28.96308732518828,3.004650205577566,15.121014183458136,1.7339400568635355,43.01534021323247,5.66238735865634,38.36369120360285,4.518587529333456,28.956857085057415,3.003854272405285,0,1.32359073643957,43.42248485866928,5.7674953973596095,38.41370496023468,4.529580289565808,29.031977011275746,3.0134669332394415,8.949958172317846,1.3992774406692907,43.88937786124038,5.8868666342953615,38.32550423955951,4.510220548286027,28.94413025668513,3.002229117722787,19.264807559936084,2.024522036885816]},{"theme":"dark","bg":"#101012","fgs":["#1C1C22","#24242A","#F0F0FA","#B3B3BD","#92929B","#686871","#92929B","#F0F0FA","#E3EEFF","#66A0FF","#0E71FF","#004CB5","#FFF5F3","#FF8378","#F60020","#AF0013","#FFF7F1","#FF962A","#D57700","#9B5500","#B9FFBF","#00C849","#00A23A","#007226","#EDEEFF","#9997FF","#7460FF","#5200DF"],"flat":[0,1.1210017368042908,0,1.2316217101091165,-97.35355975851269,16.788671896406843,-62.18912907483276,9.1408846014785,-45.183954583215964,6.160892481462449,-25.971291980892453,3.445784610130369,-45.183954583215964,6.160892481462449,-97.35355975851269,16.788671896406843,-95.07464172795706,16.23701078357789,-51.883866319617056,7.27432706128743,-33.10437599843017,4.366728841176053,-16.962076325331164,2.444617555127284,-101.23789901188303,17.745933910216884,-55.660852739640006,7.937252290960222,-33.86013630190819,4.470540437936136,-18.127983335051407,2.5633448249431416,-102.03269987499108,17.94442049171427,-60.01549050114797,8.732123022916136,-43.24478677485694,5.853976070416037,-25.11442835418257,3.342537791539289,-95.74776769254933,16.399183837627458,-58.596724506079966,8.469603286006345,-41.852118066322895,5.637910373772879,-23.122294138224145,3.108849028833141,-96.32992550494482,16.539962663017093,-52.86610846158114,7.444323350911882,-33.336065162067605,4.398429825394501,-14.383943910910213,2.194178799785328]},{"theme":"dark","bg":"#F6F8FA","fgs":["#E0E0EA","#D2D2DC","#0D0D12","#717179","#8F8F98","#BCBCC6","#8F8F98","#0D0D12","#000414","#0053C4","#1F78FF","#79ACFF","#190000","#BC0016","#FD0021","#FF978D","#1D0B00","#A65B00","#D67800","#FFA658","#000E02","#007C2A","#00A63C","#00D24E","#04001A","#5900F2","#7867FF","#A4A4FF"],"flat":[10.34292311627503,1.2313464424988367,18.024461551245693,1.4097592283337514,100.14533816071224,18.207162386274927,66.07133001527934,4.544848448940367,52.273633182943,3.011452358576948,29.740675372331037,1.769619110018133,52.273633182943,3.011452358576948,100.14533816071224,18.207162386274927,101.29489852310851,19.198605473400605,76.19115652133168,6.500760037097026,60.41041952456072,3.801604173546957,38.795795858188825,2.153958743950203,100.99755572094737,18.94295256384705,75.20084184495119,6.262487955668726,60.349100886792264,3.7945539156195487,34.56886711157111,1.9601948416949677,99.82440786870325,17.93087452582946,67.65471495589931,4.7903727911067815,52.29898260017819,3.0135204434714913,31.0522659160978,1.8184953129572932,100.53634253876818,18.544800983402446,69.13022113160365,5.036635229225181,52.44622841436909,3.025575236651027,33.364561593196164,1.9098288766402804,101.45737373266948,19.337677744697757,79.14071018974319,7.287203401685083,60.44948115564005,3.8061054053754897,37.96500981666448,2.1136413881018767]},{"theme":"dark","bg":"#1C1C1E","fgs":["#28282E","#303036","#F0F1FA","#B5B5BE","#94949D","#6C6C74","#94949D","#F0F1FA","#E3EEFF","#69A2FF","#1975FF","#004FBB","#FFF5F3","#FF877C","#FA0021","#B50014","#FFF7F1","#FF9833","#D87900","#A05800","#B9FFC0","#00CA4A","#00A43B","#007628","#EDEEFF","#9B99FF","#7664FF","#5500E7"],"flat":[0,1.1612081374032734,0,1.2977584057597347,-96.00102363023473,15.124963081358866,-61.434200333395395,8.359778481952823,-44.37695589123639,5.656999216809453,-25.852207004995158,3.269134017520218,-44.37695589123639,5.656999216809453,-96.00102363023473,15.124963081358866,-93.28410718321774,14.535439506623602,-51.011442228336165,6.654206660130301,-32.65955270222981,4.075313081844305,-16.370466566638967,2.297770413224217,-99.44736446714371,15.886233758087014,-55.07262883280597,7.299026812522581,-33.02825162884881,4.121422426501604,-17.569909707299868,2.4103015359642868,-100.24216533025177,16.063919770413232,-58.9183675206033,7.932997007393653,-42.52780974275247,5.391835753982905,-24.79898612463087,3.1522681596337323,-93.99035989085725,14.687777164248876,-57.733604751916815,7.735299184328377,-40.905085629416114,5.1638402024196015,-22.782862127638978,2.93464773079297,-94.53939096020551,14.80664328764592,-51.95162400089297,6.801199303451522,-32.707141977036414,4.081250761977603,-13.69412377461175,2.0581050526656677]},{"theme":"dark","bg":"#7C3AED","fgs":["#7A7A83","#85858D","#F2F2FD","#E3E4ED","#BBBCC5","#A6A6AF","#BBBCC5","#F2F2FD","#E8F1FF","#D5E6FF","#95BEFF","#498FFF","#FFF9F8","#FFDDD8","#FFA59B","#FF5F57","#FFFAF6","#FFDFC5","#FFA95E","#F18700","#C6FFCB","#7DFF91","#00DA51","#00B943","#F0F1FF","#E1E3FF","#B4B6FF","#8982FF"],"flat":[-8.022975593833932,1.3402714370624624,-13.06659651622355,1.5567826841029107,-72.79588351600884,5.127281558149197,-64.0643931275358,4.500912896664839,-41.08533982136385,3.0154992993085026,-29.446612137967147,2.3594628663401775,-41.08533982136385,3.0154992993085026,-72.79588351600884,5.127281558149197,-71.17583391665679,5.008590480308264,-64.08586870464498,4.502412953317652,-40.969901639859465,3.0086620775128243,-18.492326685553408,1.8062558438836862,-77.39176152066469,5.470028106981068,-64.06334225320066,4.50083949888721,-41.06186552000793,3.0141084284175577,-20.58109958398818,1.9067218663179524,-77.74802214310357,5.496966722090798,-64.16882972322054,4.508209630901898,-40.97688137217631,3.00907529195687,-26.93048349706687,2.2267208299060117,-71.5855645088526,5.038503428518723,-64.0583201718401,4.500488740880354,-41.24499881268402,3.0249663274797034,-25.961518214862377,2.176489332337808,-72.19208007215988,5.082914152992658,-64.24580556994597,4.513590807561596,-40.91738125342747,3.005553523356943,-18.74578210381434,1.818317299241165]},{"theme":"dark","bg":"#123456","fgs":["#3E3E45","#47474E","#F0F0FC","#BDBDC6","#A1A1AA","#7F7F88","#A1A1AA","#F0F0FC","#E5EFFF","#7AADFF","#3D87FF","#005FDF","#FFF6F5","#FF988E","#FF4D48","#D5001A","#FFF8F3","#FFA75A","#EA8300","#BA6700","#BDFFC3","#00D34E","#00B341","#008C31","#EEEFFF","#A5A5FF","#837AFF","#6433FF"],"flat":[0,1.1989453999458657,0,1.3802668927515667,-90.21171279539291,11.248602775503162,-60.350304841650235,6.81783465078813,-45.467647083484636,4.962060105853706,-28.90763677945104,3.2057711392885686,-45.467647083484636,4.962060105853706,-90.21171279539291,11.248602775503162,-88.52359832003891,10.974551054175622,-50.82334067294272,5.601029591424501,-33.90019185783247,3.6989242542195706,-17.94937633357589,2.2421371448335408,-94.53061684959366,11.962030249162733,-55.11693338695639,6.136943021617957,-35.65662848796355,3.880065704029081,-19.010500723408704,2.3279588656978594,-95.32685818617963,12.095472485308715,-58.832789738116276,6.617312123916825,-43.099834270009964,4.690265818580579,-27.202021451205145,3.0448373808729228,-89.02366089613628,11.055448870118658,-56.507119659393844,6.314879036055816,-41.91393002442956,4.556652813501584,-25.675484951698337,2.9041378624661793,-89.65880574741696,11.158543725868578,-51.69548126800453,5.708204299589472,-34.40239467659594,3.750316223165759,-16.078998268001875,2.0949612264666513]},{"theme":"dark","bg":"#ABCDEF","fgs":["#B4B4BE","#A8A8B1","#09090F","#55545C","#706F78","#A1A1AB","#706F78","#09090F","#00020F","#0045A5","#0060E2","#3F88FF","#140000","#A10011","#D7001B","#FF4F49","#190900","#814600","#A85D00","#EA8300","#000A01","#006320","#00822D","#00B341","#030014","#4A00CC","#6537FF","#847BFF"],"flat":[8.489900665165298,1.2445786124510558,14.633674840112363,1.428005718026211,75.26124671135368,12.018395958758797,52.798813706484495,4.5250375920814765,41.423484798334584,3.0020546798073307,18.08917030758312,1.5497949541740348,41.423484798334584,3.0020546798073307,75.26124671135368,12.018395958758797,76.15878035868923,12.51550617725489,56.792209524954714,5.308053794065892,44.82231091099692,3.370285744389143,29.010809014184062,2.057710850183084,75.84645644595844,12.343365988484052,55.500674792931,5.036843578382145,43.81499067450534,3.25473208522667,27.348178994525146,1.9655632934834584,74.80511978941351,11.764533211175078,52.793708756474935,4.52413702940673,41.44647230556496,3.0043476386355414,20.422200521634597,1.6411239547419576,75.54552640875083,12.176514522007174,52.82929323710673,4.530419449281277,41.44477504659015,3.004178254346042,21.57431352051131,1.6892460110571705,76.19495675605003,12.535354496536943,59.24767070343283,5.876074797996776,46.58341111523108,3.5866744685580465,28.54022915013225,2.030988108287992]},{"theme":"dark","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"light-ic","bg":"#FFFFFF","fgs":["#F7F7FF","#F7F7FF","#0A0A10","#71717A","#94949E","#BDBDC7","#94949E","#0A0A10","#000110","#0042E2","#3B78FF","#85AEFF","#150000","#BD0011","#FF001B","#FF998D","#1A0500","#B04A00","#E96400","#FFA174","#000B01","#007C2D","#00A73F","#00D452","#00040B","#00639C","#0089D6","#4DB3FF"],"flat":[0,1.0658201232723763,0,1.0658201232723763,104.65963437608701,19.74046779866239,70.1623092547118,4.832895561154151,53.96706418364319,3.004779237664955,33.34341874861831,1.8637664731545371,53.96706418364319,3.004779237664955,104.65963437608701,19.74046779866239,105.76809306682351,20.754602558566376,81.70242596755779,7.298044196933449,63.72862579130605,3.9499301799077116,41.321899652634976,2.211520376313395,105.32470288110206,20.35108757719671,79.14032751842986,6.6207260543602775,64.03544238362204,3.986463961054262,38.10332212672181,2.0602150617562085,104.62285904737456,19.706640245810163,73.93672186564804,5.489431142391079,57.895853978119064,3.3399091341000195,36.26437665881752,1.9807324696579875,104.97702200816887,20.032297013031588,73.2226464627747,5.355840815772706,56.123729301186145,3.182173571701684,36.54497093010938,1.9925549350335838,105.53601527934143,20.543945015271454,78.36297683006073,6.432254688155311,62.2832355663668,3.784280156272109,42.68471757482252,2.28072562681704]},{"theme":"light-ic","bg":"#000000","fgs":["#010103","#010103","#F1F1FC","#B3B3BC","#909099","#65656D","#909099","#F1F1FC","#E8F0FF","#709FFF","#2D6DFF","#0038C6","#FFF4F3","#FF8275","#F30019","#A7000E","#FFF6F1","#FF8C52","#DF5F00","#9D4100","#C2FFC9","#00C84D","#009F3C","#006E27","#DFF0FF","#10A5FF","#0082CB","#005689"],"flat":[0,1.0069471255994658,0,1.0069471255994658,-99.67218963360757,18.727928195985914,-63.80513707672789,10.090949031745104,-45.863656882792206,6.635143500688325,-26.33477890397734,3.635637824948841,-45.863656882792206,6.635143500688325,-99.67218963360757,18.727928195985914,-98.22794037003116,18.339196272629252,-53.8293996799751,8.092203727319841,-34.21813658812841,4.7430333536498335,-15.622162303180753,2.381112763636304,-102.4562582898777,19.486486384763445,-56.97432424245902,8.701923574139988,-34.76332175899368,4.824974728357804,-18.151652148372637,2.6494391947645526,-103.24826961310278,19.70448161329645,-59.10147589327934,9.125072654774526,-40.787696351505694,5.7744786453226835,-22.81212855995232,3.1897383862050708,-98.60837652742211,18.44127602271432,-60.30793382928855,9.368875012646658,-42.29572232514961,6.0245037012352585,-23.381466511664765,3.259668821930569,-97.12909045511768,18.04563033112125,-52.54162933208091,7.848085216280267,-36.273216344700636,5.055412083856902,-18.54110013749204,2.69234396770907]},{"theme":"light-ic","bg":"#808080","fgs":["#7A7A82","#7A7A82","#010103","#18171E","#37363E","#5B5B64","#37363E","#010103","#000002","#00104D","#002488","#0032B2","#040000","#3A0002","#750007","#98000B","#080100","#2E0E00","#602500","#8F3B00","#000200","#001D05","#004114","#006322","#000102","#00192E","#003A5F","#004D7C"],"flat":[0,1.0777393702545288,0,1.0777393702545288,40.18994509287661,5.280525528202377,36.81932911616927,4.505560397043893,27.93898600302036,3.0217055248761144,13.845788611978202,1.7013528597992067,27.93898600302036,3.0217055248761144,40.18994509287661,5.280525528202377,40.3306726916817,5.312553092992316,36.799792142283025,4.501224406456354,30.04772679653348,3.3069997994324063,23.797727835383583,2.533076638038498,40.23106479600237,5.289901483963394,36.80831529061993,4.503115584720142,27.941932493369514,3.022087143525339,21.190215752118235,2.2723970292907865,40.01441400964806,5.240352767492206,36.83929336929951,4.509994812829007,27.908315541084118,3.0177360720288915,16.77490328531677,1.9034128352995776,40.150141663678234,5.271436537678366,36.85731353296706,4.514000513189092,27.852320260403722,3.0105025419148825,16.643886294769857,1.8937047658181707,40.22977489962569,5.289607582090012,36.84755783504637,4.511831555904809,27.796281383025352,3.0032808914145437,21.058698283639867,2.260143784790762]},{"theme":"light-ic","bg":"#3A3A3C","fgs":["#3E3E45","#3E3E45","#F2F2FD","#BBBBC5","#9E9EA7","#7A7A82","#9E9EA7","#F2F2FD","#EAF1FF","#81ABFF","#4982FF","#0048F5","#FFF5F4","#FF9589","#FF403B","#CC0013","#FFF7F3","#FF9E6E","#F46900","#BD5000","#C6FFCC","#00D251","#00B043","#008631","#E1F1FF","#45B0FF","#0090E1","#006CA9"],"flat":[0,1.0699484563148034,0,1.0699484563148034,-89.06520303275792,10.211241554507897,-56.964055924386784,5.959497869408446,-41.60773958742316,4.2707825803649335,-24.25691832785775,2.6663016206717356,-41.60773958742316,4.2707825803649335,-89.06520303275792,10.211241554507897,-87.69551010135778,10.011246151186945,-48.263751486481056,4.97335266465752,-30.44825366943375,3.199530942583609,-12.043546933756078,1.753943193668956,-91.71639880150975,10.602849175762582,-51.72679347557047,5.3568510977778265,-31.21621892784593,3.268795397886287,-14.661523244642579,1.9330507439017046,-92.55409442667087,10.727809933310253,-53.89547122979923,5.603122076267749,-35.95806640240085,3.7112655792375078,-20.04367187639212,2.329915503818876,-87.89039906251095,10.039605852107476,-53.75183524673264,5.586666992924044,-38.313874917805634,3.940371955014153,-21.037647044419426,2.407284189061088,-86.58838125370931,9.850752189993974,-46.78872683576137,4.81370216625391,-31.383244337404896,3.2839492580871354,-15.736559790538532,2.009284883552937]},{"theme":"light-ic","bg":"#007AFF","fgs":["#797981","#797981","#020205","#16151C","#36353D","#72727A","#36353D","#020205","#000004","#000E48","#002895","#0042E3","#070000","#360001","#730006","#BE0011","#0C0200","#2B0D00","#5F2400","#B04A00","#000400","#001B05","#003F13","#007C2D","#000103","#00172B","#00395C","#00639D"],"flat":[0,1.0745907693903731,0,1.0745907693903731,39.47891404105317,5.158402202047827,36.63906407938327,4.513939148043301,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.47891404105317,5.158402202047827,39.74741500641279,5.218664231374835,36.670023859840576,4.5208004701163915,27.672031623266886,3.008810094200282,15.351341296852958,1.8098737923818007,39.57933742278427,5.181007308853048,36.654144364688555,4.517280323585742,27.751464091733354,3.019069960427185,12.671992881770905,1.6349566993987985,39.23716411822194,5.10371254671437,36.601471319299684,4.505617849545331,27.652865692456807,3.006339685224649,7.684471784052528,1.3665581876464832,39.39108202991847,5.138573889881769,36.624715730830154,4.510761781161014,27.902773646365304,3.038708829709556,0,1.3333017445114326,39.65687576920386,5.198408498908002,36.65150522712613,4.5166954713046765,27.64129393913477,3.0048491045091428,12.054359140378313,1.5979376774972005]},{"theme":"light-ic","bg":"#FF3B30","fgs":["#8A8A92","#8A8A92","#030306","#212129","#3E3D45","#76767F","#3E3D45","#030306","#000004","#001864","#002FA8","#0045ED","#080000","#4C0003","#830008","#C60012","#0D0200","#3E1500","#6C2A00","#B84D00","#000401","#002809","#004917","#00822F","#000103","#00233D","#00416A","#0068A4"],"flat":[0,1.0356140935636031,0,1.0356140935636031,43.57584669970237,5.806877937534237,38.3004661979112,4.504747008392574,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.57584669970237,5.806877937534237,43.98030914587015,5.909904848562026,38.293478372508915,4.503221171740978,28.94317626341717,3.002107337321695,17.95394033246701,1.9258322867444517,43.782795788734404,5.8597651770218455,38.38866051296957,4.524070782124866,28.984608537472,3.0074014381148784,15.156957289606964,1.7362054736619685,43.437607341847624,5.771384990646562,38.33399448678559,4.512078832150148,28.981628167432756,3.007020263830111,9.892674202630067,1.4441010369850493,43.614187977218386,5.816700203526642,38.406807106466914,4.528061863682665,29.022563392947294,3.012260432085235,8.92751562582044,1.3982348229239339,43.88976990866122,5.886966133555759,38.401524303038336,4.526899458852529,29.03631728859714,3.0140233900654434,14.306925700285792,1.683714708917422]},{"theme":"light-ic","bg":"#101012","fgs":["#15151B","#15151B","#F2F2FC","#B4B4BE","#93939C","#696972","#93939C","#F2F2FC","#E9F0FF","#74A2FF","#3371FF","#003CD0","#FFF4F3","#FF8679","#F8001A","#AF000F","#FFF6F2","#FF9058","#E36100","#A44400","#C3FFC9","#00CA4E","#00A33D","#007329","#E0F0FF","#21A8FF","#0085D0","#005B90"],"flat":[0,1.045340264415452,0,1.045340264415452,-98.5838326466521,17.08955684747884,-62.72698384194396,9.243246994613658,-45.67498009680545,6.239717532288448,-26.395025771311026,3.4974414103185016,-45.67498009680545,6.239717532288448,-98.5838326466521,17.08955684747884,-96.69585202312288,16.62869936569993,-53.53835262595417,7.561646720391368,-33.90853669724825,4.477228400656394,-15.643424374972522,2.314414627530853,-100.79897840003042,17.636700891614694,-56.5097869254783,8.089667628698802,-34.29633291417122,4.530985783372177,-18.10601775891745,2.56107712884258,-101.63443561648342,17.844851007069916,-58.71630179001587,8.4915976550775,-40.34850051574898,5.4087697654311055,-22.889197327723014,3.0820931228283226,-97.04991927160123,16.714741751700544,-59.578454823729174,8.650894122604043,-42.30409033566848,5.707629679225331,-23.50941826359813,3.1535587190594883,-95.5914257275877,16.361459308701495,-52.26386479821355,7.339891411251783,-35.96637326948896,4.765958094131511,-18.710865986535588,2.6239504003951875]},{"theme":"light-ic","bg":"#F6F8FA","fgs":["#F0F0FA","#F0F0FA","#0A0910","#707079","#8F8F99","#BCBCC5","#8F8F99","#0A0910","#000110","#0041DF","#3876FF","#82ACFF","#140000","#BB0011","#FC001B","#FF968A","#1A0500","#AE4900","#E76300","#FF9F70","#000B01","#007B2C","#00A63F","#00D252","#00040B","#00629B","#0088D4","#47B1FF"],"flat":[0,1.0634215248159875,0,1.0634215248159875,100.62201386979996,18.61882254505944,66.47667620763235,4.605948460093869,52.23653324893716,3.0084294463712395,29.780700684603257,1.7710808257265196,52.23653324893716,3.0084294463712395,100.62201386979996,18.61882254505944,101.64252534329776,19.49544279635548,78.08171358923367,6.990900026896573,60.31711036263554,3.7908833425501425,38.14751646036453,2.122399526754989,101.24542779029852,19.156162076239593,75.44574141529571,6.3202869230281635,60.62673294996029,3.82662941345977,34.94277926170305,1.9762445240799382,100.4972913238488,18.51105924752946,70.36281029021609,5.256397052662421,54.37780741212484,3.1906001595018556,32.840214506002376,1.8885138214456323,100.85145428464311,18.816958763489637,69.48045983543288,5.0977335979175304,52.4093478352768,3.0225491335598114,33.31123457222728,1.9076443919396007,101.41044755581565,19.297565623167603,74.60712675826916,6.125340559948453,58.63177587043786,3.604653750954651,39.55009522975671,2.1915837971666887]},{"theme":"light-ic","bg":"#1C1C1E","fgs":["#202028","#202028","#F1F1FD","#B6B6BF","#95959E","#6D6C75","#95959E","#F1F1FD","#E9F1FF","#76A4FF","#3875FF","#003ED7","#FFF4F3","#FF897C","#FC001B","#B50010","#FFF6F2","#FF935D","#E66300","#A94600","#C4FFCA","#00CB4F","#00A53F","#00772B","#E0F0FF","#2BA9FF","#0087D3","#005E95"],"flat":[0,1.0522076023272475,0,1.0522076023272475,-96.27140987076255,15.184146579953019,-61.97466067394124,8.452688508186561,-44.87094637123084,5.728792540137694,-25.977188885961883,3.283145241842075,-44.87094637123084,5.728792540137694,-96.27140987076255,15.184146579953019,-95.34499825068502,14.98175500729861,-52.621417231568934,6.906762415988252,-33.47849770408375,4.178062617945301,-14.893749254597887,2.163552488849563,-99.00844385529109,15.788447906049281,-55.63494162352738,7.390318911682041,-33.467257033822236,4.176644120449875,-17.547592326855693,2.408179342981014,-99.84390107174409,15.974784754119627,-57.911424929350886,7.764836527735112,-39.59013024218227,4.982347876116714,-22.33006111099907,2.8868875311347497,-95.39392806862337,14.992417327878547,-58.26045217149408,7.822951516486187,-41.372488477704024,5.229057297458628,-23.176065198031452,2.976455642863797,-93.8008911828484,14.646846343308852,-51.05892490110174,6.66159703590636,-35.06283530237813,4.380256350040106,-18.07990151348513,2.4590883821528062]},{"theme":"light-ic","bg":"#7C3AED","fgs":["#707078","#707078","#F4F4FE","#E3E4ED","#BDBDC6","#A6A6B0","#BDBDC6","#F4F4FE","#EDF3FF","#D8E6FF","#9ABDFF","#598FFF","#FFF8F7","#FFDDD8","#FFA59A","#FF6156","#FFF9F7","#FFDECE","#FFA77C","#FF7112","#D0FFD4","#7DFF93","#00D955","#00B947","#E5F3FF","#CEE8FF","#79C3FF","#0099EE"],"flat":[0,1.1608373681184396,0,1.1608373681184396,-73.98396246220521,5.215032461901156,-64.0643931275358,4.500912896664839,-41.75099800228171,3.055051474145459,-29.486682699107686,2.3616035737730514,-41.75099800228171,3.055051474145459,-73.98396246220521,5.215032461901156,-72.68698719753608,5.119268434409956,-64.43727527190525,4.526987198998707,-41.01457995095163,3.0113075347043563,-19.296469937711088,1.8446469211142276,-76.90365537296871,5.4332059719499135,-64.06334225320066,4.50083949888721,-41.0312066106476,3.012292267380192,-20.969079339398604,1.925648566411713,-77.34727635116013,5.466668081501757,-64.08510797117285,4.502359813071005,-40.89305028080999,3.004113887137535,-23.84545366268376,2.068528569757355,-72.9506558335481,5.138679113149092,-64.10668565953954,4.5038671985064,-40.827825177303865,3.0002560129496776,-26.013778316259767,2.1791858289450103,-71.69069599862902,5.046190228559761,-64.15987367011111,4.5075837056249375,-40.85863800181002,3.0020782433474276,-19.258832894479415,1.8428420367238694]},{"theme":"light-ic","bg":"#123456","fgs":["#36363D","#36363D","#F2F2FD","#BEBEC8","#A2A2AC","#808089","#A2A2AC","#F2F2FD","#EAF1FF","#86AEFF","#5189FF","#0650FF","#FFF6F4","#FF9B8F","#FF5349","#D70015","#FFF7F3","#FFA376","#FA6C00","#C75400","#C8FFCD","#00D553","#00B445","#008D34","#E2F1FF","#50B4FF","#0094E7","#0072B3"],"flat":[0,1.0609289970892934,0,1.0609289970892934,-91.39519035537737,11.4423466076867,-60.94367397154904,6.896920022598847,-46.020165423777584,5.026435795944436,-29.36949737122932,3.250019698594834,-46.020165423777584,5.026435795944436,-91.39519035537737,11.4423466076867,-90.02549742397721,11.218239018758677,-52.117590678997175,5.760385990810306,-35.453068631322715,3.8588722418561026,-17.16315496591595,2.1796295803105563,-94.48648764812391,11.954651961610136,-56.08049309972586,6.260045669338659,-36.68653001054492,3.9880923379680597,-19.42498133620683,2.3619323117143227,-94.88408174929032,12.021194381023134,-58.024655972390946,6.511549338355004,-40.19653971715428,4.366167533936967,-25.014063203920518,2.8441658803090872,-90.45906258458693,11.288985892577246,-57.51342916294683,6.44501257640493,-42.39985031117064,4.611196053798779,-26.099142978345885,2.9428672820648862,-89.03890865199516,11.05791933389335,-51.142776043878534,5.640183677550314,-35.55629387962385,3.86961291577043,-20.57349953874105,2.4573829383449617]},{"theme":"light-ic","bg":"#ABCDEF","fgs":["#C2C2CB","#C2C2CB","#06060C","#55545D","#706F78","#A1A1AA","#706F78","#06060C","#00010B","#0035BB","#044EFF","#4E86FF","#0F0000","#9F000C","#D60015","#FF4D45","#150400","#903B00","#BB4F00","#F86B00","#000801","#006322","#008230","#00B344","#000307","#005182","#0071B2","#0093E5"],"flat":[0,1.0705446339746656,0,1.0705446339746656,75.64582413051765,12.232204997602986,52.76667497081496,4.519371986307913,41.423484798334584,3.0020546798073307,18.127484103049046,1.5512322351556214,41.423484798334584,3.0020546798073307,75.64582413051765,12.232204997602986,76.30385323493446,12.594966222228084,58.55230095328762,5.70794052541694,46.62204712410211,3.5916381056717506,29.005214449343097,2.0573900879913625,76.05323451380515,12.457481581934719,55.93606418979168,5.126251708978212,44.077718465236906,3.284322001440957,27.730078776096413,1.986177453897667,75.43283879959024,12.113874624281086,52.77586207835023,4.520990578468311,41.44187963136357,3.003889327255304,23.868980516769618,1.7916338607346896,75.72958089661125,12.278654436624837,52.80881394476793,4.526802423347816,41.41072061989075,3.0007825637465393,21.536768417458934,1.687644517337887,76.14729893560808,12.509202438867769,55.739334564053664,5.085602713702602,43.07798550292857,3.173723442083943,28.271676351762963,2.015968674447834]},{"theme":"light-ic","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"dark-ic","bg":"#FFFFFF","fgs":["#E7E7F1","#D9D9E2","#0D0D12","#707079","#94949D","#BCBCC6","#94949D","#0D0D12","#000511","#005DAD","#0080EB","#67AEFF","#180001","#BB0023","#FB0032","#FF9690","#1D0D00","#A16200","#D38200","#FFA93F","#000E02","#007C2A","#00A63B","#00D24D","#060018","#6E00E2","#8F5BFF","#B49FFF"],"flat":[10.564452802176923,1.2281904700369508,18.372353597432387,1.402460683906872,104.27090588423802,19.38311547953439,70.6022439311581,4.903434648448188,54.00450766949271,3.0077315832596954,33.86624309585679,1.8839141891835494,54.00450766949271,3.0077315832596954,104.27090588423802,19.38311547953439,105.36973966240839,20.39226096069594,79.16304803673168,6.626348275559002,63.912632944603466,3.971781216419874,43.318131952880584,2.3140155027501375,105.16667360757414,20.20636839933495,79.43361118518992,6.693805381831162,64.79709340122825,4.079330310903514,38.90470830731088,2.096381256670562,103.76723276112338,18.92212884965907,70.7453629185683,4.926701923645705,54.109273202798526,3.0160152686700097,34.512224892089854,1.9092708836328856,104.66191026229393,19.742561272328775,73.25578885512941,5.361938351796974,56.58363991444959,3.2220248289870597,37.502971296697396,2.0337403797475795,105.5639998705614,20.569417077144905,82.76169093861434,7.605398027541734,65.05029978451972,4.11090668779679,42.03743585537794,2.2474520733771812]},{"theme":"dark-ic","bg":"#000000","fgs":["#030205","#08080D","#F0F0FA","#B1B1BB","#8F8F98","#64646D","#8F8F98","#F0F0FA","#DFEEFF","#4AA0FF","#007AE0","#005198","#FFF4F3","#FF7D79","#EF0030","#A6001E","#FFF7EF","#F99A00","#CA7D00","#915800","#B8FFBE","#00C648","#009F38","#006E24","#F0EDFF","#A98DFF","#894CFF","#6000C7"],"flat":[0,1.0147465549687367,0,1.0508696650285665,-99.01083964835996,18.549513785944853,-62.774763175831374,9.875849762659984,-45.37711439886134,6.5503119047070335,-25.95111170367181,3.5855589616463144,-45.37711439886134,6.5503119047070335,-99.01083964835996,18.549513785944853,-96.24720863865953,17.81140932471069,-52.09132740819334,7.763491481432673,-34.99617079306692,4.860177125212619,-17.977340963301042,2.6303715448994067,-102.4562582898777,19.486486384763445,-55.7461567674855,8.461541475204797,-34.016004840410574,4.712824540535038,-18.066789785866806,2.6401456993132153,-103.60398268701002,19.802704734429785,-61.68353878496926,9.650187857341214,-44.76775468944945,6.444757556916112,-26.060791619056427,3.599837857825526,-97.27916136850891,18.085610527348415,-59.33082759509665,9.171209294707863,-42.24705692654848,6.016359585841944,-23.350526713793172,3.2558471191579894,-97.94239544297146,18.262727694350296,-53.03355221198167,7.94095381214551,-32.857248309508414,4.541449578831631,-15.041171256747345,2.322063533056465]},{"theme":"dark-ic","bg":"#808080","fgs":["#707078","#66666E","#030305","#18171D","#37363E","#5D5D64","#37363E","#030305","#000103","#001834","#00376B","#004A8C","#070000","#390005","#750012","#99001B","#0D0400","#261300","#522F00","#865100","#000300","#001D05","#004112","#006521","#010005","#22004F","#43008F","#5800B7"],"flat":[0,1.24296889833444,9.274501031402123,1.4407196981948422,39.91548097777799,5.217615096019717,36.836632862345205,4.5094036578339445,27.93898600302036,3.0217055248761144,13.086665786299626,1.6537770693375544,27.93898600302036,3.0217055248761144,39.91548097777799,5.217615096019717,40.21965913293546,5.287302224624218,36.81767378273908,4.505192880903515,27.950777846769416,3.023233051937069,21.000772856623527,2.25477321245894,40.142120786515875,5.269603471071473,36.88792093057972,4.5208109211231795,27.866909320024153,3.0123854877509646,20.876190217747006,2.243276608438576,39.57947058767665,5.139984882718895,36.89828467875601,4.523118840482662,27.8635036155662,3.011945822214043,13.292640012092916,1.6665035199669143,40.051433392362526,5.248844071123953,36.85731353296706,4.514000513189092,27.870384117529113,3.0128341396537346,15.95953035602916,1.844055168824021,40.26997047853993,5.298759395894901,36.80600977189143,4.502603953746083,29.651273965169533,3.2515347547731954,23.802145579236715,2.533547958530533]},{"theme":"dark-ic","bg":"#3A3A3C","fgs":["#46464E","#4F4F57","#F1F1FA","#BABAC4","#9D9DA6","#797982","#9D9DA6","#F1F1FA","#E1EFFF","#62ACFF","#0087F7","#0065BB","#FFF5F4","#FF928C","#FF3644","#CA0027","#FFF8F1","#FFA533","#DD8900","#AD6A00","#BCFFC2","#00D04C","#00AF3F","#00862E","#F1EFFF","#B29CFF","#956AFF","#7700F5"],"flat":[0,1.2140718035663858,0,1.3988859584097761,-88.35603271482154,10.107493939375463,-56.41682578047019,5.8952705955406675,-41.10063170437542,4.219157833086703,-23.840055313660137,2.6320437618590202,-41.10063170437542,4.219157833086703,-88.35603271482154,10.107493939375463,-85.70519954500536,9.72347127561709,-46.57313915269262,4.790554592637207,-29.772868283167547,3.1391774083572677,-14.678197720495364,1.9342213705917548,-91.71639880150975,10.602849175762582,-50.907190680193835,5.264997878479995,-30.103011927821978,3.1686137953283313,-14.381529067368087,1.9134497328938256,-92.9101803888625,10.781105347058572,-55.36632323767275,5.772792653136124,-40.27550194757439,4.135743433221995,-23.629833528615176,2.614848030728214,-86.56463892830851,9.847321829129962,-52.75275099572737,5.472775232316544,-37.83315401910713,3.893128667535661,-21.00389596511428,2.404636707585164,-87.71836703917482,10.014570561299498,-47.715877818006604,4.91379345402092,-29.619990398118716,3.125589171245837,-10.872649839108671,1.6769004806806627]},{"theme":"dark-ic","bg":"#007AFF","fgs":["#6F6F77","#65656D","#040408","#16151B","#36353D","#72727A","#36353D","#040408","#000106","#001632","#00366A","#005EAF","#0B0000","#360004","#730011","#BD0023","#100600","#241200","#502E00","#A36300","#000501","#001B04","#004012","#007D2B","#020009","#20004A","#48009A","#6F00E5"],"flat":[0,1.2399763057879079,9.137153276370956,1.4379357071162235,39.199487305374596,5.095159290353486,36.655643103420836,4.517612479062309,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.199487305374596,5.095159290353486,39.62660370456575,5.191620542906115,36.592103069401674,4.503545882983851,27.695057695096033,3.0117807086970667,12.482735608358766,1.623488823202914,39.46146930028483,5.1544680971312,36.631002505660305,4.512153756975423,27.677944212691237,3.0095726068531357,12.751768680957815,1.6398240151911772,38.729462058672325,4.987982714010286,36.579572970042136,4.500775701626332,27.747483785710887,3.0185550274007946,0,1.2039987886628472,39.28492308543262,5.114543979353608,36.632422880871374,4.512468289727634,27.610461383700073,3.00088106913278,0,1.3173837917708568,39.63684952401951,5.1939188146581685,36.601297620325695,4.505579426399357,27.609531401873983,3.0007614645527125,16.085712499624492,1.86220122646402]},{"theme":"dark-ic","bg":"#FF3B30","fgs":["#7F7F87","#74747D","#040408","#212128","#3E3D45","#76767F","#3E3D45","#040408","#000206","#002245","#003E78","#0062B6","#0B0000","#4C0008","#820015","#C50025","#110600","#341C00","#5B3600","#A96700","#000601","#002808","#004916","#00822D","#02000A","#2E0067","#5300AD","#7400EE"],"flat":[0,1.119564506502328,0,1.304916965157494,43.43238144483196,5.770041002680692,38.322111022898454,4.5094781825179275,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.43238144483196,5.770041002680692,43.7603252928922,5.854038847974277,38.29235457140463,4.502975854531618,29.083956028514045,3.020138635078335,15.054852030501879,1.729780743830801,43.694363439742204,5.837205585263101,38.354206470247746,4.516507237210082,29.087182234169767,3.020553277003921,15.230546565278194,1.740856512459411,42.92491991360228,5.63897133999608,38.33365423714702,4.512004338988724,28.93769973409828,3.0014083479801514,0,1.2794758858630608,43.42248485866928,5.7674953973596095,38.41370496023468,4.529580289565808,29.031977011275746,3.0134669332394415,8.949958172317846,1.3992774406692907,43.85966524740055,5.879321607935794,38.335090707081115,4.512318847459295,28.952846517354917,3.003342035233188,18.7969313728254,1.9885281036480773]},{"theme":"dark-ic","bg":"#101012","fgs":["#1C1C22","#24242A","#F0F0FA","#B3B3BD","#92929B","#686871","#92929B","#F0F0FA","#E0EEFF","#4FA3FF","#007DE4","#00559F","#FFF4F3","#FF817D","#F40031","#AD001F","#FFF7EF","#FB9C00","#CE7F00","#965B00","#B9FFBF","#00C849","#00A239","#007226","#F0EEFF","#AA90FF","#8C52FF","#6500D0"],"flat":[0,1.1210017368042908,0,1.2316217101091165,-97.35355975851269,16.788671896406843,-62.18912907483276,9.1408846014785,-45.183954583215964,6.160892481462449,-25.971291980892453,3.445784610130369,-45.183954583215964,6.160892481462449,-97.35355975851269,16.788671896406843,-94.7101738760577,16.149472101206463,-51.76844025191021,7.254462182627787,-34.605057818633654,4.573999892517157,-17.84521283217997,2.534242228411278,-100.79897840003042,17.636700891614694,-55.24750846744172,7.863491890193123,-33.54190885081095,4.42668652015819,-17.815136446883052,2.5311583129351276,-101.94670279716274,17.922901715066683,-61.02785966423507,8.921519321161206,-44.34904450398409,6.02788949767693,-25.86724540779468,3.4331609270477186,-95.74776769254933,16.399183837627458,-58.596724506079966,8.469603286006345,-41.840254848173814,5.636085625741139,-23.122294138224145,3.108849028833141,-96.71860922522349,16.634224259107555,-52.4354046324141,7.369572153296626,-32.71416296796099,4.3135863728502315,-14.766203746026353,2.2302399315399106]},{"theme":"dark-ic","bg":"#F6F8FA","fgs":["#E0E0EA","#D2D2DC","#0D0D12","#717179","#8F8F98","#BCBCC6","#8F8F98","#0D0D12","#000511","#005DAD","#0080EB","#67AEFF","#180001","#BB0023","#FB0032","#FF9690","#1D0D00","#A16200","#CC7E00","#FFA93F","#000E02","#007C2A","#00A63B","#00D24D","#060018","#6E00E1","#8F5BFF","#B49FFF"],"flat":[10.34292311627503,1.2313464424988367,18.024461551245693,1.4097592283337514,100.14533816071224,18.207162386274927,66.07133001527934,4.544848448940367,52.273633182943,3.011452358576948,29.740675372331037,1.769619110018133,52.273633182943,3.011452358576948,100.14533816071224,18.207162386274927,101.24417193888262,19.155084079579783,75.03748031320593,6.224334741672483,59.78706522107772,3.730817455350078,39.19256422935483,2.1736266322828217,101.04110588404839,18.980469423093126,75.30804346166417,6.287699296730837,60.6715256777025,3.8318416601446055,34.77914058378512,1.9691960254812915,99.64166503759763,17.774143327140518,66.61979519504256,4.627804134340593,52.265155427331,3.010761189364384,30.386657168564103,1.79343744066295,100.53634253876818,18.544800983402446,69.13022113160365,5.036635229225181,52.45807219092383,3.026547994099155,33.37740357317164,1.9103555042369325,101.43843214703563,19.321492322017065,78.73738474892319,7.17242701812758,60.92473206099399,3.861502331684902,37.91186813185219,2.111101535692422]},{"theme":"dark-ic","bg":"#1C1C1E","fgs":["#28282E","#303036","#F0F1FA","#B5B5BE","#94949D","#6C6C74","#94949D","#F0F1FA","#E0EEFF","#53A4FF","#007FE8","#0058A5","#FFF4F3","#FF8580","#F80032","#B30021","#FFF7F0","#FD9D00","#D18100","#9B5E00","#B9FFC0","#00CA4A","#00A43A","#007628","#F0EEFF","#AC93FF","#8D57FF","#6800D8"],"flat":[0,1.1612081374032734,0,1.2977584057597347,-96.00102363023473,15.124963081358866,-61.434200333395395,8.359778481952823,-44.37695589123639,5.656999216809453,-25.852207004995158,3.269134017520218,-44.37695589123639,5.656999216809453,-96.00102363023473,15.124963081358866,-92.91963933131836,14.457074514504098,-50.53751898728166,6.580638229837842,-33.762466759799054,4.213972880586457,-17.265259153354464,2.3814244063048684,-99.00844385529109,15.788447906049281,-54.621704682580784,7.2261694596451385,-32.708249696736004,4.081389020198301,-17.26088719592284,2.381011454094406,-100.19905346524207,16.054261379481122,-59.919533209261445,8.101702037099825,-43.64240726631874,5.55098822531801,-25.561703474916055,3.236683846808585,-93.99035989085725,14.687777164248876,-57.733604751916815,7.735299184328377,-40.893113879964474,5.162174617584753,-22.782862127638978,2.93464773079297,-94.92807468048419,14.891026660055605,-51.83505302329671,6.7828987244211,-32.07221654016657,4.002369709649521,-14.066989001346808,2.090531498955086]},{"theme":"dark-ic","bg":"#7C3AED","fgs":["#7A7A83","#85858D","#F2F2FD","#E3E4ED","#BBBCC5","#A6A6AF","#BBBCC5","#F2F2FD","#E5F1FF","#D2E7FF","#8AC0FF","#2091FF","#FFF9F8","#FFDDDA","#FFA49E","#FF5C5D","#FFFAF5","#FFE0C0","#FFAA42","#E99000","#C6FFCB","#7DFF91","#00DA50","#00B943","#F3F1FF","#E6E2FF","#C1B2FF","#9D7AFF"],"flat":[-8.022975593833932,1.3402714370624624,-13.06659651622355,1.5567826841029107,-72.79588351600884,5.127281558149197,-64.0643931275358,4.500912896664839,-41.08533982136385,3.0154992993085026,-29.446612137967147,2.3594628663401775,-41.08533982136385,3.0154992993085026,-72.79588351600884,5.127281558149197,-70.80493468751378,4.981574213000164,-64.1741844582375,4.5085838821814495,-40.86541522684489,3.0024791016401666,-17.915271666932362,1.7789289638170434,-77.39176152066469,5.470028106981068,-64.14420274941838,4.506488572747314,-40.82594869447877,3.0001450551263997,-20.102672577261966,1.8834970356793017,-77.70409895709017,5.493642615376888,-64.40976920815079,4.525061719384059,-40.93902681557826,3.00683450902807,-28.2154352545988,2.2940964528512438,-71.5855645088526,5.038503428518723,-64.0583201718401,4.500488740880354,-41.23137463469248,3.02415799426954,-25.961518214862377,2.176489332337808,-72.5833510184408,5.111647048756453,-64.45521953810703,4.528243512077896,-40.9348520371047,3.006587427640115,-18.521762858381592,1.807654812879132]},{"theme":"dark-ic","bg":"#123456","fgs":["#3E3E45","#47474E","#F0F0FC","#BDBDC6","#A1A1AA","#7F7F88","#A1A1AA","#F0F0FC","#E1EFFF","#68AEFF","#008BFD","#006AC4","#FFF6F5","#FF9791","#FF494F","#D30029","#FFF8F1","#FFA941","#E28C00","#B46E00","#BEFFC3","#00D34D","#00B340","#008C31","#F1EFFF","#B49FFF","#9971FF","#7D09FF"],"flat":[0,1.1989453999458657,0,1.3802668927515667,-90.21171279539291,11.248602775503162,-60.350304841650235,6.81783465078813,-45.467647083484636,4.962060105853706,-28.90763677945104,3.2057711392885686,-45.467647083484636,4.962060105853706,-90.21171279539291,11.248602775503162,-88.0351868676248,10.895768939711282,-50.01919534416472,5.502978516969682,-33.92042169291382,3.7009882121251625,-19.118481017052428,2.3367853131295435,-94.53061684959366,11.962030249162733,-54.89593536437227,6.108854061876529,-35.13675470036395,3.8260433618990066,-18.695569008603144,2.3023140000481574,-95.24016771148194,12.080915286992608,-59.18458546592085,6.6635751999987605,-44.37309985295556,4.8355935478331284,-28.125674388660414,3.1315039953558483,-89.11957165172129,11.070992163190612,-56.49372778683512,6.313154752438012,-41.901500594929324,4.555261373045625,-25.675484951698337,2.9041378624661793,-90.04835436179425,11.221964232051386,-51.2922333885473,5.658542947646642,-34.21126239038315,3.7307191421666435,-15.254686874750304,2.03178289437105]},{"theme":"dark-ic","bg":"#ABCDEF","fgs":["#B4B4BE","#A8A8B1","#09090F","#55545C","#706F78","#A1A1AB","#706F78","#09090F","#00030D","#004D92","#006CC7","#008BFE","#130001","#9F001C","#D60029","#FF4B50","#190B00","#7B4A00","#A06100","#E38C00","#000A01","#006320","#00822D","#00B341","#040013","#5B00BE","#7E16FF","#9971FF"],"flat":[8.489900665165298,1.2445786124510558,14.633674840112363,1.428005718026211,75.26124671135368,12.018395958758797,52.798813706484495,4.5250375920814765,41.423484798334584,3.0020546798073307,18.08917030758312,1.5497949541740348,41.423484798334584,3.0020546798073307,75.26124671135368,12.018395958758797,76.08600684668272,12.475516218126787,55.96473752983635,5.13221099097022,43.3369682989835,3.2018590596167122,29.318326289650187,2.0754572365320043,75.88156299830736,12.362776535253154,55.82217638546274,5.102669267083783,43.91931922104849,3.266436635421996,27.87409429449102,1.9940348577564484,74.63415754799017,11.66954886455628,52.68236753448497,4.504554642142053,41.632593181405234,3.023005829909607,19.021862742942993,1.5853635699211195,75.54552640875083,12.176514522007174,52.82929323710673,4.530419449281277,41.44477504659015,3.004178254346042,21.57431352051131,1.6892460110571705,76.18160530883091,12.528031661692317,58.96992438550627,5.808209180198148,47.36214126331064,3.68860336114861,29.106756402593504,2.0632235490419992]},{"theme":"dark-ic","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]}],"resolveVars":[{"theme":"light","bg":"#FFFFFF","vars":{"--lab-bg-tone-2":"oklch(97.84197% 0.010603 286.202)","--lab-bg-tone-3":"oklch(97.84197% 0.010603 286.202)","--lab-label-primary":"oklch(19.12257% 0.014100 291.556)","--lab-label-secondary":"oklch(56.54196% 0.013721 285.953)","--lab-label-tertiary":"oklch(66.97448% 0.014606 285.999)","--lab-label-quaternary":"oklch(81.39504% 0.013897 286.087)","--lab-border-strong":"oklch(19.12257% 0.014100 291.556)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(19.39430% 0.069681 257.297)","--lab-label-brand-secondary":"oklch(57.33316% 0.205939 257.291)","--lab-label-brand-tertiary":"oklch(67.12978% 0.175419 257.690)","--lab-label-brand-quaternary":"oklch(81.31388% 0.094313 257.967)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(20.16318% 0.082740 29.234)","--lab-label-danger-secondary":"oklch(59.62536% 0.244212 28.655)","--lab-label-danger-tertiary":"oklch(69.12968% 0.198650 28.574)","--lab-label-danger-quaternary":"oklch(82.55135% 0.097676 29.389)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(19.32631% 0.042728 66.944)","--lab-label-warning-secondary":"oklch(57.27465% 0.125021 68.799)","--lab-label-warning-tertiary":"oklch(67.73500% 0.148484 68.161)","--lab-label-warning-quaternary":"oklch(82.04043% 0.139600 68.374)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(67.73500% 0.148484 68.161 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(18.59864% 0.054816 147.311)","--lab-label-success-secondary":"oklch(54.91588% 0.161394 147.418)","--lab-label-success-tertiary":"oklch(64.88263% 0.190555 147.443)","--lab-label-success-quaternary":"oklch(79.02491% 0.232159 147.432)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(64.96409% 0.172888 147.450 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(19.54489% 0.077696 259.509)","--lab-label-info-secondary":"oklch(57.68997% 0.232691 259.838)","--lab-label-info-tertiary":"oklch(67.26428% 0.173598 259.980)","--lab-label-info-quaternary":"oklch(81.49851% 0.092914 259.576)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.5","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.5","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.5","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.5","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-icon":"oklch(66.97448% 0.014606 285.999)"}},{"theme":"light","bg":"#000000","vars":{"--lab-bg-tone-2":"oklch(7.11623% 0.013707 282.350)","--lab-bg-tone-3":"oklch(7.11623% 0.013707 282.350)","--lab-label-primary":"oklch(98.65156% 0.006606 286.278)","--lab-label-secondary":"oklch(80.14049% 0.013952 286.081)","--lab-label-tertiary":"oklch(69.23260% 0.013029 286.055)","--lab-label-quaternary":"oklch(55.16643% 0.013808 285.938)","--lab-border-strong":"oklch(98.65156% 0.006606 286.278)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(98.69528% 0.006207 255.474)","--lab-label-brand-secondary":"oklch(80.06451% 0.101154 257.822)","--lab-label-brand-tertiary":"oklch(69.35433% 0.162198 257.610)","--lab-label-brand-quaternary":"oklch(55.96293% 0.201872 257.393)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(98.64532% 0.006567 28.833)","--lab-label-danger-secondary":"oklch(81.38408% 0.105319 28.863)","--lab-label-danger-tertiary":"oklch(71.19739% 0.181099 28.350)","--lab-label-danger-quaternary":"oklch(58.10538% 0.238004 28.677)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(98.72985% 0.009577 72.664)","--lab-label-warning-secondary":"oklch(80.98123% 0.149940 68.750)","--lab-label-warning-tertiary":"oklch(70.11346% 0.152989 68.857)","--lab-label-warning-quaternary":"oklch(55.86543% 0.122417 68.219)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(98.31403% 0.027558 147.033)","--lab-label-success-secondary":"oklch(77.69544% 0.228159 147.448)","--lab-label-success-tertiary":"oklch(67.10085% 0.197388 147.383)","--lab-label-success-quaternary":"oklch(53.45797% 0.157009 147.442)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(98.69528% 0.006207 255.474)","--lab-label-info-secondary":"oklch(80.30515% 0.099317 259.806)","--lab-label-info-tertiary":"oklch(69.49027% 0.160387 260.058)","--lab-label-info-quaternary":"oklch(56.23879% 0.227606 259.853)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.013010286081645773","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.012071078431372548","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.009560345877481427","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.00784313725490196","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-icon":"oklch(69.23260% 0.013029 286.055)"}},{"theme":"light","bg":"#808080","vars":{"--lab-bg-tone-2":"oklch(58.21536% 0.012099 286.018)","--lab-bg-tone-3":"oklch(58.21536% 0.012099 286.018)","--lab-label-primary":"oklch(13.77177% 0.013406 284.503)","--lab-label-secondary":"oklch(20.89710% 0.013787 291.711)","--lab-label-tertiary":"oklch(33.70211% 0.013945 291.371)","--lab-label-quaternary":"oklch(49.18380% 0.012632 285.926)","--lab-border-strong":"oklch(13.77177% 0.013406 284.503)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(13.49058% 0.050205 258.119)","--lab-label-brand-secondary":"oklch(20.89968% 0.076454 257.725)","--lab-label-brand-tertiary":"oklch(34.17043% 0.122830 257.309)","--lab-label-brand-quaternary":"oklch(49.70427% 0.178878 257.337)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.22004% 0.058352 29.234)","--lab-label-danger-secondary":"oklch(21.88171% 0.089792 29.234)","--lab-label-danger-tertiary":"oklch(35.54757% 0.145582 28.627)","--lab-label-danger-quaternary":"oklch(51.73054% 0.211869 28.643)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(13.56514% 0.029751 68.088)","--lab-label-warning-secondary":"oklch(20.90753% 0.046395 66.438)","--lab-label-warning-tertiary":"oklch(34.00266% 0.074904 67.452)","--lab-label-warning-quaternary":"oklch(49.79045% 0.108745 68.715)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(91.20808% 0.064865 69.118 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.01439% 0.039836 145.937)","--lab-label-success-secondary":"oklch(20.10741% 0.059351 147.255)","--lab-label-success-tertiary":"oklch(32.71649% 0.095649 147.616)","--lab-label-success-quaternary":"oklch(47.52181% 0.139141 147.559)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(88.67895% 0.198079 147.416 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(13.83602% 0.055501 259.684)","--lab-label-info-secondary":"oklch(21.26627% 0.087439 260.146)","--lab-label-info-tertiary":"oklch(34.39884% 0.140838 260.069)","--lab-label-info-quaternary":"oklch(50.11242% 0.202893 259.861)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.11057506131405687","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.11023622047244083","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.07169755954418727","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.04724409448818886","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-icon":"oklch(33.70211% 0.013945 291.371)","--lab-border-brand-strong":"oklch(34.17043% 0.122830 257.309 / 1)","--lab-border-danger-strong":"oklch(35.54757% 0.145582 28.627 / 1)","--lab-border-info-strong":"oklch(34.39884% 0.140838 260.069 / 1)"}},{"theme":"light","bg":"#3A3A3C","vars":{"--lab-bg-tone-2":"oklch(36.65084% 0.011912 285.797)","--lab-bg-tone-3":"oklch(36.65084% 0.011912 285.797)","--lab-label-primary":"oklch(98.92134% 0.005280 286.303)","--lab-label-secondary":"oklch(82.53513% 0.012530 281.039)","--lab-label-tertiary":"oklch(72.82755% 0.014297 286.039)","--lab-label-quaternary":"oklch(61.25907% 0.011943 286.042)","--lab-border-strong":"oklch(98.92134% 0.005280 286.303)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(98.77036% 0.005722 264.533)","--lab-label-brand-secondary":"oklch(82.44675% 0.088464 257.008)","--lab-label-brand-tertiary":"oklch(73.08383% 0.140495 257.442)","--lab-label-brand-quaternary":"oklch(61.77245% 0.208353 257.413)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(61.75781% 0.208491 257.338 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(98.86605% 0.005516 31.054)","--lab-label-danger-secondary":"oklch(83.38591% 0.092088 28.223)","--lab-label-danger-tertiary":"oklch(74.63167% 0.153784 28.842)","--lab-label-danger-quaternary":"oklch(64.10554% 0.244701 28.706)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(98.75683% 0.008520 67.727)","--lab-label-warning-secondary":"oklch(83.08426% 0.131366 68.787)","--lab-label-warning-tertiary":"oklch(73.86147% 0.161425 68.614)","--lab-label-warning-quaternary":"oklch(61.96370% 0.135496 68.532)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(98.51065% 0.024195 147.300)","--lab-label-success-secondary":"oklch(80.08887% 0.235054 147.469)","--lab-label-success-tertiary":"oklch(70.67977% 0.207519 147.455)","--lab-label-success-quaternary":"oklch(59.24524% 0.173588 147.533)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(98.77036% 0.005722 264.533)","--lab-label-info-secondary":"oklch(82.42821% 0.087905 259.666)","--lab-label-info-tertiary":"oklch(73.17409% 0.139071 259.898)","--lab-label-info-quaternary":"oklch(61.97090% 0.205878 259.740)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.06909778454881221","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.060913705583756306","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.040609137055837526","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.028061954965508236","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-icon":"oklch(72.82755% 0.014297 286.039)"}},{"theme":"light","bg":"#007AFF","vars":{"--lab-bg-tone-2":"oklch(57.87475% 0.012117 286.015)","--lab-bg-tone-3":"oklch(57.87475% 0.012117 286.015)","--lab-label-primary":"oklch(13.41536% 0.016099 291.497)","--lab-label-secondary":"oklch(20.01464% 0.013939 291.638)","--lab-label-tertiary":"oklch(33.31157% 0.013986 291.359)","--lab-label-quaternary":"oklch(48.82850% 0.012656 285.922)","--lab-border-strong":"oklch(13.41536% 0.016099 291.497)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(13.49058% 0.050205 258.119)","--lab-label-brand-secondary":"oklch(20.23588% 0.071518 256.892)","--lab-label-brand-tertiary":"oklch(33.52307% 0.122654 257.729)","--lab-label-brand-quaternary":"oklch(49.33014% 0.177503 257.333)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.22004% 0.058352 29.234)","--lab-label-danger-secondary":"oklch(20.90400% 0.085780 29.234)","--lab-label-danger-tertiary":"oklch(34.90075% 0.142917 28.593)","--lab-label-danger-quaternary":"oklch(51.33845% 0.210254 28.629)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(13.56514% 0.029751 68.088)","--lab-label-warning-secondary":"oklch(20.32691% 0.044866 67.175)","--lab-label-warning-tertiary":"oklch(33.70499% 0.073308 69.356)","--lab-label-warning-quaternary":"oklch(49.32505% 0.107532 68.994)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(90.50991% 0.070001 68.839 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.01439% 0.039836 145.937)","--lab-label-success-secondary":"oklch(19.36939% 0.056254 147.869)","--lab-label-success-tertiary":"oklch(32.05045% 0.093836 147.561)","--lab-label-success-quaternary":"oklch(47.21271% 0.138736 147.423)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(87.96697% 0.214930 147.350 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(13.83602% 0.055501 259.684)","--lab-label-info-secondary":"oklch(20.33878% 0.084139 260.257)","--lab-label-info-tertiary":"oklch(33.98919% 0.139378 260.097)","--lab-label-info-quaternary":"oklch(49.73960% 0.201561 259.877)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.1414396647356093","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.1805418331471749","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.09073588245366788","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.06430782839451565","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-icon":"oklch(33.31157% 0.013986 291.359)","--lab-border-brand-strong":"oklch(33.52307% 0.122654 257.729 / 1)","--lab-border-danger-strong":"oklch(34.90075% 0.142917 28.593 / 1)","--lab-border-info-strong":"oklch(33.98919% 0.139378 260.097 / 1)"}},{"theme":"light","bg":"#FF3B30","vars":{"--lab-bg-tone-2":"oklch(63.60048% 0.011829 286.059)","--lab-bg-tone-3":"oklch(63.60048% 0.011829 286.059)","--lab-label-primary":"oklch(13.83495% 0.015576 284.180)","--lab-label-secondary":"oklch(25.12484% 0.015017 285.269)","--lab-label-tertiary":"oklch(36.40556% 0.013673 291.446)","--lab-label-quaternary":"oklch(51.30243% 0.012497 285.951)","--lab-border-strong":"oklch(13.83495% 0.015576 284.180)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(14.05977% 0.051776 257.879)","--lab-label-brand-secondary":"oklch(25.33060% 0.091490 257.423)","--lab-label-brand-tertiary":"oklch(36.69778% 0.133197 257.540)","--lab-label-brand-quaternary":"oklch(51.93456% 0.187074 257.359)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.49010% 0.059461 29.234)","--lab-label-danger-secondary":"oklch(26.17120% 0.107130 28.474)","--lab-label-danger-tertiary":"oklch(38.31782% 0.156989 28.748)","--lab-label-danger-quaternary":"oklch(53.87750% 0.220648 28.624)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(14.09840% 0.030367 70.955)","--lab-label-warning-secondary":"oklch(25.16306% 0.055216 68.014)","--lab-label-warning-tertiary":"oklch(36.95716% 0.080989 68.209)","--lab-label-warning-quaternary":"oklch(51.83499% 0.113346 68.533)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(94.55166% 0.039779 69.746 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.47470% 0.038038 148.984)","--lab-label-success-secondary":"oklch(24.13011% 0.070822 147.468)","--lab-label-success-tertiary":"oklch(35.33425% 0.103721 147.463)","--lab-label-success-quaternary":"oklch(49.62199% 0.145290 147.559)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(93.08890% 0.111383 147.112 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(14.07469% 0.058967 260.480)","--lab-label-info-secondary":"oklch(25.53067% 0.104920 260.137)","--lab-label-info-tertiary":"oklch(37.14168% 0.149591 259.762)","--lab-label-info-quaternary":"oklch(52.13660% 0.212021 259.943)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.1835748792270531","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.4395613333149519","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.1777791862086449","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.1016772651089421","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-icon":"oklch(36.40556% 0.013673 291.446)","--lab-border-brand-strong":"oklch(36.69778% 0.133197 257.540 / 1)","--lab-border-danger-strong":"oklch(38.31782% 0.156989 28.748 / 1)","--lab-border-info-strong":"oklch(37.14168% 0.149591 259.762 / 1)"}}]} diff --git a/scripts/check-wasm-size-budget.mjs b/scripts/check-wasm-size-budget.mjs index 11b2d54d..fe920406 100644 --- a/scripts/check-wasm-size-budget.mjs +++ b/scripts/check-wasm-size-budget.mjs @@ -14,7 +14,7 @@ export const DEFAULT_BUDGET = resolve( "packages/colors/bench/wasm.json", ); export const WASM_BUDGET_FILE_SHA256 = - "1fc218f1ed02aabe298f43a484bebef3d046269e05eb479a145b86cfde8a7a30"; + "46c8830fadaa96c50d784bff41329177b4bf8742dbd8d1b1bb175b3150c55107"; const SCHEMA_VERSION = 1; const CANONICAL_ARTIFACT = "packages/colors/pkg/labcolors_bg.wasm"; diff --git a/scripts/point-support-release-contract.cjs b/scripts/point-support-release-contract.cjs index 8f8faef0..d8b222f5 100644 --- a/scripts/point-support-release-contract.cjs +++ b/scripts/point-support-release-contract.cjs @@ -21,6 +21,7 @@ const POINT_SUPPORT_SOURCE_PATHS = Object.freeze( "crates/labcolors-core/src/constraints/mod.rs", "crates/labcolors-core/src/constraints/wcag22.rs", "crates/labcolors-core/src/hash.rs", + "crates/labcolors-core/src/lcs_occurrence.rs", "crates/labcolors-core/src/lib.rs", "crates/labcolors-core/src/numerics.rs", "crates/labcolors-core/src/observation.rs", diff --git a/scripts/test_point_support_surplus_source_binding.py b/scripts/test_point_support_surplus_source_binding.py index 4949cf53..9e43f10d 100644 --- a/scripts/test_point_support_surplus_source_binding.py +++ b/scripts/test_point_support_surplus_source_binding.py @@ -55,13 +55,13 @@ def test_complete_production_dependency_cone_is_bound(self) -> None: ), ( self.observation_path, - b" &self.cases\n", - b" &[]\n", + b" self.backing.set.values(case_index)\n", + b" None\n", ), ( self.session_path, - b"ObservationHeadViewV1::Observed(current.report().observation())", - b"ObservationHeadViewV1::Empty", + b" Self::Observed(observation) => ObservationHeadViewV1::Observed(observation),\n", + b" Self::Observed(_) => ObservationHeadViewV1::Empty,\n", ), ( self.numerics_path, diff --git a/scripts/test_program_public_surface.py b/scripts/test_program_public_surface.py new file mode 100755 index 00000000..9bfe50c4 --- /dev/null +++ b/scripts/test_program_public_surface.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Mutation and fail-closed tests for the resolved rustdoc surface gate.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from verify_program_public_surface import ( + RustdocShapeError, + program_public_surface, +) + + +class ProgramPublicSurfaceTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.docs = Path(self.temporary.name) / "doc" + self.crate = self.docs / "labcolors_core" + self.crate.mkdir(parents=True) + + def write_all(self, *hrefs: str) -> None: + links = "".join(f'
  • item
  • ' for href in hrefs) + (self.crate / "all.html").write_text( + f'
      {links}
    ', + encoding="utf-8", + ) + + def write_item(self, relative: str, body: str) -> None: + path = self.crate / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{body}", encoding="utf-8") + + def test_clean_resolved_source_is_accepted(self) -> None: + self.write_all("struct.Srgb8.html") + self.write_item( + "struct.Srgb8.html", + 'Source', + ) + count, leaks = program_public_surface(self.crate) + self.assertEqual(count, 1) + self.assertEqual(leaks, []) + + def test_arbitrary_root_reexport_alias_is_rejected_by_origin(self) -> None: + self.write_all("struct.AnyClientName.html") + self.write_item( + "struct.AnyClientName.html", + 'Source', + ) + count, leaks = program_public_surface(self.crate) + self.assertEqual(count, 1) + self.assertEqual([leak.public_item for leak in leaks], ["struct.AnyClientName.html"]) + + def test_nested_alias_is_rejected_without_a_name_allowlist(self) -> None: + self.write_all("facade/struct.UnrelatedName.html") + self.write_item( + "facade/struct.UnrelatedName.html", + 'Source', + ) + _, leaks = program_public_surface(self.crate) + self.assertEqual(len(leaks), 1) + + def test_public_type_alias_route_to_program_is_rejected(self) -> None: + self.write_all("type.Alias.html") + self.write_item( + "type.Alias.html", + ( + 'Source' + 'Draft' + ), + ) + _, leaks = program_public_surface(self.crate) + self.assertEqual(len(leaks), 1) + + def test_missing_item_page_fails_closed(self) -> None: + self.write_all("struct.Missing.html") + with self.assertRaises(RustdocShapeError): + program_public_surface(self.crate) + + def test_missing_source_class_names_the_rustdoc_toolchain_contract(self) -> None: + self.write_all("struct.Srgb8.html") + self.write_item( + "struct.Srgb8.html", + 'Source', + ) + with self.assertRaisesRegex( + RustdocShapeError, + 'expected rustdoc class "src".*markup or toolchain version is incompatible', + ): + program_public_surface(self.crate) + + def test_empty_public_inventory_fails_closed(self) -> None: + self.write_all() + with self.assertRaises(RustdocShapeError): + program_public_surface(self.crate) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/verify-package-release.mjs b/scripts/verify-package-release.mjs index d03f1c78..3824ae6f 100644 --- a/scripts/verify-package-release.mjs +++ b/scripts/verify-package-release.mjs @@ -335,7 +335,7 @@ async function validatePointSupportEvidence(artifacts, numericalCapabilities) { POINT_SUPPORT_SOURCE_BINDING_EXCLUSIONS, ) || !/^[0-9a-f]{64}$/u.test(proof.source_closure_sha256 ?? "") || - proof.source_negative_controls !== 33 || + proof.source_negative_controls !== 43 || !/^[0-9a-f]{64}$/u.test(proof.proof_payload_sha256 ?? "") || !/^[0-9a-f]{64}$/u.test(proof.verifier_sha256 ?? "") || !Array.isArray(proof.source_files) || @@ -700,7 +700,6 @@ const SOLVE_FAILURE_CATEGORY_BY_CODE = new Map([ ["exceeds_range", "unreachable"], ["bounded_search_exhausted", "unresolved"], ["floor_unreachable", "unreachable"], - ["gamut_unsupported", "unsupported"], ["invalid_input", "rejected"], ]); @@ -1722,7 +1721,6 @@ export async function verifyPackageRelease() { "stable-cam16-glow-target-or-maximum-selection", "renderer-or-output-pipeline-equivalence", "spatial-glow-field", - "display-p3", ], artifacts: { tarball, diff --git a/scripts/verify_point_support_surplus.py b/scripts/verify_point_support_surplus.py old mode 100644 new mode 100755 index 682cc3da..c1f4a09b --- a/scripts/verify_point_support_surplus.py +++ b/scripts/verify_point_support_surplus.py @@ -23,6 +23,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] POINT_SOURCE = REPO_ROOT / "crates/labcolors-core/src/point_support.rs" OBSERVATION_SOURCE = REPO_ROOT / "crates/labcolors-core/src/observation.rs" +LCS_OCCURRENCE_SOURCE = REPO_ROOT / "crates/labcolors-core/src/lcs_occurrence.rs" NUMERICS_SOURCE = REPO_ROOT / "crates/labcolors-core/src/numerics.rs" SESSION_SOURCE = REPO_ROOT / "crates/labcolors-core/src/session.rs" COMPOSITION_SOURCE = REPO_ROOT / "crates/labcolors-core/src/composition.rs" @@ -57,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 = ( - "c2825216354b796924560d98e01ae5cebedf324c47e7b26332119c61aded783e" + "fe841df4f7a63adc94423660b2ef4daefad539a3d3748bc9dafff08491be0ddd" ) EXPECTED_Q55_PROOF_SHA256 = ( "ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd" @@ -93,6 +94,7 @@ SOURCE_CONE_PATHS = ( POINT_SOURCE, OBSERVATION_SOURCE, + LCS_OCCURRENCE_SOURCE, SESSION_SOURCE, NUMERICS_SOURCE, COMPOSITION_SOURCE, @@ -192,21 +194,31 @@ def verify_source_binding() -> tuple[str, int]: (POINT_SOURCE, b"NumericalSiteIdV2::PointSupportRetainedReferenceSurplusV1;", b"NumericalSiteIdV2::Wcag22Srgb8ContrastV1;"), (POINT_SOURCE, b"let current_distance = reference_distance(current_measurement)?;", b"let current_distance = baseline.distance;"), (POINT_SOURCE, b"Ok(assessment.bind(observation))", b"Ok(assessment.bind_unchecked(observation))"), + (POINT_SOURCE, b" let backdrop = values.get(*surface_index).copied().ok_or(\n", b" let backdrop = values.first().copied().ok_or(\n"), + (POINT_SOURCE, b" if !observation.shares_schema_backing_with(&plan.surface_schema) {\n", b" if observation.shares_schema_backing_with(&plan.surface_schema) {\n"), + (POINT_SOURCE, b" _permit: SessionObservationBindingPermitV1,\n", b" _permit: (),\n"), (POINT_SOURCE, b"use crate::wcag22::{Wcag22CriterionV1, Wcag22MeasurementV1, measure_wcag22_srgb8};", b"use crate::wcag22::{Wcag22CriterionV1, Wcag22MeasurementV1, measure_wcag22_srgb8 as canonical_measure_wcag22_srgb8};\nfn measure_wcag22_srgb8(foreground: [u8; 3], background: [u8; 3]) -> Wcag22MeasurementV1 { canonical_measure_wcag22_srgb8(background, foreground) }"), - (OBSERVATION_SOURCE, b" &self.cases\n", b" &[]\n"), + (OBSERVATION_SOURCE, b" self.backing.set.values(case_index)\n", b" None\n"), + (OBSERVATION_SOURCE, b" values.extend(bindings.iter().map(|binding| binding.value));\n", b" values.extend(bindings.iter().map(|_| Srgb8::new([0, 0, 0])));\n"), + (OBSERVATION_SOURCE, b" Rc::ptr_eq(&self.0, &other.0)\n", b" self == other\n"), + (OBSERVATION_SOURCE, b" Self(Rc::clone(&self.0))\n", b" Self(Rc::from(self.as_slice()))\n"), (OBSERVATION_SOURCE, b"if expected_input != actual_input", b"if expected_input == actual_input"), (OBSERVATION_SOURCE, b"Some(observation.revision)", b"None"), (OBSERVATION_SOURCE, b"(self.owner, self.observation)", b"unreachable!()"), - (OBSERVATION_SOURCE, b"tuples.push((scenario.bindings, scenario.id));", b"tuples.push((Vec::new(), scenario.id));"), - (SESSION_SOURCE, b"ObservationHeadViewV1::Observed(current.report().observation())", b"ObservationHeadViewV1::Empty"), - (SESSION_SOURCE, b"recheck: compiled.into_session_recheck(),", b"recheck: unreachable!(),"), - (SESSION_SOURCE, b".evaluate(observation, SessionObservationBindingPermitV1::mint())", b".evaluate(observation, SessionObservationBindingPermitV1::bypass())"), - (SESSION_SOURCE, b"PointSupportEvaluationErrorV1::ResourceExhausted => {\n PointSupportSessionUpdateErrorV1::ResourceExhausted", b"PointSupportEvaluationErrorV1::ResourceExhausted => {\n PointSupportSessionUpdateErrorV1::InternalInvariant"), - (SESSION_SOURCE, b"PointSupportSessionStateV1::Ready { current } => Some(current),", b"PointSupportSessionStateV1::Ready { .. } => None,"), + (OBSERVATION_SOURCE, b" && Rc::ptr_eq(&self.backing, &other.backing)\n", b" && self.backing == other.backing\n"), + (LCS_OCCURRENCE_SOURCE, b" pub(crate) const fn srgb8(self) -> Srgb8 {\n self.srgb8\n }", b" pub(crate) const fn srgb8(self) -> Srgb8 {\n Srgb8::new([0, 0, 0])\n }"), + (SESSION_SOURCE, b" Self::Observed(observation) => ObservationHeadViewV1::Observed(observation),\n", b" Self::Observed(_) => ObservationHeadViewV1::Empty,\n"), + (SESSION_SOURCE, b" let next_raw_head = SessionObservationHeadV1::Observed(observation.clone());\n", b" let next_raw_head = SessionObservationHeadV1::Empty;\n"), + (SESSION_SOURCE, b" *raw_head = next_raw_head;\n", b" *raw_head = SessionObservationHeadV1::Empty;\n"), + (SESSION_SOURCE, b" Some(previous) => SessionState::Stale { previous },", b" Some(_) => SessionState::Waiting,"), + (SESSION_SOURCE, b" SessionObservationBindingPermitV1::mint(),", b" SessionObservationBindingPermitV1::for_test(),"), + (SESSION_SOURCE, b" SessionDecision::Verified(current) => SessionState::Ready { current },", b" SessionDecision::Verified(current) => SessionState::Stale { previous: current },"), + (SESSION_SOURCE, b" SessionDecision::Violation(cause) => SessionState::Failed { cause, previous },", b" SessionDecision::Violation(_) => SessionState::Waiting,"), + (SESSION_SOURCE, b" return Err(SessionUpdateError::EvidenceBindingInvariant);\n", b" unreachable!();\n"), (NUMERICS_SOURCE, b"proof_ids: [NumericalProofIdV2::PointSupportReferenceSurplusIntegerV1],\n bound_status: Available", b"proof_ids: [NumericalProofIdV2::PointSupportReferenceSurplusIntegerV1],\n bound_status: Unavailable"), (COMPOSITION_SOURCE, b"f64::from(backdrop) + alpha * (f64::from(tint) - f64::from(backdrop))", b"f64::from(tint)"), (APPEARANCE_SOURCE, b"self.opacity\n", b"crate::composition::AdmittedOpacityV1::OPAQUE\n"), - (CONSTRAINTS_SOURCE, b"let classification = evaluator.classify(&invocation, &measurement);", b"let classification = unreachable!();"), + (CONSTRAINTS_SOURCE, b"let measurement = evaluator.evaluate(&target, &invocation)?;\n let classification = evaluator.classify(&invocation, &measurement);\n let identity = evaluator.identity();", b"let measurement = evaluator.evaluate(&target, &invocation)?;\n let classification = unreachable!();\n let identity = evaluator.identity();"), (EXACT_CONSTRAINT_SOURCE, b"if actual == *invocation", b"if actual != *invocation"), (WCAG22_CONSTRAINT_SOURCE, b"Wcag22ApplicableDecisionV1::Pass => HardDecision::Pass(Wcag22PassV1(()))", b"Wcag22ApplicableDecisionV1::Pass => HardDecision::Violation(Wcag22ViolationV1(()))"), (WCAG22_SOURCE, b"foreground_luminance: kernel::luminance_bounds(foreground),", b"foreground_luminance: kernel::luminance_bounds(background),"), @@ -373,11 +385,12 @@ def multiply( result: dict[tuple[int, ...], int] = {} for left_monomial, left_coefficient in left.items(): for right_monomial, right_coefficient in right.items(): + assert len(left_monomial) == len(right_monomial) + # Python 3.9 lacks zip(..., strict=True); this assertion gives + # the same no-truncation guarantee without raising the floor. monomial = tuple( left_power + right_power - for left_power, right_power in zip( - left_monomial, right_monomial, strict=True - ) + for left_power, right_power in zip(left_monomial, right_monomial) ) result[monomial] = ( result.get(monomial, 0) + left_coefficient * right_coefficient diff --git a/scripts/verify_program_public_surface.py b/scripts/verify_program_public_surface.py new file mode 100755 index 00000000..36279310 --- /dev/null +++ b/scripts/verify_program_public_surface.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Fail closed when staged Program code reaches the rendered public Rust API.""" + +from __future__ import annotations + +import argparse +import sys +from dataclasses import dataclass +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote, urlsplit + + +class RustdocShapeError(RuntimeError): + """The rustdoc tree is incomplete or no longer has the verified shape.""" + + +@dataclass(frozen=True) +class ProgramLeak: + public_item: str + route: str + + +class _AllItemsParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.depth = 0 + self.all_items_depth: int | None = None + self.hrefs: list[str] = [] + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + self.depth += 1 + values = dict(attrs) + classes = set((values.get("class") or "").split()) + if tag == "ul" and "all-items" in classes: + if self.all_items_depth is not None: + raise RustdocShapeError("nested rustdoc all-items lists are ambiguous") + self.all_items_depth = self.depth + elif self.all_items_depth is not None and tag == "a": + href = values.get("href") + if href: + self.hrefs.append(href) + + def handle_endtag(self, tag: str) -> None: + if tag == "ul" and self.all_items_depth == self.depth: + self.all_items_depth = None + self.depth -= 1 + if self.depth < 0: + raise RustdocShapeError("malformed rustdoc HTML nesting") + + +class _LinkParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.links: list[tuple[frozenset[str], str]] = [] + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + if tag != "a": + return + values = dict(attrs) + href = values.get("href") + if href: + self.links.append( + (frozenset((values.get("class") or "").split()), href) + ) + + +def _read(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + raise RustdocShapeError(f"cannot read {path}: {error}") from error + + +def _inside(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + +def _resolve_local_link(page: Path, href: str, docs_root: Path) -> Path | None: + parsed = urlsplit(href) + if parsed.scheme or parsed.netloc or not parsed.path: + return None + decoded = unquote(parsed.path) + if decoded.startswith("/"): + raise RustdocShapeError(f"absolute local rustdoc link is unsupported: {href}") + resolved = (page.parent / decoded).resolve() + if not _inside(resolved, docs_root): + raise RustdocShapeError(f"rustdoc link escapes its output tree: {href}") + return resolved + + +def public_item_pages(crate_doc_root: Path) -> list[tuple[str, Path]]: + crate_doc_root = crate_doc_root.resolve() + docs_root = crate_doc_root.parent + all_items = crate_doc_root / "all.html" + parser = _AllItemsParser() + parser.feed(_read(all_items)) + parser.close() + if parser.all_items_depth is not None: + raise RustdocShapeError("rustdoc all-items list is not closed") + if not parser.hrefs: + raise RustdocShapeError("rustdoc all.html contains no public item links") + + pages: list[tuple[str, Path]] = [] + seen: set[Path] = set() + for href in parser.hrefs: + page = _resolve_local_link(all_items, href, docs_root) + if page is None or not _inside(page, crate_doc_root): + raise RustdocShapeError(f"public item is not a local crate page: {href}") + if page.suffix != ".html": + raise RustdocShapeError(f"public item does not resolve to HTML: {href}") + if page in seen: + raise RustdocShapeError(f"duplicate public rustdoc item page: {href}") + if not page.is_file(): + raise RustdocShapeError(f"public rustdoc item page is missing: {href}") + seen.add(page) + pages.append((href, page)) + return pages + + +def program_public_surface(crate_doc_root: Path) -> tuple[int, list[ProgramLeak]]: + crate_doc_root = crate_doc_root.resolve() + docs_root = crate_doc_root.parent + forbidden_source = (docs_root / "src/labcolors_core/program.rs.html").resolve() + forbidden_module = (crate_doc_root / "program").resolve() + pages = public_item_pages(crate_doc_root) + leaks: list[ProgramLeak] = [] + + for public_item, page in pages: + if _inside(page, forbidden_module): + leaks.append(ProgramLeak(public_item, str(page.relative_to(docs_root)))) + continue + + parser = _LinkParser() + parser.feed(_read(page)) + parser.close() + source_links = 0 + for classes, href in parser.links: + route = _resolve_local_link(page, href, docs_root) + if route is None: + continue + if "src" in classes: + source_links += 1 + if route == forbidden_source or _inside(route, forbidden_module): + leaks.append(ProgramLeak(public_item, href)) + break + if source_links == 0: + raise RustdocShapeError( + "public rustdoc item has no compiler-emitted source link with " + 'expected rustdoc class "src"; rustdoc HTML markup or toolchain ' + f"version is incompatible: {public_item}" + ) + + return len(pages), leaks + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "crate_doc_root", + nargs="?", + default="target/doc/labcolors_core", + type=Path, + ) + args = parser.parse_args(argv) + try: + item_count, leaks = program_public_surface(args.crate_doc_root) + except RustdocShapeError as error: + print(f"Program public rustdoc surface: FAIL: {error}", file=sys.stderr) + return 1 + if leaks: + print("Program public rustdoc surface: FAIL:", file=sys.stderr) + for leak in leaks: + print( + f" public item {leak.public_item} reaches staged program via {leak.route}", + file=sys.stderr, + ) + return 1 + print(f"Program public rustdoc surface: PASS; public_items={item_count}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())