From 9ade78f52b156fc3ecb52fc44551145ebb944bf5 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics <63733699+lemone112@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:52:11 +0300 Subject: [PATCH 1/3] core: enforce exact clean-set at point presentations --- .gitattributes | 10 + .github/workflows/ci.yml | 15 +- crates/labcolors-core/Cargo.toml | 2 +- crates/labcolors-core/LICENSES/CC-BY-4.0.txt | 396 ++++++++ .../labcolors-core/LICENSES/CC-BY-SA-4.0.txt | 428 ++++++++ crates/labcolors-core/NOTICE.md | 49 + .../point-clean-set-srgb8-column-rle-v1.bin | Bin 0 -> 11370 bytes .../clean-set-srgb8-v1/receipt-v1.json | 206 ++++ .../clean-set-srgb8-v1/receipt-v1.sha256 | 1 + crates/labcolors-core/src/clean_set.rs | 162 +++ crates/labcolors-core/src/clean_set_tests.rs | 100 ++ .../src/generic_boundary_tests.rs | 43 +- crates/labcolors-core/src/lib.rs | 6 + crates/labcolors-core/src/program.rs | 273 ++++- .../src/program_boundary_tests.rs | 67 +- .../src/program_clean_set_tests.rs | 577 +++++++++++ crates/labcolors-core/src/program_identity.rs | 164 ++- .../src/program_joint_integration_tests.rs | 24 +- .../src/program_lcs_integration_tests.rs | 86 +- .../src/program_mixed_evaluator_tests.rs | 175 +++- crates/labcolors-core/src/program_session.rs | 860 ++++++++++++---- .../src/program_session_tests.rs | 29 +- crates/labcolors-core/tests/sha256.rs | 14 +- scripts/test_verify_clean_set_receipt.py | 701 +++++++++++++ scripts/verify_clean_set_receipt.py | 944 ++++++++++++++++++ 25 files changed, 4968 insertions(+), 364 deletions(-) create mode 100644 crates/labcolors-core/LICENSES/CC-BY-4.0.txt create mode 100644 crates/labcolors-core/LICENSES/CC-BY-SA-4.0.txt create mode 100644 crates/labcolors-core/NOTICE.md create mode 100644 crates/labcolors-core/contracts/clean-set-srgb8-v1/point-clean-set-srgb8-column-rle-v1.bin create mode 100644 crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.json create mode 100644 crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.sha256 create mode 100644 crates/labcolors-core/src/clean_set.rs create mode 100644 crates/labcolors-core/src/clean_set_tests.rs create mode 100644 crates/labcolors-core/src/program_clean_set_tests.rs create mode 100644 scripts/test_verify_clean_set_receipt.py create mode 100644 scripts/verify_clean_set_receipt.py diff --git a/.gitattributes b/.gitattributes index 37306b98..8763bd79 100644 --- a/.gitattributes +++ b/.gitattributes @@ -21,3 +21,13 @@ conformance/vectors/*.json text eol=lf # Bash-скрипт Swift-conformance исполняется в контейнере через `bash <файл>`. # Пин к LF: CR в shebang/командах ломает исполнение на Linux. bindings/swift/ci/*.sh text eol=lf + +# Точные идентичности clean-set считаются по байтам репозитория. Кодек бинарный; +# юридические тексты, происхождение и receipt канонизированы как UTF-8 с LF. +crates/labcolors-core/contracts/clean-set-srgb8-v1/*.bin binary +crates/labcolors-core/contracts/clean-set-srgb8-v1/*.json text eol=lf +crates/labcolors-core/contracts/clean-set-srgb8-v1/*.sha256 text eol=lf +crates/labcolors-core/LICENSES/*.txt text eol=lf whitespace=-blank-at-eof +crates/labcolors-core/NOTICE.md text eol=lf +scripts/verify_clean_set_receipt.py text eol=lf +scripts/test_verify_clean_set_receipt.py text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b25657a9..b2c3c34e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,10 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false + - name: verify exact clean-set product receipt + run: python3 scripts/verify_clean_set_receipt.py product --product-root "$GITHUB_WORKSPACE" + - name: clean-set receipt verifier hostile tests + run: python3 scripts/test_verify_clean_set_receipt.py - name: toolchain env (runner.temp) run: | echo "RUSTUP_HOME=$RUNNER_TEMP/rustup-$GITHUB_JOB" >> "$GITHUB_ENV" @@ -178,14 +182,9 @@ jobs: tar -xzf "target/package/labcolors-core-${crate_version}.crate" -C "$package_root" crate_dir="$package_root/labcolors-core-${crate_version}" test -f "$crate_dir/README.md" - test -f "$crate_dir/LICENSE" - test ! -L "$crate_dir/LICENSE" - cmp LICENSE "$crate_dir/LICENSE" - grep -q '^license = "MIT"$' "$crate_dir/Cargo.toml" - if grep -q '^license-file[[:space:]]*=' "$crate_dir/Cargo.toml"; then - echo "packaged manifest must not carry both SPDX license and license-file" >&2 - exit 1 - fi + python3 scripts/verify_clean_set_receipt.py core-package \ + --source-root "$GITHUB_WORKSPACE" \ + --package-root "$crate_dir" cargo test --doc --manifest-path "$crate_dir/Cargo.toml" --locked test: diff --git a/crates/labcolors-core/Cargo.toml b/crates/labcolors-core/Cargo.toml index 696e8e03..9c985a11 100644 --- a/crates/labcolors-core/Cargo.toml +++ b/crates/labcolors-core/Cargo.toml @@ -5,7 +5,7 @@ readme = "README.md" version.workspace = true edition.workspace = true authors.workspace = true -license.workspace = true +license = "MIT AND CC-BY-4.0 AND CC-BY-SA-4.0" repository.workspace = true rust-version.workspace = true diff --git a/crates/labcolors-core/LICENSES/CC-BY-4.0.txt b/crates/labcolors-core/LICENSES/CC-BY-4.0.txt new file mode 100644 index 00000000..da6ab6cc --- /dev/null +++ b/crates/labcolors-core/LICENSES/CC-BY-4.0.txt @@ -0,0 +1,396 @@ +Attribution 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. + diff --git a/crates/labcolors-core/LICENSES/CC-BY-SA-4.0.txt b/crates/labcolors-core/LICENSES/CC-BY-SA-4.0.txt new file mode 100644 index 00000000..2d58298e --- /dev/null +++ b/crates/labcolors-core/LICENSES/CC-BY-SA-4.0.txt @@ -0,0 +1,428 @@ +Attribution-ShareAlike 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-ShareAlike 4.0 International Public +License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-ShareAlike 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. BY-SA Compatible License means a license listed at + creativecommons.org/compatiblelicenses, approved by Creative + Commons as essentially the equivalent of this Public License. + + d. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + e. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + f. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + g. License Elements means the license attributes listed in the name + of a Creative Commons Public License. The License Elements of this + Public License are Attribution and ShareAlike. + + h. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + i. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + j. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + k. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + l. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + m. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. Additional offer from the Licensor -- Adapted Material. + Every recipient of Adapted Material from You + automatically receives an offer from the Licensor to + exercise the Licensed Rights in the Adapted Material + under the conditions of the Adapter's License You apply. + + c. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + b. ShareAlike. + + In addition to the conditions in Section 3(a), if You Share + Adapted Material You produce, the following conditions also apply. + + 1. The Adapter's License You apply must be a Creative Commons + license with the same License Elements, this version or + later, or a BY-SA Compatible License. + + 2. You must include the text of, or the URI or hyperlink to, the + Adapter's License You apply. You may satisfy this condition + in any reasonable manner based on the medium, means, and + context in which You Share Adapted Material. + + 3. You may not offer or impose any additional or different terms + or conditions on, or apply any Effective Technological + Measures to, Adapted Material that restrict exercise of the + rights granted under the Adapter's License You apply. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material, + including for purposes of Section 3(b); and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. + diff --git a/crates/labcolors-core/NOTICE.md b/crates/labcolors-core/NOTICE.md new file mode 100644 index 00000000..b9328f2b --- /dev/null +++ b/crates/labcolors-core/NOTICE.md @@ -0,0 +1,49 @@ +# Данные и атрибуция + +Исходный код `labcolors-core` распространяется по лицензии MIT из файла +`LICENSE`. Встроенная таблица +`contracts/clean-set-srgb8-v1/point-clean-set-srgb8-column-rle-v1.bin` +является адаптированным набором данных и распространяется одновременно по +условиям CC-BY-4.0 и CC-BY-SA-4.0. Полные тексты находятся в +`LICENSES/CC-BY-4.0.txt` и `LICENSES/CC-BY-SA-4.0.txt`. + +## Sato и Inoue + +- Keiko Sato, Takaaki Inoue. *Perception of color emotions for single colors + in red-green defective observers*. PeerJ 4:e2751 (2016). +- Источник: Data S1, DOI `10.7717/peerj.2751`. +- Лицензия источника: CC-BY-4.0. + +Две сессии усреднены внутри участника, после чего участники получили равный вес +внутри объявленной когорты. Полученная постфактум граница является +версионированной политикой пакета, а не универсальным законом человеческой +«чистоты» цвета. + +## CIE 1931 2° и D65 + +- International Commission on Illumination. *Colour-matching functions of CIE + 1931 standard colorimetric observer* (2019), DOI + `10.25039/CIE.DS.xvudnb9b`, CC-BY-SA-4.0. +- International Commission on Illumination. *CIE standard illuminant D65* + (2019), DOI `10.25039/CIE.DS.hjfjmt59`, CC-BY-SA-4.0. + +Из официальных таблиц взяты значения стандартного наблюдателя на +`360..780 nm` и D65; объявленная геометрия суммы точек, точный вывод и конечная +sRGB8-классификация описаны в закреплённом исследовательском выпуске. + +## Изменения и точное происхождение + +Labpics связал источники с номинальным мостом sRGB8, выполнил точный рациональный +вывод и преобразовал итоговую таблицу в канонический column-RLE-кодек. + +- research commit: `ac6d9654fc722334d8bc2054afb903770f2aad80`; +- release SHA-256: + `67cadaae38bbaea3096dba69142b5bf3d7776b7574ec224022abbcd119c45ce6`; +- raw table SHA-256: + `97bcc9f793adb7f13bd70c89e9788c8ab61baf8c77e9f8cd80335ad767d71ae2`; +- runtime codec SHA-256: + `aa6aa7c0b630437f1c1ba8c2ceafb0dadf6551c42331559504076a6cd44e6331`. + +Использование источников не означает одобрения Labpics их авторами или +издателями. Дополнительные гарантии сверх условий соответствующих лицензий не +предоставляются. diff --git a/crates/labcolors-core/contracts/clean-set-srgb8-v1/point-clean-set-srgb8-column-rle-v1.bin b/crates/labcolors-core/contracts/clean-set-srgb8-v1/point-clean-set-srgb8-column-rle-v1.bin new file mode 100644 index 0000000000000000000000000000000000000000..75e714a839cf86f895d310b29d2d12e6a1e98748 GIT binary patch literal 11370 zcmd6sXLJ?Unxbo>G;B0jv9Wv-qWY-e-Tct4`J4Urn1ec5IU-sQcFr z{V)lua1c3o2@!t$QCY7frdQ_NISq`nP7EHS5;wr)HCyEpE20*|BCh&GMS*o4suoZ1xW^O=DWe^oSV} zGc{&u%(j@LF&ASV#+1bvV_Y$RjQMM9LTrcF-mxQMXT`3H-4%Nx_G;|YSaqx^))o85 z*uTal#C3@46*n|)O5EbO&2edQXX0+eJ&V)ERm8dC{!83n;+w^{jPDxXFMeeFjQC~o zTjLMKXT{%&&yUx}o8z7Fe;5Ck<}uA%HUFmhfaYVG&uYHB`PSwKo1bZZqj_F)b#r5L zTk}ZsKR5r^gcb=M6M7{KPMDA|FJX1U&V(Zg*$KB2o+YRfNP;CHl<@Zn|B@J=_(fuu z#6F2b5+@|iO2EEPT6AwQy~WWMWi9@c z+%|@;2`PywNhvK-l2cMrK1=yLQ@%)P zmC`z;O-kF8b}8*sI;3<=`7-6Jlujv~Q@Q}=IjFb{h?PHzRl`X2 zSW+{ESY`{>g@SDbZ(qkdwsOv0tSgOiAEP~)2Jc0!?`FCG-=rJKJo6Jz8}V&-v9h07 zHI!73AvKd_&jP`=T=r~{JqH+fI_)`a@LtgQZmJZ|G*n!c>k*ZDz7XGjEmrmws|J(m z(XwZjV3j<}c>7x3A$d|+*N2`P^`6P7xbmUrGx2R#v9ga?HHcJ?A~lnUWu{=AFMHPT zj?J8NC+phJxR27FQwHyOo$tEJpZ78#pV|aeArVG}2f^CUl zUoES4u&#ZKTT-1gc+cs4*VO*Ug^FtQ2dXy^-*ylydx}*9#OmRsW<0S>Yw)b%9UD2P z*+Z=QQLG+DYQ{+(Vx24477KRCvw?GN zZ}6OG@H{M5JVQV6v=S@3i&g!_>Y=1&EU`=_);WT0kzik`csSP{)_sWf9H+cldS8y( z|DfJ82o={qtmhA6<=0|WU$J@!sTo5oQ;2o8U|T5ISMZMYoO9d9o+}#v{Su{~fvC6v zvrfKhb?22VR(2Jueh{k%lbX@QGWlcAR?fAXbswZX8I<>|-Y0qP)qDCic#JUCdE&*& z&SF(>v3d}x8AU9Uh;^1=TOin%^Nw|#v);qhdoFAIcT1IedZXf|Tu;5HsaW}?Sk+6c z{)yC#B$kQ9I#aOC7wpS;$6DUGg>&s<-D!;H809^q_kG~$@v*0}y;#*#tR6^eh7-#K zVx1w_er=F!=3J?)`+zLT)cY=J{ddZgV!EQ@)(6E@wh^no6{~+FHNOzccw(I{*yaiL zrMzPe?|d&wr@WHnqSk-AUeW;-w?CA$6sx`wtNW3fVZ<_ySf|O7CA?!b@7%<>cCzmM zjOQrjJ#DCy+$vWp`AnARVUnihEyT(eVpTV>y02I>gjmK9>r}xuN3bvE9jkcfM$WZ^ zb?=iTwD*+3cR}mFsYojB!2FvbR;=nQR`(Wb1{2F@Vx275W()R3ykjNr+`zfE%Z4Me z;k?#=qY*<@N3pt>So1Tnj3m}c#5PN?FBBXr8sd8y&tcko(%?I%^IuoVXHjuiF2E$8 zLRDL_`a7{^AhC=f)``S6Q?M@(9LsszorW0y$IGlWIi^u z6sx<7H9r!|FT^^Y*k%a!`GR8^?_9^ZwzBTsvf;RFxT+33DNqdeU^c*1Uqg~u-A%0N zM=V2$bsVux6YTQ@$5P(8mUC_4+`CxMLE4)^`LcBW9ChGPQ6q+?Vs$66rVp_UCe|^; zHdV0C6&y===NitnnRBPIo;2EfjPjk;`>&`24~iAT{SRuW?jY9mB9@`j7}3&|So;y% zFT_4UaLg2(3whT{&b^WI>}0$LXx}k||Ew;MqYCD}2+23?hjGi7#M+10h7kKW!7)Q{ z&gWe#IQIt5vxD{Sr+w*^|BNnhMIC%ppk$wXFtnw$XzfXCgNS{M;Fu;jf8|}vc=tNa zvyJucqkTsyf2KZgSslDz)JQg2w00-9fy6#ia7+=Lb9vWN-o2LdY-PQB7~c`fe_9{7 zs0rRJRFXcSzIPW&bw~zLx8vXb{X60q zBsj+jt{J?0A@5nmc{j7Z-L(HO6*#2}UQ&l17AV1Ia-FZ`nXxq^_HT$|AaRZsT+?{> zeBQHy^KN2&sf_<16*#F6UQ~zf6}=B~FiF80h&B*=C*tTwoFfF+6u~`@_blVQ8(7~? z#-Bz7j_ZTxHKE%jN-!VhkAiK8qc?F56I>Go_Z;4{l=H4*ecKuTekzcm4`ypZH%osX zv?mcq58@moxW)gyy^+t(E~PpWXFtI;LU2#vJ-_na6`XGq>)%BO4jY1} zb)hTe%2=PKS`z2?#5GKCPvSjudG9jLw}JJi(t$%%@RTleNfmzjEFxdN_dyH9y5X7; z=eNW)NN|rAJhOT463(}d_3vN;X;kopE_6X1eo)Y8(AkB!1_NgK zU_TYi(1*^e!*`36)Qg6*lZJDEINK3dU%@>>@J!{s3pn2@*1v@b?4^Ro^r37`_*U`z zlmOFP80%9lh^v?29wvAu@!olyZw2e$#02)x!K3=nSxxvxNuw#(H-dYR;2F<*=WxDd zoPPro*hL2q8$y}d@U_xUB38LPS0~~gAb7^`-kH2_3FlwO1a{KFgND#)Z8)c_@kndp z?kjjk^4@8@Zz1Pj!v?m~!8AkYq%M59yzxjJarYEF!vybS-Z!7~uVMpR>EM1UbX*s{ zpi&|)VOC~9D$EJu{#x)161)?5-(1eWf(>kDf_tgZF8j>V*m^MPRCg zu|D;s;29uz$MU|}oPQY`*vJHTQ=y~!aJD*fr%*}#{QFc(!SjRQ9mV@*aQ?+?U_BE| zr9y}G;j`+<%_1cwZ6)51Wm76_B6xZV-d}j%RL;MU4XkB?JLu3MeK=DSxn82Aq>aoc zDbLq}cQEgp#QT5c0;`zdHae7M2%pkKa!QrR_}@o53f_UdZ#?gx%LP`j!7X%Xzae}= z8@XJjL?-?|^10ycEBHq9{#jgL85`V0hxQu68QRFj`pBe)NEM9I>9{~V5WGDF-*DbP zoeL~xgB$43ZYrFvi<~c4Mv-=%(!DLs_D7Mg1>aEKKZOe{WP|INP%0HZqKjm!q7UhE$@GBc!&4hMP;X}H}SylAT%ipK$@5}pK@b%~YW4XXwHn@@r zZKJ|z`befadb9A~OL-yqzUTd;xWFtnxSR=Xp~Cz1kyGmE^`ieM(w+DJ!Uv{v!6i&+ z6CK{CkDSm%bBdLSG!N1vML8p-wz^Ti;{Aj9z!WaHhz+f$!+Q*o3{CWMi4u`!`@@JE zq%P7*@DJbv6S?4gHnf%wry3&Z+UUhnB_dr@B~lHeG)jG>nc)9{4~*r4^Vra8I=s^m zIiii8D^nuUog+Q}rNTap_fr{+YMNg?}Z@p9^!%!i;aHZQ!ItyhK9|S-6zzoD0oj!mH@W zPJJ|8Q+v5ciS+$A;sKEg3v}RvL%7fkCcK=EY}ZGRXlgGOD|}B>NbA7&L)7!1@xh-n~*ke0{NGgFzU zdcVZ?=0fAy@IpGW(GWeLt<5S`{4uDIb^(p>-*TbRZ1`6?vfdEgr>#9xCi4~2hNTg> z3l|!}hUe0ewT9?kZSCoLSlXa90=MNt!`SdFCbF7}?$*_ws)wZ=rL+L}wCp48q) zU?MB2XsWLEM7dIiwEt}c1{WH@hNm)-dw*#O^j_|lu8}k+ z65p2%PhuiVspxiH?XmKAx1K4y)VOGz@5P2EFpV zmC8eHx$tl%GLMRG)Yl$Ty}SHUfu+V~BXBYo9?C@KQqlGL+JmZhm+IjQ@_yt~Gq~^| zCNi6fuG7~ZP`$fY51*6wYmLAInaE5kx>jGiU;XYvfl|g|vu%swrn6B8LnN0;hrcl`$M zhgsDq+@6Vyp+13k!(7%V+=_{erlL#qwW+FiS@joX2h64Nn)%Z*Qkcj{D!TY1cni$M zGAz9~e!G#Rsf%MGBdF-2kKpw%7s-2#Phl{T;S#K?-Klz)U4M(Ng1O)Wxb6W6J|vkX z_!k+LYR;~|`Io@_Lf)n+<_4IpgNISkg}U0*@^|Oze^&Eg&X>2YAHsD%Tgd#-2L62g z&vrV@d2&BQTD?dwM@8NUe=$$FQOCoa`ycQ_WdE+Rcb9V&ei+P;3j{{iOva-*+teyE|2-=jQ=D>}k_S8m=)tJdEYPe&wpv9@-v@|-u{lD}UxP7X6f zmuPDD)sxp@66BR|BhFt8(WRQ&1IoL}d<7;>-fKt;KWXZu*K5Pn(UIZ$=rVQfLFKJy zz6cZBKuU|229gPbj*QetSEy?bE3Z^@HcYg7sKDtU(o36a#&6zM!ACm!a zL9ii6S}HO|7hR*OJ^oyN{+o}(q?dQl(xyPsegu!#Mc0+ro_eOhM_|(advF33nW&9! zEUV4RRp0|Kspam_@62?#r6Dp!8{JY`d-=Ws?}n*dZoWydv{d#<3zGVp>2L=_WV$B0 zU0Ljzx4~2jqtx$}c7LCeUGS(I6_%yGFsi+Z>34spx&`0K}qk9YFrJ8v;OfTgY z{d=b$TmWnk5=@2r=pqYM(St7(cs@+|@&_hqx9gTm{RkeQi!3dVrax2QX)ry7@s+$a z{vB)x579jDVMj~~YK;C11u=QkHU3hkR zWPiRwUWSoxAU)tEVFPIhb=QU$mPXQZ74jU697K!k{D7o`FogPP!YfK5*>@E(6GmF< zB}$um2n{3?fJEv;L)GE+h4N0;lmVmR1I!D~2i6aXq=L|e#;L;FUnt}u7x`r;!smms*nd%$_Y`XG^10J`81RcPZ2x$$CJ0;BZ#{7vJeHaNa4v^!5B zXTkUk;%gAwM^4%gLjaoK?BdYzdkQ%g#wQRfWYP)VT{k-8gGA~BUDUy4N*~qKAIAF- z%^+3?3g!VTlawF2z`*k0rhK`%XZjq*TM&((lC%#7KeU1ICBglVWYTyQA_1{LaDaD# z^MLU}!t{Qq19J=I*0J#dME(QD4OYe|A9Vh1<$=}D73>T|7Nn*@C1bP~1|PKkp(TOT z#|oAKkpiiPU!cQC{eLpOyQU=gZ$>jHwXS zAe9ho;2q#(jP}6bg~m6q#J}^Qf^CFY3i7rAbAok&aYG{YUZ{Li3*_(7#^n$TLEb>H z%C-iK@<8itQs!Hcr(km+=7YSJF*`UJqunrgp!N4#`oU1TdAbbO318WE40PTd{1+}|ZfhR3T!H$D4 z5URjiz%^i$6IvHa-7}whf(0_T=%3Nr-cA%qI>Rp4sCTEJLA*`T$f#4+-TQ}(n6xedW2 zGu2>gz*sAQ-_{f@@$5R%mP}wvT)0sPm9)2m<)G;Htn@gQ)>! zfzFCD8=l!^-~fSeN z>I}j)@J4X2z`h3a2Gm<Jo;EZ5RV9d~0pyU-EzLpP-1^*bF2-XP31icxh6?pt=+#coV zx8U#8A2pazX2#=+xFfIXf_x4*0#*cNM41VX%}B41|DFX-082oLC^O=b2}jNH-;aUg zzzU!UN<`c@;-E?X`#x}buskRMg#@=n>@v!K-vq72%Rs}Tn8QOJX9dh4bv*kSxUaxZ2A2!=8JOpwUxF%tp$Ph7=t`k2 zgQgs6HB=guYf+{{sU9T;6jLaoQOMvai)$Q?@O3tA40LUv?*_y7pay^*3T6ygNqz}z z4wxIDZ-KfC!+q!o&KptL>8zD9X(s0Knk44N^}PKIta^owEG0BRo$8PI1! zcMjT1(BwdU9jcoszk{-SD1CsEM<{-RqFfZ_q2L)_KF5m}c>WUkg?Ltkyka~pL2fCY zl;LqX9;xt9jRzXs*W#`Ycl5Yzz)cG0X&hwmD_d7melnhSz>9D2vNs9_pl~RPMx%HV zN@k*TA`jz-=@Je!I91$e#|FH-URF!D3->=g3O;^{f$ zUc{5jc$|Yr*YNNL9^AtHJGgfjckkoQ1KfUyTaR(`32x-#`cquX!_{ZV$;XxFxcmYa zU*bXm&KKfb5wePLwgj1_I8}y|k-aA#+fHLor_aXaWW6bpCKb3>CbWW zMcm<+$p;I*I8fMbU(r{)i@WVC>9MV}_vW(x>&pkNQjJ=ymWu3pqG^{Cv0YBacRtyo z)5*`iI`zeur`mKp)wbj5b{$T4Y=8QzcBebF%k11Xvum5oZml!FZhhvPR%g0@ai&Mh zGvEE;Y_HGHe*f9o-YIAMBxn85BCBswR=>oo{s~z>#%B+R%N`h;{ZmZ#&&{$2H9a>N z=Z4|@C|nqii&JoE7A`Nul|8tehD%3q@fa?g!1+@+mx=7N$jZjq^Eh(>nU`?-GEQB= z$sC-xhU3?faihtxo3Tf4B^tiNth#SA5`S^{A<2JS!w<&q-rq9Q0 zZaI4M7o)ed9<`;-sI6^BZf!quTZa+bz8t>2(=XdQ|FWaYupQlo?)+v*>bHYadko(7 z-Jo4Pf8N#W=iT4`w7d6Dd-@FA(|5q$en0O0vH!k-{r3OVcmJRt4h-pYU}*2OU%pQp z-s|AVo(D(uJT&^dLt}az8r$RWxNi@S?|yjVH%BIaePl|vqf@&co!;f>%+BevI;GF~ zDt+FU$A0a2Y(a-(i`pMs+%98j+l=LHGFGHAxyA82(n zt@Y7^t&bdPbL4Q_BS+dDKHC0pdiz7iIvmRAc<}g_X(zr)JK5>Lsm}XPcio@aZQq%% z_nz&(C+pkY+28Ft*E99}_d721*?#edZI}9Ox%}g%D?e?>8MN-|&^6bFue?5b>5Yl= zZ%UK)Z&)yI-ojZ67R^|=c>1Cx(-tqCx@6gur7I>cT{&slnu*IcPh7fX($Z~{mu#QB bc*m4QJEty8owi`t%wKoQSt bool { + match self { + Self::None => false, + Self::Closed { lo, hi } => lo <= blue && blue <= hi, + } + } + + #[cfg(test)] + pub(crate) const fn raw_pair_v1(self) -> [u8; 2] { + match self { + Self::None => [u8::MAX, 0], + Self::Closed { lo, hi } => [lo, hi], + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ExactNominalSrgb8CleanSetDecisionV1 { + Accepted, + Rejected(ClosedRejectedBlueIntervalV1), +} + +/// Непустой closed interval отделён от table-sentinel типом: evidence +/// `Rejected(None)` невозможно собрать даже внутри соседнего модуля. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ClosedRejectedBlueIntervalV1 { + lo: u8, + hi: u8, +} + +impl ClosedRejectedBlueIntervalV1 { + const fn from_canonical_table(lo: u8, hi: u8) -> Self { + Self { lo, hi } + } + + pub(crate) const fn endpoints(self) -> [u8; 2] { + [self.lo, self.hi] + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct ExactNominalSrgb8CleanSetV1; + +impl ExactNominalSrgb8CleanSetV1 { + pub(crate) fn classify(self, color: Srgb8) -> ExactNominalSrgb8CleanSetDecisionV1 { + // Нейтральная ось входит в declared set отдельным exact union: таблица + // описывает только chromatic complement и не вправе её исключить. + if color.is_achromatic() { + return ExactNominalSrgb8CleanSetDecisionV1::Accepted; + } + + let [red, green, blue] = color.bytes(); + let interval = self.rejected_blue_interval(red, green); + match interval { + RejectedBlueIntervalV1::Closed { lo, hi } if lo <= blue && blue <= hi => { + ExactNominalSrgb8CleanSetDecisionV1::Rejected( + ClosedRejectedBlueIntervalV1::from_canonical_table(lo, hi), + ) + } + RejectedBlueIntervalV1::None | RejectedBlueIntervalV1::Closed { .. } => { + ExactNominalSrgb8CleanSetDecisionV1::Accepted + } + } + } + + pub(crate) fn rejected_blue_interval(self, red: u8, green: u8) -> RejectedBlueIntervalV1 { + let column = usize::from(green); + let mut lower = usize::from(codec_offset(column)); + let mut upper = usize::from(codec_offset(column + 1)); + + // Каждый content-bound column непуст и начинается с red=0. Ищем + // последний run start, не превосходящий вход: максимум семь probes. + while lower + 1 < upper { + let middle = lower + (upper - lower) / 2; + if codec_record(middle)[0] <= red { + lower = middle; + } else { + upper = middle; + } + } + + let [_red_start, lo, hi] = codec_record(lower); + match [lo, hi] { + [u8::MAX, 0] => RejectedBlueIntervalV1::None, + [lo, hi] => RejectedBlueIntervalV1::Closed { lo, hi }, + } + } +} + +#[cfg(test)] +pub(crate) const fn exact_nominal_srgb8_clean_set_codec_v1() -> &'static [u8; CODEC_BYTES] { + CODEC +} + +fn codec_offset(index: usize) -> u16 { + let byte = CODEC_HEADER_BYTES + index * 2; + u16::from_be_bytes([CODEC[byte], CODEC[byte + 1]]) +} + +fn codec_record(index: usize) -> [u8; CODEC_RECORD_BYTES] { + let byte = CODEC_BODY_OFFSET + index * CODEC_RECORD_BYTES; + [CODEC[byte], CODEC[byte + 1], CODEC[byte + 2]] +} diff --git a/crates/labcolors-core/src/clean_set_tests.rs b/crates/labcolors-core/src/clean_set_tests.rs new file mode 100644 index 00000000..d9ae4677 --- /dev/null +++ b/crates/labcolors-core/src/clean_set_tests.rs @@ -0,0 +1,100 @@ +use crate::Srgb8; +use crate::clean_set::{ + EXACT_NOMINAL_SRGB8_CLEAN_SET_ACCEPTED_COUNT_V1, EXACT_NOMINAL_SRGB8_CLEAN_SET_CODEC_SHA256_V1, + EXACT_NOMINAL_SRGB8_CLEAN_SET_RAW_TABLE_SHA256_V1, ExactNominalSrgb8CleanSetDecisionV1, + ExactNominalSrgb8CleanSetV1, RejectedBlueIntervalV1, exact_nominal_srgb8_clean_set_codec_v1, +}; +use crate::sha256::Hasher; + +#[test] +fn neutral_axis_precedes_declared_rejected_interval() { + let profile = ExactNominalSrgb8CleanSetV1; + + assert_eq!( + profile.classify(Srgb8::new([0x80, 0x80, 0x80])), + ExactNominalSrgb8CleanSetDecisionV1::Accepted, + ); + assert!(matches!( + profile.classify(Srgb8::new([0x80, 0x80, 0x81])), + ExactNominalSrgb8CleanSetDecisionV1::Rejected(_), + )); +} + +#[test] +fn closed_interval_endpoints_are_rejected() { + let profile = ExactNominalSrgb8CleanSetV1; + let interval = profile.rejected_blue_interval(0, 200); + + assert_eq!(interval, RejectedBlueIntervalV1::Closed { lo: 71, hi: 101 },); + for blue in [70, 71, 101, 102] { + let decision = profile.classify(Srgb8::new([0, 200, blue])); + assert_eq!( + matches!(decision, ExactNominalSrgb8CleanSetDecisionV1::Rejected(_)), + (71..=101).contains(&blue), + "closed-boundary semantics drifted at blue={blue}", + ); + if let ExactNominalSrgb8CleanSetDecisionV1::Rejected(interval) = decision { + assert_eq!(interval.endpoints(), [71, 101]); + } + } +} + +#[test] +fn embedded_codec_has_the_package_pinned_content_identity() { + let codec = exact_nominal_srgb8_clean_set_codec_v1(); + assert_eq!(&codec[..8], b"LPCC\x01\x01\x00\x00"); + + let mut digest = Hasher::new(); + digest.update(codec); + assert_eq!( + digest.finalize().as_bytes(), + &EXACT_NOMINAL_SRGB8_CLEAN_SET_CODEC_SHA256_V1, + ); +} + +#[test] +fn table_none_sentinel_is_not_absent_final_owned_domain() { + let profile = ExactNominalSrgb8CleanSetV1; + + assert_eq!( + profile.rejected_blue_interval(255, 0), + RejectedBlueIntervalV1::None, + ); + for blue in u8::MIN..=u8::MAX { + assert_eq!( + profile.classify(Srgb8::new([255, 0, blue])), + ExactNominalSrgb8CleanSetDecisionV1::Accepted, + ); + } +} + +#[test] +fn runtime_classifier_matches_the_content_bound_total_table() { + let profile = ExactNominalSrgb8CleanSetV1; + let mut accepted = 0_u32; + let mut raw_table = Hasher::new(); + + for red in u8::MIN..=u8::MAX { + for green in u8::MIN..=u8::MAX { + let interval = profile.rejected_blue_interval(red, green); + raw_table.update(&interval.raw_pair_v1()); + for blue in u8::MIN..=u8::MAX { + let color = Srgb8::new([red, green, blue]); + let expected = red == green && green == blue || !interval.contains_closed(blue); + let actual = profile.classify(color); + assert_eq!( + matches!(actual, ExactNominalSrgb8CleanSetDecisionV1::Accepted), + expected, + "classifier/table mismatch at {color:?}", + ); + accepted += u32::from(expected); + } + } + } + + assert_eq!( + raw_table.finalize().as_bytes(), + &EXACT_NOMINAL_SRGB8_CLEAN_SET_RAW_TABLE_SHA256_V1, + ); + assert_eq!(accepted, EXACT_NOMINAL_SRGB8_CLEAN_SET_ACCEPTED_COUNT_V1,); +} diff --git a/crates/labcolors-core/src/generic_boundary_tests.rs b/crates/labcolors-core/src/generic_boundary_tests.rs index 36f4cf64..aedf28fa 100644 --- a/crates/labcolors-core/src/generic_boundary_tests.rs +++ b/crates/labcolors-core/src/generic_boundary_tests.rs @@ -785,6 +785,33 @@ fn shared_observation_ssot_has_one_backing_without_lifecycle_or_adapter_facades( } } +#[test] +fn clean_set_program_path_cannot_smuggle_auto_or_writer_contracts() { + for (path, source) in [ + ("program.rs", PROGRAM_SOURCE), + ("program_session.rs", PROGRAM_SESSION_SOURCE), + ("program_identity.rs", PROGRAM_IDENTITY_SOURCE), + ] { + let source = source.to_ascii_lowercase(); + for forbidden in [ + "pointconvention", + "autoqualityrelease", + "qualityauto", + "quality_auto", + "quality-auto", + "shortquality", + "short_quality", + "writer", + "checkpoint", + ] { + assert!( + !source.contains(forbidden), + "{path} must not couple encoded clean-set admission to `{forbidden}`", + ); + } + } +} + #[test] fn program_session_keeps_physical_evidence_separate_from_lazy_lcs_capability() { for required in [ @@ -868,7 +895,10 @@ fn program_session_keeps_physical_evidence_separate_from_lazy_lcs_capability() { "impl ProgramConstraintResultV1", "/// One canonical `physical case × constraint` report cell.", ); - assert!(result.contains("fn binding(&self) -> ProgramVisiblePointBindingV1")); + assert!( + !result.contains("fn binding(&self) -> ProgramVisiblePointBindingV1"), + "a heterogeneous result must not invent an occurrence-only binding", + ); assert!(!result.contains("modeled_lcs")); let cell = source_scope( PROGRAM_SESSION_SOURCE, @@ -879,6 +909,7 @@ fn program_session_keeps_physical_evidence_separate_from_lazy_lcs_capability() { cell.contains("result: ProgramConstraintResultV1,"), "each Program cell must own the typed evidence result", ); + assert!(cell.contains("subject: ProgramConstraintSubjectV1,")); assert!( !cell.contains("modeled_lcs_occurrence: ModeledLcsOccurrenceV1,"), "a Program cell must not duplicate the modeled occurrence already owned by evidence", @@ -932,7 +963,7 @@ fn program_session_keeps_physical_evidence_separate_from_lazy_lcs_capability() { "let outputs = compile_outputs(", ); for required in [ - "compile_constraints::(&graph, &all_occurrence_contexts, &program.constraints)?", + "compile_constraints::( &graph, &all_occurrence_contexts, &point_presentations, &program.constraints, )?", "compact_constraint_contexts(&all_occurrence_contexts, &mut constraints)?", ] { assert!( @@ -948,8 +979,10 @@ fn program_session_keeps_physical_evidence_separate_from_lazy_lcs_capability() { ); for required in [ "targets.sort_unstable(); targets.dedup();", - ".binary_search_by_key(&constraint.target_id, |binding| binding.occurrence)", - "constraint.occurrence_context_index = index;", + "CompiledProgramConstraintBodyV1::ModeledOccurrence { target_id, .. } => { Some(*target_id) }", + "CompiledProgramConstraintBodyV1::PointPresentation { .. } => None", + ".binary_search_by_key(target_id, |binding| binding.occurrence)", + "*occurrence_context_index = index;", ] { assert!( compaction.contains(required), @@ -966,7 +999,7 @@ fn program_session_keeps_physical_evidence_separate_from_lazy_lcs_capability() { !hot_evaluation.contains("binary_search"), "hot Program evaluation must consume compile-time direct indices without searching", ); - assert!(hot_evaluation.contains(".get(constraint.occurrence_context_index)")); + assert!(hot_evaluation.contains(".get(occurrence_context_index)")); for required in [ "pub(crate) struct ProgramLcsPointAdapterV1", diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index 6f7a8cc5..6f64d18c 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod wcag22; pub mod wcag22_evidence; // END WCAG22_SOURCE_ROUTES_V1 +pub(crate) mod clean_set; pub(crate) mod composition; pub(crate) mod spaces; @@ -121,6 +122,8 @@ mod program_boundary_tests; #[cfg(test)] mod program_api_tests; +#[cfg(test)] +mod program_clean_set_tests; #[cfg(test)] mod release_registry_tests; @@ -170,6 +173,9 @@ mod joint_tests; #[cfg(test)] mod constraint_tests; +#[cfg(test)] +mod clean_set_tests; + #[cfg(test)] mod wcag22_tests; diff --git a/crates/labcolors-core/src/program.rs b/crates/labcolors-core/src/program.rs index 96c14613..056ada93 100644 --- a/crates/labcolors-core/src/program.rs +++ b/crates/labcolors-core/src/program.rs @@ -67,14 +67,17 @@ use crate::program_session::{ CompiledCoreProgramV1, CompositionProfile, ConstraintId, ConstraintInvocation, CoreProgramConstraintInvocationV1, CoreProgramDraftErrorV1, CoreProgramDraftV1, CoreProgramEvaluatorErrorV1, CoreProgramEvaluatorsV1, CoreProgramPassEvidenceV1, - CoreProgramViolationEvidenceV1, DeclaredJointSelectionV1, JointCandidateStateV1, Occurrence, - OpacityInput, OutputBinding, OutputSlotId, Paint, PointPresentationRootV1, - PointPresentationTargetV1, PresentationRootId, ProgramCompileError, ProgramConflictV1, - ProgramConstraintCellV1, ProgramConstraintResultV1, ProgramContentIdentityV3, - ProgramPaintOutputV1, ProgramSessionEvaluationError, ProgramSessionInstantiateError, - ProgramSessionPlan, ProgramVerifiedV1, Source, SourceId, Surface, Target, - TargetCandidateChoiceV1, TargetCandidateId, TargetCandidateV1 as CoreTargetCandidateV1, - TargetId, + CoreProgramViolationEvidenceV1, DeclaredJointSelectionV1, + DeclaredSrgb8CleanSetPassV1 as CoreDeclaredSrgb8CleanSetPassV1, + DeclaredSrgb8CleanSetViolationV1 as CoreDeclaredSrgb8CleanSetViolationV1, + JointCandidateStateV1, Occurrence, OpacityInput, OutputBinding, OutputSlotId, Paint, + PointPresentationRootV1, PointPresentationTargetV1, PresentationRootId, ProgramCompileError, + ProgramConflictV1, ProgramConstraintCellV1, ProgramConstraintPassEvidenceV1, + ProgramConstraintResultV1, ProgramConstraintSubjectV1, ProgramConstraintViolationEvidenceV1, + ProgramContentIdentityV3, ProgramPaintOutputV1, ProgramSessionEvaluationError, + ProgramSessionInstantiateError, ProgramSessionPlan, ProgramVerifiedV1, Source, SourceId, + Surface, Target, TargetCandidateChoiceV1, TargetCandidateId, + TargetCandidateV1 as CoreTargetCandidateV1, TargetId, }; use crate::session::{Session, SessionState, SessionUpdateError}; use crate::wcag22::{ @@ -473,6 +476,8 @@ pub(crate) enum CompileErrorKindV1 { MissingOccurrenceBackdrop, /// Ограничение ссылается на отсутствующий Occurrence. MissingConstraintOccurrence, + /// Ограничение clean-set ссылается на необъявленную цель представления. + MissingConstraintPresentationTarget, /// Выход ссылается на отсутствующий Paint. MissingOutputPaint, /// Граф Paint содержит цикл. @@ -913,6 +918,15 @@ pub(crate) enum CompileErrorV1 { /// Отсутствующий Occurrence. occurrence: OccurrenceIdV1, }, + /// Ограничение clean-set ссылается не на целиком объявленную цель представления. + MissingConstraintPresentationTarget { + /// Ошибочное ограничение. + constraint: ConstraintIdV1, + /// Корень отсутствующей пары. + root: PresentationRootIdV1, + /// Целевой `Occurrence` отсутствующей пары. + occurrence: OccurrenceIdV1, + }, /// Повторно объявлен выходной слот. DuplicateOutputSlot { /// Повторный ID. @@ -992,6 +1006,9 @@ impl CompileErrorV1 { Self::EmptyOutputSet => Kind::EmptyOutputSet, Self::DuplicateConstraint { .. } => Kind::DuplicateConstraint, Self::MissingConstraintOccurrence { .. } => Kind::MissingConstraintOccurrence, + Self::MissingConstraintPresentationTarget { .. } => { + Kind::MissingConstraintPresentationTarget + } Self::DuplicateOutputSlot { .. } => Kind::DuplicateOutputSlot, Self::MissingOutputPaint { .. } => Kind::MissingOutputPaint, Self::ResourceExhausted => Kind::ResourceExhausted, @@ -1049,7 +1066,8 @@ impl CompileErrorV1 { | Self::DuplicateOutputSlot { output } | Self::MissingOutputPaint { output, .. } => Some(Handle::OutputSlot(*output)), Self::DuplicateConstraint { constraint } - | Self::MissingConstraintOccurrence { constraint, .. } => { + | Self::MissingConstraintOccurrence { constraint, .. } + | Self::MissingConstraintPresentationTarget { constraint, .. } => { Some(Handle::Constraint(*constraint)) } Self::PaintCycle(_) @@ -1082,6 +1100,7 @@ impl CompileErrorV1 { Self::MissingSurfaceInputPort { input, .. } => Some(Handle::SurfaceInputPort(*input)), Self::MissingSurfaceOccurrence { occurrence, .. } | Self::MissingConstraintOccurrence { occurrence, .. } + | Self::MissingConstraintPresentationTarget { occurrence, .. } | Self::MissingPresentationRootOccurrence { occurrence, .. } | Self::PresentationRootConsumedDownstream { occurrence, .. } | Self::DuplicatePointPresentationTarget { occurrence, .. } @@ -1379,6 +1398,50 @@ impl DraftV1 { self } + /// Требует принадлежности непустого финального вклада точной цели + /// представления к закреплённому пакетом множеству encoded sRGB8. + pub(crate) fn push_declared_srgb8_clean_set_hard( + &mut self, + id: ConstraintIdV1, + root: PresentationRootIdV1, + occurrence: OccurrenceIdV1, + ) -> &mut Self { + self.inner.push_declared_srgb8_clean_set_hard( + id.into_core(), + PointPresentationTargetV1::new(root.into_core(), occurrence.into_core()), + ); + self + } + + /// Диагностирует тот же закреплённый пакетом предикат, не влияя на выбор. + pub(crate) fn push_declared_srgb8_clean_set_report_only( + &mut self, + id: ConstraintIdV1, + root: PresentationRootIdV1, + occurrence: OccurrenceIdV1, + ) -> &mut Self { + self.inner.push_declared_srgb8_clean_set_report_only( + id.into_core(), + PointPresentationTargetV1::new(root.into_core(), occurrence.into_core()), + ); + self + } + + #[cfg(test)] + pub(crate) fn push_declared_srgb8_clean_set_final_recheck_mutant( + &mut self, + id: ConstraintIdV1, + root: PresentationRootIdV1, + occurrence: OccurrenceIdV1, + ) -> &mut Self { + self.inner + .push_declared_srgb8_clean_set_final_recheck_mutant( + id.into_core(), + PointPresentationTargetV1::new(root.into_core(), occurrence.into_core()), + ); + self + } + /// Связывает клиентский output slot с выбранным encoded Paint. pub(crate) fn push_output(&mut self, output: OutputSlotIdV1, paint: PaintIdV1) -> &mut Self { self.inner @@ -1490,6 +1553,15 @@ impl OwnerV1 { self.compiled.point_presentation_count() } + #[cfg(test)] + pub(crate) fn point_resolution_count_for_test( + &self, + session: &SessionV1, + ) -> Option<(usize, usize)> { + self.compiled + .point_resolution_count_for_test(&session.session) + } + /// Канонический порядок входных портов для однократного binding на хосте. pub(crate) fn surface_input_ports( &self, @@ -2068,6 +2140,41 @@ pub(crate) enum ConstraintModeV1 { ReportOnly, } +/// Полный физический объект ограничения без подстановки одного лишь `Occurrence`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConstraintSubjectV1 { + /// Видимый результат одного `Occurrence` в объявленном для него контексте. + ModeledOccurrence { + occurrence: OccurrenceIdV1, + context: AppearanceContextV1, + }, + /// Итоговый вклад целевого `Occurrence` в конкретный терминальный корень. + PointPresentation { + root: PresentationRootIdV1, + occurrence: OccurrenceIdV1, + terminal: OccurrenceIdV1, + }, +} + +const fn project_constraint_subject(subject: ProgramConstraintSubjectV1) -> ConstraintSubjectV1 { + match subject { + ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence, + context, + } => ConstraintSubjectV1::ModeledOccurrence { + occurrence: OccurrenceIdV1::from_core(occurrence), + context: AppearanceContextV1::from_core(context), + }, + ProgramConstraintSubjectV1::PointPresentation { target, terminal } => { + ConstraintSubjectV1::PointPresentation { + root: PresentationRootIdV1::from_core(target.root()), + occurrence: OccurrenceIdV1::from_core(target.occurrence()), + terminal: OccurrenceIdV1::from_core(terminal), + } + } + } +} + /// Одна клетка `case × constraint` выбранного или fixed состояния. #[derive(Clone, Copy)] pub(crate) struct VerifiedCellV1<'a> { @@ -2089,9 +2196,9 @@ impl<'a> VerifiedCellV1<'a> { 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 subject(self) -> ConstraintSubjectV1 { + project_constraint_subject(self.inner.subject()) } /// Возвращает роль ограничения в выборе. @@ -2131,9 +2238,9 @@ impl<'a> ConflictCellV1<'a> { 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 subject(self) -> ConstraintSubjectV1 { + project_constraint_subject(self.inner.subject()) } /// Возвращает роль ограничения в выборе. @@ -2157,26 +2264,40 @@ const fn project_constraint_mode(cell: &CoreProgramConstraintCellV1) -> Constrai 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, + ProgramConstraintResultV1::Pass(ProgramConstraintPassEvidenceV1::ModeledOccurrence( + CoreProgramPassEvidenceV1::ExactSrgb8(evidence), )) => AssessmentV1::ExactSrgb8(ExactSrgb8EvidenceV1 { + inner: ExactSrgb8EvidenceRefV1::Pass(evidence), + }), + ProgramConstraintResultV1::Violation( + ProgramConstraintViolationEvidenceV1::ModeledOccurrence( + 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, + ProgramConstraintResultV1::Pass(ProgramConstraintPassEvidenceV1::ModeledOccurrence( + CoreProgramPassEvidenceV1::Wcag22Srgb8(evidence), )) => AssessmentV1::Wcag22Srgb8(Wcag22Srgb8EvidenceV1 { + inner: Wcag22Srgb8EvidenceRefV1::Pass(evidence), + }), + ProgramConstraintResultV1::Violation( + ProgramConstraintViolationEvidenceV1::ModeledOccurrence( + CoreProgramViolationEvidenceV1::Wcag22Srgb8(evidence), + ), + ) => AssessmentV1::Wcag22Srgb8(Wcag22Srgb8EvidenceV1 { inner: Wcag22Srgb8EvidenceRefV1::Violation(evidence), }), + ProgramConstraintResultV1::Pass( + ProgramConstraintPassEvidenceV1::DeclaredSrgb8CleanSet(evidence), + ) => AssessmentV1::DeclaredSrgb8CleanSet(DeclaredSrgb8CleanSetEvidenceV1 { + inner: DeclaredSrgb8CleanSetEvidenceRefV1::Pass(evidence), + }), + ProgramConstraintResultV1::Violation( + ProgramConstraintViolationEvidenceV1::DeclaredSrgb8CleanSet(evidence), + ) => AssessmentV1::DeclaredSrgb8CleanSet(DeclaredSrgb8CleanSetEvidenceV1 { + inner: DeclaredSrgb8CleanSetEvidenceRefV1::Violation(evidence), + }), } } @@ -2187,22 +2308,76 @@ pub(crate) enum AssessmentV1<'a> { ExactSrgb8(ExactSrgb8EvidenceV1<'a>), /// Evidence применимого критерия WCAG 2.2. Wcag22Srgb8(Wcag22Srgb8EvidenceV1<'a>), + /// Свидетельство закреплённого пакетом clean-set над финальным результатом + /// представления. + DeclaredSrgb8CleanSet(DeclaredSrgb8CleanSetEvidenceV1<'a>), } -impl<'a> AssessmentV1<'a> { +impl AssessmentV1<'_> { /// Возвращает несовместимый с противоположным исход классификатора. pub(crate) const fn verdict(self) -> VerdictV1 { match self { Self::ExactSrgb8(value) => value.verdict(), Self::Wcag22Srgb8(value) => value.verdict(), + Self::DeclaredSrgb8CleanSet(value) => value.verdict(), } } +} - /// Возвращает физическую occurrence-привязку и объявленный appearance context. - 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 DeclaredSrgb8CleanSetViolationKindV1 { + FinalOwnedDomainAbsent, + Rejected, +} + +#[derive(Clone, Copy)] +enum DeclaredSrgb8CleanSetEvidenceRefV1<'a> { + Pass(&'a CoreDeclaredSrgb8CleanSetPassV1), + Violation(&'a CoreDeclaredSrgb8CleanSetViolationV1), +} + +/// Заимствованное свидетельство одной абсолютной проверки финального домена sRGB8. +#[derive(Clone, Copy)] +pub(crate) struct DeclaredSrgb8CleanSetEvidenceV1<'a> { + inner: DeclaredSrgb8CleanSetEvidenceRefV1<'a>, +} + +impl DeclaredSrgb8CleanSetEvidenceV1<'_> { + pub(crate) const fn verdict(self) -> VerdictV1 { + match self.inner { + DeclaredSrgb8CleanSetEvidenceRefV1::Pass(_) => VerdictV1::Pass, + DeclaredSrgb8CleanSetEvidenceRefV1::Violation(_) => VerdictV1::Violation, + } + } + + pub(crate) const fn violation(self) -> Option { + match self.inner { + DeclaredSrgb8CleanSetEvidenceRefV1::Pass(_) => None, + DeclaredSrgb8CleanSetEvidenceRefV1::Violation( + CoreDeclaredSrgb8CleanSetViolationV1::FinalOwnedDomainAbsent, + ) => Some(DeclaredSrgb8CleanSetViolationKindV1::FinalOwnedDomainAbsent), + DeclaredSrgb8CleanSetEvidenceRefV1::Violation( + CoreDeclaredSrgb8CleanSetViolationV1::Rejected { .. }, + ) => Some(DeclaredSrgb8CleanSetViolationKindV1::Rejected), + } + } + + pub(crate) const fn visible(self) -> Option { + match self.inner { + DeclaredSrgb8CleanSetEvidenceRefV1::Pass(evidence) => Some(evidence.visible()), + DeclaredSrgb8CleanSetEvidenceRefV1::Violation(evidence) => evidence.visible(), + } + } + + pub(crate) const fn rejected_blue_interval(self) -> Option<[u8; 2]> { + match self.inner { + DeclaredSrgb8CleanSetEvidenceRefV1::Pass(_) => None, + DeclaredSrgb8CleanSetEvidenceRefV1::Violation(evidence) => { + match evidence.rejected_blue_interval() { + Some(interval) => Some(interval.endpoints()), + None => None, + } + } } } } @@ -2855,8 +3030,8 @@ pub(crate) enum UpdateInvariantFailureV1 { case_index: usize, /// Непрозрачный ID ограничения. constraint: ConstraintIdV1, - /// Непрозрачный ID occurrence. - occurrence: OccurrenceIdV1, + /// Полный физический объект финальной перепроверки. + subject: ConstraintSubjectV1, /// Число hard-нарушений на финальной перепроверке. hard_violation_count: usize, }, @@ -3236,6 +3411,15 @@ fn map_program_compile_error(error: ProgramCompileError) -> CompileErrorV1 { constraint: ConstraintIdV1::from_core(constraint), occurrence: OccurrenceIdV1::from_core(occurrence), }, + ProgramCompileError::MissingConstraintPresentationTarget { + constraint, + root, + occurrence, + } => CompileErrorV1::MissingConstraintPresentationTarget { + constraint: ConstraintIdV1::from_core(constraint), + root: PresentationRootIdV1::from_core(root), + occurrence: OccurrenceIdV1::from_core(occurrence), + }, ProgramCompileError::DuplicateOutputSlot { output } => { CompileErrorV1::DuplicateOutputSlot { output: OutputSlotIdV1::from_core(output), @@ -3412,14 +3596,14 @@ fn map_plan_error(error: CoreProgramPlanErrorV1) -> UpdateErrorV1 { state_index, case_index, constraint, - target, + subject, hard_violation_count, } => UpdateErrorV1::InternalInvariant { source: UpdateInvariantFailureV1::SelectionRecheck { state_index, case_index, constraint: ConstraintIdV1::from_core(constraint), - occurrence: OccurrenceIdV1::from_core(target), + subject: project_constraint_subject(subject), hard_violation_count, }, }, @@ -3685,6 +3869,7 @@ mod update_error_projection_tests { #[test] fn every_unreachable_core_failure_keeps_its_subject_and_witness_facts() { use crate::observation::ObservationSchemaMismatchV1; + let subject_context = context().0; let assert_invariant = |error: UpdateErrorV1, source: UpdateInvariantFailureV1| { assert_eq!(error.kind(), UpdateErrorKindV1::InternalInvariant); @@ -3767,14 +3952,20 @@ mod update_error_projection_tests { state_index: 1, case_index: 2, constraint: ConstraintId::new(3), - target: OccurrenceId::new(4), + subject: ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence: OccurrenceId::new(4), + context: subject_context, + }, hard_violation_count: 1, }), UpdateInvariantFailureV1::SelectionRecheck { state_index: 1, case_index: 2, constraint: ConstraintIdV1::new(3), - occurrence: OccurrenceIdV1::new(4), + subject: ConstraintSubjectV1::ModeledOccurrence { + occurrence: OccurrenceIdV1::new(4), + context: AppearanceContextV1::from_core(subject_context), + }, hard_violation_count: 1, }, ); diff --git a/crates/labcolors-core/src/program_boundary_tests.rs b/crates/labcolors-core/src/program_boundary_tests.rs index 021fc458..ab9f21db 100644 --- a/crates/labcolors-core/src/program_boundary_tests.rs +++ b/crates/labcolors-core/src/program_boundary_tests.rs @@ -9,12 +9,13 @@ use crate::Srgb8; use crate::program::{ AppearanceContextErrorKindV1, AppearanceContextFieldV1, AppearanceContextV1, AssessmentV1, CertificateV1, CompileErrorHandleV1, CompileErrorKindV1, CompileErrorV1, ConstraintIdV1, - ContentIdentityV3, DraftErrorV1, DraftV1, EvidenceBoundsErrorV1, InstantiateErrorV1, - JointChoiceV1, JointOrderErrorV1, JointStateV1, NumericDomainErrorV1, ObservationHeadV1, - OccurrenceIdV1, OpacityInputIdV1, OperationV1, OutputSlotIdV1, OwnerV1, PaintIdV1, - PhysicalPointV1, PresentationRootIdV1, ProjectionV1, ScenarioV1, SessionV1, SignalV1, - SourceIdV1, StateKindV1, SurfaceIdV1, SurfaceInputPortIdV1, SurroundV1, TargetCandidateIdV1, - TargetCandidateV1, TargetIdV1, UpdateErrorKindV1, UpdateErrorV1, UpdateV1, VerdictV1, + ConstraintSubjectV1, ContentIdentityV3, DraftErrorV1, DraftV1, EvidenceBoundsErrorV1, + InstantiateErrorV1, JointChoiceV1, JointOrderErrorV1, JointStateV1, NumericDomainErrorV1, + ObservationHeadV1, OccurrenceIdV1, OpacityInputIdV1, OperationV1, OutputSlotIdV1, OwnerV1, + PaintIdV1, PhysicalPointV1, PresentationRootIdV1, ProjectionV1, ScenarioV1, SessionV1, + SignalV1, SourceIdV1, StateKindV1, SurfaceIdV1, SurfaceInputPortIdV1, SurroundV1, + TargetCandidateIdV1, TargetCandidateV1, TargetIdV1, UpdateErrorKindV1, UpdateErrorV1, UpdateV1, + VerdictV1, }; use crate::wcag22::Wcag22CriterionV1; @@ -90,13 +91,31 @@ fn assert_projection_is_owner_bound(projection: ProjectionV1<'_, '_>) { let cell = $cell; let _ = cell.case_index(); let _ = cell.constraint().value(); - let _ = cell.occurrence().value(); + match cell.subject() { + ConstraintSubjectV1::ModeledOccurrence { + occurrence, + context, + } => { + let _ = occurrence.value(); + let _ = context.adapting_luminance_cd_m2(); + } + ConstraintSubjectV1::PointPresentation { + root, + occurrence, + terminal, + } => { + let _ = root.value(); + let _ = occurrence.value(); + let _ = terminal.value(); + } + } let _ = cell.mode(); let assessment = cell.assessment(); let _: VerdictV1 = assessment.verdict(); - match assessment { + let binding = match assessment { AssessmentV1::ExactSrgb8(evidence) => { let _: Srgb8 = evidence.expected(); + Some(evidence.binding()) } AssessmentV1::Wcag22Srgb8(evidence) => { let _ = evidence.profile_id(); @@ -104,20 +123,28 @@ fn assert_projection_is_owner_bound(projection: ProjectionV1<'_, '_>) { let _ = evidence.foreground_luminance(); let _ = evidence.background_luminance(); let _ = evidence.numerical_evidence(); + Some(evidence.binding()) + } + AssessmentV1::DeclaredSrgb8CleanSet(evidence) => { + let _ = evidence.visible(); + let _ = evidence.violation(); + let _ = evidence.rejected_blue_interval(); + None } + }; + if let Some(binding) = 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 context = binding.appearance_context(); + let _ = context.adapting_luminance_cd_m2(); + let _ = context.background_luminance_ratio_yb_yw(); + let _ = context.surround(); } - 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 context = binding.appearance_context(); - let _ = context.adapting_luminance_cd_m2(); - let _ = context.background_luminance_ratio_yb_yw(); - let _ = context.surround(); }}; } match certificate { diff --git a/crates/labcolors-core/src/program_clean_set_tests.rs b/crates/labcolors-core/src/program_clean_set_tests.rs new file mode 100644 index 00000000..725578f2 --- /dev/null +++ b/crates/labcolors-core/src/program_clean_set_tests.rs @@ -0,0 +1,577 @@ +use crate::lcs_occurrence::MODELED_TRISTIMULUS_DERIVATION_CALLS; +use crate::{Srgb8, program}; +use proptest::prelude::*; + +const SOURCE: program::SourceIdV1 = program::SourceIdV1::new(1); +const TARGET: program::TargetIdV1 = program::TargetIdV1::new(2); +const PORT: program::SurfaceInputPortIdV1 = program::SurfaceInputPortIdV1::new(3); +const PAINT: program::PaintIdV1 = program::PaintIdV1::new(4); +const SURFACE: program::SurfaceIdV1 = program::SurfaceIdV1::new(5); +const OCCURRENCE: program::OccurrenceIdV1 = program::OccurrenceIdV1::new(6); +const CONSTRAINT: program::ConstraintIdV1 = program::ConstraintIdV1::new(7); +const OUTPUT: program::OutputSlotIdV1 = program::OutputSlotIdV1::new(8); +const ROOT: program::PresentationRootIdV1 = program::PresentationRootIdV1::new(9); + +fn context() -> program::AppearanceContextV1 { + program::AppearanceContextV1::try_new(64.0, 0.2, program::SurroundV1::Average).unwrap() +} + +fn fixed_draft(source: Srgb8) -> program::DraftV1 { + let mut draft = program::DraftV1::new(); + draft.push_source(SOURCE, source); + draft.push_fixed_target(TARGET, SOURCE); + draft.push_surface_input_port(PORT); + draft.push_solid_paint(PAINT, TARGET); + draft.push_input_surface(SURFACE, PORT); + draft.push_source_over_occurrence(OCCURRENCE, PAINT, SURFACE, context()); + draft.push_point_presentation_root(ROOT, OCCURRENCE); + draft.push_point_presentation_target(ROOT, OCCURRENCE); + draft.push_output(OUTPUT, PAINT); + draft +} + +fn one_case(backdrop: &[Srgb8; 1]) -> [program::ScenarioV1<'_>; 1] { + [program::ScenarioV1::new(1, backdrop)] +} + +#[test] +fn undeclared_exact_presentation_target_is_a_typed_compile_error() { + let mut draft = fixed_draft(Srgb8::new([0, 200, 71])); + draft.push_declared_srgb8_clean_set_hard( + CONSTRAINT, + program::PresentationRootIdV1::new(10), + OCCURRENCE, + ); + + let error = match draft.compile() { + Err(error) => error, + Ok(_) => panic!("undeclared presentation target must not compile"), + }; + assert_eq!( + error, + program::CompileErrorV1::MissingConstraintPresentationTarget { + constraint: CONSTRAINT, + root: program::PresentationRootIdV1::new(10), + occurrence: OCCURRENCE, + }, + ); +} + +#[test] +fn report_only_dirty_terminal_is_retained_as_a_typed_rejected_violation() { + let dirty = Srgb8::new([0, 200, 71]); + let mut draft = fixed_draft(dirty); + draft.push_declared_srgb8_clean_set_report_only(CONSTRAINT, ROOT, OCCURRENCE); + let owner = draft.compile().unwrap(); + let mut session = owner.instantiate(1).unwrap(); + let backdrop = [Srgb8::new([0; 3])]; + let scenarios = one_case(&backdrop); + let projection = owner + .update( + &mut session, + program::UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + + assert_eq!(projection.evidence().kind(), program::StateKindV1::Ready); + let Some(program::CertificateV1::Verified(certificate)) = + projection.evidence().certificates().next() + else { + panic!("report-only rejection must retain a Verified certificate"); + }; + let cell = certificate.cells().next().unwrap(); + assert_eq!( + cell.subject(), + program::ConstraintSubjectV1::PointPresentation { + root: ROOT, + occurrence: OCCURRENCE, + terminal: OCCURRENCE, + }, + ); + let program::AssessmentV1::DeclaredSrgb8CleanSet(evidence) = cell.assessment() else { + panic!("clean-set constraint must retain clean-set evidence"); + }; + assert_eq!(evidence.verdict(), program::VerdictV1::Violation); + assert_eq!( + evidence.violation(), + Some(program::DeclaredSrgb8CleanSetViolationKindV1::Rejected), + ); + assert_eq!(evidence.visible(), Some(dirty)); + assert_eq!(evidence.rejected_blue_interval(), Some([71, 101])); +} + +#[test] +fn hard_absent_final_owned_domain_is_a_violation_not_a_pass() { + let mut draft = fixed_draft(Srgb8::new([0; 3])); + draft.push_declared_srgb8_clean_set_hard(CONSTRAINT, ROOT, OCCURRENCE); + let owner = draft.compile().unwrap(); + let mut session = owner.instantiate(1).unwrap(); + let backdrop = [Srgb8::new([0; 3])]; + let scenarios = one_case(&backdrop); + let projection = owner + .update( + &mut session, + program::UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + + assert_eq!(projection.evidence().kind(), program::StateKindV1::Failed); + let Some(program::CertificateV1::Conflict(certificate)) = + projection.evidence().certificates().next() + else { + panic!("absent final-owned domain must reject a hard fixed candidate"); + }; + let cell = certificate.cells().next().unwrap(); + let program::AssessmentV1::DeclaredSrgb8CleanSet(evidence) = cell.assessment() else { + panic!("clean-set constraint must retain clean-set evidence"); + }; + assert_eq!( + evidence.violation(), + Some(program::DeclaredSrgb8CleanSetViolationKindV1::FinalOwnedDomainAbsent), + ); + assert_eq!(evidence.visible(), None); + assert_eq!(evidence.rejected_blue_interval(), None); +} + +#[test] +fn finite_search_skips_dirty_and_freshly_rechecks_the_first_clean_state() { + let dirty_id = program::TargetCandidateIdV1::new(20); + let clean_id = program::TargetCandidateIdV1::new(21); + let dirty = Srgb8::new([0, 200, 71]); + let clean = Srgb8::new([0, 200, 70]); + let mut draft = program::DraftV1::new(); + draft.push_source(SOURCE, dirty); + draft.push_finite_target( + TARGET, + SOURCE, + vec![ + program::TargetCandidateV1::new(dirty_id, dirty), + program::TargetCandidateV1::new(clean_id, clean), + ], + ); + draft + .set_joint_selection(vec![ + program::JointStateV1::new(vec![program::JointChoiceV1::new(TARGET, dirty_id)]), + program::JointStateV1::new(vec![program::JointChoiceV1::new(TARGET, clean_id)]), + ]) + .unwrap(); + draft.push_surface_input_port(PORT); + draft.push_solid_paint(PAINT, TARGET); + draft.push_input_surface(SURFACE, PORT); + draft.push_source_over_occurrence(OCCURRENCE, PAINT, SURFACE, context()); + draft.push_point_presentation_root(ROOT, OCCURRENCE); + draft.push_point_presentation_target(ROOT, OCCURRENCE); + draft.push_declared_srgb8_clean_set_hard(CONSTRAINT, ROOT, OCCURRENCE); + draft.push_output(OUTPUT, PAINT); + let owner = draft.compile().unwrap(); + let mut session = owner.instantiate(1).unwrap(); + let backdrop = [Srgb8::new([0; 3])]; + let scenarios = one_case(&backdrop); + MODELED_TRISTIMULUS_DERIVATION_CALLS.with(|calls| calls.set(0)); + 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 first clean finite state must be selected"); + }; + assert_eq!(set.source(), clean); + assert_eq!(set.certificate().selected_state_index(), Some(1)); + let program::AssessmentV1::DeclaredSrgb8CleanSet(evidence) = + set.certificate().cells().next().unwrap().assessment() + else { + panic!("final recheck must retain clean-set evidence"); + }; + assert_eq!(evidence.verdict(), program::VerdictV1::Pass); + assert_eq!(evidence.visible(), Some(clean)); + assert_eq!( + MODELED_TRISTIMULUS_DERIVATION_CALLS.with(core::cell::Cell::get), + 0, + "an encoded clean-set constraint must not derive LCS", + ); +} + +#[test] +fn two_clean_constraints_and_causal_reporting_share_one_phase_materialization() { + let mut draft = fixed_draft(Srgb8::new([0, 200, 71])); + draft.push_declared_srgb8_clean_set_report_only(CONSTRAINT, ROOT, OCCURRENCE); + draft.push_declared_srgb8_clean_set_report_only( + program::ConstraintIdV1::new(10), + ROOT, + OCCURRENCE, + ); + let owner = draft.compile().unwrap(); + let mut session = owner.instantiate(1).unwrap(); + let backdrop = [Srgb8::new([0; 3])]; + let scenarios = one_case(&backdrop); + let projection = owner + .update( + &mut session, + program::UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + + let Some(program::CertificateV1::Verified(certificate)) = + projection.evidence().certificates().next() + else { + panic!("report-only constraints must retain a Verified certificate"); + }; + assert_eq!(certificate.cells().len(), 2); + assert_eq!( + owner.point_resolution_count_for_test(&session), + Some((0, 1)) + ); +} + +#[test] +fn hard_and_report_phases_do_not_reuse_resolution_authority() { + let mut draft = fixed_draft(Srgb8::new([255, 0, 0])); + draft.push_declared_srgb8_clean_set_hard(CONSTRAINT, ROOT, OCCURRENCE); + draft.push_declared_srgb8_clean_set_report_only( + program::ConstraintIdV1::new(10), + ROOT, + OCCURRENCE, + ); + let owner = draft.compile().unwrap(); + let mut session = owner.instantiate(1).unwrap(); + let backdrop = [Srgb8::new([0; 3])]; + let scenarios = one_case(&backdrop); + owner + .update( + &mut session, + program::UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + + assert_eq!( + owner.point_resolution_count_for_test(&session), + Some((1, 1)) + ); +} + +#[test] +fn downstream_occlusion_is_absent_even_when_the_inner_nominal_color_is_rejected() { + let clean_source = program::SourceIdV1::new(11); + let clean_target = program::TargetIdV1::new(12); + let clean_paint = program::PaintIdV1::new(13); + let derived_surface = program::SurfaceIdV1::new(14); + let terminal = program::OccurrenceIdV1::new(15); + let dirty = Srgb8::new([0, 200, 71]); + let mut draft = program::DraftV1::new(); + draft.push_source(SOURCE, dirty); + draft.push_source(clean_source, Srgb8::new([255, 0, 0])); + draft.push_fixed_target(TARGET, SOURCE); + draft.push_fixed_target(clean_target, clean_source); + draft.push_surface_input_port(PORT); + draft.push_solid_paint(PAINT, TARGET); + draft.push_solid_paint(clean_paint, clean_target); + draft.push_input_surface(SURFACE, PORT); + draft.push_source_over_occurrence(OCCURRENCE, PAINT, SURFACE, context()); + draft.push_occurrence_surface(derived_surface, OCCURRENCE); + draft.push_source_over_occurrence(terminal, clean_paint, derived_surface, context()); + draft.push_point_presentation_root(ROOT, terminal); + draft.push_point_presentation_target(ROOT, OCCURRENCE); + draft.push_declared_srgb8_clean_set_hard(CONSTRAINT, ROOT, OCCURRENCE); + draft.push_output(OUTPUT, clean_paint); + let owner = draft.compile().unwrap(); + let mut session = owner.instantiate(1).unwrap(); + let backdrop = [Srgb8::new([0; 3])]; + let scenarios = one_case(&backdrop); + let projection = owner + .update( + &mut session, + program::UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + + let Some(program::CertificateV1::Conflict(certificate)) = + projection.evidence().certificates().next() + else { + panic!("opaque downstream replacement must erase the inner final-owned domain"); + }; + let cell = certificate.cells().next().unwrap(); + assert_eq!( + cell.subject(), + program::ConstraintSubjectV1::PointPresentation { + root: ROOT, + occurrence: OCCURRENCE, + terminal, + }, + ); + let program::AssessmentV1::DeclaredSrgb8CleanSet(evidence) = cell.assessment() else { + panic!("clean-set constraint must retain clean-set evidence"); + }; + assert_eq!( + evidence.violation(), + Some(program::DeclaredSrgb8CleanSetViolationKindV1::FinalOwnedDomainAbsent), + ); +} + +fn nested_identity_draft(clean_target_occurrence: program::OccurrenceIdV1) -> program::DraftV1 { + let upper_source = program::SourceIdV1::new(11); + let upper_target = program::TargetIdV1::new(12); + let upper_paint = program::PaintIdV1::new(13); + let derived_surface = program::SurfaceIdV1::new(14); + let terminal = program::OccurrenceIdV1::new(15); + let mut draft = program::DraftV1::new(); + draft.push_source(SOURCE, Srgb8::new([0, 200, 71])); + draft.push_source(upper_source, Srgb8::new([255, 0, 0])); + draft.push_fixed_target(TARGET, SOURCE); + draft.push_fixed_target(upper_target, upper_source); + draft.push_surface_input_port(PORT); + draft.push_solid_paint(PAINT, TARGET); + draft.push_solid_paint(upper_paint, upper_target); + draft.push_input_surface(SURFACE, PORT); + draft.push_source_over_occurrence(OCCURRENCE, PAINT, SURFACE, context()); + draft.push_occurrence_surface(derived_surface, OCCURRENCE); + draft.push_source_over_occurrence(terminal, upper_paint, derived_surface, context()); + draft.push_point_presentation_root(ROOT, terminal); + draft.push_point_presentation_target(ROOT, OCCURRENCE); + draft.push_point_presentation_target(ROOT, terminal); + draft.push_declared_srgb8_clean_set_hard(CONSTRAINT, ROOT, clean_target_occurrence); + draft.push_output(OUTPUT, upper_paint); + draft +} + +#[test] +fn clean_constraint_identity_binds_the_whole_presentation_subject() { + let inner = nested_identity_draft(OCCURRENCE).compile().unwrap(); + let terminal_id = program::OccurrenceIdV1::new(15); + let terminal = nested_identity_draft(terminal_id).compile().unwrap(); + + assert_ne!(inner.content_identity(), terminal.content_identity()); +} + +#[test] +fn clean_constraint_mode_is_content_bound() { + let hard = fixed_draft(Srgb8::new([255, 0, 0])); + let mut hard = hard; + hard.push_declared_srgb8_clean_set_hard(CONSTRAINT, ROOT, OCCURRENCE); + let mut report = fixed_draft(Srgb8::new([255, 0, 0])); + report.push_declared_srgb8_clean_set_report_only(CONSTRAINT, ROOT, OCCURRENCE); + + assert_ne!( + hard.compile().unwrap().content_identity(), + report.compile().unwrap().content_identity(), + ); +} + +#[test] +fn clean_family_fresh_recheck_failure_retains_the_presentation_subject() { + let candidate = program::TargetCandidateIdV1::new(20); + let clean = Srgb8::new([255, 0, 0]); + let mut draft = program::DraftV1::new(); + draft.push_source(SOURCE, clean); + draft.push_finite_target( + TARGET, + SOURCE, + vec![program::TargetCandidateV1::new(candidate, clean)], + ); + draft + .set_joint_selection(vec![program::JointStateV1::new(vec![ + program::JointChoiceV1::new(TARGET, candidate), + ])]) + .unwrap(); + draft.push_surface_input_port(PORT); + draft.push_solid_paint(PAINT, TARGET); + draft.push_input_surface(SURFACE, PORT); + draft.push_source_over_occurrence(OCCURRENCE, PAINT, SURFACE, context()); + draft.push_point_presentation_root(ROOT, OCCURRENCE); + draft.push_point_presentation_target(ROOT, OCCURRENCE); + draft.push_declared_srgb8_clean_set_final_recheck_mutant(CONSTRAINT, ROOT, OCCURRENCE); + draft.push_output(OUTPUT, PAINT); + let owner = draft.compile().unwrap(); + let mut session = owner.instantiate(1).unwrap(); + let backdrop = [Srgb8::new([0; 3])]; + let scenarios = one_case(&backdrop); + let error = match owner.update( + &mut session, + program::UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) { + Err(error) => error, + Ok(_) => panic!("mutant must diverge only at the fresh hard recheck"), + }; + + let program::UpdateErrorV1::InternalInvariant { + source: + program::UpdateInvariantFailureV1::SelectionRecheck { + state_index, + case_index, + constraint, + subject, + hard_violation_count, + }, + } = error + else { + panic!("fresh clean-set divergence must use the typed recheck invariant"); + }; + assert_eq!((state_index, case_index, constraint), (0, 0, CONSTRAINT)); + assert_eq!(hard_violation_count, 1); + assert_eq!( + subject, + program::ConstraintSubjectV1::PointPresentation { + root: ROOT, + occurrence: OCCURRENCE, + terminal: OCCURRENCE, + }, + ); +} + +fn opaque_named_clean_identity(name: u32) -> program::ContentIdentityV3 { + let source = program::SourceIdV1::new(name); + let target = program::TargetIdV1::new(name); + let port = program::SurfaceInputPortIdV1::new(name); + let paint = program::PaintIdV1::new(name); + let surface = program::SurfaceIdV1::new(name); + let occurrence = program::OccurrenceIdV1::new(name); + let root = program::PresentationRootIdV1::new(name); + let constraint = program::ConstraintIdV1::new(name); + let output = program::OutputSlotIdV1::new(name); + let mut draft = program::DraftV1::new(); + draft.push_source(source, Srgb8::new([255, 0, 0])); + draft.push_fixed_target(target, source); + draft.push_surface_input_port(port); + draft.push_solid_paint(paint, target); + draft.push_input_surface(surface, port); + draft.push_source_over_occurrence(occurrence, paint, surface, context()); + draft.push_point_presentation_root(root, occurrence); + draft.push_point_presentation_target(root, occurrence); + draft.push_declared_srgb8_clean_set_hard(constraint, root, occurrence); + draft.push_output(output, paint); + draft.compile().unwrap().content_identity() +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + #[test] + fn clean_constraint_identity_is_invariant_under_opaque_renaming(name in any::()) { + prop_assert_eq!(opaque_named_clean_identity(1), opaque_named_clean_identity(name)); + } +} + +#[test] +fn clean_constraint_declaration_order_is_not_policy() { + let mut first = fixed_draft(Srgb8::new([255, 0, 0])); + first.push_declared_srgb8_clean_set_report_only(CONSTRAINT, ROOT, OCCURRENCE); + first.push_declared_srgb8_clean_set_report_only( + program::ConstraintIdV1::new(10), + ROOT, + OCCURRENCE, + ); + let mut second = fixed_draft(Srgb8::new([255, 0, 0])); + second.push_declared_srgb8_clean_set_report_only( + program::ConstraintIdV1::new(10), + ROOT, + OCCURRENCE, + ); + second.push_declared_srgb8_clean_set_report_only(CONSTRAINT, ROOT, OCCURRENCE); + + assert_eq!( + first.compile().unwrap().content_identity(), + second.compile().unwrap().content_identity(), + ); +} + +fn finite_clean_owner(colors: &[Srgb8]) -> program::OwnerV1 { + let candidates = colors + .iter() + .copied() + .enumerate() + .map(|(index, color)| { + program::TargetCandidateV1::new( + program::TargetCandidateIdV1::new(index as u32 + 20), + color, + ) + }) + .collect::>(); + let states = (0..colors.len()) + .map(|index| { + program::JointStateV1::new(vec![program::JointChoiceV1::new( + TARGET, + program::TargetCandidateIdV1::new(index as u32 + 20), + )]) + }) + .collect::>(); + let mut draft = program::DraftV1::new(); + draft.push_source(SOURCE, colors[0]); + draft.push_finite_target(TARGET, SOURCE, candidates); + draft.set_joint_selection(states).unwrap(); + draft.push_surface_input_port(PORT); + draft.push_solid_paint(PAINT, TARGET); + draft.push_input_surface(SURFACE, PORT); + draft.push_source_over_occurrence(OCCURRENCE, PAINT, SURFACE, context()); + draft.push_point_presentation_root(ROOT, OCCURRENCE); + draft.push_point_presentation_target(ROOT, OCCURRENCE); + draft.push_declared_srgb8_clean_set_hard(CONSTRAINT, ROOT, OCCURRENCE); + draft.push_output(OUTPUT, PAINT); + draft.compile().unwrap() +} + +#[test] +fn rejected_clean_search_states_do_not_add_hot_path_allocations() { + let direct = finite_clean_owner(&[Srgb8::new([255, 0, 0])]); + let rejected = finite_clean_owner(&[ + Srgb8::new([0, 200, 71]), + Srgb8::new([0, 200, 72]), + Srgb8::new([0, 200, 73]), + Srgb8::new([255, 0, 0]), + ]); + let mut direct_session = direct.instantiate(1).unwrap(); + let mut rejected_session = rejected.instantiate(2).unwrap(); + let backdrop = [Srgb8::new([0; 3])]; + let scenarios = one_case(&backdrop); + + let (_, direct_allocations) = crate::test_support::measured_allocations(|| { + direct + .update( + &mut direct_session, + program::UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap() + .evidence() + .kind() + }); + let (_, rejected_allocations) = crate::test_support::measured_allocations(|| { + rejected + .update( + &mut rejected_session, + program::UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap() + .evidence() + .kind() + }); + + assert_eq!(rejected_allocations, direct_allocations); +} diff --git a/crates/labcolors-core/src/program_identity.rs b/crates/labcolors-core/src/program_identity.rs index 38b6c10b..f548ec95 100644 --- a/crates/labcolors-core/src/program_identity.rs +++ b/crates/labcolors-core/src/program_identity.rs @@ -8,11 +8,11 @@ use super::*; const DOMAIN_V3: &[u8] = b"labcolors.program-content-identity.v3\0"; -// Максимальный V3-цвет принадлежит Occurrence: теги вершины, композиции, -// контекста и frame, два binary64-параметра наблюдения и surround. Явная -// граница устраняет аллокацию на каждую вершину и требует пересмотра при -// расширении схемы вместо скрытого runtime-лимита. -const COLOR_CAPACITY: usize = 1 + 1 + 1 + 4 + 8 + 8 + 1; +// Максимальный V3-цвет принадлежит ограничению clean-set: тег вершины, +// семейство и полный дайджест выпуска. Явная граница устраняет аллокацию на +// каждую вершину и требует пересмотра при расширении схемы вместо скрытого +// лимита времени исполнения. +const COLOR_CAPACITY: usize = 1 + 1 + 32; mod release_tag { pub(super) const PROGRAM_SCHEMA_V3: u8 = 3; @@ -60,8 +60,9 @@ mod release_tag { 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; + pub(super) const DECLARED_SRGB8_CLEAN_SET_FAMILY_V1: u8 = 3; #[cfg(test)] - pub(super) const MODELED_LCS_PROBE_FAMILY_V1: u8 = 3; + pub(super) const MODELED_LCS_PROBE_FAMILY_V1: u8 = 4; } /// Устойчивый к коллизиям адрес канонизированного содержимого Program V3. @@ -173,6 +174,7 @@ enum EdgeRoleV1 { PresentationRootTerminal = 18, PresentationTargetRoot = 19, PresentationTargetOccurrence = 20, + ConstraintPresentationTarget = 21, } #[derive(Debug, Clone, Copy)] @@ -543,6 +545,44 @@ fn constraint_color( Ok(color) } +fn declared_srgb8_clean_set_constraint_color( + mode_tag: u8, +) -> Result { + declared_srgb8_clean_set_constraint_color_for_release( + mode_tag, + crate::clean_set::EXACT_NOMINAL_SRGB8_CLEAN_SET_RELEASE_SHA256_V1, + ) +} + +fn declared_srgb8_clean_set_constraint_color_for_release( + mode_tag: u8, + release: [u8; 32], +) -> Result { + let mut color = VertexColorV1::new(mode_tag); + color.push_u8(release_tag::DECLARED_SRGB8_CLEAN_SET_FAMILY_V1)?; + for byte in release { + color.push_u8(byte)?; + } + Ok(color) +} + +fn presentation_target_vertex( + targets: &[(PointPresentationTargetV1, usize)], + target: PointPresentationTargetV1, +) -> Result { + let key = (target.root(), target.occurrence()); + let index = targets + .binary_search_by_key(&key, |(candidate, _)| { + (candidate.root(), candidate.occurrence()) + }) + .map_err(|_| ProgramCompileError::InternalInvariant)?; + let (candidate, vertex) = targets[index]; + if candidate != target { + return Err(ProgramCompileError::InternalInvariant); + } + Ok(vertex) +} + fn build_graph( program: &Program, ) -> Result @@ -718,42 +758,88 @@ where EdgeRoleV1::PresentationRootTerminal, )?; } - for (target, vertex) in presentation_targets { + for (target, vertex) in &presentation_targets { graph.add_edge( - vertex, + *vertex, presentation_roots.get(target.root())?, EdgeRoleV1::PresentationTargetRoot, )?; graph.add_edge( - vertex, + *vertex, occurrences.get(target.occurrence())?, EdgeRoleV1::PresentationTargetOccurrence, )?; } for constraint in &program.constraints.hard { - let color = constraint_color( - vertex_tag::CONSTRAINT_HARD, - program.evaluator.constraint_content(constraint.invocation), - )?; + let (color, target, role) = match *constraint.body() { + ProgramConstraintBodyV1::ModeledOccurrence { + occurrence, + invocation, + } => ( + constraint_color( + vertex_tag::CONSTRAINT_HARD, + program.evaluator.constraint_content(invocation), + )?, + occurrences.get(occurrence)?, + EdgeRoleV1::ConstraintOccurrence, + ), + ProgramConstraintBodyV1::DeclaredSrgb8CleanSet { target } => ( + declared_srgb8_clean_set_constraint_color(vertex_tag::CONSTRAINT_HARD)?, + presentation_target_vertex(&presentation_targets, target)?, + EdgeRoleV1::ConstraintPresentationTarget, + ), + #[cfg(test)] + ProgramConstraintBodyV1::DeclaredSrgb8CleanSetFinalRecheckMutant { target } => { + let mut release = crate::clean_set::EXACT_NOMINAL_SRGB8_CLEAN_SET_RELEASE_SHA256_V1; + release[0] ^= 1; + ( + declared_srgb8_clean_set_constraint_color_for_release( + vertex_tag::CONSTRAINT_HARD, + release, + )?, + presentation_target_vertex(&presentation_targets, target)?, + EdgeRoleV1::ConstraintPresentationTarget, + ) + } + }; let vertex = graph.add_member(color)?; - graph.add_edge( - vertex, - occurrences.get(constraint.target)?, - EdgeRoleV1::ConstraintOccurrence, - )?; + graph.add_edge(vertex, target, role)?; } for constraint in &program.constraints.report_only { - let color = constraint_color( - vertex_tag::CONSTRAINT_REPORT_ONLY, - program.evaluator.constraint_content(constraint.invocation), - )?; + let (color, target, role) = match *constraint.body() { + ProgramConstraintBodyV1::ModeledOccurrence { + occurrence, + invocation, + } => ( + constraint_color( + vertex_tag::CONSTRAINT_REPORT_ONLY, + program.evaluator.constraint_content(invocation), + )?, + occurrences.get(occurrence)?, + EdgeRoleV1::ConstraintOccurrence, + ), + ProgramConstraintBodyV1::DeclaredSrgb8CleanSet { target } => ( + declared_srgb8_clean_set_constraint_color(vertex_tag::CONSTRAINT_REPORT_ONLY)?, + presentation_target_vertex(&presentation_targets, target)?, + EdgeRoleV1::ConstraintPresentationTarget, + ), + #[cfg(test)] + ProgramConstraintBodyV1::DeclaredSrgb8CleanSetFinalRecheckMutant { target } => { + let mut release = crate::clean_set::EXACT_NOMINAL_SRGB8_CLEAN_SET_RELEASE_SHA256_V1; + release[0] ^= 1; + ( + declared_srgb8_clean_set_constraint_color_for_release( + vertex_tag::CONSTRAINT_REPORT_ONLY, + release, + )?, + presentation_target_vertex(&presentation_targets, target)?, + EdgeRoleV1::ConstraintPresentationTarget, + ) + } + }; let vertex = graph.add_member(color)?; - graph.add_edge( - vertex, - occurrences.get(constraint.target)?, - EdgeRoleV1::ConstraintOccurrence, - )?; + graph.add_edge(vertex, target, role)?; } for output in &program.outputs { let vertex = graph.add_member(VertexColorV1::new(vertex_tag::OUTPUT))?; @@ -1415,6 +1501,30 @@ where #[cfg(test)] mod tests { use super::*; + + #[test] + fn declared_clean_set_constraint_color_binds_every_release_digest_byte() { + let release = crate::clean_set::EXACT_NOMINAL_SRGB8_CLEAN_SET_RELEASE_SHA256_V1; + let baseline = declared_srgb8_clean_set_constraint_color_for_release( + vertex_tag::CONSTRAINT_HARD, + release, + ) + .unwrap(); + + for byte_index in 0..release.len() { + let mut mutant = release; + mutant[byte_index] ^= 1; + assert_ne!( + baseline, + declared_srgb8_clean_set_constraint_color_for_release( + vertex_tag::CONSTRAINT_HARD, + mutant, + ) + .unwrap(), + "release digest byte {byte_index} escaped identity", + ); + } + } use proptest::prelude::*; #[test] diff --git a/crates/labcolors-core/src/program_joint_integration_tests.rs b/crates/labcolors-core/src/program_joint_integration_tests.rs index 08600eb0..c7dc831f 100644 --- a/crates/labcolors-core/src/program_joint_integration_tests.rs +++ b/crates/labcolors-core/src/program_joint_integration_tests.rs @@ -21,9 +21,9 @@ use crate::program_session::{ CompositionProfile, ConstraintId, ConstraintInvocation, ConstraintSet, DeclaredJointSelectionV1, HardModeV1, JointCandidateStateV1, ObservationGroup, Occurrence, OutputBinding, OutputSlotId, Paint, Program, ProgramCompileError, - ProgramConstraintEvaluatorSetV1, ProgramSessionEvaluationError, ReportModeV1, Source, SourceId, - Surface, Target, TargetCandidateChoiceV1, TargetCandidateId, TargetCandidateV1, TargetDomainV1, - TargetId, checked_program_evaluation_cell_counts_for_test, + ProgramConstraintEvaluatorSetV1, ProgramConstraintSubjectV1, ProgramSessionEvaluationError, + ReportModeV1, 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}; @@ -1089,10 +1089,9 @@ fn bijective_source_target_and_candidate_renaming_preserves_joint_evidence() { cell.candidate_state_index(), cell.case_index(), cell.constraint(), - cell.target(), + cell.subject(), cell.is_hard(), cell.result().is_violation(), - cell.appearance_context(), ) }) .collect::>(); @@ -1769,7 +1768,10 @@ fn lower_id_diagnostic_cannot_consume_the_selected_state_final_recheck() { state_index: 0, case_index: 0, constraint: hard, - target: OCCURRENCE, + subject: ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence: OCCURRENCE, + context: appearance_context(), + }, hard_violation_count: 1, }), ); @@ -1810,7 +1812,10 @@ fn diagnostic_error_cannot_mask_a_selected_state_final_recheck_violation() { state_index: 0, case_index: 0, constraint: hard, - target: OCCURRENCE, + subject: ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence: OCCURRENCE, + context: appearance_context(), + }, hard_violation_count: 1, }), ); @@ -1873,7 +1878,10 @@ fn final_recheck_violation_is_typed_and_retains_the_previous_certificate() { state_index: 0, case_index: 0, constraint: ConstraintId::new(1), - target: OCCURRENCE, + subject: ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence: OCCURRENCE, + context: appearance_context(), + }, hard_violation_count: 1, }) ); diff --git a/crates/labcolors-core/src/program_lcs_integration_tests.rs b/crates/labcolors-core/src/program_lcs_integration_tests.rs index 2e333b12..23c4d350 100644 --- a/crates/labcolors-core/src/program_lcs_integration_tests.rs +++ b/crates/labcolors-core/src/program_lcs_integration_tests.rs @@ -19,7 +19,8 @@ use crate::observation::{ use crate::program_session::{ CompiledProgram, CompositionProfile, ConstraintId, ConstraintInvocation, ConstraintSet, ObservationGroup, Occurrence, OpacityInput, OutputBinding, OutputSlotId, Paint, Program, - ProgramConstraintResultV1, Source, SourceId, Surface, Target, TargetId, + ProgramConstraintPassEvidenceV1, ProgramConstraintResultV1, ProgramConstraintSubjectV1, + ProgramConstraintViolationEvidenceV1, Source, SourceId, Surface, Target, TargetId, }; use crate::session::SessionState; use crate::spaces::cam16::FORWARD_CALLS; @@ -303,8 +304,17 @@ fn ready_cell_binds_the_actual_visible_signal_and_declared_context_without_lcs() panic!("one physical case times one constraint must produce one cell"); }; - assert_eq!(cell.appearance_context(), average); - let ProgramConstraintResultV1::Pass(evidence) = cell.result() else { + assert_eq!( + cell.subject(), + ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence: AVERAGE_OCCURRENCE, + context: average, + }, + ); + let ProgramConstraintResultV1::Pass(ProgramConstraintPassEvidenceV1::ModeledOccurrence( + evidence, + )) = cell.result() + else { panic!("exact equality must retain typed pass evidence"); }; assert_binding_matches_physical(*evidence.binding(), average, Srgb8::new([0x80; 3])); @@ -333,14 +343,25 @@ fn identical_physical_bytes_keep_distinct_declared_contexts_without_deriving_vie let [average_cell, dim_cell] = current.report().cells() else { panic!("one case times two constraints must produce two canonical cells"); }; - assert_eq!(average_cell.appearance_context(), average); - assert_eq!(dim_cell.appearance_context(), dim); - assert_ne!( - average_cell.appearance_context(), - dim_cell.appearance_context() + assert_eq!( + average_cell.subject(), + ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence: AVERAGE_OCCURRENCE, + context: average, + }, + ); + assert_eq!( + dim_cell.subject(), + ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence: DIM_OCCURRENCE, + context: dim, + }, ); for cell in [average_cell, dim_cell] { - let ProgramConstraintResultV1::Pass(evidence) = cell.result() else { + let ProgramConstraintResultV1::Pass(ProgramConstraintPassEvidenceV1::ModeledOccurrence( + evidence, + )) = cell.result() + else { panic!("both exact constraints must pass"); }; assert_eq!( @@ -368,8 +389,17 @@ fn exact_black_visible_occurrence_does_not_construct_a_colorimetric_view() { let [cell] = current.report().cells() else { panic!("the complete report must retain its sole visible occurrence"); }; - assert_eq!(cell.appearance_context(), average); - let ProgramConstraintResultV1::Pass(evidence) = cell.result() else { + assert_eq!( + cell.subject(), + ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence: AVERAGE_OCCURRENCE, + context: average, + }, + ); + let ProgramConstraintResultV1::Pass(ProgramConstraintPassEvidenceV1::ModeledOccurrence( + evidence, + )) = cell.result() + else { panic!("exact black identity must pass"); }; assert_eq!(*evidence.measurement().value(), Srgb8::new([0; 3])); @@ -397,8 +427,17 @@ fn hard_violation_retains_physical_binding_and_context_without_current_outputs() panic!("the complete failed report must retain its sole cell"); }; assert!(cell.result().is_violation()); - assert_eq!(cell.appearance_context(), average); - let ProgramConstraintResultV1::Violation(evidence) = cell.result() else { + assert_eq!( + cell.subject(), + ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence: AVERAGE_OCCURRENCE, + context: average, + }, + ); + let ProgramConstraintResultV1::Violation( + ProgramConstraintViolationEvidenceV1::ModeledOccurrence(evidence), + ) = cell.result() + else { panic!("exact mismatch must retain typed violation evidence"); }; assert_binding_matches_physical(*evidence.binding(), average, Srgb8::new([0x80; 3])); @@ -419,7 +458,10 @@ fn program_wcag_pass_binds_physical_occurrence_and_declared_context() { let [cell] = current.report().cells() else { panic!("one WCAG declaration must produce one report cell"); }; - let ProgramConstraintResultV1::Pass(evidence) = cell.result() else { + let ProgramConstraintResultV1::Pass(ProgramConstraintPassEvidenceV1::ModeledOccurrence( + evidence, + )) = cell.result() + else { panic!("WCAG pass must retain typed pass evidence"); }; assert_binding_matches_physical( @@ -450,7 +492,10 @@ fn program_wcag_violation_retains_physical_evidence_without_current_outputs() { let [cell] = cause.report().cells() else { panic!("one WCAG declaration must produce one failed report cell"); }; - let ProgramConstraintResultV1::Violation(evidence) = cell.result() else { + let ProgramConstraintResultV1::Violation( + ProgramConstraintViolationEvidenceV1::ModeledOccurrence(evidence), + ) = cell.result() + else { panic!("WCAG failure must retain typed violation evidence"); }; assert_binding_matches_physical( @@ -637,7 +682,13 @@ fn encoded_only_program_is_not_rejected_by_an_lcs_incompatible_declared_context( let [cell] = current.report().cells() else { panic!("the exact constraint must still emit one evidence cell"); }; - assert_eq!(cell.appearance_context(), incompatible); + assert_eq!( + cell.subject(), + ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence: AVERAGE_OCCURRENCE, + context: incompatible, + }, + ); assert_eq!( MODELED_TRISTIMULUS_DERIVATION_CALLS.with(|calls| calls.get()), 0, @@ -677,8 +728,7 @@ fn declaration_permutations_preserve_canonical_context_cells_and_output_signals( ( cell.case_index(), cell.constraint(), - cell.target(), - cell.appearance_context(), + cell.subject(), cell.result().is_violation(), ) }; diff --git a/crates/labcolors-core/src/program_mixed_evaluator_tests.rs b/crates/labcolors-core/src/program_mixed_evaluator_tests.rs index ffc50b71..25a067cb 100644 --- a/crates/labcolors-core/src/program_mixed_evaluator_tests.rs +++ b/crates/labcolors-core/src/program_mixed_evaluator_tests.rs @@ -20,9 +20,10 @@ use crate::observation::{ }; use crate::program::{ AccessErrorV1, AssessmentV1, CertificateV1, ConflictCellV1, ConstraintModeV1, - ExactSrgb8EvidenceV1, ObservationHeadV1, ObservationV1, OperationV1, OutputSlotIdV1, OwnerV1, - PhysicalPointV1, ProjectionV1, ScenarioV1, SessionV1, SignalV1, StateKindV1, SurroundV1, - UpdateErrorKindV1, UpdateErrorV1, UpdateV1, VerdictV1, VerifiedCellV1, Wcag22Srgb8EvidenceV1, + ConstraintSubjectV1, ExactSrgb8EvidenceV1, ObservationHeadV1, ObservationV1, OperationV1, + OutputSlotIdV1, OwnerV1, PhysicalPointV1, ProjectionV1, ScenarioV1, SessionV1, SignalV1, + StateKindV1, SurroundV1, UpdateErrorKindV1, UpdateErrorV1, UpdateV1, VerdictV1, VerifiedCellV1, + Wcag22Srgb8EvidenceV1, }; use crate::program_session::{ CORE_PROGRAM_ASSESSMENT_CALLS, CompiledCoreProgramV1, CompositionProfile, ConstraintId, @@ -30,8 +31,9 @@ use crate::program_session::{ CoreProgramEvaluatorsV1, CoreProgramPassEvidenceV1, CoreProgramV1, CoreProgramViolationEvidenceV1, DeclaredJointSelectionV1, JointCandidateStateV1, ObservationGroup, Occurrence, OpacityInput, OutputBinding, OutputSlotId, Paint, Program, - ProgramConstraintCellV1, ProgramConstraintResultV1, Source, SourceId, Surface, Target, - TargetCandidateChoiceV1, TargetCandidateId, TargetCandidateV1, TargetId, + ProgramConstraintCellV1, ProgramConstraintPassEvidenceV1, ProgramConstraintResultV1, + ProgramConstraintSubjectV1, ProgramConstraintViolationEvidenceV1, Source, SourceId, Surface, + Target, TargetCandidateChoiceV1, TargetCandidateId, TargetCandidateV1, TargetId, }; use crate::session::SessionState; use crate::wcag22::{Wcag22CriterionV1, wcag22_profile_v1}; @@ -261,7 +263,14 @@ fn assert_public_binding_matches_core( 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(); + let public_binding = match public { + AssessmentV1::ExactSrgb8(evidence) => evidence.binding(), + AssessmentV1::Wcag22Srgb8(evidence) => evidence.binding(), + AssessmentV1::DeclaredSrgb8CleanSet(_) => { + panic!("fixture contains only occurrence-subject evaluators") + } + }; + let PhysicalPointV1::EncodedSrgb8SourceOver(public_physical) = public_binding.physical(); assert_eq!( public_physical.subject_paint().value(), core_program_occurrence.subject().value() @@ -287,7 +296,7 @@ fn assert_public_binding_matches_core( Srgb8::new(core_occurrence.output_rgb()) ); - let public_context = public.binding().appearance_context(); + let public_context = public_binding.appearance_context(); let core_context = core.context(); assert_eq!( public_context.adapting_luminance_cd_m2().to_bits(), @@ -361,7 +370,9 @@ fn assert_public_assessment_matches_core( match (public, core) { ( AssessmentV1::ExactSrgb8(public), - ProgramConstraintResultV1::Pass(CoreProgramPassEvidenceV1::ExactSrgb8(core)), + ProgramConstraintResultV1::Pass(ProgramConstraintPassEvidenceV1::ModeledOccurrence( + CoreProgramPassEvidenceV1::ExactSrgb8(core), + )), ) => assert_exact_matches( public, VerdictV1::Pass, @@ -372,7 +383,11 @@ fn assert_public_assessment_matches_core( ), ( AssessmentV1::ExactSrgb8(public), - ProgramConstraintResultV1::Violation(CoreProgramViolationEvidenceV1::ExactSrgb8(core)), + ProgramConstraintResultV1::Violation( + ProgramConstraintViolationEvidenceV1::ModeledOccurrence( + CoreProgramViolationEvidenceV1::ExactSrgb8(core), + ), + ), ) => assert_exact_matches( public, VerdictV1::Violation, @@ -383,7 +398,9 @@ fn assert_public_assessment_matches_core( ), ( AssessmentV1::Wcag22Srgb8(public), - ProgramConstraintResultV1::Pass(CoreProgramPassEvidenceV1::Wcag22Srgb8(core)), + ProgramConstraintResultV1::Pass(ProgramConstraintPassEvidenceV1::ModeledOccurrence( + CoreProgramPassEvidenceV1::Wcag22Srgb8(core), + )), ) => assert_wcag_matches( public, VerdictV1::Pass, @@ -393,7 +410,11 @@ fn assert_public_assessment_matches_core( ), ( AssessmentV1::Wcag22Srgb8(public), - ProgramConstraintResultV1::Violation(CoreProgramViolationEvidenceV1::Wcag22Srgb8(core)), + ProgramConstraintResultV1::Violation( + ProgramConstraintViolationEvidenceV1::ModeledOccurrence( + CoreProgramViolationEvidenceV1::Wcag22Srgb8(core), + ), + ), ) => assert_wcag_matches( public, VerdictV1::Violation, @@ -413,12 +434,36 @@ fn assert_verified_cell_matches_core( 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()); + let (occurrence, context) = match core.subject() { + ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence, + context, + } => (occurrence, context), + ProgramConstraintSubjectV1::PointPresentation { .. } => { + panic!("fixture contains only occurrence-subject evaluators") + } + }; + assert_eq!( + public.subject(), + ConstraintSubjectV1::ModeledOccurrence { + occurrence: crate::program::OccurrenceIdV1::new(occurrence.value()), + context: crate::program::AppearanceContextV1::try_new( + context.adapting_luminance_cd_m2(), + context.background_luminance_ratio(), + match context.surround_profile() { + SurroundProfileId::AverageV1 => SurroundV1::Average, + SurroundProfileId::DimV1 => SurroundV1::Dim, + SurroundProfileId::DarkV1 => SurroundV1::Dark, + }, + ) + .unwrap(), + }, + ); assert_eq!( matches!(public.mode(), ConstraintModeV1::Hard), core.is_hard() ); - assert_public_assessment_matches_core(public.assessment(), core.result(), core.target()); + assert_public_assessment_matches_core(public.assessment(), core.result(), occurrence); } fn assert_conflict_cell_matches_core( @@ -428,12 +473,32 @@ fn assert_conflict_cell_matches_core( 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()); + let (occurrence, context) = match core.subject() { + ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence, + context, + } => (occurrence, context), + ProgramConstraintSubjectV1::PointPresentation { .. } => { + panic!("fixture contains only occurrence-subject evaluators") + } + }; + let ConstraintSubjectV1::ModeledOccurrence { + occurrence: public_occurrence, + context: public_context, + } = public.subject() + else { + panic!("fixture contains only occurrence-subject evaluators"); + }; + assert_eq!(public_occurrence.value(), occurrence.value()); + assert_eq!( + public_context.adapting_luminance_cd_m2().to_bits(), + context.adapting_luminance_cd_m2().to_bits(), + ); assert_eq!( matches!(public.mode(), ConstraintModeV1::Hard), core.is_hard() ); - assert_public_assessment_matches_core(public.assessment(), core.result(), core.target()); + assert_public_assessment_matches_core(public.assessment(), core.result(), occurrence); } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -517,10 +582,11 @@ fn consume_public_assessment(assessment: AssessmentV1<'_>, probe: &mut Projectio VerdictV1::Pass => 1, VerdictV1::Violation => 2, }); - match assessment { + let binding = match assessment { AssessmentV1::ExactSrgb8(evidence) => { probe.exact_assessments += 1; probe.mix_srgb8(evidence.expected()); + Some(evidence.binding()) } AssessmentV1::Wcag22Srgb8(evidence) => { probe.wcag_assessments += 1; @@ -531,10 +597,21 @@ fn consume_public_assessment(assessment: AssessmentV1<'_>, probe: &mut Projectio probe.mix(evidence.background_luminance().lower()); probe.mix(evidence.background_luminance().upper()); probe.mix_bytes(evidence.numerical_evidence().class_key().as_bytes()); + Some(evidence.binding()) } - } + AssessmentV1::DeclaredSrgb8CleanSet(evidence) => { + probe.mix(evidence.visible().map_or(0, |value| { + let [r, g, b] = value.bytes(); + (u64::from(r) << 16) | (u64::from(g) << 8) | u64::from(b) + })); + None + } + }; - let PhysicalPointV1::EncodedSrgb8SourceOver(physical) = assessment.binding().physical(); + let Some(binding) = binding else { + return; + }; + let PhysicalPointV1::EncodedSrgb8SourceOver(physical) = binding.physical(); probe.mix(u64::from(physical.subject_paint().value())); probe.mix(u64::from(physical.backdrop_surface().value())); probe.mix_srgb8(physical.subject()); @@ -542,7 +619,7 @@ fn consume_public_assessment(assessment: AssessmentV1<'_>, probe: &mut Projectio probe.mix_srgb8(physical.backdrop()); probe.mix_srgb8(physical.visible()); - let context = assessment.binding().appearance_context(); + let context = binding.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() { @@ -616,7 +693,20 @@ fn consume_public_projection(projection: ProjectionV1<'_, '_>) -> ProjectionProb probe.cells += 1; probe.mix(cell.case_index() as u64); probe.mix(u64::from(cell.constraint().value())); - probe.mix(u64::from(cell.occurrence().value())); + match cell.subject() { + ConstraintSubjectV1::ModeledOccurrence { occurrence, .. } => { + probe.mix(u64::from(occurrence.value())); + } + ConstraintSubjectV1::PointPresentation { + root, + occurrence, + terminal, + } => { + probe.mix(u64::from(root.value())); + probe.mix(u64::from(occurrence.value())); + probe.mix(u64::from(terminal.value())); + } + } probe.mix(match cell.mode() { ConstraintModeV1::Hard => 1, ConstraintModeV1::ReportOnly => 2, @@ -638,7 +728,20 @@ fn consume_public_projection(projection: ProjectionV1<'_, '_>) -> ProjectionProb 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())); + match cell.subject() { + ConstraintSubjectV1::ModeledOccurrence { occurrence, .. } => { + probe.mix(u64::from(occurrence.value())); + } + ConstraintSubjectV1::PointPresentation { + root, + occurrence, + terminal, + } => { + probe.mix(u64::from(root.value())); + probe.mix(u64::from(occurrence.value())); + probe.mix(u64::from(terminal.value())); + } + } probe.mix(match cell.mode() { ConstraintModeV1::Hard => 1, ConstraintModeV1::ReportOnly => 2, @@ -748,8 +851,9 @@ fn one_program_retains_typed_exact_and_wcag22_outcomes() { panic!("one case times two heterogeneous constraints must produce two cells"); }; - let ProgramConstraintResultV1::Pass(CoreProgramPassEvidenceV1::ExactSrgb8(evidence)) = - exact.result() + let ProgramConstraintResultV1::Pass(ProgramConstraintPassEvidenceV1::ModeledOccurrence( + CoreProgramPassEvidenceV1::ExactSrgb8(evidence), + )) = exact.result() else { panic!("the first cell must retain Exact-specific pass evidence"); }; @@ -764,8 +868,9 @@ fn one_program_retains_typed_exact_and_wcag22_outcomes() { ); assert_eq!(evidence.binding().context(), declared_context); - let ProgramConstraintResultV1::Pass(CoreProgramPassEvidenceV1::Wcag22Srgb8(evidence)) = - wcag.result() + let ProgramConstraintResultV1::Pass(ProgramConstraintPassEvidenceV1::ModeledOccurrence( + CoreProgramPassEvidenceV1::Wcag22Srgb8(evidence), + )) = wcag.result() else { panic!("the second cell must retain WCAG22-specific pass evidence"); }; @@ -843,13 +948,17 @@ fn mixed_families_select_only_a_state_that_passes_every_case_then_recheck_it() { for cell in [cells[0].result(), cells[2].result()] { assert!(matches!( cell, - ProgramConstraintResultV1::Pass(CoreProgramPassEvidenceV1::ExactSrgb8(_)) + ProgramConstraintResultV1::Pass(ProgramConstraintPassEvidenceV1::ModeledOccurrence( + CoreProgramPassEvidenceV1::ExactSrgb8(_) + )) )); } for cell in [cells[1].result(), cells[3].result()] { assert!(matches!( cell, - ProgramConstraintResultV1::Pass(CoreProgramPassEvidenceV1::Wcag22Srgb8(_)) + ProgramConstraintResultV1::Pass(ProgramConstraintPassEvidenceV1::ModeledOccurrence( + CoreProgramPassEvidenceV1::Wcag22Srgb8(_) + )) )); } } @@ -884,11 +993,19 @@ fn mixed_family_conflict_is_exhaustive_and_keeps_report_only_non_gating() { assert!(cells.iter().all(|cell| cell.result().is_violation())); assert!(matches!( cells[0].result(), - ProgramConstraintResultV1::Violation(CoreProgramViolationEvidenceV1::ExactSrgb8(_)) + ProgramConstraintResultV1::Violation( + ProgramConstraintViolationEvidenceV1::ModeledOccurrence( + CoreProgramViolationEvidenceV1::ExactSrgb8(_) + ) + ) )); assert!(matches!( cells[1].result(), - ProgramConstraintResultV1::Violation(CoreProgramViolationEvidenceV1::Wcag22Srgb8(_)) + ProgramConstraintResultV1::Violation( + ProgramConstraintViolationEvidenceV1::ModeledOccurrence( + CoreProgramViolationEvidenceV1::Wcag22Srgb8(_) + ) + ) )); } diff --git a/crates/labcolors-core/src/program_session.rs b/crates/labcolors-core/src/program_session.rs index 119bafce..58ef7917 100644 --- a/crates/labcolors-core/src/program_session.rs +++ b/crates/labcolors-core/src/program_session.rs @@ -27,14 +27,16 @@ use std::rc::{Rc, Weak}; use crate::Srgb8; use crate::appearance::{ - AdmittedAppearanceBindings, AppearanceBindings, AppearanceGraphSpec, AppearanceWorkspace, - BindingError, ColorInputId, CompileError, CompiledAppearanceGraph, CompiledColorInputSlotV1, - CompiledOccurrenceSlotV1, CompiledPaintSlotV1, CompiledPointPresentationPathV1, - EncodedPointPaintV1, ExactFinalOwnedPointDomainV1, OccurrenceId, OccurrenceSpec, - OpacityInputId, PaintId, PaintSpec, PointOccurrenceAbsenceReleaseV1, - PointOccurrenceAbsenceReplayErrorV1, PointOccurrenceAbsenceStepV1, - PointOccurrenceAbsenceSummaryV1, PointPresentationPathErrorV1, SurfaceId, SurfaceInputPortId, - SurfaceSpec, + AdmittedAppearanceBindings, AppearanceBindings, AppearanceEvaluationView, AppearanceGraphSpec, + AppearanceWorkspace, BindingError, ColorInputId, CompileError, CompiledAppearanceGraph, + CompiledColorInputSlotV1, CompiledOccurrenceSlotV1, CompiledPaintSlotV1, + CompiledPointPresentationPathV1, EncodedPointPaintV1, ExactFinalOwnedPointDomainV1, + OccurrenceId, OccurrenceSpec, OpacityInputId, PaintId, PaintSpec, + PointOccurrenceAbsenceReleaseV1, PointOccurrenceAbsenceStepV1, PointOccurrenceAbsenceSummaryV1, + PointPresentationPathErrorV1, SurfaceId, SurfaceInputPortId, SurfaceSpec, +}; +use crate::clean_set::{ + ClosedRejectedBlueIntervalV1, ExactNominalSrgb8CleanSetDecisionV1, ExactNominalSrgb8CleanSetV1, }; use crate::composition::CompositionProfileV1; use crate::constraints::{ @@ -460,12 +462,30 @@ pub enum HardModeV1 {} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReportModeV1 {} -/// One typed evaluator invocation over one exact visible occurrence. +/// Атомарное тело одного ограничения над одним физически типизированным +/// объектом. Закрытая сумма не позволяет оценщику `Occurrence` и конвенции +/// `PointPresentation` притвориться взаимозаменяемыми либо хранить объект +/// ограничения отдельным полем. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProgramConstraintBodyV1 { + ModeledOccurrence { + occurrence: OccurrenceId, + invocation: Invocation, + }, + DeclaredSrgb8CleanSet { + target: PointPresentationTargetV1, + }, + #[cfg(test)] + DeclaredSrgb8CleanSetFinalRecheckMutant { + target: PointPresentationTargetV1, + }, +} + +/// Одно атомарное типизированное ограничение над одним точным физическим объектом. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ConstraintInvocation { id: ConstraintId, - target: OccurrenceId, - invocation: Invocation, + body: ProgramConstraintBodyV1, mode: PhantomData Mode>, } @@ -473,8 +493,34 @@ impl ConstraintInvocation { pub const fn hard(id: ConstraintId, target: OccurrenceId, invocation: Invocation) -> Self { Self { id, - target, - invocation, + body: ProgramConstraintBodyV1::ModeledOccurrence { + occurrence: target, + invocation, + }, + mode: PhantomData, + } + } + + pub(crate) const fn declared_srgb8_clean_set_hard( + id: ConstraintId, + target: PointPresentationTargetV1, + ) -> Self { + Self { + id, + body: ProgramConstraintBodyV1::DeclaredSrgb8CleanSet { target }, + mode: PhantomData, + } + } + + #[cfg(test)] + pub(crate) fn declared_srgb8_clean_set_final_recheck_mutant( + id: ConstraintId, + target: PointPresentationTargetV1, + ) -> Self { + CLEAN_SET_FINAL_RECHECK_MUTANT_CALLS.with(|calls| calls.set(0)); + Self { + id, + body: ProgramConstraintBodyV1::DeclaredSrgb8CleanSetFinalRecheckMutant { target }, mode: PhantomData, } } @@ -488,8 +534,21 @@ impl ConstraintInvocation { ) -> Self { Self { id, - target, - invocation, + body: ProgramConstraintBodyV1::ModeledOccurrence { + occurrence: target, + invocation, + }, + mode: PhantomData, + } + } + + pub(crate) const fn declared_srgb8_clean_set_report_only( + id: ConstraintId, + target: PointPresentationTargetV1, + ) -> Self { + Self { + id, + body: ProgramConstraintBodyV1::DeclaredSrgb8CleanSet { target }, mode: PhantomData, } } @@ -500,12 +559,8 @@ impl ConstraintInvocation { self.id } - pub const fn target(&self) -> OccurrenceId { - self.target - } - - pub const fn invocation(&self) -> &Invocation { - &self.invocation + pub(crate) const fn body(&self) -> &ProgramConstraintBodyV1 { + &self.body } } @@ -956,6 +1011,41 @@ impl CoreProgramDraftV1 { self.program.constraints.report_only.push(constraint); } + pub(crate) fn push_declared_srgb8_clean_set_hard( + &mut self, + id: ConstraintId, + target: PointPresentationTargetV1, + ) { + self.program + .constraints + .hard + .push(ConstraintInvocation::declared_srgb8_clean_set_hard( + id, target, + )); + } + + pub(crate) fn push_declared_srgb8_clean_set_report_only( + &mut self, + id: ConstraintId, + target: PointPresentationTargetV1, + ) { + self.program.constraints.report_only.push( + ConstraintInvocation::declared_srgb8_clean_set_report_only(id, target), + ); + } + + #[cfg(test)] + pub(crate) fn push_declared_srgb8_clean_set_final_recheck_mutant( + &mut self, + id: ConstraintId, + target: PointPresentationTargetV1, + ) { + self.program + .constraints + .hard + .push(ConstraintInvocation::declared_srgb8_clean_set_final_recheck_mutant(id, target)); + } + pub(crate) fn push_output(&mut self, output: OutputBinding) { self.program.outputs.push(output); } @@ -1123,6 +1213,11 @@ pub enum ProgramCompileError { constraint: ConstraintId, occurrence: OccurrenceId, }, + MissingConstraintPresentationTarget { + constraint: ConstraintId, + root: PresentationRootId, + occurrence: OccurrenceId, + }, DuplicateOutputSlot { output: OutputSlotId, }, @@ -1185,13 +1280,69 @@ impl CompiledConstraintPhasesV1 { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct DeclaredSrgb8CleanSetV1 { + classifier: ExactNominalSrgb8CleanSetV1, + #[cfg(test)] + final_recheck_mutant: bool, +} + +impl DeclaredSrgb8CleanSetV1 { + const fn package_pinned() -> Self { + Self { + classifier: ExactNominalSrgb8CleanSetV1, + #[cfg(test)] + final_recheck_mutant: false, + } + } + + #[cfg(test)] + const fn final_recheck_mutant() -> Self { + Self { + classifier: ExactNominalSrgb8CleanSetV1, + final_recheck_mutant: true, + } + } + + fn forces_absent_mutation(self) -> bool { + #[cfg(test)] + if self.final_recheck_mutant { + return CLEAN_SET_FINAL_RECHECK_MUTANT_CALLS.with(|calls| { + let previous = calls.get(); + calls.set(previous + 1); + previous != 0 + }); + } + false + } +} + +#[cfg(test)] +std::thread_local! { + static CLEAN_SET_FINAL_RECHECK_MUTANT_CALLS: std::cell::Cell = const { + std::cell::Cell::new(0) + }; +} + +#[derive(Clone, Copy)] +enum CompiledProgramConstraintBodyV1 { + ModeledOccurrence { + target_id: OccurrenceId, + target: CompiledOccurrenceSlotV1, + occurrence_context_index: usize, + invocation: Invocation, + }, + PointPresentation { + presentation_ordinal: usize, + terminal: OccurrenceId, + convention: DeclaredSrgb8CleanSetV1, + }, +} + struct CompiledPointConstraint { id: ConstraintId, - target_id: OccurrenceId, - target: CompiledOccurrenceSlotV1, - occurrence_context_index: usize, mode: CompiledConstraintModeV1, - invocation: Invocation, + body: CompiledProgramConstraintBodyV1, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1384,6 +1535,15 @@ where ) } + #[cfg(test)] + pub(crate) fn point_resolution_count_for_test( + &self, + session: &Session>, + ) -> Option<(usize, usize)> { + self.owns_session(session) + .then(|| session.plan().presentation_cache.replay_counts()) + } + /// 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 @@ -1402,12 +1562,16 @@ where .graph .new_workspace() .map_err(map_session_instantiate_error)?; + let presentation_cache = + ProgramPresentationCacheV1::try_new(&self.owner_generation.point_presentations) + .map_err(|()| ProgramSessionInstantiateError::ResourceExhausted)?; Ok(Session::new( stream, ProgramSessionPlan { owner_generation: Rc::downgrade(&self.owner_generation), bindings, workspace, + presentation_cache, }, )) } @@ -1427,13 +1591,86 @@ fn map_session_instantiate_error(error: BindingError) -> ProgramSessionInstantia } } +/// Физический объект одной ячейки ограничения. Варианты не дают цели +/// представления доступ к API контекста, предназначенному только для +/// `Occurrence`, и тем самым не подменяют финальный корень внутренним цветом. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProgramConstraintSubjectV1 { + ModeledOccurrence { + occurrence: OccurrenceId, + context: AppearanceContextId, + }, + PointPresentation { + target: PointPresentationTargetV1, + terminal: OccurrenceId, + }, +} + +/// Точное положительное свидетельство закреплённого пакетом clean-set над +/// непустым финальным доменом точки. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DeclaredSrgb8CleanSetPassV1 { + visible: Srgb8, +} + +impl DeclaredSrgb8CleanSetPassV1 { + pub(crate) const fn visible(self) -> Srgb8 { + self.visible + } +} + +/// Два взаимоисключающих способа нарушить предикат clean-set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DeclaredSrgb8CleanSetViolationV1 { + FinalOwnedDomainAbsent, + Rejected { + visible: Srgb8, + rejected_blue_interval: ClosedRejectedBlueIntervalV1, + }, +} + +impl DeclaredSrgb8CleanSetViolationV1 { + pub(crate) const fn visible(self) -> Option { + match self { + Self::FinalOwnedDomainAbsent => None, + Self::Rejected { visible, .. } => Some(visible), + } + } + + pub(crate) const fn rejected_blue_interval(self) -> Option { + match self { + Self::FinalOwnedDomainAbsent => None, + Self::Rejected { + rejected_blue_interval, + .. + } => Some(rejected_blue_interval), + } + } +} + +pub(crate) enum ProgramConstraintPassEvidenceV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + ModeledOccurrence(Evaluation::PassEvidence), + DeclaredSrgb8CleanSet(DeclaredSrgb8CleanSetPassV1), +} + +pub(crate) enum ProgramConstraintViolationEvidenceV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + ModeledOccurrence(Evaluation::ViolationEvidence), + DeclaredSrgb8CleanSet(DeclaredSrgb8CleanSetViolationV1), +} + /// One evaluator classification retained in the complete Program report. pub enum ProgramConstraintResultV1 where Evaluation: ProgramConstraintEvaluatorSetV1, { - Pass(Evaluation::PassEvidence), - Violation(Evaluation::ViolationEvidence), + Pass(ProgramConstraintPassEvidenceV1), + Violation(ProgramConstraintViolationEvidenceV1), } impl ProgramConstraintResultV1 @@ -1443,13 +1680,6 @@ where 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), - } - } } /// One canonical `physical case × constraint` report cell. @@ -1460,7 +1690,7 @@ where candidate_state_index: usize, case_index: usize, constraint: ConstraintId, - target: OccurrenceId, + subject: ProgramConstraintSubjectV1, mode: CompiledConstraintModeV1, result: ProgramConstraintResultV1, } @@ -1481,12 +1711,8 @@ where self.constraint } - pub const fn target(&self) -> OccurrenceId { - self.target - } - - pub fn appearance_context(&self) -> AppearanceContextId { - self.result.binding().context() + pub(crate) const fn subject(&self) -> ProgramConstraintSubjectV1 { + self.subject } pub const fn is_hard(&self) -> bool { @@ -1518,6 +1744,112 @@ impl NonEmptyReplaySpanV1 { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ResolvedPointPresentationV1 { + domain: ExactFinalOwnedPointDomainV1, + replay: Option, +} + +/// Принадлежащий сессии временный буфер одной фазы. Каждая фаза начинает с +/// пустого кеша, поэтому поиск, отчёт и финальная перепроверка не наследуют +/// полномочия друг друга. +struct ProgramPresentationCacheV1 { + domains: Vec>, + scratch_steps: Vec, + #[cfg(test)] + phase: ProgramEvaluationPhaseV1, + #[cfg(test)] + replay_counts: [usize; 2], +} + +impl ProgramPresentationCacheV1 { + fn try_new(presentations: &CompiledPointPresentationsV1) -> Result { + let mut domains = Vec::new(); + domains + .try_reserve_exact(presentations.len()) + .map_err(|_| ())?; + domains.resize(presentations.len(), None); + let mut scratch_steps = Vec::new(); + scratch_steps + .try_reserve_exact(presentations.steps_per_case()) + .map_err(|_| ())?; + Ok(Self { + domains, + scratch_steps, + #[cfg(test)] + phase: ProgramEvaluationPhaseV1::Hard, + #[cfg(test)] + replay_counts: [0; 2], + }) + } + + fn begin_case(&mut self, _phase: ProgramEvaluationPhaseV1) { + self.domains.fill(None); + self.scratch_steps.clear(); + #[cfg(test)] + { + self.phase = _phase; + } + } + + fn resolve( + &mut self, + evaluation: &AppearanceEvaluationView<'_, '_>, + presentation_ordinal: usize, + presentation: &CompiledPointPresentationV1, + destination: Option<&mut Vec>, + ) -> Result { + let cached = *self.domains.get(presentation_ordinal).ok_or(())?; + if let Some(domain) = cached { + return Ok(ResolvedPointPresentationV1 { + domain, + replay: None, + }); + } + + let steps = destination.unwrap_or(&mut self.scratch_steps); + if steps.capacity().saturating_sub(steps.len()) < presentation.path.len() { + return Err(()); + } + let start = steps.len(); + let replay = evaluation + .replay_point_occurrence_absence_into( + &presentation.path, + presentation.absence_release, + steps, + ) + .map_err(|_| ())?; + if replay.release() != presentation.absence_release + || replay.target() != presentation.target + || replay.root() != presentation.terminal + || replay.steps().len() != presentation.path.len() + { + return Err(()); + } + let domain = replay.domain(); + let end = start.checked_add(replay.steps().len()).ok_or(())?; + let span = NonEmptyReplaySpanV1::from_bounds(start, end).ok_or(())?; + self.domains[presentation_ordinal] = Some(domain); + #[cfg(test)] + { + let phase_index = match self.phase { + ProgramEvaluationPhaseV1::Hard => 0, + ProgramEvaluationPhaseV1::ReportOnly => 1, + }; + self.replay_counts[phase_index] += 1; + } + Ok(ResolvedPointPresentationV1 { + domain, + replay: Some(span), + }) + } + + #[cfg(test)] + const fn replay_counts(&self) -> (usize, usize) { + (self.replay_counts[0], self.replay_counts[1]) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ProgramPointCausalRecordV1 { /// Только exhaustive conflict хранит рассмотренное состояние в строке. @@ -1829,7 +2161,7 @@ pub enum ProgramSessionEvaluationError { state_index: usize, case_index: usize, constraint: ConstraintId, - target: OccurrenceId, + subject: ProgramConstraintSubjectV1, hard_violation_count: usize, }, InternalInvariant, @@ -2276,6 +2608,7 @@ where owner_generation: Weak>, bindings: AdmittedAppearanceBindings, workspace: AppearanceWorkspace, + presentation_cache: ProgramPresentationCacheV1, } impl session_private::PlanSealed for ProgramSessionPlan @@ -2555,7 +2888,7 @@ where state_index, case_index: first.case_index, constraint: first.constraint, - target: first.target, + subject: first.subject, hard_violation_count, }); } @@ -2680,6 +3013,7 @@ where .graph .evaluate_admitted_into(&plan.bindings, &mut plan.workspace) .map_err(map_program_execution_binding_error)?; + plan.presentation_cache.begin_case(phase); if let Some(point_causal) = point_causal.as_mut() { // Предварительный расчёт зарезервировал арены целиком. Локальная @@ -2697,33 +3031,19 @@ where { return Err(ProgramSessionEvaluationError::InternalInvariant); } - for presentation in epoch.point_presentations.iter() { - let start = point_causal.steps.len(); - let replay = evaluation - .replay_point_occurrence_absence_into( - &presentation.path, - presentation.absence_release, - point_causal.steps, + for (presentation_ordinal, presentation) in epoch.point_presentations.iter().enumerate() + { + let resolved = plan + .presentation_cache + .resolve( + &evaluation, + presentation_ordinal, + presentation, + Some(point_causal.steps), ) - .map_err(|error| match error { - // Ёмкость проверена выше, а каждый путь presentation - // скомпилирован для этой же версии графа. - PointOccurrenceAbsenceReplayErrorV1::InsufficientCapacity - | PointOccurrenceAbsenceReplayErrorV1::IncompatibleEvaluation => { - ProgramSessionEvaluationError::InternalInvariant - } - })?; - if replay.release() != presentation.absence_release - || replay.target() != presentation.target - || replay.root() != presentation.terminal - || replay.steps().len() != presentation.path.len() - { - return Err(ProgramSessionEvaluationError::InternalInvariant); - } - let end = start - .checked_add(replay.steps().len()) - .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; - let replay = NonEmptyReplaySpanV1::from_bounds(start, end) + .map_err(|()| ProgramSessionEvaluationError::InternalInvariant)?; + let replay = resolved + .replay .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; point_causal.records.push(ProgramPointCausalRecordV1 { considered_state_index: point_causal.considered_state_index, @@ -2740,49 +3060,153 @@ where .iter() .filter(|constraint| phase.includes(constraint.mode)) { - let source = evaluation - .occurrence_at(constraint.target) - .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; - if source.visible() != source.certificate().output_rgb() { - return Err(ProgramSessionEvaluationError::InternalInvariant); - } - let binding = epoch - .occurrence_contexts - .get(constraint.occurrence_context_index) - .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; - if binding.occurrence != constraint.target_id || binding.target != constraint.target { - return Err(ProgramSessionEvaluationError::InternalInvariant); - } - let point = ProgramPointOccurrenceV1::from_resolved(source, binding.context); - let decision = Evaluation::assess(&epoch.evaluator, point, constraint.invocation) - .map_err(|error| match error { - ProgramPointAssessmentErrorV1::Evaluator(source) => { - ProgramSessionEvaluationError::Evaluator { - case_index, - constraint: constraint.id, - occurrence: constraint.target_id, - context: binding.context, - source, - } + let (subject, result) = match constraint.body { + CompiledProgramConstraintBodyV1::ModeledOccurrence { + target_id, + target, + occurrence_context_index, + invocation, + } => { + let source = evaluation + .occurrence_at(target) + .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; + if source.visible() != source.certificate().output_rgb() { + return Err(ProgramSessionEvaluationError::InternalInvariant); } - })?; - let result = match decision { - HardDecision::Pass(evidence) => ProgramConstraintResultV1::Pass(evidence), - HardDecision::Violation(evidence) => { - if constraint.mode.rejects_candidate() { - has_hard_violation = true; + let binding = epoch + .occurrence_contexts + .get(occurrence_context_index) + .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; + if binding.occurrence != target_id || binding.target != target { + return Err(ProgramSessionEvaluationError::InternalInvariant); } - ProgramConstraintResultV1::Violation(evidence) + let point = ProgramPointOccurrenceV1::from_resolved(source, binding.context); + let decision = Evaluation::assess(&epoch.evaluator, point, invocation) + .map_err(|error| match error { + ProgramPointAssessmentErrorV1::Evaluator(source) => { + ProgramSessionEvaluationError::Evaluator { + case_index, + constraint: constraint.id, + occurrence: target_id, + context: binding.context, + source, + } + } + })?; + let result = match decision { + HardDecision::Pass(evidence) => { + debug_assert_eq!( + Evaluation::pass_binding(&evidence).physical(), + source.visible_point_binding(), + ); + debug_assert_eq!( + Evaluation::pass_binding(&evidence).context(), + binding.context, + ); + ProgramConstraintResultV1::Pass( + ProgramConstraintPassEvidenceV1::ModeledOccurrence(evidence), + ) + } + HardDecision::Violation(evidence) => { + debug_assert_eq!( + Evaluation::violation_binding(&evidence).physical(), + source.visible_point_binding(), + ); + debug_assert_eq!( + Evaluation::violation_binding(&evidence).context(), + binding.context, + ); + ProgramConstraintResultV1::Violation( + ProgramConstraintViolationEvidenceV1::ModeledOccurrence(evidence), + ) + } + }; + ( + ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence: target_id, + context: binding.context, + }, + result, + ) + } + CompiledProgramConstraintBodyV1::PointPresentation { + presentation_ordinal, + terminal, + convention, + } => { + let presentation = epoch + .point_presentations + .entries + .get(presentation_ordinal) + .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; + if presentation.terminal != terminal { + return Err(ProgramSessionEvaluationError::InternalInvariant); + } + let resolved = plan + .presentation_cache + .resolve(&evaluation, presentation_ordinal, presentation, None) + .map_err(|()| ProgramSessionEvaluationError::InternalInvariant)?; + let result = if convention.forces_absent_mutation() { + ProgramConstraintResultV1::Violation( + ProgramConstraintViolationEvidenceV1::DeclaredSrgb8CleanSet( + DeclaredSrgb8CleanSetViolationV1::FinalOwnedDomainAbsent, + ), + ) + } else { + match resolved.domain { + ExactFinalOwnedPointDomainV1::Empty => { + ProgramConstraintResultV1::Violation( + ProgramConstraintViolationEvidenceV1::DeclaredSrgb8CleanSet( + DeclaredSrgb8CleanSetViolationV1::FinalOwnedDomainAbsent, + ), + ) + } + ExactFinalOwnedPointDomainV1::Singleton { visible } => { + let visible = Srgb8::new(visible); + match convention.classifier.classify(visible) { + ExactNominalSrgb8CleanSetDecisionV1::Accepted => { + ProgramConstraintResultV1::Pass( + ProgramConstraintPassEvidenceV1::DeclaredSrgb8CleanSet( + DeclaredSrgb8CleanSetPassV1 { visible }, + ), + ) + } + ExactNominalSrgb8CleanSetDecisionV1::Rejected(interval) => { + ProgramConstraintResultV1::Violation( + ProgramConstraintViolationEvidenceV1::DeclaredSrgb8CleanSet( + DeclaredSrgb8CleanSetViolationV1::Rejected { + visible, + rejected_blue_interval: interval, + }, + ), + ) + } + } + } + } + }; + ( + ProgramConstraintSubjectV1::PointPresentation { + target: PointPresentationTargetV1 { + root: presentation.root, + occurrence: presentation.target, + absence_release: presentation.absence_release, + }, + terminal, + }, + result, + ) } }; - debug_assert_eq!(result.binding().physical(), source.visible_point_binding()); - debug_assert_eq!(result.binding().context(), binding.context); + if constraint.mode.rejects_candidate() && result.is_violation() { + has_hard_violation = true; + } if let Some(cells) = cells.as_deref_mut() { cells.push(ProgramConstraintCellV1 { candidate_state_index, case_index, constraint: constraint.id, - target: constraint.target_id, + subject, mode: constraint.mode, result, }); @@ -2930,23 +3354,27 @@ where 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 constraint_phases = CompiledConstraintPhasesV1::from_authored(&program.constraints); - let occurrence_contexts = - compact_constraint_contexts(&all_occurrence_contexts, &mut constraints)?; let point_presentations = compile_point_presentations( &graph, &mut program.presentation_roots, &mut program.presentation_targets, )?; + let mut constraints = compile_constraints::( + &graph, + &all_occurrence_contexts, + &point_presentations, + &program.constraints, + )?; + validate_terminal_dependency_cone(&program, &constraints)?; + let constraint_phases = CompiledConstraintPhasesV1::from_authored(&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_v3(&program)?; Ok(ProgramEpochV1 { @@ -3278,37 +3706,19 @@ fn false_slots(len: usize) -> Result, ProgramCompileError> { fn validate_terminal_dependency_cone( program: &Program, + constraints: &[CompiledPointConstraint>], ) -> 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, - }) + // Объекты ограничений здесь уже скомпилированы, поэтому более точная + // диагностика отсутствующей ссылки ещё может принадлежать только выходу. + if program.outputs.iter().any(|output| { + !program.paints.iter().any(|paint| match *paint { + Paint::Solid { id, .. } | Paint::Opacity { id, .. } => id == output.paint, }) - { + }) { return Ok(()); } @@ -3316,18 +3726,7 @@ where 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), - ), + constraints.iter().map(compiled_constraint_dependency_root), )?; for (target_index, target) in program.targets.iter().enumerate() { if matches!(&target.domain, TargetDomainV1::Finite(_)) && !scratch.targets[target_index] { @@ -3353,19 +3752,7 @@ where .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), - ) - { + for target in constraints.iter().map(compiled_constraint_dependency_root) { scratch.scan(&index, [target])?; if program.targets.iter().enumerate().all(|(index, target)| { !matches!(&target.domain, TargetDomainV1::Finite(_)) || scratch.targets[index] @@ -3381,6 +3768,15 @@ where Ok(()) } +fn compiled_constraint_dependency_root( + constraint: &CompiledPointConstraint, +) -> OccurrenceId { + match &constraint.body { + CompiledProgramConstraintBodyV1::ModeledOccurrence { target_id, .. } => *target_id, + CompiledProgramConstraintBodyV1::PointPresentation { terminal, .. } => *terminal, + } +} + fn map_observation_schema_compile_error(error: ObservationError) -> ProgramCompileError { match error { ObservationError::ResourceExhausted => ProgramCompileError::ResourceExhausted, @@ -3390,9 +3786,8 @@ fn map_observation_schema_compile_error(error: ObservationError) -> ProgramCompi struct LoweredConstraint { id: ConstraintId, - target: OccurrenceId, mode: CompiledConstraintModeV1, - invocation: Invocation, + body: ProgramConstraintBodyV1, } fn compile_targets( @@ -3597,6 +3992,7 @@ fn compile_occurrence_contexts( fn compile_constraints( graph: &CompiledAppearanceGraph, occurrence_contexts: &[CompiledOccurrenceContextV1], + presentations: &CompiledPointPresentationsV1, authored: &ConstraintSet>, ) -> Result< Box<[CompiledPointConstraint>]>, @@ -3617,9 +4013,8 @@ where .map_err(|_| ProgramCompileError::ResourceExhausted)?; lowered.extend(authored.hard.iter().map(|constraint| LoweredConstraint { id: constraint.id, - target: constraint.target, mode: CompiledConstraintModeV1::Hard, - invocation: constraint.invocation, + body: *constraint.body(), })); lowered.extend( authored @@ -3627,9 +4022,8 @@ where .iter() .map(|constraint| LoweredConstraint { id: constraint.id, - target: constraint.target, mode: CompiledConstraintModeV1::ReportOnly, - invocation: constraint.invocation, + body: *constraint.body(), }), ); lowered.sort_unstable_by_key(|constraint| constraint.id); @@ -3642,36 +4036,97 @@ where 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 occurrence_context_index = occurrence_contexts - .binary_search_by_key(&constraint.target, |binding| binding.occurrence) - .map_err(|_| ProgramCompileError::InternalInvariant)?; - if occurrence_contexts[occurrence_context_index].target != target { - return Err(ProgramCompileError::InternalInvariant); - } + let body = match constraint.body { + ProgramConstraintBodyV1::ModeledOccurrence { + occurrence, + invocation, + } => { + let target = graph.bind_occurrence(occurrence).ok_or( + ProgramCompileError::MissingConstraintOccurrence { + constraint: constraint.id, + occurrence, + }, + )?; + let occurrence_context_index = occurrence_contexts + .binary_search_by_key(&occurrence, |binding| binding.occurrence) + .map_err(|_| ProgramCompileError::InternalInvariant)?; + if occurrence_contexts[occurrence_context_index].target != target { + return Err(ProgramCompileError::InternalInvariant); + } + CompiledProgramConstraintBodyV1::ModeledOccurrence { + target_id: occurrence, + target, + occurrence_context_index, + invocation, + } + } + ProgramConstraintBodyV1::DeclaredSrgb8CleanSet { target } => { + let key = (target.root(), target.occurrence()); + let presentation_ordinal = presentations + .entries + .binary_search_by_key(&key, |presentation| { + (presentation.root, presentation.target) + }) + .map_err( + |_| ProgramCompileError::MissingConstraintPresentationTarget { + constraint: constraint.id, + root: target.root(), + occurrence: target.occurrence(), + }, + )?; + let presentation = &presentations.entries[presentation_ordinal]; + if presentation.absence_release != target.absence_release() { + return Err(ProgramCompileError::MissingConstraintPresentationTarget { + constraint: constraint.id, + root: target.root(), + occurrence: target.occurrence(), + }); + } + CompiledProgramConstraintBodyV1::PointPresentation { + presentation_ordinal, + terminal: presentation.terminal, + convention: DeclaredSrgb8CleanSetV1::package_pinned(), + } + } + #[cfg(test)] + ProgramConstraintBodyV1::DeclaredSrgb8CleanSetFinalRecheckMutant { target } => { + let key = (target.root(), target.occurrence()); + let presentation_ordinal = presentations + .entries + .binary_search_by_key(&key, |presentation| { + (presentation.root, presentation.target) + }) + .map_err( + |_| ProgramCompileError::MissingConstraintPresentationTarget { + constraint: constraint.id, + root: target.root(), + occurrence: target.occurrence(), + }, + )?; + let presentation = &presentations.entries[presentation_ordinal]; + if presentation.absence_release != target.absence_release() { + return Err(ProgramCompileError::MissingConstraintPresentationTarget { + constraint: constraint.id, + root: target.root(), + occurrence: target.occurrence(), + }); + } + CompiledProgramConstraintBodyV1::PointPresentation { + presentation_ordinal, + terminal: presentation.terminal, + convention: DeclaredSrgb8CleanSetV1::final_recheck_mutant(), + } + } + }; compiled.push(CompiledPointConstraint { id: constraint.id, - target_id: constraint.target, - target, - occurrence_context_index, mode: constraint.mode, - invocation: constraint.invocation, + body, }); } Ok(compiled.into_boxed_slice()) @@ -3685,7 +4140,16 @@ fn compact_constraint_contexts( targets .try_reserve_exact(constraints.len()) .map_err(|_| ProgramCompileError::ResourceExhausted)?; - targets.extend(constraints.iter().map(|constraint| constraint.target_id)); + targets.extend( + constraints + .iter() + .filter_map(|constraint| match &constraint.body { + CompiledProgramConstraintBodyV1::ModeledOccurrence { target_id, .. } => { + Some(*target_id) + } + CompiledProgramConstraintBodyV1::PointPresentation { .. } => None, + }), + ); targets.sort_unstable(); targets.dedup(); @@ -3701,13 +4165,21 @@ fn compact_constraint_contexts( } 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); + if let CompiledProgramConstraintBodyV1::ModeledOccurrence { + target_id, + target, + occurrence_context_index, + .. + } = &mut constraint.body + { + let index = compact + .binary_search_by_key(target_id, |binding| binding.occurrence) + .map_err(|_| ProgramCompileError::InternalInvariant)?; + if compact[index].target != *target { + return Err(ProgramCompileError::InternalInvariant); + } + *occurrence_context_index = index; } - constraint.occurrence_context_index = index; } Ok(compact.into_boxed_slice()) } diff --git a/crates/labcolors-core/src/program_session_tests.rs b/crates/labcolors-core/src/program_session_tests.rs index bc933725..09d8fb0c 100644 --- a/crates/labcolors-core/src/program_session_tests.rs +++ b/crates/labcolors-core/src/program_session_tests.rs @@ -13,8 +13,8 @@ use crate::observation::{ 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, + ProgramConstraintBodyV1, ProgramConstraintSubjectV1, Source, SourceId, Surface, Target, + TargetId, canonical_surface_input_port_sequence_matches, check_render_node_count, }; use crate::session::{SessionPlanV1, SessionState, SessionUpdateError}; @@ -170,8 +170,13 @@ fn authored_modes_are_marker_typed_and_values_preserve_exact_ids() { 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.hard()[0].body(), + ProgramConstraintBodyV1::ModeledOccurrence { + occurrence: OCCURRENCE, + invocation: Srgb8::new([0x80; 3]), + }, + ); assert_eq!(set.report_only()[0].id(), ConstraintId::new(51)); let output = OutputBinding::new(OUTPUT, TRANSLUCENT); @@ -636,7 +641,13 @@ fn multi_case_hard_failure_retains_the_full_matrix_without_outputs() { 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!(cells.iter().all(|cell| { + cell.subject() + == ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence: OCCURRENCE, + context: appearance_context(), + } + })); assert_eq!( cells .iter() @@ -698,7 +709,13 @@ fn mixed_modes_retain_the_full_canonical_matrix_without_outputs_on_hard_failure( .collect::>(), vec![true, false, false, true], ); - assert!(cells.iter().all(|cell| cell.target() == OCCURRENCE)); + assert!(cells.iter().all(|cell| { + cell.subject() + == ProgramConstraintSubjectV1::ModeledOccurrence { + occurrence: OCCURRENCE, + context: appearance_context(), + } + })); // `cause` is `ProgramConflictV1`: the failure surface exposes only this // complete report, while Paint outputs exist only on `ProgramVerifiedV1`. } diff --git a/crates/labcolors-core/tests/sha256.rs b/crates/labcolors-core/tests/sha256.rs index f1e66980..0c58ef6e 100644 --- a/crates/labcolors-core/tests/sha256.rs +++ b/crates/labcolors-core/tests/sha256.rs @@ -177,7 +177,7 @@ fn deterministic_corpus_matches_python_hashlib() { .expect("python3 is part of the repository CI toolchain"); let mut stdin = child.stdin.take().expect("piped Python stdin"); - let (output, write_result) = std::thread::scope(|scope| { + let output = std::thread::scope(|scope| { let corpus_for_writer = &corpus; let writer = scope.spawn(move || { const HEX: &[u8; 16] = b"0123456789abcdef"; @@ -188,20 +188,20 @@ fn deterministic_corpus_matches_python_hashlib() { line.push(HEX[usize::from(byte & 0x0f)]); } line.push(b'\n'); - stdin.write_all(&line)?; + // `panic` закрывает принадлежащий потоку канал до ожидания в + // родителе, поэтому ошибка записи не оставит дочерний процесс. + stdin.write_all(&line).expect("write corpus to hashlib"); } - Ok::<(), std::io::Error>(()) }); let output = child.wait_with_output().expect("wait for hashlib oracle"); - let write_result = writer.join().expect("hashlib stdin writer panicked"); - (output, write_result) + writer.join().expect("hashlib stdin writer panicked"); + output }); assert!( output.status.success(), - "hashlib oracle failed (stdin={write_result:?}): {}", + "hashlib oracle failed: {}", String::from_utf8_lossy(&output.stderr), ); - write_result.expect("write corpus to hashlib"); let expected: Vec<_> = String::from_utf8(output.stdout) .expect("hashlib emits ASCII") .lines() diff --git a/scripts/test_verify_clean_set_receipt.py b/scripts/test_verify_clean_set_receipt.py new file mode 100644 index 00000000..4052c226 --- /dev/null +++ b/scripts/test_verify_clean_set_receipt.py @@ -0,0 +1,701 @@ +#!/usr/bin/env python3 +"""Hostile-тесты точного clean-set receipt и замыкания Cargo-пакета.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import os +import subprocess +import tempfile +import unittest +from dataclasses import dataclass +from pathlib import Path + +from verify_clean_set_receipt import ( + CODEC_PATH, + CORE_LICENSE_EXPRESSION, + EXCLUDED_CLAIMS, + PRODUCT_ARTIFACT_PATHS, + RECEIPT_PIN_PATH, + RECEIPT_PATH, + VerificationError, + VerifierPolicy, + canonical_json_bytes, + verify_core_package, + verify_product_receipt, + verify_receipt, +) + + +REPO_ROOT = Path(__file__).resolve().parent.parent +CANONICAL_CODEC = (REPO_ROOT / CODEC_PATH).read_bytes() +TRANSITIVE_EXECUTOR_ROLES = ( + "appearance_executor_source", + "composition_executor_source", + "content_digest_source", + "joint_selection_source", + "observation_runtime_source", + "session_runtime_source", + "signal_transport_source", +) + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _decode_raw(codec: bytes) -> bytes: + offsets = [ + int.from_bytes(codec[8 + index * 2 : 10 + index * 2], "big") + for index in range(257) + ] + body = codec[522:] + columns: list[list[tuple[int, int, int]]] = [] + for green in range(256): + columns.append( + [ + tuple(body[index * 3 : index * 3 + 3]) + for index in range(offsets[green], offsets[green + 1]) + ] + ) + + raw = bytearray() + for red in range(256): + for green in range(256): + record = columns[green][0] + for candidate in columns[green][1:]: + if candidate[0] > red: + break + record = candidate + raw.extend(record[1:]) + return bytes(raw) + + +CANONICAL_RAW = _decode_raw(CANONICAL_CODEC) + + +def _git(root: Path, *args: str) -> str: + return subprocess.check_output( + ["git", "-C", str(root), *args], + text=True, + env={ + **os.environ, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "LC_ALL": "C", + }, + ).strip() + + +def _write(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + +def _artifact(path: str, role: str, license_id: str, data: bytes) -> dict[str, object]: + return { + "bytes": len(data), + "license": license_id, + "path": path, + "role": role, + "sha256": _sha256(data), + } + + +@dataclass +class ReceiptFixture: + product: Path + research: Path + receipt: dict[str, object] + policy: VerifierPolicy + + @property + def receipt_path(self) -> Path: + return self.product / RECEIPT_PATH + + def write_receipt(self) -> str: + data = canonical_json_bytes(self.receipt) + _write(self.receipt_path, data) + return _sha256(data) + + def write_pin(self) -> str: + digest = self.write_receipt() + _write( + self.product / RECEIPT_PIN_PATH, + f"{digest} receipt-v1.json\n".encode("ascii"), + ) + return digest + + def verify(self) -> None: + verify_receipt( + self.product, + self.research, + self.write_receipt(), + policy=self.policy, + ) + + +def _research_fixture(root: Path) -> tuple[str, str]: + files: dict[str, tuple[str, str, bytes]] = {} + + profile = { + "admission": { + "authority": "ExactTechnicalDerivation", + "convention": "DeclaredPackagePolicyCandidate", + "production_auto_minted": False, + }, + "excluded_claims": list(EXCLUDED_CLAIMS), + "geometry": {}, + "neutral_axis": {"id": "srgb8-output-neutral-axis-v1"}, + "nominal_bridge": {}, + "output_release": {}, + "policy": {}, + "release_id": "exact-nominal-srgb8-point-clean-set-v1", + "schema": "lab-point-clean-set-srgb8-profile/1", + } + profile_bytes = canonical_json_bytes(profile) + proof = { + "admission": copy.deepcopy(profile["admission"]), + "artifacts": { + "certificates_bytes": 8, + "certificates_sha256": _sha256(b"certificate"), + "codec_bytes": len(CANONICAL_CODEC), + "codec_sha256": _sha256(CANONICAL_CODEC), + "profile_sha256": _sha256(profile_bytes), + "table_bytes": len(CANONICAL_RAW), + "table_sha256": _sha256(CANONICAL_RAW), + }, + "certificate_encoding": {}, + "codec_encoding": { + "body_offset": 522, + "column_axis": "green", + "empty_interval": [255, 0], + "header_hex": "4c50434301010000", + "id": "green-column-red-run-start-rle-v1", + "index": {}, + "record": {}, + "records": 3616, + "run_axis": "red", + }, + "cone_certificates": [], + "counts": { + "accepted_chromatic": 8_232_593, + "boundary_unproven": 0, + "chromatic_points": 16_776_960, + "continuous_nonempty_discrete_empty_columns": 0, + "cube_points": 16_777_216, + "empty_columns": 21_379, + "full_columns": 0, + "neutral_points": 256, + "no_positive_ray": 0, + "rejected_chromatic": 8_544_367, + "singleton_columns": 1, + }, + "excluded_claims": list(EXCLUDED_CLAIMS), + "generator_sha256": "1" * 64, + "inputs": [], + "law": { + "chromatic_accept": "q / T_policy not in Z", + "equality": "reject", + "neutral_outer_union": "red == green == blue", + "runtime": "neutral or blue outside closed dirty interval", + }, + "release_id": "exact-nominal-srgb8-point-clean-set-v1", + "schema": "lab-point-clean-set-srgb8-proof/1", + "witnesses": {}, + } + proof_bytes = canonical_json_bytes(proof) + + definitions = [ + ( + "evidence/point-clean-set-srgb8/profile-v1.json", + "semantic_profile", + "CC-BY-SA-4.0", + profile_bytes, + ), + ( + "evidence/frontier/artifact-v1.json", + "policy_frontier", + "CC-BY-SA-4.0", + b"frontier", + ), + ( + "evidence/cie-2019/CIE_xyz_1931_2deg.csv", + "cie_1931_2deg_source", + "CC-BY-SA-4.0", + b"cmf", + ), + ( + "evidence/cie-2019/CIE_std_illum_D65.csv", + "cie_d65_source", + "CC-BY-SA-4.0", + b"d65", + ), + ( + "evidence/point-clean-set-srgb8/artifact-v1.bin", + "canonical_raw_table", + "CC-BY-SA-4.0", + CANONICAL_RAW, + ), + ( + "evidence/point-clean-set-srgb8/point-clean-set-srgb8-column-rle-v1.bin", + "runtime_codec_table", + "CC-BY-SA-4.0", + CANONICAL_CODEC, + ), + ( + "evidence/point-clean-set-srgb8/certificates-v1.bin", + "boundary_certificates", + "CC-BY-SA-4.0", + b"certificate", + ), + ("evidence/point-clean-set-srgb8/proof-v1.json", "proof", "CC-BY-SA-4.0", proof_bytes), + ("evidence/point-clean-set-srgb8/generate.py", "generator", "MIT", b"generator"), + ("evidence/cie/ciegen.py", "generator_cie_reader", "MIT", b"cie generator"), + ("evidence/point-clean-set-srgb8/verify.py", "independent_verifier", "MIT", b"verifier"), + ("evidence/cie/ciever.py", "verifier_cie_reader", "MIT", b"cie verifier"), + ("evidence/point-clean-set-srgb8/NOTICE.md", "data_notice", "CC-BY-SA-4.0", b"notice"), + ] + for path, role, license_id, data in definitions: + files[path] = (role, license_id, data) + _write(root / path, data) + + release = { + "artifacts": [ + _artifact(path, role, license_id, data) + for path, (role, license_id, data) in files.items() + ], + "bundle_root": "cleanliness-repository-v1", + "codec_id": "green-column-red-run-start-rle-v1", + "encoding_id": "raw-dirty-blue-interval-u8-pair-v1", + "license": "CC-BY-SA-4.0", + "release_id": "exact-nominal-srgb8-point-clean-set-v1", + "schema": "lab-point-clean-set-srgb8-release/1", + } + release_bytes = canonical_json_bytes(release) + _write(root / "evidence/point-clean-set-srgb8/release-v1.json", release_bytes) + + _git(root, "init", "--quiet") + _git(root, "config", "user.name", "Receipt fixture") + _git(root, "config", "user.email", "receipt@example.invalid") + _git(root, "add", ".") + _git(root, "commit", "--quiet", "-m", "fixture") + return _git(root, "rev-parse", "HEAD"), _sha256(release_bytes) + + +def _product_fixture(root: Path, research_commit: str, release_sha256: str) -> dict[str, object]: + artifacts = [] + for role, path in PRODUCT_ARTIFACT_PATHS.items(): + data = CANONICAL_CODEC if role == "runtime_codec" else f"{role}\n".encode() + _write(root / path, data) + license_id = ( + "CC-BY-4.0 AND CC-BY-SA-4.0" if role == "runtime_codec" else "MIT" + ) + artifacts.append(_artifact(path, role, license_id, data)) + artifacts.sort(key=lambda artifact: str(artifact["role"])) + + legal_definitions = [ + ("LICENSE", "mit_text", b"MIT fixture\n"), + ( + "crates/labcolors-core/LICENSES/CC-BY-4.0.txt", + "cc_by_4_0_text", + b"CC BY fixture\n", + ), + ( + "crates/labcolors-core/LICENSES/CC-BY-SA-4.0.txt", + "cc_by_sa_4_0_text", + b"CC BY-SA fixture\n", + ), + ( + "crates/labcolors-core/NOTICE.md", + "attribution_notice", + ( + "Sato & Inoue 2016 DOI 10.7717/peerj.2751 CC-BY-4.0\n" + "CIE 1931 2 Degree DOI 10.25039/CIE.DS.xvudnb9b CC-BY-SA-4.0\n" + "CIE D65 DOI 10.25039/CIE.DS.hjfjmt59 CC-BY-SA-4.0\n" + ).encode(), + ), + ( + "crates/labcolors-core/Cargo.toml", + "core_manifest", + ( + "[package]\n" + 'name = "labcolors-core"\n' + f'license = "{CORE_LICENSE_EXPRESSION}"\n' + ).encode(), + ), + ] + legal_files = [] + for path, role, data in legal_definitions: + _write(root / path, data) + legal_files.append( + { + "bytes": len(data), + "path": path, + "role": role, + "sha256": _sha256(data), + } + ) + legal_files.sort(key=lambda artifact: str(artifact["role"])) + + return { + "admission": { + "authority": "ExactTechnicalDerivation", + "convention": "DeclaredPackagePolicyCandidate", + "production_auto_minted": False, + }, + "artifacts": artifacts, + "excluded_claims": list(EXCLUDED_CLAIMS), + "license_scope": { + "codec_spdx": "CC-BY-4.0 AND CC-BY-SA-4.0", + "core_package_spdx": CORE_LICENSE_EXPRESSION, + "legal_files": legal_files, + "receipt_spdx": "CC-BY-4.0 AND CC-BY-SA-4.0", + "software_spdx": "MIT", + }, + "release_id": "exact-nominal-srgb8-point-clean-set-v1", + "research": { + "commit": research_commit, + "object_format": "sha1", + "release_path": "evidence/point-clean-set-srgb8/release-v1.json", + "release_sha256": release_sha256, + }, + "runtime_contract": { + "accepted_points": 8_232_849, + "codec": { + "bytes": len(CANONICAL_CODEC), + "header_hex": "4c50434301010000", + "id": "green-column-red-run-start-rle-v1", + "records": 3616, + "sha256": _sha256(CANONICAL_CODEC), + }, + "domain_id": "encoded-srgb8-u8-cube-v1", + "domain_points": 16_777_216, + "law_id": "neutral-or-blue-outside-closed-dirty-interval-v1", + "neutral_axis_id": "srgb8-output-neutral-axis-v1", + "raw": { + "bytes": len(CANONICAL_RAW), + "id": "raw-dirty-blue-interval-u8-pair-v1", + "sha256": _sha256(CANONICAL_RAW), + }, + }, + "schema": "labcolors-exact-point-clean-set-product-receipt/1", + } + + +def _fixture(root: Path) -> ReceiptFixture: + product = root / "product" + research = root / "research" + product.mkdir() + research.mkdir() + commit, release_sha256 = _research_fixture(research) + policy = VerifierPolicy(commit, release_sha256) + return ReceiptFixture( + product, + research, + _product_fixture(product, commit, release_sha256), + policy, + ) + + +class ReceiptHostileTests(unittest.TestCase): + def test_valid_receipt_binds_committed_research_and_product_codec(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + _fixture(Path(temporary)).verify() + + def test_product_only_mode_detects_stale_product_source_without_research_repo(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + fixture.write_pin() + result = verify_product_receipt(fixture.product, policy=fixture.policy) + self.assertFalse(result.research_replayed) + + source = fixture.product / PRODUCT_ARTIFACT_PATHS["classifier_source"] + source.write_bytes(source.read_bytes() + b"stale\n") + with self.assertRaisesRegex(VerificationError, "receipt metadata"): + verify_product_receipt(fixture.product, policy=fixture.policy) + + def test_product_only_mode_rejects_each_missing_transitive_executor(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + fixture.write_pin() + for role in TRANSITIVE_EXECUTOR_ROLES: + with self.subTest(role=role): + path = fixture.product / PRODUCT_ARTIFACT_PATHS[role] + original = path.read_bytes() + path.unlink() + with self.assertRaisesRegex(VerificationError, "unavailable"): + verify_product_receipt(fixture.product, policy=fixture.policy) + path.write_bytes(original) + + def test_product_only_mode_rejects_each_mutated_transitive_executor(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + fixture.write_pin() + for role in TRANSITIVE_EXECUTOR_ROLES: + with self.subTest(role=role): + path = fixture.product / PRODUCT_ARTIFACT_PATHS[role] + original = path.read_bytes() + path.write_bytes(original + b"mutant\n") + with self.assertRaisesRegex(VerificationError, "receipt metadata"): + verify_product_receipt(fixture.product, policy=fixture.policy) + path.write_bytes(original) + + def test_product_only_mode_rejects_a_receipt_changed_without_external_pin(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + fixture.write_pin() + fixture.receipt["claim"] = "not admitted" + _write(fixture.receipt_path, canonical_json_bytes(fixture.receipt)) + with self.assertRaisesRegex(VerificationError, "caller-pinned"): + verify_product_receipt(fixture.product, policy=fixture.policy) + + def test_product_only_mode_rejects_deleting_both_receipt_and_pin(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + fixture.write_pin() + fixture.receipt_path.unlink() + (fixture.product / RECEIPT_PIN_PATH).unlink() + with self.assertRaisesRegex(VerificationError, "product receipt pin"): + verify_product_receipt(fixture.product, policy=fixture.policy) + + def test_product_pin_rejects_noncanonical_name_or_digest(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + fixture.write_receipt() + _write( + fixture.product / RECEIPT_PIN_PATH, + b"A" * 64 + b" other.json\n", + ) + with self.assertRaisesRegex(VerificationError, "receipt-v1.json"): + verify_product_receipt(fixture.product, policy=fixture.policy) + + def test_numeric_zero_cannot_impersonate_false(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + fixture.receipt["admission"]["production_auto_minted"] = 0 + with self.assertRaisesRegex(VerificationError, "production_auto_minted"): + fixture.verify() + + def test_duplicate_json_key_is_rejected_before_semantic_validation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + source = canonical_json_bytes(fixture.receipt) + duplicate = source[:-2] + b',\n "schema": "duplicate"\n}\n' + _write(fixture.receipt_path, duplicate) + with self.assertRaisesRegex(VerificationError, "duplicate JSON key"): + verify_receipt( + fixture.product, + fixture.research, + _sha256(duplicate), + policy=fixture.policy, + ) + + def test_receipt_requires_an_external_caller_pinned_digest(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + fixture.write_receipt() + with self.assertRaisesRegex(VerificationError, "caller-pinned"): + verify_receipt( + fixture.product, + fixture.research, + "0" * 64, + policy=fixture.policy, + ) + + def test_path_traversal_is_rejected_even_when_digest_matches(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + fixture.receipt["artifacts"][0]["path"] = "../escape" + with self.assertRaisesRegex(VerificationError, "portable relative path"): + fixture.verify() + + def test_dirty_research_worktree_cannot_replace_committed_release(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + release = fixture.research / "evidence/point-clean-set-srgb8/release-v1.json" + release.write_text("not the committed release\n", encoding="utf-8") + fixture.verify() + + def test_wrong_research_commit_is_rejected_before_blob_lookup(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + fixture.receipt["research"]["commit"] = "0" * 40 + with self.assertRaisesRegex(VerificationError, "research commit"): + fixture.verify() + + def test_new_commit_cannot_rebless_a_different_release_blob(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + release = fixture.research / "evidence/point-clean-set-srgb8/release-v1.json" + release.write_bytes(release.read_bytes() + b"\n") + _git(fixture.research, "add", ".") + _git(fixture.research, "commit", "--quiet", "-m", "mutated release") + new_commit = _git(fixture.research, "rev-parse", "HEAD") + fixture.receipt["research"]["commit"] = new_commit + fixture.policy = VerifierPolicy( + new_commit, + fixture.policy.research_release_sha256, + ) + with self.assertRaisesRegex(VerificationError, "release blob"): + fixture.verify() + + def test_one_codec_bit_cannot_be_reblessed_by_artifact_metadata(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + codec_path = fixture.product / CODEC_PATH + mutant = bytearray(codec_path.read_bytes()) + mutant[-1] ^= 1 + codec_path.write_bytes(mutant) + codec_artifact = next( + item for item in fixture.receipt["artifacts"] if item["role"] == "runtime_codec" + ) + codec_artifact["sha256"] = _sha256(mutant) + with self.assertRaisesRegex(VerificationError, "runtime codec identity"): + fixture.verify() + + def test_unknown_receipt_field_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + fixture.receipt["claim"] = "human cleanliness law" + with self.assertRaisesRegex(VerificationError, "receipt fields"): + fixture.verify() + + def test_rehashed_mit_only_core_manifest_cannot_bypass_product_receipt(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + manifest = b'[package]\nname = "labcolors-core"\nlicense = "MIT"\n' + manifest_path = fixture.product / "crates/labcolors-core/Cargo.toml" + manifest_path.write_bytes(manifest) + manifest_entry = next( + item + for item in fixture.receipt["license_scope"]["legal_files"] + if item["role"] == "core_manifest" + ) + manifest_entry["bytes"] = len(manifest) + manifest_entry["sha256"] = _sha256(manifest) + with self.assertRaisesRegex(VerificationError, "package license"): + fixture.verify() + + +class CorePackageLicenseTests(unittest.TestCase): + def _package_fixture(self, root: Path) -> tuple[Path, Path]: + source = root / "source" + package = root / "package" + source.mkdir() + package.mkdir() + + receipt = b'{"fixture":true}\n' + receipt_pin = f"{_sha256(receipt)} receipt-v1.json\n".encode("ascii") + files = { + "LICENSE": b"MIT fixture\n", + "crates/labcolors-core/LICENSES/CC-BY-4.0.txt": b"CC BY fixture\n", + "crates/labcolors-core/LICENSES/CC-BY-SA-4.0.txt": b"CC BY-SA fixture\n", + "crates/labcolors-core/NOTICE.md": ( + b"Sato & Inoue DOI 10.7717/peerj.2751 CC-BY-4.0\n" + b"CIE DOI 10.25039/CIE.DS.xvudnb9b CC-BY-SA-4.0\n" + b"D65 DOI 10.25039/CIE.DS.hjfjmt59 CC-BY-SA-4.0\n" + ), + CODEC_PATH: CANONICAL_CODEC, + RECEIPT_PATH: receipt, + RECEIPT_PIN_PATH: receipt_pin, + } + for path, data in files.items(): + _write(source / path, data) + + packaged = { + "LICENSE": files["LICENSE"], + "LICENSES/CC-BY-4.0.txt": files[ + "crates/labcolors-core/LICENSES/CC-BY-4.0.txt" + ], + "LICENSES/CC-BY-SA-4.0.txt": files[ + "crates/labcolors-core/LICENSES/CC-BY-SA-4.0.txt" + ], + "NOTICE.md": files["crates/labcolors-core/NOTICE.md"], + "contracts/clean-set-srgb8-v1/point-clean-set-srgb8-column-rle-v1.bin": CANONICAL_CODEC, + "contracts/clean-set-srgb8-v1/receipt-v1.json": receipt, + "contracts/clean-set-srgb8-v1/receipt-v1.sha256": receipt_pin, + "Cargo.toml": ( + "[package]\n" + 'name = "labcolors-core"\n' + f'license = "{CORE_LICENSE_EXPRESSION}"\n' + ).encode(), + } + for path, data in packaged.items(): + _write(package / path, data) + return source, package + + def test_exact_core_package_license_closure_passes(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + source, package = self._package_fixture(Path(temporary)) + verify_core_package(source, package) + + def test_mit_only_package_metadata_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + source, package = self._package_fixture(Path(temporary)) + (package / "Cargo.toml").write_text( + '[package]\nname = "labcolors-core"\nlicense = "MIT"\n', + encoding="utf-8", + ) + with self.assertRaisesRegex(VerificationError, "package license"): + verify_core_package(source, package) + + def test_missing_cc_text_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + source, package = self._package_fixture(Path(temporary)) + (package / "LICENSES/CC-BY-4.0.txt").unlink() + with self.assertRaisesRegex(VerificationError, "CC-BY-4.0"): + verify_core_package(source, package) + + def test_missing_packaged_receipt_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + source, package = self._package_fixture(Path(temporary)) + (package / "contracts/clean-set-srgb8-v1/receipt-v1.json").unlink() + with self.assertRaisesRegex(VerificationError, "receipt-v1.json"): + verify_core_package(source, package) + + def test_missing_packaged_receipt_pin_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + source, package = self._package_fixture(Path(temporary)) + (package / "contracts/clean-set-srgb8-v1/receipt-v1.sha256").unlink() + with self.assertRaisesRegex(VerificationError, "receipt-v1.sha256"): + verify_core_package(source, package) + + def test_coherently_substituted_packaged_receipt_and_pin_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + source, package = self._package_fixture(Path(temporary)) + receipt = b'{"substituted":true}\n' + (package / "contracts/clean-set-srgb8-v1/receipt-v1.json").write_bytes(receipt) + (package / "contracts/clean-set-srgb8-v1/receipt-v1.sha256").write_bytes( + f"{_sha256(receipt)} receipt-v1.json\n".encode("ascii") + ) + with self.assertRaisesRegex(VerificationError, "canonical product bytes"): + verify_core_package(source, package) + + def test_notice_without_one_source_attribution_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + source, package = self._package_fixture(Path(temporary)) + notice = package / "NOTICE.md" + notice.write_bytes(notice.read_bytes().replace(b"10.7717/peerj.2751", b"missing")) + source_notice = source / "crates/labcolors-core/NOTICE.md" + source_notice.write_bytes( + source_notice.read_bytes().replace(b"10.7717/peerj.2751", b"missing") + ) + with self.assertRaisesRegex(VerificationError, "10.7717/peerj.2751"): + verify_core_package(source, package) + + @unittest.skipUnless(hasattr(os, "symlink"), "platform has no symlink support") + def test_symlinked_notice_is_not_a_packaged_regular_file(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + source, package = self._package_fixture(Path(temporary)) + notice = package / "NOTICE.md" + notice.unlink() + notice.symlink_to(package / "LICENSE") + with self.assertRaisesRegex(VerificationError, "symlink"): + verify_core_package(source, package) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/verify_clean_set_receipt.py b/scripts/verify_clean_set_receipt.py new file mode 100644 index 00000000..73dea4eb --- /dev/null +++ b/scripts/verify_clean_set_receipt.py @@ -0,0 +1,944 @@ +#!/usr/bin/env python3 +"""Офлайн-верификатор точного product receipt для clean-set sRGB8. + +Receipt намеренно вынесен из исследовательского репозитория: он связывает один +source-cone продукта с неизменяемым исследовательским коммитом, не копируя в +продукт исходную таблицу, сертификаты, датасеты и исследовательские программы. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import stat +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, NoReturn + + +RECEIPT_PATH = "crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.json" +RECEIPT_PIN_PATH = "crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.sha256" +CODEC_PATH = ( + "crates/labcolors-core/contracts/clean-set-srgb8-v1/" + "point-clean-set-srgb8-column-rle-v1.bin" +) +RESEARCH_RELEASE_PATH = "evidence/point-clean-set-srgb8/release-v1.json" +RESEARCH_COMMIT = "ac6d9654fc722334d8bc2054afb903770f2aad80" +RESEARCH_RELEASE_SHA256 = ( + "67cadaae38bbaea3096dba69142b5bf3d7776b7574ec224022abbcd119c45ce6" +) + +RELEASE_ID = "exact-nominal-srgb8-point-clean-set-v1" +RECEIPT_SCHEMA = "labcolors-exact-point-clean-set-product-receipt/1" +CORE_LICENSE_EXPRESSION = "MIT AND CC-BY-4.0 AND CC-BY-SA-4.0" +DATA_LICENSE_EXPRESSION = "CC-BY-4.0 AND CC-BY-SA-4.0" + +CODEC_SHA256 = "aa6aa7c0b630437f1c1ba8c2ceafb0dadf6551c42331559504076a6cd44e6331" +RAW_SHA256 = "97bcc9f793adb7f13bd70c89e9788c8ab61baf8c77e9f8cd80335ad767d71ae2" +CODEC_HEADER = b"LPCC\x01\x01\x00\x00" +CODEC_BYTES = 11_370 +CODEC_RECORDS = 3_616 +RAW_BYTES = 131_072 +DOMAIN_POINTS = 16_777_216 +ACCEPTED_POINTS = 8_232_849 + +EXCLUDED_CLAIMS = ( + "ideal algebraic IEC 61966-2-1 transfer semantics", + "chromatic adaptation", + "physical applicability of object-colour geometry to self-luminous display", + "human cleanliness law or population guarantee", +) + +# Первый точный product receipt намеренно связывает файлы целиком. После +# разделения исходников схему надо выпустить заново, а не ослаблять замыкание +# незаметно. +PRODUCT_ARTIFACT_PATHS = { + "appearance_executor_source": "crates/labcolors-core/src/appearance.rs", + "classifier_source": "crates/labcolors-core/src/clean_set.rs", + "classifier_tests": "crates/labcolors-core/src/clean_set_tests.rs", + "composition_executor_source": "crates/labcolors-core/src/composition.rs", + "content_digest_source": "crates/labcolors-core/src/sha256.rs", + "joint_selection_source": "crates/labcolors-core/src/joint.rs", + "module_registration_source": "crates/labcolors-core/src/lib.rs", + "observation_runtime_source": "crates/labcolors-core/src/observation.rs", + "program_facade_source": "crates/labcolors-core/src/program.rs", + "program_identity_source": "crates/labcolors-core/src/program_identity.rs", + "program_source": "crates/labcolors-core/src/program_session.rs", + "program_tests": "crates/labcolors-core/src/program_clean_set_tests.rs", + "runtime_codec": CODEC_PATH, + "session_runtime_source": "crates/labcolors-core/src/session.rs", + "signal_transport_source": "crates/labcolors-core/src/lcs_occurrence.rs", + "srgb8_source": "crates/labcolors-core/src/srgb8.rs", + "verifier_source": "scripts/verify_clean_set_receipt.py", + "verifier_tests": "scripts/test_verify_clean_set_receipt.py", +} + +PRODUCT_ARTIFACT_LICENSES = { + role: DATA_LICENSE_EXPRESSION if role == "runtime_codec" else "MIT" + for role in PRODUCT_ARTIFACT_PATHS +} + +LEGAL_FILE_PATHS = { + "attribution_notice": "crates/labcolors-core/NOTICE.md", + "cc_by_4_0_text": "crates/labcolors-core/LICENSES/CC-BY-4.0.txt", + "cc_by_sa_4_0_text": "crates/labcolors-core/LICENSES/CC-BY-SA-4.0.txt", + "core_manifest": "crates/labcolors-core/Cargo.toml", + "mit_text": "LICENSE", +} + +RESEARCH_ARTIFACT_LICENSES = { + "semantic_profile": "CC-BY-SA-4.0", + "policy_frontier": "CC-BY-SA-4.0", + "cie_1931_2deg_source": "CC-BY-SA-4.0", + "cie_d65_source": "CC-BY-SA-4.0", + "canonical_raw_table": "CC-BY-SA-4.0", + "runtime_codec_table": "CC-BY-SA-4.0", + "boundary_certificates": "CC-BY-SA-4.0", + "proof": "CC-BY-SA-4.0", + "generator": "MIT", + "generator_cie_reader": "MIT", + "independent_verifier": "MIT", + "verifier_cie_reader": "MIT", + "data_notice": "CC-BY-SA-4.0", +} + +NOTICE_TOKENS = ( + "10.7717/peerj.2751", + "CC-BY-4.0", + "10.25039/CIE.DS.xvudnb9b", + "10.25039/CIE.DS.hjfjmt59", + "CC-BY-SA-4.0", +) + +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +GIT_SHA1_RE = re.compile(r"^[0-9a-f]{40}$") +PORTABLE_PATH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$") + + +class VerificationError(RuntimeError): + """Единый fail-closed исход для receipt, provenance, кодека и лицензий.""" + + +@dataclass(frozen=True) +class VerifierPolicy: + research_commit: str + research_release_sha256: str + + +PRODUCTION_POLICY = VerifierPolicy(RESEARCH_COMMIT, RESEARCH_RELEASE_SHA256) + + +@dataclass(frozen=True) +class VerificationResult: + receipt_sha256: str + research_replayed: bool + + +def _fail(message: str) -> NoReturn: + raise VerificationError(message) + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def canonical_json_bytes(value: Any) -> bytes: + try: + source = json.dumps( + value, + allow_nan=False, + ensure_ascii=True, + indent=2, + sort_keys=True, + ) + except (TypeError, ValueError) as error: + _fail(f"value is not canonical JSON: {error}") + return f"{source}\n".encode("ascii") + + +def _reject_duplicate_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + _fail(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def _reject_float(source: str) -> NoReturn: + _fail(f"floating JSON number is unsupported: {source}") + + +def _reject_constant(source: str) -> NoReturn: + _fail(f"non-finite JSON number is unsupported: {source}") + + +def _parse_json(data: bytes, label: str, *, canonical: bool) -> Any: + if data.startswith(b"\xef\xbb\xbf"): + _fail(f"{label} has a UTF-8 BOM") + try: + source = data.decode("utf-8", errors="strict") + value = json.loads( + source, + object_pairs_hook=_reject_duplicate_pairs, + parse_float=_reject_float, + parse_constant=_reject_constant, + ) + except VerificationError: + raise + except (UnicodeError, json.JSONDecodeError) as error: + _fail(f"{label} is not strict JSON: {error}") + if canonical and data != canonical_json_bytes(value): + _fail(f"{label} is not canonical sorted LF JSON") + return value + + +def _exact_keys(value: Any, expected: tuple[str, ...], label: str) -> dict[str, Any]: + if type(value) is not dict: + _fail(f"{label} must be an object") + actual = tuple(sorted(value)) + canonical = tuple(sorted(expected)) + if actual != canonical: + _fail(f"{label} fields {actual!r} differ from {canonical!r}") + return value + + +def _exact_string(value: Any, expected: str, label: str) -> None: + if type(value) is not str or value != expected: + _fail(f"{label} must equal {expected!r}") + + +def _exact_int(value: Any, expected: int, label: str) -> None: + if type(value) is not int or value != expected: + _fail(f"{label} must equal integer {expected}") + + +def _positive_int(value: Any, label: str) -> int: + if type(value) is not int or value <= 0 or value > 2**53 - 1: + _fail(f"{label} must be a positive interoperable integer") + return value + + +def _sha256_string(value: Any, label: str) -> str: + if type(value) is not str or SHA256_RE.fullmatch(value) is None: + _fail(f"{label} must be one lower-case SHA-256") + return value + + +def _portable_path(value: Any, label: str) -> str: + if type(value) is not str or PORTABLE_PATH_RE.fullmatch(value) is None: + _fail(f"{label} must be a portable relative path") + if "\\" in value or "\x00" in value: + _fail(f"{label} must be a portable relative path") + path = PurePosixPath(value) + if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts): + _fail(f"{label} must be a portable relative path") + if path.as_posix() != value: + _fail(f"{label} must be a canonical portable relative path") + return value + + +def _read_regular_file_once(root: Path, relative_path: str, label: str) -> bytes: + path_text = _portable_path(relative_path, label) + try: + canonical_root = root.resolve(strict=True) + except OSError as error: + _fail(f"{label} root is unavailable: {error}") + if not canonical_root.is_dir(): + _fail(f"{label} root is not a directory") + + current = canonical_root + for part in PurePosixPath(path_text).parts: + current = current / part + try: + mode = current.lstat().st_mode + except OSError as error: + _fail(f"{label} is unavailable: {error}") + if stat.S_ISLNK(mode): + _fail(f"{label} contains a symlink: {path_text}") + + try: + mode = current.stat().st_mode + if not stat.S_ISREG(mode): + _fail(f"{label} is not a regular file: {path_text}") + with current.open("rb") as source: + data = source.read() + except OSError as error: + _fail(f"{label} cannot be read: {error}") + if not data: + _fail(f"{label} is empty: {path_text}") + return data + + +def _verify_file_metadata( + entry: Any, + root: Path, + label: str, + *, + expected_role: str, + expected_path: str, + expected_license: str | None, +) -> bytes: + fields = ("bytes", "path", "role", "sha256") + if expected_license is not None: + fields = ("bytes", "license", "path", "role", "sha256") + item = _exact_keys(entry, fields, label) + _exact_string(item["role"], expected_role, f"{label}.role") + actual_path = _portable_path(item["path"], f"{label}.path") + _exact_string(actual_path, expected_path, f"{label}.path") + if expected_license is not None: + _exact_string(item["license"], expected_license, f"{label}.license") + expected_bytes = _positive_int(item["bytes"], f"{label}.bytes") + expected_sha = _sha256_string(item["sha256"], f"{label}.sha256") + data = _read_regular_file_once(root, expected_path, label) + if len(data) != expected_bytes or sha256(data) != expected_sha: + _fail(f"{label} bytes do not match receipt metadata") + return data + + +def _validate_notice(data: bytes, label: str) -> None: + if b"\r" in data: + _fail(f"{label} must use LF line endings") + try: + source = data.decode("utf-8", errors="strict") + except UnicodeError as error: + _fail(f"{label} is not UTF-8: {error}") + for token in NOTICE_TOKENS: + if token not in source: + _fail(f"{label} lacks required attribution token {token}") + + +def _parse_receipt_pin(data: bytes, label: str) -> str: + try: + source = data.decode("ascii", errors="strict") + except UnicodeError as error: + _fail(f"{label} is not ASCII: {error}") + expected_suffix = " receipt-v1.json\n" + if not source.endswith(expected_suffix): + _fail(f"{label} must name receipt-v1.json with one terminal LF") + digest = source[: -len(expected_suffix)] + return _sha256_string(digest, f"{label} digest") + + +def _receipt_pin(product_root: Path) -> str: + data = _read_regular_file_once(product_root, RECEIPT_PIN_PATH, "product receipt pin") + return _parse_receipt_pin(data, "product receipt pin") + + +def _verify_admission(value: Any, label: str) -> None: + admission = _exact_keys( + value, + ("authority", "convention", "production_auto_minted"), + label, + ) + _exact_string(admission["authority"], "ExactTechnicalDerivation", f"{label}.authority") + _exact_string( + admission["convention"], + "DeclaredPackagePolicyCandidate", + f"{label}.convention", + ) + if type(admission["production_auto_minted"]) is not bool: + _fail(f"{label}.production_auto_minted must be a boolean") + if admission["production_auto_minted"]: + _fail(f"{label}.production_auto_minted must remain false") + + +def _verify_excluded_claims(value: Any, label: str) -> None: + if type(value) is not list or tuple(value) != EXCLUDED_CLAIMS: + _fail(f"{label} must equal the exact ordered excluded-claim boundary") + + +def _decode_codec(codec: bytes, expected_records: int = CODEC_RECORDS) -> tuple[bytes, int]: + expected_bytes = 8 + 257 * 2 + expected_records * 3 + if len(codec) != expected_bytes: + _fail(f"runtime codec has {len(codec)} bytes, expected {expected_bytes}") + if codec[:8] != CODEC_HEADER: + _fail("runtime codec header differs from LPCC v1") + + offsets = [ + int.from_bytes(codec[8 + index * 2 : 10 + index * 2], "big") + for index in range(257) + ] + if offsets[0] != 0 or offsets[-1] != expected_records: + _fail("runtime codec offsets do not bind the complete record body") + if any(left >= right for left, right in zip(offsets, offsets[1:])): + _fail("runtime codec must contain one non-empty canonical run list per green column") + + body = codec[522:] + columns: list[list[tuple[int, int, int]]] = [] + for green in range(256): + records = [ + tuple(body[index * 3 : index * 3 + 3]) + for index in range(offsets[green], offsets[green + 1]) + ] + if records[0][0] != 0: + _fail(f"runtime codec green={green} does not start at red=0") + previous_start = -1 + previous_interval: tuple[int, int] | None = None + for red_start, lo, hi in records: + if red_start <= previous_start: + _fail(f"runtime codec green={green} has non-increasing red starts") + interval = (lo, hi) + if lo > hi and interval != (255, 0): + _fail(f"runtime codec green={green} has a non-canonical reversed interval") + if previous_interval == interval: + _fail(f"runtime codec green={green} splits one canonical run") + previous_start = red_start + previous_interval = interval + columns.append(records) + + raw = bytearray() + accepted = 0 + rejected = 0 + for red in range(256): + for green in range(256): + record = columns[green][0] + for candidate in columns[green][1:]: + if candidate[0] > red: + break + record = candidate + lo, hi = record[1], record[2] + raw.extend((lo, hi)) + rejected_here = 0 if (lo, hi) == (255, 0) else hi - lo + 1 + accepted_here = 256 - rejected_here + if red == green and rejected_here and lo <= red <= hi: + accepted_here += 1 + rejected_here -= 1 + accepted += accepted_here + rejected += rejected_here + + if accepted == 0 or rejected == 0: + _fail("runtime codec replay is vacuous") + return bytes(raw), accepted + + +def _git(root: Path, args: list[str], label: str) -> bytes: + environment = { + **os.environ, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_NO_REPLACE_OBJECTS": "1", + "GIT_OPTIONAL_LOCKS": "0", + "LC_ALL": "C", + } + try: + return subprocess.check_output( + ["git", "--no-replace-objects", "-C", str(root), *args], + stderr=subprocess.STDOUT, + env=environment, + ) + except (OSError, subprocess.CalledProcessError) as error: + detail = getattr(error, "output", b"").decode("utf-8", errors="replace").strip() + _fail(f"{label} Git lookup failed{': ' + detail if detail else ''}") + + +def _git_blob(root: Path, commit: str, path: str, label: str) -> bytes: + relative = _portable_path(path, label) + listing = _git(root, ["ls-tree", "-z", commit, "--", relative], label) + records = [record for record in listing.split(b"\x00") if record] + if len(records) != 1 or not records[0].startswith((b"100644 blob ", b"100755 blob ")): + _fail(f"{label} is not one committed regular blob") + return _git(root, ["cat-file", "blob", f"{commit}:{relative}"], label) + + +def _verify_research( + research_root: Path, + research: Any, + runtime: dict[str, Any], + product_codec: bytes, + policy: VerifierPolicy, +) -> None: + value = _exact_keys( + research, + ("commit", "object_format", "release_path", "release_sha256"), + "receipt.research", + ) + commit = value["commit"] + if type(commit) is not str or GIT_SHA1_RE.fullmatch(commit) is None: + _fail("receipt research commit must be one lower-case 40-hex Git commit") + if commit != policy.research_commit: + _fail("receipt research commit differs from the admitted immutable commit") + _exact_string(value["object_format"], "sha1", "receipt.research.object_format") + _exact_string(value["release_path"], RESEARCH_RELEASE_PATH, "receipt.research.release_path") + _exact_string( + value["release_sha256"], + policy.research_release_sha256, + "receipt.research.release_sha256", + ) + + object_type = _git(research_root, ["cat-file", "-t", commit], "research commit").strip() + if object_type != b"commit": + _fail("receipt research commit does not name a commit object") + peeled = _git( + research_root, + ["rev-parse", "--verify", f"{commit}^{{commit}}"], + "research commit", + ).decode("ascii", errors="strict").strip() + if peeled != commit: + _fail("receipt research commit does not resolve to its exact object identity") + + release_bytes = _git_blob(research_root, commit, RESEARCH_RELEASE_PATH, "research release") + if sha256(release_bytes) != policy.research_release_sha256: + _fail("research release blob differs from the admitted release SHA-256") + release = _exact_keys( + _parse_json(release_bytes, "research release", canonical=False), + ("artifacts", "bundle_root", "codec_id", "encoding_id", "license", "release_id", "schema"), + "research release", + ) + _exact_string( + release["bundle_root"], + "cleanliness-repository-v1", + "research release.bundle_root", + ) + _exact_string(release["codec_id"], runtime["codec"]["id"], "research release.codec_id") + _exact_string(release["encoding_id"], runtime["raw"]["id"], "research release.encoding_id") + _exact_string(release["license"], "CC-BY-SA-4.0", "research release.license") + _exact_string(release["release_id"], RELEASE_ID, "research release.release_id") + _exact_string( + release["schema"], + "lab-point-clean-set-srgb8-release/1", + "research release.schema", + ) + + artifacts = release["artifacts"] + if type(artifacts) is not list or len(artifacts) != len(RESEARCH_ARTIFACT_LICENSES): + _fail("research release has an incomplete artifact closure") + by_role: dict[str, tuple[dict[str, Any], bytes]] = {} + paths: set[str] = set() + casefold_paths: set[str] = set() + for index, entry in enumerate(artifacts): + item = _exact_keys( + entry, + ("bytes", "license", "path", "role", "sha256"), + f"research release artifact[{index}]", + ) + role = item["role"] + if type(role) is not str or role not in RESEARCH_ARTIFACT_LICENSES or role in by_role: + _fail("research release has an unknown or duplicate artifact role") + path = _portable_path(item["path"], f"research release artifact[{index}].path") + if path in paths or path.casefold() in casefold_paths: + _fail("research release has a duplicate or case-colliding artifact path") + paths.add(path) + casefold_paths.add(path.casefold()) + _exact_string( + item["license"], + RESEARCH_ARTIFACT_LICENSES[role], + f"research release artifact[{index}].license", + ) + expected_bytes = _positive_int( + item["bytes"], + f"research release artifact[{index}].bytes", + ) + expected_sha = _sha256_string( + item["sha256"], + f"research release artifact[{index}].sha256", + ) + data = _git_blob(research_root, commit, path, f"research artifact {role}") + if len(data) != expected_bytes or sha256(data) != expected_sha: + _fail(f"research artifact {role} differs from release metadata") + by_role[role] = (item, data) + if set(by_role) != set(RESEARCH_ARTIFACT_LICENSES): + _fail("research release artifact roles differ from the admitted closure") + + profile = _exact_keys( + _parse_json(by_role["semantic_profile"][1], "research profile", canonical=False), + ( + "admission", + "excluded_claims", + "geometry", + "neutral_axis", + "nominal_bridge", + "output_release", + "policy", + "release_id", + "schema", + ), + "research profile", + ) + proof = _exact_keys( + _parse_json(by_role["proof"][1], "research proof", canonical=False), + ( + "admission", + "artifacts", + "certificate_encoding", + "codec_encoding", + "cone_certificates", + "counts", + "excluded_claims", + "generator_sha256", + "inputs", + "law", + "release_id", + "schema", + "witnesses", + ), + "research proof", + ) + for label, document in (("research profile", profile), ("research proof", proof)): + _verify_admission(document["admission"], f"{label}.admission") + _verify_excluded_claims(document["excluded_claims"], f"{label}.excluded_claims") + _exact_string(document["release_id"], RELEASE_ID, f"{label}.release_id") + + proof_artifacts = _exact_keys( + proof["artifacts"], + ( + "certificates_bytes", + "certificates_sha256", + "codec_bytes", + "codec_sha256", + "profile_sha256", + "table_bytes", + "table_sha256", + ), + "research proof.artifacts", + ) + _exact_int(proof_artifacts["codec_bytes"], CODEC_BYTES, "research proof codec bytes") + _exact_string(proof_artifacts["codec_sha256"], CODEC_SHA256, "research proof codec SHA-256") + _exact_int(proof_artifacts["table_bytes"], RAW_BYTES, "research proof raw bytes") + _exact_string(proof_artifacts["table_sha256"], RAW_SHA256, "research proof raw SHA-256") + _exact_string( + proof_artifacts["profile_sha256"], + sha256(by_role["semantic_profile"][1]), + "research proof profile SHA-256", + ) + + counts = proof["counts"] + if type(counts) is not dict: + _fail("research proof.counts must be an object") + _exact_int(counts.get("cube_points"), DOMAIN_POINTS, "research proof cube points") + _exact_int(counts.get("neutral_points"), 256, "research proof neutral points") + _exact_int( + counts.get("accepted_chromatic") + counts.get("neutral_points") + if type(counts.get("accepted_chromatic")) is int + and type(counts.get("neutral_points")) is int + else None, + ACCEPTED_POINTS, + "research proof accepted points", + ) + + codec_encoding = proof["codec_encoding"] + if type(codec_encoding) is not dict: + _fail("research proof.codec_encoding must be an object") + _exact_int(codec_encoding.get("records"), CODEC_RECORDS, "research proof codec records") + _exact_string( + codec_encoding.get("header_hex"), + CODEC_HEADER.hex(), + "research proof codec header", + ) + _exact_string(codec_encoding.get("id"), runtime["codec"]["id"], "research proof codec ID") + + research_codec = by_role["runtime_codec_table"][1] + research_raw = by_role["canonical_raw_table"][1] + if research_codec != product_codec: + _fail("product codec bytes differ from the committed research codec") + if sha256(research_raw) != RAW_SHA256 or len(research_raw) != RAW_BYTES: + _fail("committed research raw table identity drifted") + + +def verify_receipt( + product_root: Path | str, + research_root: Path | str | None, + expected_receipt_sha256: str, + *, + policy: VerifierPolicy = PRODUCTION_POLICY, +) -> VerificationResult: + product = Path(product_root) + research = Path(research_root) if research_root is not None else None + expected_receipt = _sha256_string(expected_receipt_sha256, "expected receipt SHA-256") + receipt_bytes = _read_regular_file_once(product, RECEIPT_PATH, "product receipt") + if sha256(receipt_bytes) != expected_receipt: + _fail("product receipt differs from the caller-pinned SHA-256") + receipt = _exact_keys( + _parse_json(receipt_bytes, "product receipt", canonical=True), + ( + "admission", + "artifacts", + "excluded_claims", + "license_scope", + "release_id", + "research", + "runtime_contract", + "schema", + ), + "receipt", + ) + _exact_string(receipt["schema"], RECEIPT_SCHEMA, "receipt.schema") + _exact_string(receipt["release_id"], RELEASE_ID, "receipt.release_id") + _verify_admission(receipt["admission"], "receipt.admission") + _verify_excluded_claims(receipt["excluded_claims"], "receipt.excluded_claims") + + artifacts = receipt["artifacts"] + if type(artifacts) is not list: + _fail("receipt.artifacts must be an array") + expected_roles = tuple(sorted(PRODUCT_ARTIFACT_PATHS)) + actual_roles = tuple(item.get("role") if type(item) is dict else None for item in artifacts) + if actual_roles != expected_roles: + _fail("receipt artifact roles must equal the exact sorted product source cone") + paths: set[str] = set() + casefold_paths: set[str] = set() + product_codec: bytes | None = None + for index, (role, item) in enumerate(zip(expected_roles, artifacts)): + data = _verify_file_metadata( + item, + product, + f"receipt artifact[{index}]", + expected_role=role, + expected_path=PRODUCT_ARTIFACT_PATHS[role], + expected_license=PRODUCT_ARTIFACT_LICENSES[role], + ) + path = PRODUCT_ARTIFACT_PATHS[role] + if path in paths or path.casefold() in casefold_paths: + _fail("receipt has a duplicate or case-colliding product path") + paths.add(path) + casefold_paths.add(path.casefold()) + if role == "runtime_codec": + product_codec = data + if product_codec is None: + _fail("receipt lacks the runtime codec") + + license_scope = _exact_keys( + receipt["license_scope"], + ("codec_spdx", "core_package_spdx", "legal_files", "receipt_spdx", "software_spdx"), + "receipt.license_scope", + ) + _exact_string(license_scope["codec_spdx"], DATA_LICENSE_EXPRESSION, "receipt codec SPDX") + _exact_string( + license_scope["core_package_spdx"], + CORE_LICENSE_EXPRESSION, + "receipt core package SPDX", + ) + _exact_string(license_scope["receipt_spdx"], DATA_LICENSE_EXPRESSION, "receipt SPDX") + _exact_string(license_scope["software_spdx"], "MIT", "receipt software SPDX") + legal_files = license_scope["legal_files"] + expected_legal_roles = tuple(sorted(LEGAL_FILE_PATHS)) + if type(legal_files) is not list or tuple( + item.get("role") if type(item) is dict else None for item in legal_files + ) != expected_legal_roles: + _fail("receipt legal files must equal the exact sorted license closure") + core_manifest: bytes | None = None + notice: bytes | None = None + for index, (role, item) in enumerate(zip(expected_legal_roles, legal_files)): + data = _verify_file_metadata( + item, + product, + f"receipt legal file[{index}]", + expected_role=role, + expected_path=LEGAL_FILE_PATHS[role], + expected_license=None, + ) + if role == "attribution_notice": + notice = data + elif role == "core_manifest": + core_manifest = data + if core_manifest is None: + _fail("receipt lacks the core package manifest") + _package_license(core_manifest) + if notice is None: + _fail("receipt lacks the attribution notice") + _validate_notice(notice, "product attribution notice") + + runtime = _exact_keys( + receipt["runtime_contract"], + ( + "accepted_points", + "codec", + "domain_id", + "domain_points", + "law_id", + "neutral_axis_id", + "raw", + ), + "receipt.runtime_contract", + ) + _exact_int(runtime["accepted_points"], ACCEPTED_POINTS, "receipt accepted points") + _exact_string(runtime["domain_id"], "encoded-srgb8-u8-cube-v1", "receipt domain ID") + _exact_int(runtime["domain_points"], DOMAIN_POINTS, "receipt domain points") + _exact_string( + runtime["law_id"], + "neutral-or-blue-outside-closed-dirty-interval-v1", + "receipt law ID", + ) + _exact_string( + runtime["neutral_axis_id"], + "srgb8-output-neutral-axis-v1", + "receipt neutral-axis ID", + ) + codec_contract = _exact_keys( + runtime["codec"], + ("bytes", "header_hex", "id", "records", "sha256"), + "receipt runtime codec", + ) + _exact_int(codec_contract["bytes"], CODEC_BYTES, "receipt codec bytes") + _exact_string(codec_contract["header_hex"], CODEC_HEADER.hex(), "receipt codec header") + _exact_string(codec_contract["id"], "green-column-red-run-start-rle-v1", "receipt codec ID") + _exact_int(codec_contract["records"], CODEC_RECORDS, "receipt codec records") + _exact_string(codec_contract["sha256"], CODEC_SHA256, "receipt codec SHA-256") + if len(product_codec) != CODEC_BYTES or sha256(product_codec) != CODEC_SHA256: + _fail("product runtime codec identity differs from the admitted codec") + + raw_contract = _exact_keys(runtime["raw"], ("bytes", "id", "sha256"), "receipt raw table") + _exact_int(raw_contract["bytes"], RAW_BYTES, "receipt raw bytes") + _exact_string(raw_contract["id"], "raw-dirty-blue-interval-u8-pair-v1", "receipt raw ID") + _exact_string(raw_contract["sha256"], RAW_SHA256, "receipt raw SHA-256") + decoded_raw, accepted = _decode_codec(product_codec) + if len(decoded_raw) != RAW_BYTES or sha256(decoded_raw) != RAW_SHA256: + _fail("runtime codec does not decode to the admitted raw table identity") + if accepted != ACCEPTED_POINTS: + _fail("runtime codec accepted-point count differs from the admitted finite domain") + + research_descriptor = _exact_keys( + receipt["research"], + ("commit", "object_format", "release_path", "release_sha256"), + "receipt.research", + ) + commit = research_descriptor["commit"] + if type(commit) is not str or GIT_SHA1_RE.fullmatch(commit) is None: + _fail("receipt research commit must be one lower-case 40-hex Git commit") + _exact_string(commit, policy.research_commit, "receipt research commit") + _exact_string(research_descriptor["object_format"], "sha1", "receipt research object format") + _exact_string( + research_descriptor["release_path"], + RESEARCH_RELEASE_PATH, + "receipt research release path", + ) + _exact_string( + research_descriptor["release_sha256"], + policy.research_release_sha256, + "receipt research release SHA-256", + ) + + if research is not None: + _verify_research(research, research_descriptor, runtime, product_codec, policy) + return VerificationResult(expected_receipt, research is not None) + + +def verify_product_receipt( + product_root: Path | str, + *, + policy: VerifierPolicy = PRODUCTION_POLICY, +) -> VerificationResult: + product = Path(product_root) + expected_receipt = _receipt_pin(product) + return verify_receipt(product, None, expected_receipt, policy=policy) + + +def _package_license(cargo_toml: bytes) -> str: + if b"\r" in cargo_toml: + _fail("packaged Cargo.toml must use LF line endings") + try: + source = cargo_toml.decode("utf-8", errors="strict") + except UnicodeError as error: + _fail(f"packaged Cargo.toml is not UTF-8: {error}") + current_table = "" + licenses: list[str] = [] + for line in source.splitlines(): + table = re.fullmatch(r"\s*\[([^][]+)]\s*", line) + if table: + current_table = table.group(1).strip() + continue + if current_table != "package": + continue + if re.match(r"\s*license-file(?:\s|=)", line): + _fail("packaged Cargo.toml must not use license-file beside SPDX metadata") + match = re.fullmatch(r'\s*license\s*=\s*"([^"]+)"\s*', line) + if match: + licenses.append(match.group(1)) + if re.match(r"\s*license\.workspace\s*=", line): + _fail("packaged Cargo.toml must resolve the inherited package license") + if licenses != [CORE_LICENSE_EXPRESSION]: + _fail(f"package license must equal {CORE_LICENSE_EXPRESSION!r}") + return licenses[0] + + +def verify_core_package(source_root: Path | str, package_root: Path | str) -> None: + source = Path(source_root) + package = Path(package_root) + copies = { + "LICENSE": "LICENSE", + "LICENSES/CC-BY-4.0.txt": "crates/labcolors-core/LICENSES/CC-BY-4.0.txt", + "LICENSES/CC-BY-SA-4.0.txt": "crates/labcolors-core/LICENSES/CC-BY-SA-4.0.txt", + "NOTICE.md": "crates/labcolors-core/NOTICE.md", + "contracts/clean-set-srgb8-v1/point-clean-set-srgb8-column-rle-v1.bin": CODEC_PATH, + "contracts/clean-set-srgb8-v1/receipt-v1.json": RECEIPT_PATH, + "contracts/clean-set-srgb8-v1/receipt-v1.sha256": RECEIPT_PIN_PATH, + } + packaged: dict[str, bytes] = {} + for package_path, source_path in copies.items(): + canonical = _read_regular_file_once(source, source_path, f"canonical {source_path}") + actual = _read_regular_file_once(package, package_path, f"packaged {package_path}") + if actual != canonical: + _fail(f"packaged {package_path} differs from canonical product bytes") + packaged[package_path] = actual + + codec = packaged["contracts/clean-set-srgb8-v1/point-clean-set-srgb8-column-rle-v1.bin"] + if len(codec) != CODEC_BYTES or sha256(codec) != CODEC_SHA256: + _fail("packaged runtime codec identity differs from the admitted codec") + receipt = packaged["contracts/clean-set-srgb8-v1/receipt-v1.json"] + receipt_pin = packaged["contracts/clean-set-srgb8-v1/receipt-v1.sha256"] + if sha256(receipt) != _parse_receipt_pin(receipt_pin, "packaged receipt pin"): + _fail("packaged receipt differs from its external pin") + _validate_notice(packaged["NOTICE.md"], "packaged NOTICE.md") + cargo_toml = _read_regular_file_once(package, "Cargo.toml", "packaged Cargo.toml") + _package_license(cargo_toml) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + + product_parser = subparsers.add_parser( + "product", + help="verify the caller-pinned product cone without claiming research replay", + ) + product_parser.add_argument("--product-root", required=True, type=Path) + + full_parser = subparsers.add_parser( + "full", + help="verify product plus committed research closure", + ) + full_parser.add_argument("--product-root", required=True, type=Path) + full_parser.add_argument("--research-root", required=True, type=Path) + + package_parser = subparsers.add_parser( + "core-package", + help="verify the extracted labcolors-core license and codec closure", + ) + package_parser.add_argument("--source-root", required=True, type=Path) + package_parser.add_argument("--package-root", required=True, type=Path) + + arguments = parser.parse_args(argv) + try: + if arguments.command == "product": + result = verify_product_receipt(arguments.product_root) + print( + "clean-set product receipt: PRODUCT_IDENTITY_VERIFIED; " + "RESEARCH_REPLAY_NOT_EXECUTED; " + f"receipt_sha256={result.receipt_sha256}" + ) + elif arguments.command == "full": + expected_receipt = _receipt_pin(arguments.product_root) + result = verify_receipt( + arguments.product_root, + arguments.research_root, + expected_receipt, + ) + print( + "clean-set product receipt: PRODUCT_AND_RESEARCH_VERIFIED; " + f"receipt_sha256={result.receipt_sha256}" + ) + else: + verify_core_package(arguments.source_root, arguments.package_root) + print("labcolors-core license and codec closure: VERIFIED") + except VerificationError as error: + print(f"clean-set verification: FAIL: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From b9c4ffa2a79f0e90ef98bc9d1d17404ff4649c64 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics <63733699+lemone112@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:37:21 +0300 Subject: [PATCH 2/3] core: close R3a review gates --- .../clean-set-srgb8-v1/receipt-v1.json | 16 +- .../clean-set-srgb8-v1/receipt-v1.sha256 | 2 +- ...rt-reference-surplus-q55-bps-proof-v1.json | 2 +- .../src/generic_boundary_tests.rs | 52 +++++- crates/labcolors-core/src/program_identity.rs | 122 ++++++------ .../src/program_mixed_evaluator_tests.rs | 97 +++++++++- crates/labcolors-core/src/program_session.rs | 93 +++++----- .../colors/test/release-contract.test.mjs | 173 ++++++++++++++---- scripts/test_verify_clean_set_receipt.py | 85 ++++++++- scripts/verify_clean_set_receipt.py | 50 +++-- scripts/verify_point_support_surplus.py | 2 +- 11 files changed, 508 insertions(+), 186 deletions(-) diff --git a/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.json b/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.json index bb93c962..002bc370 100644 --- a/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.json +++ b/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.json @@ -69,18 +69,18 @@ "sha256": "10195d665228073345b3c453c49c4cce99bc168da5dd5e70932f34e0cead1873" }, { - "bytes": 72497, + "bytes": 71522, "license": "MIT", "path": "crates/labcolors-core/src/program_identity.rs", "role": "program_identity_source", - "sha256": "0064b6a897f7c87b581c3f71bf7c469733c2d63311da187a5222c2270f039751" + "sha256": "8f0366079e6fa0006360ab19b0449e622add48fdb08431a02258236e1371d78a" }, { - "bytes": 155942, + "bytes": 154789, "license": "MIT", "path": "crates/labcolors-core/src/program_session.rs", "role": "program_source", - "sha256": "c942b28fc37812054c7c6dce9144734d1dd4eabb863a49a31db1c076d3b61757" + "sha256": "a236381dcf80760e1ea39743b3a20d10de938fe1a9d6c64e9a9075f6265b6eb3" }, { "bytes": 21581, @@ -118,18 +118,18 @@ "sha256": "6c95324eb05476f35f75375a9af0b2b4a41b8b2978c46e67d2ce1aea5adde342" }, { - "bytes": 37589, + "bytes": 39302, "license": "MIT", "path": "scripts/verify_clean_set_receipt.py", "role": "verifier_source", - "sha256": "edbc1b6af3b917db8cfd67eb03808e273aa409c9d420e8e7137696019c13e5fc" + "sha256": "c6d9025235c1c53b54ce4c7b231fb02a412d5505740f82cdbba42f80ea375472" }, { - "bytes": 27965, + "bytes": 31194, "license": "MIT", "path": "scripts/test_verify_clean_set_receipt.py", "role": "verifier_tests", - "sha256": "e401dbe626fe9f2a047ec0d591b461c70278ba7eb2b9981942ed78b254fd8f62" + "sha256": "2831e67431294c28ba60753a892d0ad5e3ff972144d483500eeb10c47bb9d760" } ], "excluded_claims": [ diff --git a/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.sha256 b/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.sha256 index da8b7192..81b5e007 100644 --- a/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.sha256 +++ b/crates/labcolors-core/contracts/clean-set-srgb8-v1/receipt-v1.sha256 @@ -1 +1 @@ -39508e2c251b54b769ecb9147696264b48e239683e38618c98a2c3cf7919ed56 receipt-v1.json +874926f880901e5b9ffeb0fb23a6466f9745a5a120ca29b47309b7c078aa9a40 receipt-v1.json 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 0d4ff734..13bbc792 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":"02a85b609b5f02bf1f1d9a4cfb96f7350dd441b628591d0addf15ea716f9354e","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":"7a42172d1775daa09aaf2f4e8d16ab9f3b123b33a666b143e7aa6ad8f4ad98ee","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":"2302258dec70a76acbb4ac5c3a1a472997716c3e58fc7bc1b9aaeb105526a709"},{"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":"aba84c05a203af12ef2e445334409d9bd385a854c9058f0e30bbf8542addddbc"},{"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":"78d37406e9bdc37f126b72987c9c92b452c13b3233c0aeb0a75ed25dadb83a68"},{"kind":"rust-source","path":"crates/labcolors-core/src/lib.rs","sha256":"53ffeb0d9dc760a9fc7515f6324bec9d7ae085f777b150d0454974bf15a07e4c"},{"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":"fb307c44a991abfd8ee0acf7b481cc844f32e65526d38f5aacbed95b20c7d01f"} +{"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":"c74c614be730d8699be6c3e82643341f78339bd44d9141d38eb704a34f150bd4","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":"cb3a375a5558c263bc611c814aa33cf45a0ad4c5652582275de3f70f8e7d09dc","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":"2302258dec70a76acbb4ac5c3a1a472997716c3e58fc7bc1b9aaeb105526a709"},{"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":"aba84c05a203af12ef2e445334409d9bd385a854c9058f0e30bbf8542addddbc"},{"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":"78d37406e9bdc37f126b72987c9c92b452c13b3233c0aeb0a75ed25dadb83a68"},{"kind":"rust-source","path":"crates/labcolors-core/src/lib.rs","sha256":"b30300edd3910e3d9da1e56896ce14c3db508b1a6fc5608e01032a5ad95777b1"},{"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":"c9926045ef62e604a81b690665416aa2349b070dc897ce18bbfce52685bc7ff7"} diff --git a/crates/labcolors-core/src/generic_boundary_tests.rs b/crates/labcolors-core/src/generic_boundary_tests.rs index aedf28fa..46a8100a 100644 --- a/crates/labcolors-core/src/generic_boundary_tests.rs +++ b/crates/labcolors-core/src/generic_boundary_tests.rs @@ -1,7 +1,15 @@ use std::ffi::OsStr; use std::path::PathBuf; +#[expect( + dead_code, + reason = "the shared scanner also exposes a syntax projection for sibling integration gates" +)] +#[path = "../tests/common/source.rs"] +mod source_scanner; + const APPEARANCE_SOURCE: &str = include_str!("appearance.rs"); +const CLEAN_SET_SOURCE: &str = include_str!("clean_set.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"); @@ -69,6 +77,47 @@ fn contains_rust_identifier(source: &str, identifier: &str) -> bool { }) } +fn normalized_production_code(source: &str) -> String { + source_scanner::production_code_lines(source) + .into_iter() + .map(|(_, line)| line) + .collect::>() + .join("\n") + .to_ascii_lowercase() +} + +#[test] +fn rust_comment_stripping_ignores_prose_without_erasing_live_identifiers() { + let source = concat!( + "//! writer in documentation\n", + "/* quality_auto in a nested /* checkpoint */ comment */\n", + "const URL: &str = \"https://example.test/path\";\n", + "fn checkpoint_writer() {}\n", + ); + let code = normalized_production_code(source); + + assert_eq!(code.matches("writer").count(), 1); + assert_eq!(code.matches("checkpoint").count(), 1); + assert!(!code.contains("quality_auto")); + assert!(code.contains("https://example.test/path")); +} + +#[test] +fn rust_comment_stripping_preserves_live_identifiers_after_literal_comment_tokens() { + for source in [ + r##"fn probe() { let _ = (r#""//"#, |writer: ()| writer); }"##, + r##"fn probe() { let _ = (br#""//"#, |writer: ()| writer); }"##, + r#"fn probe() { let _ = ('"', "//", |writer: ()| writer); }"#, + ] { + let code = normalized_production_code(source); + assert_eq!( + code.matches("writer").count(), + 2, + "literal content must not hide live identifiers: {source}", + ); + } +} + fn assert_only_in_compile_fail(source: &str, needle: &str) { let mut in_compile_fail = false; let mut occurrences = 0; @@ -788,11 +837,12 @@ fn shared_observation_ssot_has_one_backing_without_lifecycle_or_adapter_facades( #[test] fn clean_set_program_path_cannot_smuggle_auto_or_writer_contracts() { for (path, source) in [ + ("clean_set.rs", CLEAN_SET_SOURCE), ("program.rs", PROGRAM_SOURCE), ("program_session.rs", PROGRAM_SESSION_SOURCE), ("program_identity.rs", PROGRAM_IDENTITY_SOURCE), ] { - let source = source.to_ascii_lowercase(); + let source = normalized_production_code(source); for forbidden in [ "pointconvention", "autoqualityrelease", diff --git a/crates/labcolors-core/src/program_identity.rs b/crates/labcolors-core/src/program_identity.rs index f548ec95..2f76ed53 100644 --- a/crates/labcolors-core/src/program_identity.rs +++ b/crates/labcolors-core/src/program_identity.rs @@ -583,6 +583,46 @@ fn presentation_target_vertex( Ok(vertex) } +fn add_constraint_graph_binding( + graph: &mut GraphBuilderV1, + evaluator: &Evaluation, + presentation_targets: &[(PointPresentationTargetV1, usize)], + occurrences: &IdIndexV1, + mode_tag: u8, + body: ProgramConstraintBodyV1>, +) -> Result<(), ProgramCompileError> +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + let (color, target, role) = match body { + ProgramConstraintBodyV1::ModeledOccurrence { + occurrence, + invocation, + } => ( + constraint_color(mode_tag, evaluator.constraint_content(invocation))?, + occurrences.get(occurrence)?, + EdgeRoleV1::ConstraintOccurrence, + ), + ProgramConstraintBodyV1::DeclaredSrgb8CleanSet { target } => ( + declared_srgb8_clean_set_constraint_color(mode_tag)?, + presentation_target_vertex(presentation_targets, target)?, + EdgeRoleV1::ConstraintPresentationTarget, + ), + #[cfg(test)] + ProgramConstraintBodyV1::DeclaredSrgb8CleanSetFinalRecheckMutant { target } => { + let mut release = crate::clean_set::EXACT_NOMINAL_SRGB8_CLEAN_SET_RELEASE_SHA256_V1; + release[0] ^= 1; + ( + declared_srgb8_clean_set_constraint_color_for_release(mode_tag, release)?, + presentation_target_vertex(presentation_targets, target)?, + EdgeRoleV1::ConstraintPresentationTarget, + ) + } + }; + let vertex = graph.add_member(color)?; + graph.add_edge(vertex, target, role) +} + fn build_graph( program: &Program, ) -> Result @@ -772,74 +812,24 @@ where } for constraint in &program.constraints.hard { - let (color, target, role) = match *constraint.body() { - ProgramConstraintBodyV1::ModeledOccurrence { - occurrence, - invocation, - } => ( - constraint_color( - vertex_tag::CONSTRAINT_HARD, - program.evaluator.constraint_content(invocation), - )?, - occurrences.get(occurrence)?, - EdgeRoleV1::ConstraintOccurrence, - ), - ProgramConstraintBodyV1::DeclaredSrgb8CleanSet { target } => ( - declared_srgb8_clean_set_constraint_color(vertex_tag::CONSTRAINT_HARD)?, - presentation_target_vertex(&presentation_targets, target)?, - EdgeRoleV1::ConstraintPresentationTarget, - ), - #[cfg(test)] - ProgramConstraintBodyV1::DeclaredSrgb8CleanSetFinalRecheckMutant { target } => { - let mut release = crate::clean_set::EXACT_NOMINAL_SRGB8_CLEAN_SET_RELEASE_SHA256_V1; - release[0] ^= 1; - ( - declared_srgb8_clean_set_constraint_color_for_release( - vertex_tag::CONSTRAINT_HARD, - release, - )?, - presentation_target_vertex(&presentation_targets, target)?, - EdgeRoleV1::ConstraintPresentationTarget, - ) - } - }; - let vertex = graph.add_member(color)?; - graph.add_edge(vertex, target, role)?; + add_constraint_graph_binding( + &mut graph, + &program.evaluator, + &presentation_targets, + &occurrences, + vertex_tag::CONSTRAINT_HARD, + *constraint.body(), + )?; } for constraint in &program.constraints.report_only { - let (color, target, role) = match *constraint.body() { - ProgramConstraintBodyV1::ModeledOccurrence { - occurrence, - invocation, - } => ( - constraint_color( - vertex_tag::CONSTRAINT_REPORT_ONLY, - program.evaluator.constraint_content(invocation), - )?, - occurrences.get(occurrence)?, - EdgeRoleV1::ConstraintOccurrence, - ), - ProgramConstraintBodyV1::DeclaredSrgb8CleanSet { target } => ( - declared_srgb8_clean_set_constraint_color(vertex_tag::CONSTRAINT_REPORT_ONLY)?, - presentation_target_vertex(&presentation_targets, target)?, - EdgeRoleV1::ConstraintPresentationTarget, - ), - #[cfg(test)] - ProgramConstraintBodyV1::DeclaredSrgb8CleanSetFinalRecheckMutant { target } => { - let mut release = crate::clean_set::EXACT_NOMINAL_SRGB8_CLEAN_SET_RELEASE_SHA256_V1; - release[0] ^= 1; - ( - declared_srgb8_clean_set_constraint_color_for_release( - vertex_tag::CONSTRAINT_REPORT_ONLY, - release, - )?, - presentation_target_vertex(&presentation_targets, target)?, - EdgeRoleV1::ConstraintPresentationTarget, - ) - } - }; - let vertex = graph.add_member(color)?; - graph.add_edge(vertex, target, role)?; + add_constraint_graph_binding( + &mut graph, + &program.evaluator, + &presentation_targets, + &occurrences, + vertex_tag::CONSTRAINT_REPORT_ONLY, + *constraint.body(), + )?; } for output in &program.outputs { let vertex = graph.add_member(VertexColorV1::new(vertex_tag::OUTPUT))?; diff --git a/crates/labcolors-core/src/program_mixed_evaluator_tests.rs b/crates/labcolors-core/src/program_mixed_evaluator_tests.rs index 25a067cb..649d5655 100644 --- a/crates/labcolors-core/src/program_mixed_evaluator_tests.rs +++ b/crates/labcolors-core/src/program_mixed_evaluator_tests.rs @@ -20,17 +20,18 @@ use crate::observation::{ }; use crate::program::{ AccessErrorV1, AssessmentV1, CertificateV1, ConflictCellV1, ConstraintModeV1, - ConstraintSubjectV1, ExactSrgb8EvidenceV1, ObservationHeadV1, ObservationV1, OperationV1, - OutputSlotIdV1, OwnerV1, PhysicalPointV1, ProjectionV1, ScenarioV1, SessionV1, SignalV1, - StateKindV1, SurroundV1, UpdateErrorKindV1, UpdateErrorV1, UpdateV1, VerdictV1, VerifiedCellV1, - Wcag22Srgb8EvidenceV1, + ConstraintSubjectV1, DeclaredSrgb8CleanSetViolationKindV1, ExactSrgb8EvidenceV1, + ObservationHeadV1, ObservationV1, OperationV1, OutputSlotIdV1, OwnerV1, PhysicalPointV1, + ProjectionV1, ScenarioV1, SessionV1, SignalV1, StateKindV1, SurroundV1, UpdateErrorKindV1, + UpdateErrorV1, 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, + ObservationGroup, Occurrence, OpacityInput, OutputBinding, OutputSlotId, Paint, + PointPresentationRootV1, PointPresentationTargetV1, PresentationRootId, Program, ProgramConstraintCellV1, ProgramConstraintPassEvidenceV1, ProgramConstraintResultV1, ProgramConstraintSubjectV1, ProgramConstraintViolationEvidenceV1, Source, SourceId, Surface, Target, TargetCandidateChoiceV1, TargetCandidateId, TargetCandidateV1, TargetId, @@ -50,6 +51,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); +const CLEAN_CONSTRAINT: ConstraintId = ConstraintId::new(20); +const PRESENTATION_ROOT: PresentationRootId = PresentationRootId::new(21); fn signal(bytes: [u8; 3]) -> ColorSignal { ColorSignal::from_srgb8(Srgb8::new(bytes)) @@ -201,6 +204,46 @@ fn fixed_translucent_program() -> CompiledCoreProgramV1 { .unwrap() } +fn fixed_clean_set_program(source: [u8; 3]) -> CompiledCoreProgramV1 { + let target = PointPresentationTargetV1::new(PRESENTATION_ROOT, OCCURRENCE); + Program::new( + vec![Source::new(SOURCE, signal(source))], + 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![], + vec![ConstraintInvocation::declared_srgb8_clean_set_report_only( + CLEAN_CONSTRAINT, + target, + )], + ), + vec![OutputBinding::new(OUTPUT, PAINT)], + CoreProgramEvaluatorsV1, + ) + .with_point_presentations( + vec![PointPresentationRootV1::new(PRESENTATION_ROOT, OCCURRENCE)], + vec![target], + ) + .compile() + .unwrap() +} + fn assert_public_observation_matches_core( public: ObservationV1<'_>, core: &crate::observation::RevisionBoundObservationV1, @@ -604,6 +647,18 @@ fn consume_public_assessment(assessment: AssessmentV1<'_>, probe: &mut Projectio let [r, g, b] = value.bytes(); (u64::from(r) << 16) | (u64::from(g) << 8) | u64::from(b) })); + probe.mix(match evidence.violation() { + None => 0, + Some(DeclaredSrgb8CleanSetViolationKindV1::FinalOwnedDomainAbsent) => 1, + Some(DeclaredSrgb8CleanSetViolationKindV1::Rejected) => 2, + }); + probe.mix( + evidence + .rejected_blue_interval() + .map_or(0, |[lower, upper]| { + (u64::from(lower) << 8) | u64::from(upper) + }), + ); None } }; @@ -629,6 +684,38 @@ fn consume_public_assessment(assessment: AssessmentV1<'_>, probe: &mut Projectio }); } +#[test] +fn clean_set_projection_probe_binds_violation_kind_and_rejected_interval() { + let owner = OwnerV1::from_compiled(fixed_clean_set_program([0, 200, 71])); + let mut session = owner.instantiate(STREAM.value()).unwrap(); + let backdrop = [Srgb8::new([0; 3])]; + let scenarios = [ScenarioV1::new(1, &backdrop)]; + let projection = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + let Some(CertificateV1::Verified(certificate)) = projection.evidence().certificates().next() + else { + panic!("report-only clean-set rejection must retain a verified certificate"); + }; + let assessment = certificate.cells().next().unwrap().assessment(); + + let mut actual = ProjectionProbe::new(); + consume_public_assessment(assessment, &mut actual); + + let mut expected = ProjectionProbe::new(); + expected.mix(2); + expected.mix((u64::from(200_u8) << 8) | u64::from(71_u8)); + expected.mix(2); + expected.mix((u64::from(71_u8) << 8) | u64::from(101_u8)); + assert_eq!(actual.checksum, expected.checksum); +} + fn consume_public_projection(projection: ProjectionV1<'_, '_>) -> ProjectionProbe { let view = projection.evidence(); let mut probe = ProjectionProbe::new(); diff --git a/crates/labcolors-core/src/program_session.rs b/crates/labcolors-core/src/program_session.rs index 58ef7917..aaaeb18c 100644 --- a/crates/labcolors-core/src/program_session.rs +++ b/crates/labcolors-core/src/program_session.rs @@ -3989,6 +3989,35 @@ fn compile_occurrence_contexts( Ok(compiled.into_boxed_slice()) } +fn compile_declared_clean_set_body( + presentations: &CompiledPointPresentationsV1, + constraint: ConstraintId, + target: PointPresentationTargetV1, + convention: DeclaredSrgb8CleanSetV1, +) -> Result, ProgramCompileError> { + let missing = || ProgramCompileError::MissingConstraintPresentationTarget { + constraint, + root: target.root(), + occurrence: target.occurrence(), + }; + let key = (target.root(), target.occurrence()); + let presentation_ordinal = presentations + .entries + .binary_search_by_key(&key, |presentation| { + (presentation.root, presentation.target) + }) + .map_err(|_| missing())?; + let presentation = &presentations.entries[presentation_ordinal]; + if presentation.absence_release != target.absence_release() { + return Err(missing()); + } + Ok(CompiledProgramConstraintBodyV1::PointPresentation { + presentation_ordinal, + terminal: presentation.terminal, + convention, + }) +} + fn compile_constraints( graph: &CompiledAppearanceGraph, occurrence_contexts: &[CompiledOccurrenceContextV1], @@ -4066,61 +4095,21 @@ where } } ProgramConstraintBodyV1::DeclaredSrgb8CleanSet { target } => { - let key = (target.root(), target.occurrence()); - let presentation_ordinal = presentations - .entries - .binary_search_by_key(&key, |presentation| { - (presentation.root, presentation.target) - }) - .map_err( - |_| ProgramCompileError::MissingConstraintPresentationTarget { - constraint: constraint.id, - root: target.root(), - occurrence: target.occurrence(), - }, - )?; - let presentation = &presentations.entries[presentation_ordinal]; - if presentation.absence_release != target.absence_release() { - return Err(ProgramCompileError::MissingConstraintPresentationTarget { - constraint: constraint.id, - root: target.root(), - occurrence: target.occurrence(), - }); - } - CompiledProgramConstraintBodyV1::PointPresentation { - presentation_ordinal, - terminal: presentation.terminal, - convention: DeclaredSrgb8CleanSetV1::package_pinned(), - } + compile_declared_clean_set_body( + presentations, + constraint.id, + target, + DeclaredSrgb8CleanSetV1::package_pinned(), + )? } #[cfg(test)] ProgramConstraintBodyV1::DeclaredSrgb8CleanSetFinalRecheckMutant { target } => { - let key = (target.root(), target.occurrence()); - let presentation_ordinal = presentations - .entries - .binary_search_by_key(&key, |presentation| { - (presentation.root, presentation.target) - }) - .map_err( - |_| ProgramCompileError::MissingConstraintPresentationTarget { - constraint: constraint.id, - root: target.root(), - occurrence: target.occurrence(), - }, - )?; - let presentation = &presentations.entries[presentation_ordinal]; - if presentation.absence_release != target.absence_release() { - return Err(ProgramCompileError::MissingConstraintPresentationTarget { - constraint: constraint.id, - root: target.root(), - occurrence: target.occurrence(), - }); - } - CompiledProgramConstraintBodyV1::PointPresentation { - presentation_ordinal, - terminal: presentation.terminal, - convention: DeclaredSrgb8CleanSetV1::final_recheck_mutant(), - } + compile_declared_clean_set_body( + presentations, + constraint.id, + target, + DeclaredSrgb8CleanSetV1::final_recheck_mutant(), + )? } }; compiled.push(CompiledPointConstraint { diff --git a/packages/colors/test/release-contract.test.mjs b/packages/colors/test/release-contract.test.mjs index b8c22103..dd32feac 100644 --- a/packages/colors/test/release-contract.test.mjs +++ b/packages/colors/test/release-contract.test.mjs @@ -36,35 +36,51 @@ const root = resolve(here, "../../.."); const read = (...parts) => readFileSync(join(root, ...parts), "utf8"); function workflowNodeScript(workflow, stepName) { - const step = workflow.indexOf(stepName); - assert.ok(step >= 0, `workflow step not found: ${stepName}`); + const runScript = workflowRunScript(workflow, stepName); const marker = "node <<'NODE'\n"; - const start = workflow.indexOf(marker, step); + const start = runScript.indexOf(marker); assert.ok(start >= 0, `node heredoc not found after: ${stepName}`); const bodyStart = start + marker.length; - const end = workflow.indexOf("\n NODE", bodyStart); + const end = runScript.indexOf("\nNODE", bodyStart); assert.ok(end >= 0, `node heredoc terminator not found after: ${stepName}`); - return workflow - .slice(bodyStart, end) - .split("\n") - .map((line) => line.startsWith(" ") ? line.slice(10) : line) - .join("\n"); + return runScript.slice(bodyStart, end); +} + +function workflowStepLines(workflow, stepName) { + const lines = workflow.replaceAll("\r\n", "\n").split("\n"); + const starts = lines + .map((line, index) => ({ line, index })) + .filter(({ line }) => line.trim() === `- ${stepName}`); + assert.equal(starts.length, 1, `expected exactly one workflow step: ${stepName}`); + const start = starts[0].index; + const indentation = starts[0].line.length - starts[0].line.trimStart().length; + let end = start + 1; + while (end < lines.length) { + const candidate = lines[end]; + const candidateIndentation = candidate.length - candidate.trimStart().length; + if (candidate.trim().length > 0 && candidateIndentation <= indentation) break; + end += 1; + } + return lines.slice(start, end); } function workflowRunScript(workflow, stepName) { - const step = workflow.indexOf(stepName); - assert.ok(step >= 0, `workflow step not found: ${stepName}`); - const marker = "\n run: |\n"; - const start = workflow.indexOf(marker, step); - assert.ok(start >= 0, `run block not found after: ${stepName}`); - const bodyStart = start + marker.length; - const end = workflow.indexOf("\n - ", bodyStart); - assert.ok(end >= 0, `next workflow step not found after: ${stepName}`); - return workflow - .slice(bodyStart, end) - .split("\n") - .map((line) => line.startsWith(" ") ? line.slice(10) : line) - .join("\n"); + const step = workflowStepLines(workflow, stepName); + const runLines = step + .map((line, index) => ({ line, index })) + .filter(({ line }) => line.trim() === "run: |"); + assert.equal(runLines.length, 1, `expected one run block in workflow step: ${stepName}`); + const run = runLines[0]; + const runIndentation = run.line.length - run.line.trimStart().length; + const body = []; + for (let cursor = run.index + 1; cursor < step.length; cursor += 1) { + const line = step[cursor]; + const indentation = line.length - line.trimStart().length; + if (line.trim().length > 0 && indentation <= runIndentation) break; + body.push(line.length >= runIndentation + 2 ? line.slice(runIndentation + 2) : ""); + } + assert.ok(body.some((line) => line.length > 0), `empty run block: ${stepName}`); + return body.join("\n"); } function assertCheckoutCredentialsAreEphemeral(workflow, name) { @@ -101,6 +117,20 @@ function tomlString(table, key) { return matches[0][1]; } +function packageTable(source) { + const lines = source.split(/\r?\n/u); + const packageHeaders = lines + .map((line, index) => ({ line, index })) + .filter(({ line }) => /^[ \t]*\[package\][ \t]*(?:#.*)?$/u.test(line)); + assert.equal(packageHeaders.length, 1, "expected exactly one [package] table"); + const start = packageHeaders[0].index + 1; + const relativeEnd = lines.slice(start).findIndex((line) => + /^[ \t]*(?:\[[^\[\]\r\n]+\]|\[\[[^\[\]\r\n]+\]\])[ \t]*(?:#.*)?$/u.test(line) + ); + const end = relativeEnd < 0 ? lines.length : start + relativeEnd; + return lines.slice(start, end); +} + function assertWorkspaceReleaseMetadata(source) { const workspacePackage = workspacePackageTable(source); assert.equal(tomlString(workspacePackage, "version"), "0.3.0"); @@ -287,6 +317,24 @@ test("MSRV and packaged Rust crate gates are executable CI contracts", () => { ...publishableCargoRoots, ...wasmPackRoots, ])].sort(); + const coreRoot = resolve(root, "crates", "labcolors-core"); + const coreReceipt = JSON.parse(read( + "crates", + "labcolors-core", + "contracts", + "clean-set-srgb8-v1", + "receipt-v1.json", + )); + const coreSpdx = coreReceipt.license_scope?.core_package_spdx; + assert.equal(typeof coreSpdx, "string", "clean-set receipt must own Core SPDX"); + const workspaceSpdx = tomlString( + workspacePackageTable(read("Cargo.toml")), + "license", + ); + const packageMetadataByRoot = new Map( + cargoMetadata.packages.map((crate) => [dirname(crate.manifest_path), crate]), + ); + assert.ok(distributableRoots.includes(coreRoot), "anti-vacuum: Core is distributable"); assert.deepEqual( distributableRoots .filter((crateRoot) => !existsSync(join(crateRoot, "LICENSE"))) @@ -301,8 +349,19 @@ test("MSRV and packaged Rust crate gates are executable CI contracts", () => { assert.equal(readlinkSync(license), canonicalTarget); assert.equal(readFileSync(license, "utf8"), read("LICENSE")); const manifest = readFileSync(join(crateRoot, "Cargo.toml"), "utf8"); - assert.match(manifest, /^license\.workspace = true$/mu); - assert.doesNotMatch(manifest, /^license-file\s*=/mu); + const licenseDeclarations = packageTable(manifest) + .filter((line) => /^license(?:\.workspace)?\s*=/u.test(line)); + const packageMetadata = packageMetadataByRoot.get(crateRoot); + assert.ok(packageMetadata, `cargo metadata omitted ${crateRoot}`); + assert.equal(packageMetadata.license_file, null, `${crateRoot} must use SPDX only`); + if (crateRoot === coreRoot) { + assert.deepEqual(licenseDeclarations, [`license = "${coreSpdx}"`]); + assert.equal(packageMetadata.license, coreSpdx); + } else { + assert.deepEqual(licenseDeclarations, ["license.workspace = true"]); + assert.equal(packageMetadata.license, workspaceSpdx); + } + assert.doesNotMatch(manifest, /^[ \t]*license-file\s*=/mu); } const coreManifest = read("crates", "labcolors-core", "Cargo.toml"); const coreLib = read("crates", "labcolors-core", "src", "lib.rs"); @@ -339,15 +398,63 @@ test("MSRV and packaged Rust crate gates are executable CI contracts", () => { assert.match(ci, /^\s*msrv:$/m); assert.match(ci, /cargo check --workspace --all-targets --locked/); assert.match(ci, /cargo package -p labcolors-core --locked/); - assert.match(ci, /test -L crates\/labcolors-core\/LICENSE/); - assert.match(ci, /cmp LICENSE crates\/labcolors-core\/LICENSE/); - assert.match(ci, /tar -xzf .*labcolors-core-\$\{crate_version\}\.crate/); - assert.match(ci, /test ! -L "\$crate_dir\/LICENSE"/); - assert.match(ci, /cmp LICENSE "\$crate_dir\/LICENSE"/); - assert.match( - ci, - /cargo test --doc --manifest-path "\$crate_dir\/Cargo\.toml" --locked/, - ); + const corePackageStepName = + "name: package labcolors-core and run extracted package doctests"; + const assertCorePackageGate = (workflow) => { + const step = workflowStepLines(workflow, corePackageStepName); + assert.deepEqual( + step.filter((line) => /^(?:if|continue-on-error):/u.test(line.trim())), + [], + "Core package verification step cannot be disabled or made non-blocking", + ); + const lines = workflowRunScript(workflow, corePackageStepName).split(/\r?\n/u); + assert.equal( + lines[0], + "set -euo pipefail", + "Core package verification must start in fail-closed shell mode", + ); + assert.deepEqual( + lines.filter((line) => /^\s*set(?:\s|$)/u.test(line)), + ["set -euo pipefail"], + "Core package verification cannot disable fail-fast after its prologue", + ); + assert.ok(lines.includes("test -L crates/labcolors-core/LICENSE")); + assert.ok(lines.includes("cmp LICENSE crates/labcolors-core/LICENSE")); + const extract = lines.indexOf( + 'tar -xzf "target/package/labcolors-core-${crate_version}.crate" -C "$package_root"', + ); + const shellContinuation = "\\"; + const verifierCommand = [ + `python3 scripts/verify_clean_set_receipt.py core-package ${shellContinuation}`, + ` --source-root "$GITHUB_WORKSPACE" ${shellContinuation}`, + ' --package-root "$crate_dir"', + ]; + const verify = lines.indexOf(verifierCommand[0]); + assert.deepEqual(lines.slice(verify, verify + 3), verifierCommand); + const doctest = lines.indexOf( + 'cargo test --doc --manifest-path "$crate_dir/Cargo.toml" --locked', + ); + assert.ok( + extract >= 0 && extract < verify && verify < doctest, + "extracted Core package must be verified before its doctests", + ); + }; + assertCorePackageGate(ci); + assertCorePackageGate(ci.replaceAll("\n", "\r\n")); + const stepLine = ` - ${corePackageStepName}`; + for (const bypass of ["if: false", "continue-on-error: true"]) { + const mutated = ci.replace(stepLine, `${stepLine}\n ${bypass}`); + assert.notEqual(mutated, ci, `workflow mutation must insert ${bypass}`); + assert.throws(() => assertCorePackageGate(mutated)); + } + const failOpenShell = ci.replace(" set -euo pipefail", " set +e"); + assert.notEqual(failOpenShell, ci, "workflow mutation must disable shell fail-fast"); + assert.throws(() => assertCorePackageGate(failOpenShell)); + const verifierLine = + " python3 scripts/verify_clean_set_receipt.py core-package \\"; + const commentedVerifier = ci.replace(verifierLine, ` # ${verifierLine.trim()}`); + assert.notEqual(commentedVerifier, ci, "workflow mutation must comment the verifier"); + assert.throws(() => assertCorePackageGate(commentedVerifier)); assert.match(ci, /id: verified-release[\s\S]*npm run release:verify/); assert.match(ci, /actions\/upload-artifact@[0-9a-f]{40}[\s\S]*steps\.verified-release\.outputs\.tarball/); assert.match(ci, /steps\.verified-release\.outputs\.manifest/); diff --git a/scripts/test_verify_clean_set_receipt.py b/scripts/test_verify_clean_set_receipt.py index 4052c226..1bc4c627 100644 --- a/scripts/test_verify_clean_set_receipt.py +++ b/scripts/test_verify_clean_set_receipt.py @@ -12,6 +12,9 @@ import unittest from dataclasses import dataclass from pathlib import Path +from unittest import mock + +import verify_clean_set_receipt as verifier from verify_clean_set_receipt import ( CODEC_PATH, @@ -47,11 +50,21 @@ def _sha256(data: bytes) -> str: def _decode_raw(codec: bytes) -> bytes: + header_bytes = len(b"LPCC\x01\x01\x00\x00") + index_entries = 256 + 1 + index_entry_bytes = 2 + body_offset = header_bytes + index_entries * index_entry_bytes offsets = [ - int.from_bytes(codec[8 + index * 2 : 10 + index * 2], "big") - for index in range(257) + int.from_bytes( + codec[ + header_bytes + index * index_entry_bytes : + header_bytes + (index + 1) * index_entry_bytes + ], + "big", + ) + for index in range(index_entries) ] - body = codec[522:] + body = codec[body_offset:] columns: list[list[tuple[int, int, int]]] = [] for green in range(256): columns.append( @@ -461,17 +474,44 @@ def test_product_only_mode_rejects_deleting_both_receipt_and_pin(self) -> None: with self.assertRaisesRegex(VerificationError, "product receipt pin"): verify_product_receipt(fixture.product, policy=fixture.policy) - def test_product_pin_rejects_noncanonical_name_or_digest(self) -> None: + def test_product_pin_rejects_noncanonical_name(self) -> None: with tempfile.TemporaryDirectory() as temporary: fixture = _fixture(Path(temporary)) - fixture.write_receipt() + digest = fixture.write_receipt() _write( fixture.product / RECEIPT_PIN_PATH, - b"A" * 64 + b" other.json\n", + f"{digest} other.json\n".encode("ascii"), ) with self.assertRaisesRegex(VerificationError, "receipt-v1.json"): verify_product_receipt(fixture.product, policy=fixture.policy) + def test_product_pin_rejects_noncanonical_digest(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = _fixture(Path(temporary)) + fixture.write_receipt() + _write( + fixture.product / RECEIPT_PIN_PATH, + b"A" * 64 + b" receipt-v1.json\n", + ) + with self.assertRaisesRegex(VerificationError, "lower-case SHA-256"): + verify_product_receipt(fixture.product, policy=fixture.policy) + + def test_git_timeout_is_a_verification_error_when_output_is_absent(self) -> None: + timeout = subprocess.TimeoutExpired(["git"], 1, output=None) + with mock.patch.object( + verifier.subprocess, + "check_output", + side_effect=timeout, + ) as check_output: + with self.assertRaisesRegex(VerificationError, "research commit Git lookup failed"): + verifier._git(Path("."), ["cat-file", "-t", "0" * 40], "research commit") + + self.assertEqual( + check_output.call_args.kwargs.get("timeout"), + verifier.GIT_LOOKUP_TIMEOUT_SECONDS, + ) + self.assertGreater(verifier.GIT_LOOKUP_TIMEOUT_SECONDS, 0) + def test_numeric_zero_cannot_impersonate_false(self) -> None: with tempfile.TemporaryDirectory() as temporary: fixture = _fixture(Path(temporary)) @@ -642,6 +682,39 @@ def test_mit_only_package_metadata_is_rejected(self) -> None: with self.assertRaisesRegex(VerificationError, "package license"): verify_core_package(source, package) + def test_array_table_cannot_supply_the_package_license(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + source, package = self._package_fixture(Path(temporary)) + (package / "Cargo.toml").write_text( + ( + '[package]\nname = "labcolors-core"\n\n' + '[[bin]]\nname = "fixture"\npath = "src/main.rs"\n' + f'license = "{CORE_LICENSE_EXPRESSION}"\n' + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(VerificationError, "package license"): + verify_core_package(source, package) + + def test_commented_table_headers_cannot_supply_the_package_license(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + source, package = self._package_fixture(Path(temporary)) + for table_header in ( + "[[bin]] # executable target", + "[dependencies] # package table has ended", + ): + with self.subTest(table_header=table_header): + (package / "Cargo.toml").write_text( + ( + '[package]\nname = "labcolors-core"\n\n' + f"{table_header}\n" + f'license = "{CORE_LICENSE_EXPRESSION}"\n' + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(VerificationError, "package license"): + verify_core_package(source, package) + def test_missing_cc_text_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as temporary: source, package = self._package_fixture(Path(temporary)) diff --git a/scripts/verify_clean_set_receipt.py b/scripts/verify_clean_set_receipt.py index 73dea4eb..c7d377a4 100644 --- a/scripts/verify_clean_set_receipt.py +++ b/scripts/verify_clean_set_receipt.py @@ -41,12 +41,23 @@ CODEC_SHA256 = "aa6aa7c0b630437f1c1ba8c2ceafb0dadf6551c42331559504076a6cd44e6331" RAW_SHA256 = "97bcc9f793adb7f13bd70c89e9788c8ab61baf8c77e9f8cd80335ad767d71ae2" CODEC_HEADER = b"LPCC\x01\x01\x00\x00" +# LPCC v1 связывает 256 green-колонок конечным смещением и трёхбайтовыми +# записями; эти величины меняются только вместе с версией формата в заголовке. +CODEC_INDEX_ENTRIES = 256 + 1 +CODEC_INDEX_ENTRY_BYTES = 2 +CODEC_RECORD_BYTES = 3 +CODEC_BODY_OFFSET = len(CODEC_HEADER) + CODEC_INDEX_ENTRIES * CODEC_INDEX_ENTRY_BYTES CODEC_BYTES = 11_370 CODEC_RECORDS = 3_616 RAW_BYTES = 131_072 DOMAIN_POINTS = 16_777_216 ACCEPTED_POINTS = 8_232_849 +# Здесь Git читает только локальные неизменяемые объекты. 30 секунд — принятый +# операционный предел быстрого отказа, а не замер скорости; менять его следует +# по замеру самого медленного поддерживаемого репозитория и runner с явным запасом. +GIT_LOOKUP_TIMEOUT_SECONDS = 30 + EXCLUDED_CLAIMS = ( "ideal algebraic IEC 61966-2-1 transfer semantics", "chromatic adaptation", @@ -354,22 +365,28 @@ def _verify_excluded_claims(value: Any, label: str) -> None: def _decode_codec(codec: bytes, expected_records: int = CODEC_RECORDS) -> tuple[bytes, int]: - expected_bytes = 8 + 257 * 2 + expected_records * 3 + expected_bytes = CODEC_BODY_OFFSET + expected_records * CODEC_RECORD_BYTES if len(codec) != expected_bytes: _fail(f"runtime codec has {len(codec)} bytes, expected {expected_bytes}") - if codec[:8] != CODEC_HEADER: + if codec[: len(CODEC_HEADER)] != CODEC_HEADER: _fail("runtime codec header differs from LPCC v1") offsets = [ - int.from_bytes(codec[8 + index * 2 : 10 + index * 2], "big") - for index in range(257) + int.from_bytes( + codec[ + len(CODEC_HEADER) + index * CODEC_INDEX_ENTRY_BYTES : + len(CODEC_HEADER) + (index + 1) * CODEC_INDEX_ENTRY_BYTES + ], + "big", + ) + for index in range(CODEC_INDEX_ENTRIES) ] if offsets[0] != 0 or offsets[-1] != expected_records: _fail("runtime codec offsets do not bind the complete record body") if any(left >= right for left, right in zip(offsets, offsets[1:])): _fail("runtime codec must contain one non-empty canonical run list per green column") - body = codec[522:] + body = codec[CODEC_BODY_OFFSET:] columns: list[list[tuple[int, int, int]]] = [] for green in range(256): records = [ @@ -431,9 +448,11 @@ def _git(root: Path, args: list[str], label: str) -> bytes: ["git", "--no-replace-objects", "-C", str(root), *args], stderr=subprocess.STDOUT, env=environment, + timeout=GIT_LOOKUP_TIMEOUT_SECONDS, ) - except (OSError, subprocess.CalledProcessError) as error: - detail = getattr(error, "output", b"").decode("utf-8", errors="replace").strip() + except (OSError, subprocess.SubprocessError) as error: + output: bytes = getattr(error, "output", None) or b"" + detail = output.decode("utf-8", errors="replace").strip() _fail(f"{label} Git lookup failed{': ' + detail if detail else ''}") @@ -612,11 +631,12 @@ def _verify_research( _fail("research proof.counts must be an object") _exact_int(counts.get("cube_points"), DOMAIN_POINTS, "research proof cube points") _exact_int(counts.get("neutral_points"), 256, "research proof neutral points") + accepted_chromatic = counts.get("accepted_chromatic") + neutral_points = counts.get("neutral_points") + if type(accepted_chromatic) is not int or type(neutral_points) is not int: + _fail("research proof accepted points must be integers") _exact_int( - counts.get("accepted_chromatic") + counts.get("neutral_points") - if type(counts.get("accepted_chromatic")) is int - and type(counts.get("neutral_points")) is int - else None, + accepted_chromatic + neutral_points, ACCEPTED_POINTS, "research proof accepted points", ) @@ -837,7 +857,13 @@ def _package_license(cargo_toml: bytes) -> str: current_table = "" licenses: list[str] = [] for line in source.splitlines(): - table = re.fullmatch(r"\s*\[([^][]+)]\s*", line) + # Любой заголовок таблицы завершает область `[package]`: иначе поле из + # массива таблиц вроде `[[bin]]` было бы ошибочно засчитано пакету. + array_table = re.fullmatch(r"\s*\[\[([^][]+)]]\s*(?:#.*)?", line) + if array_table: + current_table = "" + continue + table = re.fullmatch(r"\s*\[([^][]+)]\s*(?:#.*)?", line) if table: current_table = table.group(1).strip() continue diff --git a/scripts/verify_point_support_surplus.py b/scripts/verify_point_support_surplus.py index 6a756236..0c4cc1c4 100755 --- a/scripts/verify_point_support_surplus.py +++ b/scripts/verify_point_support_surplus.py @@ -58,7 +58,7 @@ SOURCE_BINDING_LAW = "point-support-rust-whole-file-semantic-cone-v2" SOURCE_BINDING_DOMAIN = b"labcolors.point-support.rust-whole-file-semantic-cone.v2" EXPECTED_SOURCE_CAPSULE_SHA256 = ( - "7a42172d1775daa09aaf2f4e8d16ab9f3b123b33a666b143e7aa6ad8f4ad98ee" + "cb3a375a5558c263bc611c814aa33cf45a0ad4c5652582275de3f70f8e7d09dc" ) EXPECTED_Q55_PROOF_SHA256 = ( "ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd" From c0c2f0837dd30a38a30034b5afe5da467d70ab6f Mon Sep 17 00:00:00 2001 From: Daniel from Labpics <63733699+lemone112@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:43:01 +0300 Subject: [PATCH 3/3] test: make R3a review guards non-vacuous --- .../src/generic_boundary_tests.rs | 33 +++++++++++++--- .../src/program_mixed_evaluator_tests.rs | 38 +++++++++++++------ 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/crates/labcolors-core/src/generic_boundary_tests.rs b/crates/labcolors-core/src/generic_boundary_tests.rs index 46a8100a..79d48752 100644 --- a/crates/labcolors-core/src/generic_boundary_tests.rs +++ b/crates/labcolors-core/src/generic_boundary_tests.rs @@ -31,6 +31,13 @@ const GENERIC_SOURCES: [(&str, &str); 4] = [ ("program_session.rs", PROGRAM_SESSION_SOURCE), ]; +const CLEAN_SET_PROGRAM_SOURCES: &[(&str, &str)] = &[ + ("clean_set.rs", CLEAN_SET_SOURCE), + ("program.rs", PROGRAM_SOURCE), + ("program_session.rs", PROGRAM_SESSION_SOURCE), + ("program_identity.rs", PROGRAM_IDENTITY_SOURCE), +]; + const CLIENT_OR_LEGACY_VOCABULARY: [&str; 13] = [ "Lab UI", "ThemeConfig", @@ -118,6 +125,25 @@ fn rust_comment_stripping_preserves_live_identifiers_after_literal_comment_token } } +#[test] +fn clean_set_program_guard_covers_the_complete_classifier_and_program_path() { + let mut covered = CLEAN_SET_PROGRAM_SOURCES + .iter() + .map(|(path, _)| *path) + .collect::>(); + covered.sort_unstable(); + + assert_eq!( + covered, + [ + "clean_set.rs", + "program.rs", + "program_identity.rs", + "program_session.rs", + ], + ); +} + fn assert_only_in_compile_fail(source: &str, needle: &str) { let mut in_compile_fail = false; let mut occurrences = 0; @@ -836,12 +862,7 @@ fn shared_observation_ssot_has_one_backing_without_lifecycle_or_adapter_facades( #[test] fn clean_set_program_path_cannot_smuggle_auto_or_writer_contracts() { - for (path, source) in [ - ("clean_set.rs", CLEAN_SET_SOURCE), - ("program.rs", PROGRAM_SOURCE), - ("program_session.rs", PROGRAM_SESSION_SOURCE), - ("program_identity.rs", PROGRAM_IDENTITY_SOURCE), - ] { + for &(path, source) in CLEAN_SET_PROGRAM_SOURCES { let source = normalized_production_code(source); for forbidden in [ "pointconvention", diff --git a/crates/labcolors-core/src/program_mixed_evaluator_tests.rs b/crates/labcolors-core/src/program_mixed_evaluator_tests.rs index 649d5655..1d39e544 100644 --- a/crates/labcolors-core/src/program_mixed_evaluator_tests.rs +++ b/crates/labcolors-core/src/program_mixed_evaluator_tests.rs @@ -684,9 +684,8 @@ fn consume_public_assessment(assessment: AssessmentV1<'_>, probe: &mut Projectio }); } -#[test] -fn clean_set_projection_probe_binds_violation_kind_and_rejected_interval() { - let owner = OwnerV1::from_compiled(fixed_clean_set_program([0, 200, 71])); +fn clean_set_projection_probe(source: [u8; 3]) -> ProjectionProbe { + let owner = OwnerV1::from_compiled(fixed_clean_set_program(source)); let mut session = owner.instantiate(STREAM.value()).unwrap(); let backdrop = [Srgb8::new([0; 3])]; let scenarios = [ScenarioV1::new(1, &backdrop)]; @@ -701,19 +700,34 @@ fn clean_set_projection_probe_binds_violation_kind_and_rejected_interval() { .unwrap(); let Some(CertificateV1::Verified(certificate)) = projection.evidence().certificates().next() else { - panic!("report-only clean-set rejection must retain a verified certificate"); + panic!("report-only clean-set outcome must retain a verified certificate"); }; + assert_eq!(certificate.cells().len(), 1); let assessment = certificate.cells().next().unwrap().assessment(); - let mut actual = ProjectionProbe::new(); - consume_public_assessment(assessment, &mut actual); + let mut probe = ProjectionProbe::new(); + consume_public_assessment(assessment, &mut probe); + probe +} - let mut expected = ProjectionProbe::new(); - expected.mix(2); - expected.mix((u64::from(200_u8) << 8) | u64::from(71_u8)); - expected.mix(2); - expected.mix((u64::from(71_u8) << 8) | u64::from(101_u8)); - assert_eq!(actual.checksum, expected.checksum); +#[test] +fn clean_set_projection_probe_binds_pass_absence_rejection_and_interval() { + for (name, source, components) in [ + ("pass", [255, 0, 0], [1, 0xFF_00_00, 0, 0]), + ("final-owned domain absent", [0, 0, 0], [2, 0, 1, 0]), + ( + "rejected with interval", + [0, 200, 71], + [2, 0x00_C8_47, 2, 0x47_65], + ), + ] { + let actual = clean_set_projection_probe(source); + let mut expected = ProjectionProbe::new(); + for component in components { + expected.mix(component); + } + assert_eq!(actual.checksum, expected.checksum, "{name}"); + } } fn consume_public_projection(projection: ProjectionV1<'_, '_>) -> ProjectionProbe {