diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2c3c34e..ff564f10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -485,7 +485,8 @@ jobs: # runner.temp first: shared self-hosted images can retain stale lists # whose package URLs already return 404. `apt-get download` fetches # .deb and `dpkg --extract` unpacks it without root. - # Chrome/chromedriver need libnspr4, libnss3, libasound2t64, libgbm1 + # Chrome/chromedriver need libnspr4, libnss3, distro-versioned ALSA, + # and libgbm1 # which are absent on the slim WSL runner image. # 3. Diagnose chromedriver session creation (Chrome's stderr) before wasm-pack. run: | @@ -516,10 +517,64 @@ jobs: DEBS_DIR="$DEPS_DIR/debs" APT_LISTS="$DEPS_DIR/apt-lists" APT_CACHE="$DEPS_DIR/apt-cache" - mkdir -p "$DEBS_DIR" "$APT_LISTS/partial" "$APT_CACHE/archives/partial" - # Keep every mutable APT path job-local and disable global locks. The - # system sources/keyrings and dpkg status remain read-only inputs. + readonly APT_SOURCES="$DEPS_DIR/apt-sources" + mkdir -p \ + "$DEBS_DIR" \ + "$APT_LISTS/partial" \ + "$APT_CACHE/archives/partial" \ + "$APT_SOURCES/sources.list.d" + # Общие sources раннера содержат посторонние vendor-репозитории. + # Недоступный vendor не должен отменять browser-gate, которому нужны + # только пакеты дистрибутива, поэтому зависимость объявлена явно. + source /etc/os-release + : "${ID:?missing distro ID}" + : "${VERSION_CODENAME:?missing distro codename}" + # Debian 12 / Ubuntu 22.04 predate the time64 package rename; Debian + # 13 / Ubuntu 24.04 use the t64 ABI name. Unknown images fail closed + # instead of guessing a package from mutable ambient APT metadata. + case "$ID:$VERSION_CODENAME" in + debian:bookworm|ubuntu:jammy) + ALSA_PACKAGE=libasound2 + ;; + debian:trixie|ubuntu:noble) + ALSA_PACKAGE=libasound2t64 + ;; + *) + echo "unsupported Chrome dependency release: $ID:$VERSION_CODENAME" >&2 + exit 1 + ;; + esac + readonly ALSA_PACKAGE + case "$ID" in + debian) + DISTRO_KEYRING=/usr/share/keyrings/debian-archive-keyring.gpg + test -r "$DISTRO_KEYRING" + printf '%s\n' \ + "deb [signed-by=$DISTRO_KEYRING] https://deb.debian.org/debian $VERSION_CODENAME main" \ + "deb [signed-by=$DISTRO_KEYRING] https://deb.debian.org/debian $VERSION_CODENAME-updates main" \ + "deb [signed-by=$DISTRO_KEYRING] https://security.debian.org/debian-security $VERSION_CODENAME-security main" \ + > "$APT_SOURCES/sources.list" + ;; + ubuntu) + DISTRO_KEYRING=/usr/share/keyrings/ubuntu-archive-keyring.gpg + test -r "$DISTRO_KEYRING" + printf '%s\n' \ + "deb [signed-by=$DISTRO_KEYRING] https://archive.ubuntu.com/ubuntu $VERSION_CODENAME main" \ + "deb [signed-by=$DISTRO_KEYRING] https://archive.ubuntu.com/ubuntu $VERSION_CODENAME-updates main" \ + "deb [signed-by=$DISTRO_KEYRING] https://security.ubuntu.com/ubuntu $VERSION_CODENAME-security main" \ + > "$APT_SOURCES/sources.list" + ;; + *) + echo "unsupported Chrome dependency distro: $ID" >&2 + exit 1 + ;; + esac + readonly DISTRO_KEYRING + # Все изменяемые пути APT и полный inventory sources принадлежат job; + # keyring и dpkg status остаются read-only входами. APT_OPTIONS=( + -o "Dir::Etc::sourcelist=$APT_SOURCES/sources.list" + -o "Dir::Etc::sourceparts=$APT_SOURCES/sources.list.d" -o "Dir::State::lists=$APT_LISTS" -o "Dir::State::status=/var/lib/dpkg/status" -o "Dir::Cache=$APT_CACHE" @@ -527,11 +582,12 @@ jobs: -o "Debug::NoLocking=1" -o "Acquire::Retries=3" ) + readonly APT_OPTIONS apt-get "${APT_OPTIONS[@]}" update # libnspr4/libnss3: NSS/NSPR crypto deps for Chrome and chromedriver. - # libasound2t64: ALSA after the current Debian/Ubuntu time64 transition. + # ALSA package: selected above from the explicit supported distro matrix. # libgbm1: GPU buffer manager (needed by Chrome headless for DRM/render node). - (cd "$DEBS_DIR" && apt-get "${APT_OPTIONS[@]}" download libnspr4 libnss3 libasound2t64 libgbm1 2>&1) + (cd "$DEBS_DIR" && apt-get "${APT_OPTIONS[@]}" download libnspr4 libnss3 "$ALSA_PACKAGE" libgbm1 2>&1) for deb in "$DEBS_DIR"/*.deb; do dpkg --extract "$deb" "$DEPS_DIR" done 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 1081aecb..300f49a0 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 @@ -55,11 +55,11 @@ "sha256": "b30300edd3910e3d9da1e56896ce14c3db508b1a6fc5608e01032a5ad95777b1" }, { - "bytes": 33725, + "bytes": 38255, "license": "MIT", "path": "crates/labcolors-core/src/observation.rs", "role": "observation_runtime_source", - "sha256": "90e2ca62acac950fc38e711369a9fd3ef24262f7776f1e8ed2aeac92733a4911" + "sha256": "d31f5d5f95fbb2300c90899a26905c3cd2804be7c0e9290999005fd9e2c8f6d6" }, { "bytes": 7951, @@ -69,32 +69,32 @@ "sha256": "ec6585496208972e183d3a5bcd1f67bae4d89cc8e6a797d9c6443d8e0754502b" }, { - "bytes": 46758, + "bytes": 46204, "license": "MIT", "path": "crates/labcolors-core/src/program/attachment.rs", "role": "point_attachment_source", - "sha256": "721fa5852a400184908a2751f2caab782d6a88cb16bf6e64d5ff0f4d4867fc74" + "sha256": "172fa95ca1a4508c149c1ec0905baa7d1f93195e63a7574d4ed4b133e50099cc" }, { - "bytes": 25241, + "bytes": 35835, "license": "MIT", "path": "crates/labcolors-core/src/program/attachment/support.rs", "role": "point_attachment_test_support", - "sha256": "2ea5dc94f3a101428947ad34dc64cb9fbc7a5a7b666b6795ab837540f98aa7e1" + "sha256": "0c8a7bb59464e1ddcace07b01ba24a64f8c9d3aeff547651dc38c276a0fc3322" }, { - "bytes": 54584, + "bytes": 67914, "license": "MIT", "path": "crates/labcolors-core/src/program/attachment/tests.rs", "role": "point_attachment_tests", - "sha256": "1a45dafe529c0855b7cabc054b94d096c661e81243d2a80d78f740bcb595d105" + "sha256": "a0838361c5b82c17766af1c8175f9ed2fca9d483a50ebe1aab80ee3ef3690e86" }, { - "bytes": 162073, + "bytes": 162822, "license": "MIT", "path": "crates/labcolors-core/src/program.rs", "role": "program_facade_source", - "sha256": "1f8e732bf70876cbc69ddfaf2503a682bdf1e2881f3655961386c2472d875976" + "sha256": "62572c59cdc16e0e809d662b11e9d90ea658ef63c70c34962016ee3f39efc20e" }, { "bytes": 71522, @@ -104,11 +104,11 @@ "sha256": "8f0366079e6fa0006360ab19b0449e622add48fdb08431a02258236e1371d78a" }, { - "bytes": 159952, + "bytes": 168354, "license": "MIT", "path": "crates/labcolors-core/src/program_session.rs", "role": "program_source", - "sha256": "4768f1dbfd564b1f1b21bbb02d20cc0213a2f12ebca9a9939129cf62fdd9fd3b" + "sha256": "6b418c3988626c7037479feed1047134e3209e59c7c5c65b5d3ad9cbdeaa38d9" }, { "bytes": 21546, @@ -125,11 +125,11 @@ "sha256": "aa6aa7c0b630437f1c1ba8c2ceafb0dadf6551c42331559504076a6cd44e6331" }, { - "bytes": 21061, + "bytes": 26928, "license": "MIT", "path": "crates/labcolors-core/src/session.rs", "role": "session_runtime_source", - "sha256": "faf5bdffa7bc82a17d4ead99d34f1a22c24b0b511e253e8280ded86629c24e2e" + "sha256": "c7a1706ad4837adfdfbe58a302d20b613912530ebff260fd877a47210b02ef46" }, { "bytes": 34105, 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 5602f685..62f79f8d 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 @@ -644195947c48b841dd319e7586700986ddf4fd4553d593d093c4e109a3002fe3 receipt-v1.json +e278f39f2659dee0ddfe81f416fabcbe3c25018abfea9e8cbfa65ceec3222c21 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 8d83f5ea..57849677 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":"39ccc41facf3f4218fc245d76167acfde420dcd54c07905387d1f748eea441e2","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":"bc676c172691493be0b2a2a0910505a9b6047ca02e3e49d39fb4ffa8dbd92e4f","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":"019899f04cf91a29e4710609cd8d8c334784833a0972204cfedb179517a1e732"},{"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":"90e2ca62acac950fc38e711369a9fd3ef24262f7776f1e8ed2aeac92733a4911"},{"kind":"rust-source","path":"crates/labcolors-core/src/point_support.rs","sha256":"6f6a376ff036d3d65960c004e6566e1bca580f19f5bd3cd333a80b0da5b5c242"},{"kind":"rust-source","path":"crates/labcolors-core/src/session.rs","sha256":"faf5bdffa7bc82a17d4ead99d34f1a22c24b0b511e253e8280ded86629c24e2e"},{"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":"fbd0929026e90f32da5f8b616a0e83ad902ad629302d3c20b48677286a80336e"} +{"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":"89457a39c51a6d3ff82a205b137902a01e3154328df90110573cee1f4287befb","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":"472d7e40538fc8864b66ddc65ab45728183b3a8cd309efa49011f3ce2b9f6619","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":"019899f04cf91a29e4710609cd8d8c334784833a0972204cfedb179517a1e732"},{"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":"d31f5d5f95fbb2300c90899a26905c3cd2804be7c0e9290999005fd9e2c8f6d6"},{"kind":"rust-source","path":"crates/labcolors-core/src/point_support.rs","sha256":"6f6a376ff036d3d65960c004e6566e1bca580f19f5bd3cd333a80b0da5b5c242"},{"kind":"rust-source","path":"crates/labcolors-core/src/session.rs","sha256":"c7a1706ad4837adfdfbe58a302d20b613912530ebff260fd877a47210b02ef46"},{"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":"7607cc9b4ff7ecc78a3b9ae07d16c952055b5b0e2c2da0c5dbb9d5d180962bc9"} diff --git a/crates/labcolors-core/src/generic_boundary_tests.rs b/crates/labcolors-core/src/generic_boundary_tests.rs index 0c7093a2..39f3c23b 100644 --- a/crates/labcolors-core/src/generic_boundary_tests.rs +++ b/crates/labcolors-core/src/generic_boundary_tests.rs @@ -1,10 +1,6 @@ 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; @@ -96,6 +92,35 @@ fn normalized_production_code(source: &str) -> String { .to_ascii_lowercase() } +pub(crate) fn compact_production_syntax(source: &str) -> String { + let mut compact = String::new(); + for (_, line) in source_scanner::production_syntax_lines(source) { + compact.extend(line.chars().filter(|character| !character.is_whitespace())); + } + compact +} + +#[test] +fn program_arena_return_route_has_one_slot_authority() { + let lease = source_scope( + PROGRAM_SESSION_SOURCE, + "struct ProgramEvaluationArenaLeaseV1", + "/// Единственный return-route появляется при retirement", + ); + assert!( + !lease.contains("slot: ObservationArenaSlotV1"), + "an evaluator lease must not duplicate the observation-owned arena route", + ); + + let report = source_scope( + PROGRAM_SESSION_SOURCE, + "impl ProgramReportV1", + "/// Один encoded Paint из Program", + ); + assert!(report.contains("let slot = observation.arena_slot();")); + assert!(report.contains("ProgramEvaluationArenaReturnV1 {")); +} + #[test] fn rust_comment_stripping_ignores_prose_without_erasing_live_identifiers() { let source = concat!( @@ -494,8 +519,8 @@ fn staged_session_is_evidence_only_and_retired_operation_authority_cannot_return for required in [ "raw_head: &'session mut SessionObservationHeadV1,", "state: &'session mut SessionState,", - "pending: PendingSessionTransition,", - "owner: Plan::OwnerLease,", + "deferred_retirement: &'session mut Option>,", + "guard: PendingSessionTransitionGuard<'session, Plan>,", ] { assert_eq!( prepared_owner.matches(required).count(), @@ -507,15 +532,46 @@ fn staged_session_is_evidence_only_and_retired_operation_authority_cannot_return !prepared_owner.contains("derive(Clone") && !prepared_owner.contains("derive(Copy"), "prepared lifecycle authority must remain linear", ); + + let abort_guard = source_scope( + SESSION_SOURCE, + "struct PendingSessionTransitionGuard<'session, Plan: SessionPlanV1> {", + "impl PendingSessionTransitionGuard<'_, Plan>", + ); + for required in [ + "plan: &'session mut Plan,", + "pending: Option>,", + "owner: Option,", + ] { + assert_eq!( + abort_guard.matches(required).count(), + 1, + "the abort guard must own exactly one `{required}`", + ); + } assert!( - prepared_owner - .find("pending: PendingSessionTransition,") - .expect("prepared transition must own pending evidence") - < prepared_owner - .find("owner: Plan::OwnerLease,") - .expect("prepared transition must retain its exact owner lease"), + abort_guard + .find("pending: Option>,") + .expect("abort guard must own pending evidence") + < abort_guard + .find("owner: Option,") + .expect("abort guard must retain its exact owner lease"), "Rust drops fields in declaration order, so pending evidence must precede its owner lease", ); + let abort_drop = source_scope( + SESSION_SOURCE, + "impl Drop for PendingSessionTransitionGuard<'_, Plan>", + "/// Линейный, полностью вычисленный", + ); + assert!( + abort_drop + .find("retire_pending_transition(self.plan, pending);") + .expect("abort must recycle prospective evidence") + < abort_drop + .find("drop(self.owner.take());") + .expect("abort must release the exact owner"), + "prospective evidence must retire before the exact owner, including unwind cleanup", + ); let core_commit = source_scope( SESSION_SOURCE, @@ -538,8 +594,10 @@ fn staged_session_is_evidence_only_and_retired_operation_authority_cannot_return ); } for required in [ - "let (view, retirement) = self.commit_deferred();", - "drop(retirement);", + "let (pending, owner) = guard.take_parts();", + "let (view, retirement) = publish_session_transition(", + "retirement.retire_into(guard.plan);", + "*deferred_retirement = Some(retirement);", "mem::replace(raw_head,", "mem::replace(state, next_state)", "_owner: owner,", @@ -566,16 +624,38 @@ fn staged_session_is_evidence_only_and_retired_operation_authority_cannot_return "pub(crate) struct DeferredSessionRetirement", "/// Линейный, полностью вычисленный", ); - let retired_evidence = deferred_retirement - .find("_retired_verified: Option,") - .expect("retirement must own displaced verified evidence"); let retired_owner = deferred_retirement .find("_owner: Plan::OwnerLease,") .expect("retirement must retain the exact owner"); - assert!( - retired_evidence < retired_owner, - "retired evidence must drop before its exact owner", + for retired in [ + "retired_raw_head: Option,", + "retired_verified: Option,", + "retired_violation: Option,", + "displaced_placeholder: SessionState,", + ] { + assert!( + deferred_retirement + .find(retired) + .unwrap_or_else(|| panic!("retirement must own `{retired}`")) + < retired_owner, + "`{retired}` must drop before its exact owner, including unwind cleanup", + ); + } + let retirement_impl = source_scope( + SESSION_SOURCE, + "impl DeferredSessionRetirement", + "/// Abort-guard:", ); + for required in [ + "plan.retire_verified(verified);", + "plan.retire_violation(violation);", + "drop(self.retired_raw_head.take());", + ] { + assert!( + retirement_impl.contains(required), + "retirement must recycle arenas while the exact owner is pinned; missing `{required}`", + ); + } let concrete_prepared = source_scope( PROGRAM_SOURCE, @@ -633,34 +713,74 @@ fn staged_session_is_evidence_only_and_retired_operation_authority_cannot_return #[test] fn shared_observation_ssot_has_one_backing_without_lifecycle_or_adapter_facades() { - assert_eq!( - normalized_source_scope( - OBSERVATION_SOURCE, - "struct ObservedScenarioSet {", - "impl ObservedScenarioSet", - ), - concat!( - "struct ObservedScenarioSet { ", - "cases: Box<[PhysicalScenario]>, ", - "values: Box<[ColorSignal]>, ", - "provenance: Box<[ScenarioId]>, ", - "}", - ), - "the canonical scenario set must keep one flat physical backing", + let observation_syntax = compact_production_syntax(OBSERVATION_SOURCE); + let scenario_set = source_scope( + OBSERVATION_SOURCE, + "struct ObservedScenarioSet {", + "impl ObservedScenarioSet", + ); + for reusable_field in [ + "cases: Vec,", + "values: Vec,", + "provenance: Vec,", + ] { + assert_eq!( + scenario_set.matches(reusable_field).count(), + 1, + "the canonical scenario set must own exactly one reusable `{reusable_field}`", + ); + } + for retired_storage in ["Box<[", "Rc<", "RefCell<"] { + assert!( + !scenario_set.contains(retired_storage), + "the canonical scenario arrays must remain direct reusable Vec storage, not `{retired_storage}`", + ); + } + + let observation_backing = source_scope( + OBSERVATION_SOURCE, + "pub(super) struct ObservationBackingV1 {", + "impl ObservationBackingV1", + ); + for backing_field in [ + "arena_slot: ObservationArenaSlotV1,", + "schema: CanonicalObservationSchemaV1,", + "set: ObservedScenarioSet,", + ] { + assert_eq!( + observation_backing.matches(backing_field).count(), + 1, + "one backing must own exactly one `{backing_field}`", + ); + } + let observation_pool = source_scope( + OBSERVATION_SOURCE, + "pub(crate) struct ObservationArenaPoolV1 {", + "use arena::ObservationBackingV1;", ); assert_eq!( - normalized_source_scope( - OBSERVATION_SOURCE, - "struct ObservationBackingV1 {", - "/// Sealed observation admitted", - ), - concat!( - "struct ObservationBackingV1 { ", - "schema: CanonicalObservationSchemaV1, ", - "set: ObservedScenarioSet, ", - "}", - ), - "the shared backing must own only canonical schema and scenario data", + observation_pool + .matches("slots: [Rc; OBSERVATION_ARENA_SLOT_COUNT_V1],",) + .count(), + 1, + "one Session-owned pool must retain every reusable observation backing", + ); + for required in [ + "pub(crate)constOBSERVATION_ARENA_SLOT_COUNT_V1:usize=3;", + "Rc::new(ObservationBackingV1{", + "Rc::get_mut(&mutself.slots[slot_index])", + "Ok(Rc::clone(&self.slots[slot_index]))", + ] { + assert!( + observation_syntax.contains(required), + "the three-slot reuse proof is incomplete; missing `{required}`", + ); + } + assert!( + OBSERVATION_SOURCE.contains("mod arena {") + && OBSERVATION_SOURCE.contains("use arena::ObservationBackingV1;") + && OBSERVATION_SOURCE.contains("pub(crate) use arena::ObservationArenaPoolV1;"), + "compiler privacy must isolate backing construction inside the arena module", ); assert_eq!( normalized_source_scope( @@ -703,8 +823,8 @@ fn shared_observation_ssot_has_one_backing_without_lifecycle_or_adapter_facades( OBSERVATION_SOURCE .matches("schema.share_for_observation()") .count(), - 2, - "only keyed and schema-ordered admission may share a schema handle", + 1, + "only the persistent arena-pool constructor may share a schema handle", ); assert!( !OBSERVATION_SOURCE.contains("schema: schema.clone()"), @@ -754,8 +874,10 @@ fn shared_observation_ssot_has_one_backing_without_lifecycle_or_adapter_facades( "impl Session", ); for required in [ + "observation_arenas: ObservationArenaPoolV1,", "raw_head: SessionObservationHeadV1,", "state: SessionState,", + "deferred_retirement: Option>,", ] { assert_eq!( session_owner.matches(required).count(), @@ -767,6 +889,24 @@ fn shared_observation_ssot_has_one_backing_without_lifecycle_or_adapter_facades( !session_owner.contains("schema: CanonicalObservationSchemaV1,"), "the concrete plan is the sole Session-local owner of its canonical schema", ); + assert!( + !PROGRAM_ATTACHMENT_SOURCE.contains("retired_session"), + "deferred Session retirement belongs to Session, never Attachment", + ); + let session_prepare = source_scope( + SESSION_SOURCE, + "pub(crate) fn prepare_update(", + "/// Stream-affine `Unknown` admission", + ); + assert!( + session_prepare + .find("self.drain_deferred_retirement();") + .expect("prepare must drain internal retirement") + < session_prepare + .find(".try_acquire_owner()") + .expect("prepare must acquire the exact owner"), + "internal retirement must drain before owner acquisition or admission", + ); for forbidden in [ "current_unknown", "observation: RevisionBoundObservationV1", diff --git a/crates/labcolors-core/src/joint_tests.rs b/crates/labcolors-core/src/joint_tests.rs index e58051af..bd6d556d 100644 --- a/crates/labcolors-core/src/joint_tests.rs +++ b/crates/labcolors-core/src/joint_tests.rs @@ -12,10 +12,10 @@ use crate::joint::{ }; use crate::lcs_occurrence::ColorSignal; use crate::observation::{ - ObservationHeadViewV1, ObservationOwnerV1, ObservationPayloadInput, ObservationStreamId, - ObservationUpdateInput, ObservedScenarioSetInput, PreparedObservationUpdateV1, Revision, - RevisionBoundObservationV1, ScenarioId, ScenarioInput, SurfaceInputBinding, - canonicalize_observation_schema, prepare_observation, + ObservationArenaPoolV1, ObservationHeadViewV1, ObservationOwnerV1, ObservationPayloadInput, + ObservationStreamId, ObservationUpdateInput, ObservedScenarioSetInput, + PreparedObservationUpdateV1, Revision, RevisionBoundObservationV1, ScenarioId, ScenarioInput, + SurfaceInputBinding, canonicalize_observation_schema, prepare_observation, }; use crate::session::SessionObservationBindingPermitV1; use crate::wcag22::Wcag22CriterionV1; @@ -84,8 +84,10 @@ fn exact_lower(id: u32, target: [u8; 3]) -> PointwiseJointHardConstraintV1) -> RevisionBoundObservationV1 { let mut owner = EmptyObservationOwner; let schema = canonicalize_observation_schema(vec![ROOT]).unwrap(); + let mut arenas = ObservationArenaPoolV1::new(&schema); let prepared = prepare_observation( &mut owner, + &mut arenas, STREAM, &schema, ObservationUpdateInput { @@ -121,8 +123,10 @@ fn observation_with_unrelated_surface( let unrelated_surface = SurfaceInputPortId::new(ROOT.value() - 1); let mut owner = EmptyObservationOwner; let schema = canonicalize_observation_schema(vec![ROOT, unrelated_surface]).unwrap(); + let mut arenas = ObservationArenaPoolV1::new(&schema); let prepared = prepare_observation( &mut owner, + &mut arenas, STREAM, &schema, ObservationUpdateInput { diff --git a/crates/labcolors-core/src/observation.rs b/crates/labcolors-core/src/observation.rs index a0525004..734c2d8f 100644 --- a/crates/labcolors-core/src/observation.rs +++ b/crates/labcolors-core/src/observation.rs @@ -183,16 +183,26 @@ impl ObservationSchemaMismatchV1 { } } -/// Canonical nonempty correlated set. Values and provenance each use one flat -/// allocation; a physical case owns only ranges into those arrays. +/// Повторно используемое хранилище связанного набора сценариев. Успешно +/// выданный `RevisionBoundObservationV1` всегда непуст; `empty()` создаёт +/// только начальное состояние свободного pool-слота. Значения и provenance +/// лежат в плоских буферах. #[derive(Debug, PartialEq, Eq)] struct ObservedScenarioSet { - cases: Box<[PhysicalScenario]>, - values: Box<[ColorSignal]>, - provenance: Box<[ScenarioId]>, + cases: Vec, + values: Vec, + provenance: Vec, } impl ObservedScenarioSet { + const fn empty() -> Self { + Self { + cases: Vec::new(), + values: Vec::new(), + provenance: Vec::new(), + } + } + fn values(&self, case_index: usize) -> Option<&[ColorSignal]> { let values = &self.cases.get(case_index)?.values; self.values.get(values.start..values.end) @@ -237,12 +247,106 @@ impl CanonicalObservationSchemaV1 { } } -#[derive(Debug, PartialEq, Eq)] -struct ObservationBackingV1 { - schema: CanonicalObservationSchemaV1, - set: ObservedScenarioSet, +/// Автомату Session достаточно ровно трёх observation-lease: Failed удерживает +/// `cause + previous`, пока полностью материализованный prospective update ждёт +/// commit. Четвёртый слот был бы недоказанным запасом, а двух недостаточно для +/// атомарного отказа. +pub(crate) const OBSERVATION_ARENA_SLOT_COUNT_V1: usize = 3; + +/// Закрытая identity общего слота observation backing и evaluator arena. +/// Она идентифицирует только хранилище; Session остаётся lifecycle-authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ObservationArenaSlotV1(u8); + +impl ObservationArenaSlotV1 { + const ALL: [Self; OBSERVATION_ARENA_SLOT_COUNT_V1] = [Self(0), Self(1), Self(2)]; + + pub(crate) const fn index(self) -> usize { + self.0 as usize + } +} + +mod arena { + use super::{ + CanonicalObservationSchemaV1, OBSERVATION_ARENA_SLOT_COUNT_V1, ObservationArenaSlotV1, + ObservationError, ObservedScenarioSet, + }; + use std::rc::Rc; + + /// Приватные поля делают construction backing недоступным даже через alias; + /// родительский admission получает только готовый pool-owned handle. + #[derive(Debug, PartialEq, Eq)] + pub(super) struct ObservationBackingV1 { + arena_slot: ObservationArenaSlotV1, + schema: CanonicalObservationSchemaV1, + set: ObservedScenarioSet, + } + + impl ObservationBackingV1 { + pub(super) const fn arena_slot(&self) -> ObservationArenaSlotV1 { + self.arena_slot + } + + pub(super) const fn schema(&self) -> &CanonicalObservationSchemaV1 { + &self.schema + } + + pub(super) const fn set(&self) -> &ObservedScenarioSet { + &self.set + } + } + + /// Три постоянных backing allocation во владении одной Session. + /// + /// Pool хранит ровно один `Rc` каждого свободного слота. Raw-head и evidence + /// клонируют только этот control block, поэтому уникальность без аллокации + /// доказывает, что освобождённый слот можно перезаписать. + #[derive(Debug)] + pub(crate) struct ObservationArenaPoolV1 { + slots: [Rc; OBSERVATION_ARENA_SLOT_COUNT_V1], + } + + impl ObservationArenaPoolV1 { + pub(crate) fn new(schema: &CanonicalObservationSchemaV1) -> Self { + Self { + slots: ObservationArenaSlotV1::ALL.map(|arena_slot| { + Rc::new(ObservationBackingV1 { + arena_slot, + schema: schema.share_for_observation(), + set: ObservedScenarioSet::empty(), + }) + }), + } + } + + pub(super) fn materialize_into( + &mut self, + materialize: impl FnOnce(&mut ObservedScenarioSet) -> Result<(), ObservationError>, + ) -> Result, ObservationError> { + for slot_index in 0..OBSERVATION_ARENA_SLOT_COUNT_V1 { + let Some(backing) = Rc::get_mut(&mut self.slots[slot_index]) else { + continue; + }; + materialize(&mut backing.set)?; + return Ok(Rc::clone(&self.slots[slot_index])); + } + Err(ObservationError::InternalInvariant) + } + + pub(super) fn shares_schema_backing_with( + &self, + schema: &CanonicalObservationSchemaV1, + ) -> bool { + self.slots + .iter() + .all(|slot| slot.schema.shares_backing_with(schema)) + } + } } +pub(crate) use arena::ObservationArenaPoolV1; +use arena::ObservationBackingV1; + /// Sealed observation admitted against the exact schema owned by its sealed /// Session plan. #[derive(Debug, Clone, PartialEq, Eq)] @@ -261,20 +365,24 @@ impl RevisionBoundObservationV1 { self.revision } + pub(crate) fn arena_slot(&self) -> ObservationArenaSlotV1 { + self.backing.arena_slot() + } + pub(crate) fn schema(&self) -> &[SurfaceInputPortId] { - self.backing.schema.as_slice() + self.backing.schema().as_slice() } pub(crate) fn physical_case_count(&self) -> usize { - self.backing.set.cases.len() + self.backing.set().cases.len() } pub(crate) fn physical_values(&self, case_index: usize) -> Option<&[ColorSignal]> { - self.backing.set.values(case_index) + self.backing.set().values(case_index) } pub(crate) fn provenance(&self, case_index: usize) -> Option<&[ScenarioId]> { - self.backing.set.provenance(case_index) + self.backing.set().provenance(case_index) } pub(crate) fn validate_surface_schema( @@ -302,7 +410,7 @@ impl RevisionBoundObservationV1 { &self, expected: &CanonicalObservationSchemaV1, ) -> bool { - self.backing.schema.shares_backing_with(expected) + self.backing.schema().shares_backing_with(expected) } pub(crate) fn is_same_binding_as(&self, other: &Self) -> bool { @@ -316,7 +424,8 @@ impl RevisionBoundObservationV1 { schema: &CanonicalObservationSchemaV1, scenarios: &[ScenarioInput], ) -> bool { - &self.backing.schema == schema && canonical_input_matches_set(&self.backing.set, scenarios) + self.backing.schema() == schema + && canonical_input_matches_set(self.backing.set(), scenarios) } fn has_schema_ordered_input( @@ -325,9 +434,9 @@ impl RevisionBoundObservationV1 { source: &Source, order: &[usize], ) -> bool { - &self.backing.schema == schema + self.backing.schema() == schema && schema_ordered_input_matches_set( - &self.backing.set, + self.backing.set(), source, order, schema.as_slice().len(), @@ -341,7 +450,7 @@ impl RevisionBoundObservationV1 { #[cfg(test)] pub(crate) fn schema_ptr_for_test(&self) -> *const SurfaceInputPortId { - self.backing.schema.backing_ptr_for_test() + self.backing.schema().backing_ptr_for_test() } } @@ -431,6 +540,7 @@ pub(crate) enum ObservationError { revision: Revision, }, ResourceExhausted, + InternalInvariant, } /// Exact replay performed no state transition. @@ -517,10 +627,14 @@ pub(crate) fn canonicalize_observation_schema( /// path directly. pub(crate) fn prepare_observation<'owner, Owner: ObservationOwnerV1>( owner: &'owner mut Owner, + arenas: &mut ObservationArenaPoolV1, stream: ObservationStreamId, schema: &CanonicalObservationSchemaV1, update: ObservationUpdateInput, ) -> Result, ObservationError> { + if !arenas.shares_schema_backing_with(schema) { + return Err(ObservationError::InternalInvariant); + } if update.stream != stream { return Err(ObservationError::StreamMismatch { expected: stream, @@ -598,16 +712,15 @@ pub(crate) fn prepare_observation<'owner, Owner: ObservationOwnerV1>( }; } - let set = materialize_scenarios(schema.as_slice(), scenarios)?; + let backing = arenas.materialize_into(|set| { + materialize_scenarios_into(set, schema.as_slice(), scenarios) + })?; Ok(PreparedObservationUpdateV1::Observed(PreparedObservedV1 { owner, observation: RevisionBoundObservationV1 { stream, revision: update.revision, - backing: Rc::new(ObservationBackingV1 { - schema: schema.share_for_observation(), - set, - }), + backing, }, })) } @@ -628,12 +741,16 @@ pub(crate) fn prepare_schema_ordered_observation< Source: SchemaOrderedScenarioSourceV1, >( owner: &'owner mut Owner, + arenas: &mut ObservationArenaPoolV1, stream: ObservationStreamId, schema: &CanonicalObservationSchemaV1, revision: Revision, source: &Source, order_scratch: &mut Vec, ) -> Result, ObservationError> { + if !arenas.shares_schema_backing_with(schema) { + return Err(ObservationError::InternalInvariant); + } let scenario_count = source.scenario_count(); if scenario_count == 0 { return Err(ObservationError::EmptyScenarioSet); @@ -699,16 +816,15 @@ pub(crate) fn prepare_schema_ordered_observation< }; } - let set = materialize_schema_ordered_scenarios(schema.as_slice(), source, order_scratch)?; + let backing = arenas.materialize_into(|set| { + materialize_schema_ordered_scenarios_into(set, schema.as_slice(), source, order_scratch) + })?; Ok(PreparedObservationUpdateV1::Observed(PreparedObservedV1 { owner, observation: RevisionBoundObservationV1 { stream, revision, - backing: Rc::new(ObservationBackingV1 { - schema: schema.share_for_observation(), - set, - }), + backing, }, })) } @@ -898,11 +1014,12 @@ fn schema_ordered_scenarios_equal( }) } -fn materialize_schema_ordered_scenarios( +fn materialize_schema_ordered_scenarios_into( + set: &mut ObservedScenarioSet, schema: &[SurfaceInputPortId], source: &Source, order: &[usize], -) -> Result { +) -> Result<(), ObservationError> { debug_assert!(!order.is_empty()); let unique_case_count = 1 + order @@ -913,29 +1030,23 @@ fn materialize_schema_ordered_scenarios( .checked_mul(schema.len()) .ok_or(ObservationError::ResourceExhausted)?; - let mut cases = Vec::new(); - cases - .try_reserve_exact(unique_case_count) - .map_err(|_| ObservationError::ResourceExhausted)?; - let mut values = Vec::new(); - values - .try_reserve_exact(value_count) - .map_err(|_| ObservationError::ResourceExhausted)?; - let mut provenance = Vec::new(); - provenance - .try_reserve_exact(order.len()) - .map_err(|_| ObservationError::ResourceExhausted)?; + try_reserve_total(&mut set.cases, unique_case_count)?; + try_reserve_total(&mut set.values, value_count)?; + try_reserve_total(&mut set.provenance, order.len())?; + set.cases.clear(); + set.values.clear(); + set.provenance.clear(); let mut scenario_ordinal = 0; while scenario_ordinal < order.len() { let first_source_index = order[scenario_ordinal]; - let values_start = values.len(); - values.extend((0..schema.len()).map(|binding_index| { + let values_start = set.values.len(); + set.values.extend((0..schema.len()).map(|binding_index| { ColorSignal::from_srgb8(source.value(first_source_index, binding_index)) })); - let values_end = values.len(); - let provenance_start = provenance.len(); - provenance.push(source.scenario_id(first_source_index)); + let values_end = set.values.len(); + let provenance_start = set.provenance.len(); + set.provenance.push(source.scenario_id(first_source_index)); scenario_ordinal += 1; while scenario_ordinal < order.len() && schema_ordered_scenarios_equal( @@ -945,29 +1056,27 @@ fn materialize_schema_ordered_scenarios( schema.len(), ) { - provenance.push(source.scenario_id(order[scenario_ordinal])); + set.provenance + .push(source.scenario_id(order[scenario_ordinal])); scenario_ordinal += 1; } - let provenance_end = provenance.len(); - cases.push(PhysicalScenario { + let provenance_end = set.provenance.len(); + set.cases.push(PhysicalScenario { values: values_start..values_end, provenance: provenance_start..provenance_end, }); } - debug_assert_eq!(cases.len(), unique_case_count); - debug_assert_eq!(values.len(), value_count); - Ok(ObservedScenarioSet { - cases: cases.into_boxed_slice(), - values: values.into_boxed_slice(), - provenance: provenance.into_boxed_slice(), - }) + debug_assert_eq!(set.cases.len(), unique_case_count); + debug_assert_eq!(set.values.len(), value_count); + Ok(()) } -fn materialize_scenarios( +fn materialize_scenarios_into( + set: &mut ObservedScenarioSet, schema: &[SurfaceInputPortId], scenarios: Vec, -) -> Result { +) -> Result<(), ObservationError> { debug_assert!(!scenarios.is_empty()); let unique_case_count = 1 + scenarios @@ -978,22 +1087,15 @@ fn materialize_scenarios( .checked_mul(schema.len()) .ok_or(ObservationError::ResourceExhausted)?; - // Capacity for every variable-sized Vec used by grouping is fallibly - // reserved before the first push/extend, so grouping cannot grow one of - // those Vecs. The final boxed representation and its later Rc owner still - // follow the global allocator's OOM behavior. - let mut cases = Vec::new(); - cases - .try_reserve_exact(unique_case_count) - .map_err(|_| ObservationError::ResourceExhausted)?; - let mut values = Vec::new(); - values - .try_reserve_exact(value_count) - .map_err(|_| ObservationError::ResourceExhausted)?; - let mut provenance = Vec::new(); - provenance - .try_reserve_exact(scenarios.len()) - .map_err(|_| ObservationError::ResourceExhausted)?; + // Все буферы растут до очистки и записи: ошибка reserve оставляет свободный + // слот пригодным к повтору и не открывает частично записанный набор. + let provenance_count = scenarios.len(); + try_reserve_total(&mut set.cases, unique_case_count)?; + try_reserve_total(&mut set.values, value_count)?; + try_reserve_total(&mut set.provenance, provenance_count)?; + set.cases.clear(); + set.values.clear(); + set.provenance.clear(); let mut scenarios = scenarios.into_iter().peekable(); while let Some(ScenarioInput { @@ -1001,30 +1103,36 @@ fn materialize_scenarios( bindings, }) = scenarios.next() { - let values_start = values.len(); - values.extend(bindings.iter().map(|binding| binding.value)); - let values_end = values.len(); - let provenance_start = provenance.len(); - provenance.push(first_id); - while matches!(scenarios.peek(), Some(candidate) if candidate.bindings.as_slice() == bindings.as_slice()) + let values_start = set.values.len(); + set.values + .extend(bindings.iter().map(|binding| binding.value)); + let values_end = set.values.len(); + let provenance_start = set.provenance.len(); + set.provenance.push(first_id); + while let Some(ScenarioInput { id, .. }) = + scenarios.next_if(|candidate| candidate.bindings.as_slice() == bindings.as_slice()) { - let ScenarioInput { id, .. } = scenarios - .next() - .unwrap_or_else(|| unreachable!("peek observed the next scenario")); - provenance.push(id); + set.provenance.push(id); } - let provenance_end = provenance.len(); - cases.push(PhysicalScenario { + let provenance_end = set.provenance.len(); + set.cases.push(PhysicalScenario { values: values_start..values_end, provenance: provenance_start..provenance_end, }); } - debug_assert_eq!(cases.len(), unique_case_count); - debug_assert_eq!(values.len(), value_count); - Ok(ObservedScenarioSet { - cases: cases.into_boxed_slice(), - values: values.into_boxed_slice(), - provenance: provenance.into_boxed_slice(), - }) + debug_assert_eq!(set.cases.len(), unique_case_count); + debug_assert_eq!(set.values.len(), value_count); + Ok(()) +} + +fn try_reserve_total(storage: &mut Vec, required: usize) -> Result<(), ObservationError> { + if storage.capacity() < required { + // Vec гарантирует `len <= capacity < required`, поэтому дополнительный + // объём вычисляется точно и без отдельной недостижимой ветки ошибки. + storage + .try_reserve_exact(required - storage.len()) + .map_err(|_| ObservationError::ResourceExhausted)?; + } + Ok(()) } diff --git a/crates/labcolors-core/src/observation_tests.rs b/crates/labcolors-core/src/observation_tests.rs index 983da339..d5b30365 100644 --- a/crates/labcolors-core/src/observation_tests.rs +++ b/crates/labcolors-core/src/observation_tests.rs @@ -12,11 +12,12 @@ use crate::appearance::{ }; use crate::lcs_occurrence::ColorSignal; use crate::observation::{ - CanonicalObservationSchemaV1, ObservationError, ObservationHeadViewV1, ObservationOwnerV1, - ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, ObservedScenarioSetInput, - PreparedObservationUpdateV1, Revision, RevisionBoundObservationV1, RevisionBoundUnknownV1, - ScenarioId, ScenarioInput, SchemaOrderedScenarioSourceV1, SurfaceInputBinding, UnknownReasonId, - canonicalize_observation_schema, prepare_observation, prepare_schema_ordered_observation, + CanonicalObservationSchemaV1, ObservationArenaPoolV1, ObservationError, ObservationHeadViewV1, + ObservationOwnerV1, ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, + ObservedScenarioSetInput, PreparedObservationUpdateV1, Revision, RevisionBoundObservationV1, + RevisionBoundUnknownV1, ScenarioId, ScenarioInput, SchemaOrderedScenarioSourceV1, + SurfaceInputBinding, UnknownReasonId, canonicalize_observation_schema, prepare_observation, + prepare_schema_ordered_observation, }; const PORT_A: SurfaceInputPortId = SurfaceInputPortId::new(10); @@ -46,21 +47,44 @@ impl ObservationOwnerV1 for TestOwner { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug)] struct TestState { stream: ObservationStreamId, schema: CanonicalObservationSchemaV1, + arenas: ObservationArenaPoolV1, owner: TestOwner, } +impl Clone for TestState { + fn clone(&self) -> Self { + let schema = self.schema.clone(); + Self { + stream: self.stream, + arenas: ObservationArenaPoolV1::new(&schema), + schema, + owner: self.owner.clone(), + } + } +} + +impl PartialEq for TestState { + fn eq(&self, other: &Self) -> bool { + self.stream == other.stream && self.schema == other.schema && self.owner == other.owner + } +} + +impl Eq for TestState {} + impl TestState { fn new( stream: ObservationStreamId, schema: Vec, ) -> Result { + let schema = canonicalize_observation_schema(schema)?; Ok(Self { stream, - schema: canonicalize_observation_schema(schema)?, + arenas: ObservationArenaPoolV1::new(&schema), + schema, owner: TestOwner::Empty, }) } @@ -69,7 +93,13 @@ impl TestState { &mut self, update: ObservationUpdateInput, ) -> Result, ObservationError> { - prepare_observation(&mut self.owner, self.stream, &self.schema, update) + prepare_observation( + &mut self.owner, + &mut self.arenas, + self.stream, + &self.schema, + update, + ) } fn apply( @@ -265,6 +295,7 @@ fn prepare_ordered<'owner>( ) -> Result, ObservationError> { prepare_schema_ordered_observation( &mut state.owner, + &mut state.arenas, state.stream, &state.schema, Revision::new(revision), @@ -537,10 +568,12 @@ fn keyed_schema_is_intrinsic_to_revision_bound_observation_identity() { ); let alternate_schema = canonicalize_observation_schema(vec![PORT_B]).unwrap(); + let mut alternate_arenas = ObservationArenaPoolV1::new(&alternate_schema); let before = left.clone(); assert!(matches!( prepare_observation( &mut left.owner, + &mut alternate_arenas, STREAM, &alternate_schema, observed_update( @@ -1034,6 +1067,7 @@ fn schema_ordered_scratch_exhaustion_precedes_every_source_access() { assert!(matches!( prepare_schema_ordered_observation( &mut state.owner, + &mut state.arenas, state.stream, &state.schema, Revision::new(1), diff --git a/crates/labcolors-core/src/point_support_tests.rs b/crates/labcolors-core/src/point_support_tests.rs index df8df6d5..000a843b 100644 --- a/crates/labcolors-core/src/point_support_tests.rs +++ b/crates/labcolors-core/src/point_support_tests.rs @@ -5,8 +5,9 @@ use crate::appearance::{ use crate::composition::{AdmittedOpacityV1, CompositionProfileV1}; use crate::lcs_occurrence::ColorSignal; use crate::observation::{ - ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, ObservedScenarioSetInput, - Revision, ScenarioId, ScenarioInput, SurfaceInputBinding, + OBSERVATION_ARENA_SLOT_COUNT_V1, ObservationPayloadInput, ObservationStreamId, + ObservationUpdateInput, ObservedScenarioSetInput, Revision, ScenarioId, ScenarioInput, + SurfaceInputBinding, }; use crate::point_support::{ CompiledPointSupportRecheckV1, PointSupportCompileErrorV1, PointSupportCriterionAggregateV1, @@ -62,7 +63,7 @@ fn compiled( } #[test] -fn point_support_session_owns_exactly_one_canonical_schema_handle() { +fn point_support_observation_arenas_share_the_canonical_schema_for_the_session_lifetime() { let requirements = compiled(vec![occurrence( OCCURRENCE_A, SURFACE_A, @@ -79,19 +80,23 @@ fn point_support_session_owns_exactly_one_canonical_schema_handle() { let schema_ptr = requirements.observation_schema(&()).backing_ptr_for_test(); let mut session = Session::new(STREAM, requirements); + let session_schema_handle_count = 1 + OBSERVATION_ARENA_SLOT_COUNT_V1; assert_eq!( session .plan() .observation_schema(&()) .strong_count_for_test(), - 1, + session_schema_handle_count, ); - let report_schema_ptr = match session + let (report_schema_ptr, report_backing_ptr) = match session .commit(observed_update(1, [(1, vec![(SURFACE_A, [0; 3])])])) .unwrap() { - SessionState::Ready { current } => current.report().observation().schema_ptr_for_test(), + SessionState::Ready { current } => ( + current.report().observation().schema_ptr_for_test(), + current.report().observation().backing_ptr_for_test(), + ), _ => panic!("the exact point-support requirement must verify"), }; assert_eq!(report_schema_ptr, schema_ptr); @@ -100,18 +105,51 @@ fn point_support_session_owns_exactly_one_canonical_schema_handle() { .plan() .observation_schema(&()) .strong_count_for_test(), - 2, + session_schema_handle_count, ); - session + let idempotent_report_backing_ptr = match session .commit(observed_update(1, [(1, vec![(SURFACE_A, [0; 3])])])) - .unwrap(); + .unwrap() + { + SessionState::Ready { current } => current.report().observation().backing_ptr_for_test(), + _ => panic!("an exact replay must retain the verified report"), + }; + assert_eq!(idempotent_report_backing_ptr, report_backing_ptr); assert_eq!( session .plan() .observation_schema(&()) .strong_count_for_test(), - 2, + session_schema_handle_count, + ); + + let observation_clone = match session.state() { + SessionState::Ready { current } => current.report().observation().clone(), + _ => panic!("the verified report must remain current"), + }; + assert_eq!(observation_clone.schema_ptr_for_test(), schema_ptr); + assert_eq!( + session + .plan() + .observation_schema(&()) + .strong_count_for_test(), + session_schema_handle_count, + "cloning an observation shares its backing, not the schema Rc directly", + ); + drop(observation_clone); + + let schema_probe = session.plan().observation_schema(&()).clone(); + assert_eq!( + schema_probe.strong_count_for_test(), + session_schema_handle_count + 1, + ); + drop(session); + assert_eq!(schema_probe.backing_ptr_for_test(), schema_ptr); + assert_eq!( + schema_probe.strong_count_for_test(), + 1, + "dropping the Session must release its plan and all persistent observation arenas", ); } diff --git a/crates/labcolors-core/src/program.rs b/crates/labcolors-core/src/program.rs index b09229a0..1e804586 100644 --- a/crates/labcolors-core/src/program.rs +++ b/crates/labcolors-core/src/program.rs @@ -70,8 +70,7 @@ use crate::program_session::{ TargetCandidateV1 as CoreTargetCandidateV1, TargetId, }; use crate::session::{ - DeferredSessionRetirement, PreparedSessionTransition, Session, SessionState, - SessionUpdateError, SessionView, + PreparedSessionTransition, Session, SessionState, SessionUpdateError, SessionView, }; use crate::wcag22::{ Wcag22ClientDeclaredNotApplicableV1, Wcag22CriterionV1, Wcag22EvaluationErrorV1, @@ -85,7 +84,6 @@ type CoreProgramSessionV1 = Session; type CoreProgramStateV1 = SessionState; type CoreProgramSessionViewV1<'a> = SessionView<'a, CoreProgramPlanV1>; type CorePreparedSessionTransitionV1<'a> = PreparedSessionTransition<'a, CoreProgramPlanV1>; -type CoreDeferredSessionRetirementV1 = DeferredSessionRetirement; type CoreProgramPlanErrorV1 = ProgramSessionEvaluationError; type CoreProgramConstraintCellV1 = ProgramConstraintCellV1; type CoreExactPassEvidenceV1 = ProgramVisiblePointPassEvidence; @@ -2751,6 +2749,8 @@ pub(crate) enum EvaluatorProtocolFailureV1 { pub(crate) enum UpdateInvariantV1 { /// Заимствованный matching Owner не удержал свою эпоху живой. OwnerAuthority, + /// Закрытое хранилище наблюдений нарушило собственный arena-контракт. + ObservationStorage, /// Каноническая observation schema разошлась со скомпилированным binding. ObservationBinding, /// Сохранённое evidence не принадлежит допускаемому observation. @@ -2773,6 +2773,8 @@ pub(crate) enum UpdateInvariantV1 { pub(crate) enum UpdateInvariantFailureV1 { /// Заимствованный matching Owner не удержал свою эпоху живой. OwnerAuthority, + /// Закрытое хранилище наблюдений нарушило собственный arena-контракт. + ObservationStorage, /// Каноническая observation schema разошлась со скомпилированным binding. ObservationBinding { /// Точное расхождение schema или stream. @@ -2824,6 +2826,7 @@ impl UpdateInvariantFailureV1 { pub(crate) const fn contract(&self) -> UpdateInvariantV1 { match self { Self::OwnerAuthority => UpdateInvariantV1::OwnerAuthority, + Self::ObservationStorage => UpdateInvariantV1::ObservationStorage, Self::ObservationBinding { .. } => UpdateInvariantV1::ObservationBinding, Self::EvidenceBinding => UpdateInvariantV1::EvidenceBinding, Self::EvaluatorProtocol { .. } => UpdateInvariantV1::EvaluatorProtocol, @@ -3322,6 +3325,9 @@ fn map_observation_error(error: ObservationError) -> UpdateErrorV1 { ObservationError::ResourceExhausted => UpdateErrorV1::ResourceExhausted { phase: UpdatePhaseV1::ObservationAdmission, }, + ObservationError::InternalInvariant => UpdateErrorV1::InternalInvariant { + source: UpdateInvariantFailureV1::ObservationStorage, + }, } } @@ -3665,6 +3671,9 @@ mod update_error_projection_tests { UpdateInvariantFailureV1::OwnerAuthority => { UpdateInvariantV1::OwnerAuthority } + UpdateInvariantFailureV1::ObservationStorage => { + UpdateInvariantV1::ObservationStorage + } UpdateInvariantFailureV1::ObservationBinding { .. } => { UpdateInvariantV1::ObservationBinding } @@ -3691,6 +3700,10 @@ mod update_error_projection_tests { map_session_update_error(SessionUpdateError::OwnerExpired), UpdateInvariantFailureV1::OwnerAuthority, ); + assert_invariant( + map_observation_error(ObservationError::InternalInvariant), + UpdateInvariantFailureV1::ObservationStorage, + ); assert_invariant( map_session_update_error(SessionUpdateError::EvidenceBindingInvariant), UpdateInvariantFailureV1::EvidenceBinding, diff --git a/crates/labcolors-core/src/program/attachment.rs b/crates/labcolors-core/src/program/attachment.rs index 2a2f38a5..c71ef96b 100644 --- a/crates/labcolors-core/src/program/attachment.rs +++ b/crates/labcolors-core/src/program/attachment.rs @@ -14,9 +14,9 @@ use crate::program_session::{ use crate::session::PreparedSessionDispositionV1; use super::{ - CoreDeferredSessionRetirementV1, CorePreparedSessionTransitionV1, EvidenceViewV1, - InstantiateErrorV1, OccurrenceIdV1, OutputSlotIdV1, OwnerV1, PresentationRootIdV1, - SessionState, SessionV1, UpdateErrorV1, UpdateV1, VerifiedCertificateV1, + CorePreparedSessionTransitionV1, EvidenceViewV1, InstantiateErrorV1, OccurrenceIdV1, + OutputSlotIdV1, OwnerV1, PresentationRootIdV1, SessionState, SessionV1, UpdateErrorV1, + UpdateV1, VerifiedCertificateV1, }; /// Sealing оставляет реализации физического writer внутри пакета. @@ -578,7 +578,7 @@ pub(crate) struct AttachedPublishedStampV1<'a> { sink: &'a PointSinkStampV1, } -impl<'a> AttachedPublishedStampV1<'a> { +impl AttachedPublishedStampV1<'_> { pub(crate) const fn revision(self) -> u64 { self.revision } @@ -726,9 +726,6 @@ where scratch_render_patch: Vec>, expected_sink_stamp: PointSinkStampV1, committed_revision: Option, - // Вытеснённые Session evidence и transaction owner после install только - // переносятся сюда и освобождаются до следующего prepare. - retired_session: Option, _owner_pin: ProgramOwnerLeaseV1, } @@ -793,7 +790,7 @@ where ) -> Self { // POST_ADMISSION_TAIL_START_V1 let (sink, initial_sink_stamp) = admission.into_parts(); - let attachment = Self { + Self { sink, session: prepared.session, emissions: prepared.emissions, @@ -804,11 +801,9 @@ where scratch_render_patch: prepared.scratch_render_patch, expected_sink_stamp: initial_sink_stamp, committed_revision: None, - retired_session: None, _owner_pin: prepared.owner_pin, - }; + } // POST_ADMISSION_TAIL_END_V1 - attachment } } @@ -1035,7 +1030,6 @@ where { /// Готовит, атомарно устанавливает и infallibly публикует целый update. pub(crate) fn update(&mut self, update: UpdateV1<'_>) -> AttachmentUpdateResultV1<'_, L> { - drop(self.retired_session.take()); let transition = self .session .prepare_update(update) @@ -1128,8 +1122,7 @@ where .map_err(AttachmentUpdateErrorV1::SinkInstall)?; // Ниже только moves/swaps/writes в заранее пустые retirement-слоты. - let (installed_sink, retired_session) = prepared.commit_session(); - self.retired_session = Some(retired_session); + let installed_sink = prepared.commit_session(); match &action { PreparedPatchActionV1::SetAll { .. } | PreparedPatchActionV1::RevokeAll { .. } => { mem::swap(&mut self.committed_sink_patch, &mut self.scratch_sink_patch); @@ -1248,10 +1241,10 @@ where self.sink.try_install() } - fn commit_session(self) -> (L::Prepared<'sink>, CoreDeferredSessionRetirementV1) { + fn commit_session(self) -> L::Prepared<'sink> { let Self { transition, sink } = self; - let (_view, retirement) = transition.commit_deferred(); - (sink, retirement) + let _view = transition.commit_deferred(); + sink } } diff --git a/crates/labcolors-core/src/program/attachment/support.rs b/crates/labcolors-core/src/program/attachment/support.rs index 155c0b35..fe5aed17 100644 --- a/crates/labcolors-core/src/program/attachment/support.rs +++ b/crates/labcolors-core/src/program/attachment/support.rs @@ -44,6 +44,9 @@ fn next_test_sink_epoch() -> Option { pub(crate) enum InMemoryPointSinkErrorV1 { Busy, BindingDrift, + /// Test sink observed either a stale stamp or a patch whose shape/output + /// does not match its single admitted scope; production semantics do not + /// require those oracle diagnostics to be distinguished. StampMismatch, RejectedPrepare, RejectedInstall, @@ -376,6 +379,309 @@ pub(crate) fn in_memory_point_sink( ) } +/// Однослотовый sink для whole-update allocator oracle. +/// +/// Его snapshot и staging имеют фиксированный размер, поэтому после cold +/// construction тест приписывает каждый allocator event самому Core path, а +/// не test adapter-у. Полный поведенческий sink выше остаётся независимым +/// oracle транзакционной семантики. +struct AllocatorPointSinkStateV1 { + entry: Cell>, + revision: Cell>, + stamp: Cell>, + busy: Cell, + reject_next_prepare: Cell, + reject_next_install: Cell, + reject_next_install_after_swap: Cell, +} + +pub(crate) struct AllocatorPointSinkLeaseV1 { + owned_scope: [TestSinkOutputIdV1; 1], + shared: Rc, +} + +pub(crate) struct ClosedAllocatorPointSinkLeaseV1 { + owned_scope: [TestSinkOutputIdV1; 1], + binding_epoch: PointSinkBindingEpochV1, + shared: Rc, +} + +#[derive(Clone)] +pub(crate) struct AllocatorPointSinkProbeV1 { + shared: Rc, +} + +impl AllocatorPointSinkProbeV1 { + pub(crate) fn clear_stamp_for_test(&self) { + self.shared.stamp.set(None); + } + + pub(crate) fn reject_next_prepare(&self) { + self.shared.reject_next_prepare.set(true); + } + + pub(crate) fn reject_next_install(&self) { + self.shared.reject_next_install.set(true); + } + + pub(crate) fn reject_next_install_after_swap(&self) { + self.shared.reject_next_install_after_swap.set(true); + } + + pub(crate) fn revision(&self) -> Option { + self.shared.revision.get() + } + + pub(crate) fn entry(&self) -> Option { + self.shared.entry.get() + } + + pub(crate) fn stamp(&self) -> PointSinkStampV1 { + self.shared + .stamp + .get() + .unwrap_or_else(|| unreachable!("allocator sink is admitted before updates")) + } + + pub(crate) fn is_busy(&self) -> bool { + self.shared.busy.get() + } +} + +pub(crate) fn allocator_point_sink( + owned_output: u32, +) -> (AllocatorPointSinkLeaseV1, AllocatorPointSinkProbeV1) { + let shared = Rc::new(AllocatorPointSinkStateV1 { + entry: Cell::new(None), + revision: Cell::new(None), + stamp: Cell::new(None), + busy: Cell::new(false), + reject_next_prepare: Cell::new(false), + reject_next_install: Cell::new(false), + reject_next_install_after_swap: Cell::new(false), + }); + ( + AllocatorPointSinkLeaseV1 { + owned_scope: [TestSinkOutputIdV1::new(owned_output)], + shared: Rc::clone(&shared), + }, + AllocatorPointSinkProbeV1 { shared }, + ) +} + +impl sink_private::Sealed for AllocatorPointSinkLeaseV1 {} +impl sink_private::Sealed for ClosedAllocatorPointSinkLeaseV1 {} + +impl UnboundPointSinkLeaseV1 for AllocatorPointSinkLeaseV1 { + type OutputId = TestSinkOutputIdV1; + type Closed = ClosedAllocatorPointSinkLeaseV1; + type AdmissionError = InMemoryPointSinkAdmissionErrorV1; + + fn owned_output_scope(&self) -> &[Self::OutputId] { + &self.owned_scope + } + + fn try_admit_closed( + self, + scope: BoundPointSinkScopePermitV1<'_, Self::OutputId>, + ) -> Result, PointSinkAdmissionFailureV1> { + let mut actual = scope.output_scope(); + if actual.next() != self.owned_scope.first().copied() || actual.next().is_some() { + return Err(PointSinkAdmissionFailureV1::new( + InMemoryPointSinkAdmissionErrorV1::ScopeChanged, + self, + )); + } + let Some(binding_epoch) = next_test_sink_epoch() else { + return Err(PointSinkAdmissionFailureV1::new( + InMemoryPointSinkAdmissionErrorV1::EpochExhausted, + self, + )); + }; + let shared = Rc::clone(&self.shared); + let admission = ClosedPointSinkAdmissionV1::new(ClosedAllocatorPointSinkLeaseV1 { + owned_scope: self.owned_scope, + binding_epoch, + shared: self.shared, + }); + shared.stamp.set(Some(admission.initial_stamp())); + Ok(admission) + } +} + +#[derive(Clone, Copy)] +enum AllocatorPointSinkStagingV1 { + SetAll { + revision: u64, + entry: TestSnapshotEntryV1, + }, + RevokeAll { + revision: u64, + }, + ConfirmExact { + revision: u64, + }, +} + +pub(crate) struct AllocatorPreparedPointSinkWriteV1<'lease> { + lease: &'lease mut ClosedAllocatorPointSinkLeaseV1, + base_stamp: PointSinkStampV1, + desired_stamp: Option, + staging: AllocatorPointSinkStagingV1, + finished: bool, +} + +impl ClosedPointSinkLeaseV1 for ClosedAllocatorPointSinkLeaseV1 { + type OutputId = TestSinkOutputIdV1; + type Error = InMemoryPointSinkErrorV1; + type Prepared<'lease> = AllocatorPreparedPointSinkWriteV1<'lease>; + + fn binding_epoch(&self) -> PointSinkBindingEpochV1 { + self.binding_epoch + } + + fn prepare<'lease>( + &'lease mut self, + intent: PointSinkIntentV1<'_, Self::OutputId>, + ) -> Result, Self::Error> { + if self.shared.busy.replace(true) { + return Err(InMemoryPointSinkErrorV1::Busy); + } + if self.shared.reject_next_prepare.replace(false) { + self.shared.busy.set(false); + return Err(InMemoryPointSinkErrorV1::RejectedPrepare); + } + + let Some(base_stamp) = self.shared.stamp.get() else { + self.shared.busy.set(false); + return Err(InMemoryPointSinkErrorV1::StampMismatch); + }; + let (staging, desired_stamp) = match intent { + PointSinkIntentV1::SetAll { + revision, + stamp, + patch, + } => { + if stamp.expected() != base_stamp { + self.shared.busy.set(false); + return Err(InMemoryPointSinkErrorV1::StampMismatch); + } + let [patch] = patch else { + self.shared.busy.set(false); + return Err(InMemoryPointSinkErrorV1::StampMismatch); + }; + if patch.sink_output() != self.owned_scope[0] { + self.shared.busy.set(false); + return Err(InMemoryPointSinkErrorV1::StampMismatch); + } + ( + AllocatorPointSinkStagingV1::SetAll { + revision, + entry: TestSnapshotEntryV1 { + output: patch.output(), + sink_output: patch.sink_output(), + paint: patch.paint(), + }, + }, + stamp.desired(), + ) + } + PointSinkIntentV1::RevokeAll { revision, stamp } => { + if stamp.expected() != base_stamp { + self.shared.busy.set(false); + return Err(InMemoryPointSinkErrorV1::StampMismatch); + } + ( + AllocatorPointSinkStagingV1::RevokeAll { revision }, + stamp.desired(), + ) + } + PointSinkIntentV1::ConfirmExact { + revision, + published_stamp, + } => { + if published_stamp != base_stamp || self.shared.revision.get() != Some(revision) { + self.shared.busy.set(false); + return Err(InMemoryPointSinkErrorV1::StampMismatch); + } + ( + AllocatorPointSinkStagingV1::ConfirmExact { revision }, + published_stamp, + ) + } + }; + Ok(AllocatorPreparedPointSinkWriteV1 { + lease: self, + base_stamp, + desired_stamp: Some(desired_stamp), + staging, + finished: false, + }) + } + + fn close_before_release(&mut self) { + self.shared.entry.set(None); + self.shared.revision.set(None); + } +} + +impl PreparedPointSinkWriteV1 for AllocatorPreparedPointSinkWriteV1<'_> { + type Error = InMemoryPointSinkErrorV1; + + fn try_install(&mut self) -> Result<(), Self::Error> { + if self.lease.shared.reject_next_install.replace(false) { + return Err(InMemoryPointSinkErrorV1::RejectedInstall); + } + if self.lease.shared.stamp.get() != Some(self.base_stamp) { + return Err(InMemoryPointSinkErrorV1::StampMismatch); + } + let Some(desired_stamp) = self.desired_stamp.take() else { + return Err(InMemoryPointSinkErrorV1::StampMismatch); + }; + let previous_entry = self.lease.shared.entry.get(); + let previous_revision = self.lease.shared.revision.get(); + let revision = match self.staging { + AllocatorPointSinkStagingV1::SetAll { revision, entry } => { + self.lease.shared.entry.set(Some(entry)); + revision + } + AllocatorPointSinkStagingV1::RevokeAll { revision } => { + self.lease.shared.entry.set(None); + revision + } + AllocatorPointSinkStagingV1::ConfirmExact { revision } => revision, + }; + self.lease.shared.revision.set(Some(revision)); + self.lease.shared.stamp.set(Some(desired_stamp)); + if self + .lease + .shared + .reject_next_install_after_swap + .replace(false) + { + self.lease.shared.entry.set(previous_entry); + self.lease.shared.revision.set(previous_revision); + self.lease.shared.stamp.set(Some(self.base_stamp)); + self.desired_stamp = Some(desired_stamp); + return Err(InMemoryPointSinkErrorV1::RejectedInstallAfterSwap); + } + Ok(()) + } + + fn finish_after_session(mut self) { + self.lease.shared.busy.set(false); + self.finished = true; + } +} + +impl Drop for AllocatorPreparedPointSinkWriteV1<'_> { + fn drop(&mut self) { + if !self.finished { + self.lease.shared.busy.set(false); + } + } +} + pub(crate) const fn authored_emission( output: u32, sink_output: u32, diff --git a/crates/labcolors-core/src/program/attachment/tests.rs b/crates/labcolors-core/src/program/attachment/tests.rs index bddee5e9..8bbdc154 100644 --- a/crates/labcolors-core/src/program/attachment/tests.rs +++ b/crates/labcolors-core/src/program/attachment/tests.rs @@ -1,13 +1,15 @@ use super::support::{ InMemoryPointSinkAdmissionErrorV1, InMemoryPointSinkErrorV1, TestHostBindingAxisV1, - authored_emission, authored_presentation, in_memory_point_sink, + allocator_point_sink, authored_emission, authored_presentation, in_memory_point_sink, }; use super::*; use crate::Srgb8; use crate::program::{ - AppearanceContextV1, ConstraintIdV1, DraftV1, PaintIdV1, ScenarioV1, SourceIdV1, StateKindV1, - SurfaceIdV1, SurfaceInputPortIdV1, SurroundV1, TargetIdV1, + AppearanceContextV1, ConstraintIdV1, DraftV1, JointChoiceV1, JointStateV1, PaintIdV1, + ScenarioV1, SourceIdV1, StateKindV1, SurfaceIdV1, SurfaceInputPortIdV1, SurroundV1, + TargetCandidateIdV1, TargetCandidateV1, TargetIdV1, }; +use crate::wcag22::Wcag22CriterionV1; use proptest::prelude::*; const SOURCE: SourceIdV1 = SourceIdV1::new(1); @@ -725,6 +727,308 @@ fn attachment_terminal_tail_has_no_allocator_events() { assert_eq!(probe.intent_counts().confirm_exact, 1); } +#[test] +fn warmed_attachment_complete_lifecycle_has_no_allocator_events() { + let owner = allocator_owner(); + let (sink, probe) = allocator_point_sink(900); + let emissions = [authored_emission(OUTPUT_A.value(), 900)]; + let presentations = [authored_presentation( + OUTPUT_A.value(), + ROOT.value(), + INNER.value(), + )]; + let mut attachment = owner.attach(1, &emissions, &presentations, sink).unwrap(); + let white = [Srgb8::new([0xFF; 3])]; + let light = [Srgb8::new([0xF0; 3])]; + let black = [Srgb8::new([0; 3])]; + let ready_scenarios = [ScenarioV1::new(41, &white), ScenarioV1::new(42, &light)]; + let conflict_scenarios = [ScenarioV1::new(41, &white), ScenarioV1::new(43, &black)]; + + // Отдельная заведомая аллокация доказывает работоспособность наблюдателя, + // не превращая аллокацию холодного пути Core в вечный контракт. + let (sentinel, observer_control) = + crate::test_support::measured_allocator_events(|| Box::new(0_u8)); + assert_eq!(*sentinel, 0); + assert_ne!( + observer_control, + crate::test_support::AllocatorEvents::default(), + "the allocator observer must see a known sentinel allocation", + ); + drop(sentinel); + + assert_eq!( + attachment + .update(observed(1, &ready_scenarios)) + .unwrap() + .evidence() + .kind(), + StateKindV1::Ready, + ); + + // Три наблюдения равной мощности доказывают high-water автомата: + // Ready A, Failed(cause B + previous A), затем prospective/committed Ready C. + assert_eq!( + attachment + .update(observed(2, &conflict_scenarios)) + .unwrap() + .evidence() + .kind(), + StateKindV1::Failed, + ); + assert_eq!( + attachment + .update(observed(3, &ready_scenarios)) + .unwrap() + .evidence() + .kind(), + StateKindV1::Ready, + ); + + let (failed, events) = crate::test_support::measured_allocator_events(|| { + attachment + .update(observed(4, &conflict_scenarios)) + .map(|committed| committed.evidence().kind()) + }); + assert_eq!(failed.unwrap(), StateKindV1::Failed); + assert_eq!( + events, + crate::test_support::AllocatorEvents::default(), + "warmed Ready -> Failed must reuse observation, report and output arenas", + ); + + let (ready, events) = crate::test_support::measured_allocator_events(|| { + attachment + .update(observed(5, &ready_scenarios)) + .map(|committed| committed.evidence().kind()) + }); + assert_eq!(ready.unwrap(), StateKindV1::Ready); + assert_eq!( + events, + crate::test_support::AllocatorEvents::default(), + "warmed Failed -> Ready must retire two witnesses without allocator traffic", + ); + + let unknown = UpdateV1::Unknown { + revision: 6, + reason_id: 77, + }; + let (stale, events) = crate::test_support::measured_allocator_events(|| { + attachment + .update(unknown) + .map(|committed| committed.evidence().kind()) + }); + assert_eq!(stale.unwrap(), StateKindV1::Stale); + assert_eq!( + events, + crate::test_support::AllocatorEvents::default(), + "Unknown -> Stale must retain the verified arena without allocation or release", + ); + + let (confirmed_unknown, events) = crate::test_support::measured_allocator_events(|| { + attachment + .update(unknown) + .map(|committed| committed.evidence().kind()) + }); + assert_eq!(confirmed_unknown.unwrap(), StateKindV1::Stale); + assert_eq!( + events, + crate::test_support::AllocatorEvents::default(), + "ConfirmExact over a closed Unknown snapshot must not acquire an arena", + ); + + let (ready, events) = crate::test_support::measured_allocator_events(|| { + attachment + .update(observed(7, &ready_scenarios)) + .map(|committed| committed.evidence().kind()) + }); + assert_eq!(ready.unwrap(), StateKindV1::Ready); + assert_eq!(events, crate::test_support::AllocatorEvents::default()); + + let (confirmed_ready, events) = crate::test_support::measured_allocator_events(|| { + attachment + .update(observed(7, &ready_scenarios)) + .map(|committed| committed.evidence().kind()) + }); + assert_eq!(confirmed_ready.unwrap(), StateKindV1::Ready); + assert_eq!( + events, + crate::test_support::AllocatorEvents::default(), + "ConfirmExact over a published snapshot must not evaluate or acquire an arena", + ); + + let committed_entry = probe.entry(); + let committed_stamp = probe.stamp(); + probe.reject_next_prepare(); + let (rejected, events) = crate::test_support::measured_allocator_events(|| { + attachment.update(observed(8, &ready_scenarios)).map(|_| ()) + }); + assert!(matches!( + rejected, + Err(AttachmentUpdateErrorV1::SinkPrepare( + InMemoryPointSinkErrorV1::RejectedPrepare + )) + )); + assert_eq!(events, crate::test_support::AllocatorEvents::default()); + assert_eq!(attachment.committed_revision, Some(7)); + assert_eq!(probe.revision(), Some(7)); + assert_eq!(probe.entry(), committed_entry); + assert_eq!(probe.stamp(), committed_stamp); + assert!(!probe.is_busy()); + + probe.reject_next_install(); + let (rejected, events) = crate::test_support::measured_allocator_events(|| { + attachment.update(observed(8, &ready_scenarios)).map(|_| ()) + }); + assert!(matches!( + rejected, + Err(AttachmentUpdateErrorV1::SinkInstall( + InMemoryPointSinkErrorV1::RejectedInstall + )) + )); + assert_eq!(events, crate::test_support::AllocatorEvents::default()); + assert_eq!(attachment.committed_revision, Some(7)); + assert_eq!(probe.revision(), Some(7)); + assert_eq!(probe.entry(), committed_entry); + assert_eq!(probe.stamp(), committed_stamp); + assert!(!probe.is_busy()); + + probe.reject_next_install_after_swap(); + let (rejected, events) = crate::test_support::measured_allocator_events(|| { + attachment.update(observed(8, &ready_scenarios)).map(|_| ()) + }); + assert!(matches!( + rejected, + Err(AttachmentUpdateErrorV1::SinkInstall( + InMemoryPointSinkErrorV1::RejectedInstallAfterSwap + )) + )); + assert_eq!(events, crate::test_support::AllocatorEvents::default()); + assert_eq!(attachment.committed_revision, Some(7)); + assert_eq!(probe.revision(), Some(7)); + assert_eq!(probe.entry(), committed_entry); + assert_eq!(probe.stamp(), committed_stamp); + assert!(!probe.is_busy()); + + let (retried, events) = crate::test_support::measured_allocator_events(|| { + attachment + .update(observed(8, &ready_scenarios)) + .map(|committed| committed.evidence().kind()) + }); + assert_eq!(retried.unwrap(), StateKindV1::Ready); + assert_eq!( + events, + crate::test_support::AllocatorEvents::default(), + "all rejected prospective leases must return to the same warmed Session", + ); + assert_eq!(attachment.committed_revision, Some(8)); + assert_eq!(probe.revision(), Some(8)); + assert!(!probe.is_busy()); +} + +#[test] +fn allocator_sink_missing_stamp_is_a_typed_prepare_failure() { + let owner = allocator_owner(); + let (sink, probe) = allocator_point_sink(900); + let emissions = [authored_emission(OUTPUT_A.value(), 900)]; + let presentations = [authored_presentation( + OUTPUT_A.value(), + ROOT.value(), + INNER.value(), + )]; + let mut attachment = owner.attach(1, &emissions, &presentations, sink).unwrap(); + + probe.clear_stamp_for_test(); + assert!(matches!( + attachment.update(UpdateV1::Unknown { + revision: 1, + reason_id: 77, + }), + Err(AttachmentUpdateErrorV1::SinkPrepare( + InMemoryPointSinkErrorV1::StampMismatch + )) + )); + assert_eq!(attachment.committed_revision, None); + assert!(matches!( + attachment.session.evidence().observation_head(), + super::super::ObservationHeadV1::Empty + )); + assert_eq!(probe.revision(), None); + assert!(!probe.is_busy()); +} + +#[test] +fn session_hot_path_contains_no_retired_per_update_storage_constructors() { + let compact = crate::generic_boundary_tests::compact_production_syntax; + let observation_source = include_str!("../../observation.rs"); + let observation = compact(observation_source); + for retired in [ + "cases: cases.into_boxed_slice()", + "values: values.into_boxed_slice()", + "provenance: provenance.into_boxed_slice()", + "backing: Rc::new(ObservationBackingV1", + ] { + let retired = compact(retired); + assert!( + !observation.contains(&retired), + "observation hot path still constructs retired per-update storage: {retired}", + ); + } + let evaluation = compact(include_str!("../../program_session.rs")); + assert!( + !evaluation.contains("ProgramReportBuffersV1"), + "the retired selected/exhaustive report-buffer type must not return", + ); + for retired in [ + "let mut selected = ProgramReportBuffersV1::empty()", + "let mut exhaustive_conflict = ProgramReportBuffersV1::empty()", + "let mut outputs = Vec::new()", + "std::mem::replace(&mut self.selected, ProgramReportBuffersV1::empty())", + "std::mem::take(&mut self.outputs)", + ] { + let retired = compact(retired); + assert!( + !evaluation.contains(&retired), + "evaluation hot path still constructs or detaches retired per-update storage: {retired}", + ); + } +} + +fn allocator_owner() -> OwnerV1 { + const BLACK: TargetCandidateIdV1 = TargetCandidateIdV1::new(101); + const GRAY: TargetCandidateIdV1 = TargetCandidateIdV1::new(102); + + let context = AppearanceContextV1::try_new(64.0, 0.2, SurroundV1::Dim).unwrap(); + let mut draft = DraftV1::new(); + draft.push_source(SOURCE, Srgb8::new([0; 3])); + draft.push_finite_target( + TARGET, + SOURCE, + vec![ + TargetCandidateV1::new(BLACK, Srgb8::new([0; 3])), + TargetCandidateV1::new(GRAY, Srgb8::new([0x80; 3])), + ], + ); + draft + .set_joint_selection(vec![ + JointStateV1::new(vec![JointChoiceV1::new(TARGET, BLACK)]), + JointStateV1::new(vec![JointChoiceV1::new(TARGET, GRAY)]), + ]) + .unwrap(); + draft.push_surface_input_port(INPUT); + draft.push_solid_paint(PAINT, TARGET); + draft.push_input_surface(INPUT_SURFACE, INPUT); + draft.push_source_over_occurrence(INNER, PAINT, INPUT_SURFACE, context); + draft.push_point_presentation_root(ROOT, INNER); + draft.push_point_presentation_target(ROOT, INNER); + draft.push_wcag22_hard( + ConstraintIdV1::new(10), + INNER, + Wcag22CriterionV1::Sc143TextDefault, + ); + draft.push_output(OUTPUT_A, PAINT); + draft.compile().unwrap() +} + fn owner( source: Srgb8, expected: Srgb8, @@ -1289,6 +1593,7 @@ fn installed_retirement_waits_for_the_next_preinstall_drain_and_retry_is_clean() #[test] fn source_guards_keep_the_post_install_tail_destructor_free() { let attachment_source = include_str!("../attachment.rs"); + let session_source = include_str!("../../session.rs"); let support_source = include_str!("support.rs"); let cold_prepare = attachment_source @@ -1341,25 +1646,63 @@ fn source_guards_keep_the_post_install_tail_destructor_free() { !attachment_source.contains("let _ = transition.commit();"), "eager Session commit must not return to the post-install tail", ); - - let session_drain = attachment_source - .find("drop(self.retired_session.take())") - .expect("Attachment must drain deferred Session retirement"); - let prepare = attachment_source - .find(".prepare_update(update)") - .expect("Attachment must prepare one Session transition"); - let install = attachment_source - .find(".try_install()") - .expect("Attachment must install the prepared sink transaction"); assert!( - session_drain < prepare && prepare < install, - "Session retirement must drain before prepare and install", + !attachment_source.contains("retired_session"), + "Attachment must not duplicate Session-owned deferred retirement", + ); + let session_owner = session_source + .split("pub(crate) struct Session {") + .nth(1) + .expect("generic Session owner must exist") + .split("impl Session") + .next() + .expect("Session owner fields must precede its implementation"); + assert_eq!( + session_owner + .matches("deferred_retirement: Option>,") + .count(), + 1, + "Session must own exactly one deferred-retirement slot", + ); + let session_prepare = session_source + .split("pub(crate) fn prepare_update(") + .nth(1) + .expect("Session must prepare updates") + .split("/// Stream-affine `Unknown` admission") + .next() + .expect("prepare_update body must be bounded"); + assert!( + session_prepare + .find("self.drain_deferred_retirement();") + .expect("Session prepare must drain deferred retirement") + < session_prepare + .find(".try_acquire_owner()") + .expect("Session prepare must acquire the exact owner"), + "deferred retirement must drain before owner acquisition and admission", + ); + let deferred_commit = session_source + .split("pub(crate) fn commit_deferred(self)") + .nth(1) + .expect("Session must expose its internal deferred-commit seam") + .split("fn publish_session_transition") + .next() + .expect("commit_deferred body must precede publication helper"); + assert!( + deferred_commit.contains("*deferred_retirement = Some(retirement);"), + "deferred commit must park retirement inside Session", ); - let sink_prepare = support_source + let in_memory_sink = support_source + .split("impl ClosedPointSinkLeaseV1 for ClosedInMemoryPointSinkLeaseV1 {") + .nth(1) + .expect("in-memory test sink must implement the closed lease") + .split("impl PreparedPointSinkWriteV1 for InMemoryPreparedPointSinkWriteV1") + .next() + .expect("closed-lease implementation must precede prepared-write implementation"); + let sink_prepare = in_memory_sink .split("fn prepare<'lease>(") .nth(1) - .expect("test sink must implement prepare") + .expect("in-memory test sink must implement prepare") .split("fn close_before_release") .next() .expect("prepare body must precede close implementation"); @@ -1373,13 +1716,17 @@ fn source_guards_keep_the_post_install_tail_destructor_free() { "retired sink state must drain before Busy is acquired", ); - let finish = support_source - .split("fn finish_after_session(mut self)") + let in_memory_prepared = support_source + .split("impl PreparedPointSinkWriteV1 for InMemoryPreparedPointSinkWriteV1") .nth(1) - .expect("prepared sink must implement finish_after_session") + .expect("in-memory prepared sink must implement the prepared write") .split("impl Drop for InMemoryPreparedPointSinkWriteV1") .next() - .expect("finish body must precede prepared-sink Drop"); + .expect("prepared-write implementation must precede prepared-sink Drop"); + let finish = in_memory_prepared + .split("fn finish_after_session(mut self)") + .nth(1) + .expect("prepared sink must implement finish_after_session"); assert!( !finish.contains("drop("), "post-install finish must not run a destructor", diff --git a/crates/labcolors-core/src/program_joint_integration_tests.rs b/crates/labcolors-core/src/program_joint_integration_tests.rs index e325c3eb..37f91c02 100644 --- a/crates/labcolors-core/src/program_joint_integration_tests.rs +++ b/crates/labcolors-core/src/program_joint_integration_tests.rs @@ -24,7 +24,7 @@ use crate::program_session::{ 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, + fail_program_preflight_reservation_for_test, program_preflight_failure_remaining_for_test, }; use crate::session::{SessionState, SessionUpdateError}; use crate::session_tests::CommitSessionUpdateForTest as _; @@ -292,6 +292,68 @@ impl ProgramConstraintEvaluatorSetV1 for FinalViolationDiagnosticErrorEvaluatorS } } +#[derive(Debug)] +struct PanicOnceEvaluatorControlV1 { + armed: std::cell::Cell, + calls: std::cell::Cell, +} + +/// Паникует только на первой реальной оценке: тест отличает unwind после +/// получения evaluation-arena lease от более ранней паники. +#[derive(Debug, Clone)] +struct PanicOnceEvaluatorSetV1 { + control: std::rc::Rc, +} + +impl PanicOnceEvaluatorSetV1 { + fn new() -> Self { + Self { + control: std::rc::Rc::new(PanicOnceEvaluatorControlV1 { + armed: std::cell::Cell::new(true), + calls: std::cell::Cell::new(0), + }), + } + } + + fn calls(&self) -> usize { + self.control.calls.get() + } +} + +impl ProgramConstraintEvaluatorSetV1 for PanicOnceEvaluatorSetV1 { + type Invocation = Wcag22CriterionV1; + type PassEvidence = ProgramVisiblePointPassEvidence; + type ViolationEvidence = ProgramVisiblePointViolationEvidence; + type Error = ApplicableWcag22EvaluationErrorV1; + + fn assess( + &self, + point: ProgramPointOccurrenceV1, + invocation: Self::Invocation, + ) -> Result< + HardDecision, + ProgramPointAssessmentErrorV1, + > { + self.control.calls.set(self.control.calls.get() + 1); + if self.control.armed.replace(false) { + panic!("hostile evaluator panic after the arena lease was acquired"); + } + assess_program_point_hard(point, &Wcag22Srgb8V1, invocation) + } + + fn pass_binding(evidence: &Self::PassEvidence) -> ProgramVisiblePointBindingV1 { + *evidence.binding() + } + + fn violation_binding(evidence: &Self::ViolationEvidence) -> ProgramVisiblePointBindingV1 { + *evidence.binding() + } + + fn constraint_content(&self, invocation: Self::Invocation) -> ProgramConstraintContentV1 { + Wcag22Srgb8V1.program_constraint_content_v1(invocation) + } +} + fn appearance_context() -> AppearanceContextId { AppearanceContextId::from_inputs( AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, @@ -643,6 +705,82 @@ fn authored_finite_target_values_keep_only_opaque_identity_and_explicit_policy() assert_eq!(order.states(), &[state]); } +#[test] +fn evaluator_unwind_does_not_consume_the_reusable_evaluation_arena() { + let evaluator = PanicOnceEvaluatorSetV1::new(); + let probe = evaluator.clone(); + let compiled = point_program( + signal(0), + Target::fixed(TARGET, SOURCE), + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextDefault, + )], + vec![], + evaluator, + ) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let first = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = session.commit(update(1, 0xFF)); + })); + assert!(first.is_err(), "the hostile evaluator must exercise unwind"); + assert_eq!( + probe.calls(), + 1, + "the panic must originate inside the evaluator, after arena acquisition", + ); + + let SessionState::Ready { current } = session + .commit(update(1, 0xFF)) + .expect("unwind must return the arena slot for the lawful retry") + else { + panic!("opaque black on white must remain a normal verified outcome"); + }; + assert_eq!(current.outputs()[0].source_signal(), signal(0)); + assert!( + probe.calls() > 1, + "the retry must reach the evaluator again" + ); +} + +#[test] +fn prepared_transition_unwind_returns_the_reusable_evaluation_arena() { + let compiled = point_program( + signal(0), + Target::fixed(TARGET, SOURCE), + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextDefault, + )], + vec![], + Wcag22Srgb8V1, + ) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _prepared = session + .prepare_update(update(1, 0xFF)) + .expect("the transition must own one reusable arena before unwind"); + panic!("host unwound after prepare and before commit"); + })); + assert!(unwind.is_err(), "the hostile host unwind must be observed"); + + let SessionState::Ready { current } = session + .commit(update(1, 0xFF)) + .expect("unwind retirement must return the exact arena for retry") + else { + panic!("opaque black on white must remain a normal verified outcome"); + }; + assert_eq!(current.outputs()[0].source_signal(), signal(0)); +} + #[test] fn terminal_safety_rejects_an_output_outside_every_assessment_cone() { let error = match Program::new( @@ -1484,8 +1622,10 @@ fn evaluation_cell_cardinality_checks_both_products_without_a_numeric_cap() { } #[test] -fn every_fallible_joint_preflight_reservation_precedes_evaluator_work() { - const FIRST_UNUSED_RESERVATION_INDEX: usize = 3; +fn every_required_joint_arena_reservation_is_fail_before_work_and_retryable() { + // Joint-оценка без point-causal evidence имеет две непустые координаты + // arena: constraint cells и committed outputs. + const FIRST_UNUSED_RESERVATION_INDEX: usize = 2; for reservation_index in 0..=FIRST_UNUSED_RESERVATION_INDEX { let evaluator = CountingProgramWcag22Srgb8V1::default(); @@ -1554,6 +1694,16 @@ fn every_fallible_joint_preflight_reservation_precedes_evaluator_work() { ); assert!(calls.calls().is_empty()); assert!(matches!(session.state(), SessionState::Waiting)); + assert_eq!(session.raw_head(), ObservationHeadViewV1::Empty); + + let SessionState::Ready { current } = session + .commit(update(1, 0x00)) + .expect("a failed preflight must return the arena for an exact retry") + else { + panic!("the retry must select the first certifying joint state"); + }; + assert_eq!(current.selected_state_index(), Some(1)); + assert!(!calls.calls().is_empty()); } } @@ -1624,6 +1774,33 @@ fn every_fallible_fixed_preflight_reservation_precedes_evaluator_work() { } } +#[test] +fn warmed_program_preflight_still_visits_every_nonempty_coordinate() { + const NONEMPTY_COORDINATE_COUNT: usize = 2; + + let evaluator = CountingProgramWcag22Srgb8V1::default(); + let calls = evaluator.clone(); + let compiled = counting_fixed_program(evaluator); + let mut session = compiled.instantiate(STREAM).unwrap(); + assert!(matches!( + session.commit(update(1, 0x00)).unwrap(), + SessionState::Ready { .. } + )); + assert!(matches!( + session.commit(update(2, 0x00)).unwrap(), + SessionState::Ready { .. } + )); + let calls_before = calls.calls().len(); + + let _failure = fail_program_preflight_reservation_for_test(NONEMPTY_COORDINATE_COUNT); + assert!(matches!( + session.commit(update(3, 0x00)).unwrap(), + SessionState::Ready { .. } + )); + assert_eq!(program_preflight_failure_remaining_for_test(), Some(0)); + assert!(calls.calls().len() > calls_before); +} + fn counting_fixed_program( evaluator: CountingProgramWcag22Srgb8V1, ) -> crate::program_session::CompiledProgram { diff --git a/crates/labcolors-core/src/program_mixed_evaluator_tests.rs b/crates/labcolors-core/src/program_mixed_evaluator_tests.rs index 4a83419b..642a944c 100644 --- a/crates/labcolors-core/src/program_mixed_evaluator_tests.rs +++ b/crates/labcolors-core/src/program_mixed_evaluator_tests.rs @@ -15,8 +15,9 @@ use crate::lcs_occurrence::{ MODELED_TRISTIMULUS_DERIVATION_CALLS, SurroundProfileId, }; use crate::observation::{ - ObservationGroupId, ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, - ObservedScenarioSetInput, Revision, ScenarioId, ScenarioInput, SurfaceInputBinding, + OBSERVATION_ARENA_SLOT_COUNT_V1, ObservationGroupId, ObservationPayloadInput, + ObservationStreamId, ObservationUpdateInput, ObservedScenarioSetInput, Revision, ScenarioId, + ScenarioInput, SurfaceInputBinding, }; use crate::program::{ AssessmentV1, CertificateV1, ConflictCellV1, ConstraintModeV1, ConstraintSubjectV1, @@ -37,7 +38,7 @@ use crate::program_session::{ ProgramConstraintSubjectV1, ProgramConstraintViolationEvidenceV1, Source, SourceId, Surface, Target, TargetCandidateChoiceV1, TargetCandidateId, TargetCandidateV1, TargetId, }; -use crate::session::SessionState; +use crate::session::{PreparedSessionDispositionV1, SessionState}; use crate::session_tests::CommitSessionUpdateForTest as _; use crate::wcag22::{Wcag22CriterionV1, wcag22_profile_v1}; @@ -2422,6 +2423,139 @@ fn historical_evidence_identity_probe_rejects_recreated_equal_certificates() { ); } +#[test] +fn failed_state_and_prospective_update_require_three_distinct_storage_leases() { + assert_eq!( + OBSERVATION_ARENA_SLOT_COUNT_V1, 3, + "Ready A + Failed B(cause, previous A) + prospective C is the exact storage high-water", + ); + let compiled = finite_program([[0; 3], [0x80; 3]]); + let mut session = compiled.instantiate(STREAM).unwrap(); + let update = |revision: u64, backdrops: &[[u8; 3]]| ObservationUpdateInput { + stream: STREAM, + revision: Revision::new(revision), + payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { + scenarios: backdrops + .iter() + .enumerate() + .map(|(index, backdrop)| ScenarioInput { + id: ScenarioId::new(index as u32 + 1), + bindings: vec![SurfaceInputBinding::new(SURFACE_PORT, signal(*backdrop))], + }) + .collect(), + }), + }; + + let SessionState::Ready { .. } = session.commit(update(1, &[[0xFF; 3]])).unwrap() else { + panic!("black must verify on white"); + }; + let SessionState::Failed { + cause, + previous: Some(previous), + } = session.commit(update(2, &[[0xFF; 3], [0; 3]])).unwrap() + else { + panic!("opposing backdrops must retain cause B and previous A"); + }; + + let previous_fingerprint = ( + previous.report().observation().revision(), + previous.report().observation().backing_ptr_for_test(), + previous.report().cells().as_ptr(), + previous.report().cells().len(), + previous + .report() + .cells() + .iter() + .filter(|cell| cell.result().is_violation()) + .count(), + previous.outputs().as_ptr(), + previous.outputs().len(), + previous.selected_state_index(), + ); + let cause_fingerprint = ( + cause.report().observation().revision(), + cause.report().observation().backing_ptr_for_test(), + cause.report().cells().as_ptr(), + cause.report().cells().len(), + cause + .report() + .cells() + .iter() + .filter(|cell| cell.result().is_violation()) + .count(), + cause.considered_state_count(), + ); + assert_ne!(previous_fingerprint.1, cause_fingerprint.1); + assert_ne!(previous_fingerprint.2, cause_fingerprint.2); + + let prepared = session + .prepare_update(update(3, &[[0xFF; 3]])) + .expect("a lawful prospective C needs the third logical lease"); + let PreparedSessionDispositionV1::Verified(prospective) = prepared.disposition() else { + panic!("the prospective black-on-white state must verify"); + }; + let prospective_observation = prospective.report().observation(); + assert_ne!( + prospective_observation.backing_ptr_for_test(), + previous_fingerprint.1, + ); + assert_ne!( + prospective_observation.backing_ptr_for_test(), + cause_fingerprint.1, + ); + assert_ne!( + prospective.report().cells().as_ptr(), + previous_fingerprint.2 + ); + assert_ne!(prospective.report().cells().as_ptr(), cause_fingerprint.2); + assert_ne!(prospective.outputs().as_ptr(), previous_fingerprint.5); + drop(prepared); + + let SessionState::Failed { + cause, + previous: Some(previous), + } = session.state() + else { + panic!("dropping prospective C must preserve the exact failed state"); + }; + assert_eq!( + ( + previous.report().observation().revision(), + previous.report().observation().backing_ptr_for_test(), + previous.report().cells().as_ptr(), + previous.report().cells().len(), + previous + .report() + .cells() + .iter() + .filter(|cell| cell.result().is_violation()) + .count(), + previous.outputs().as_ptr(), + previous.outputs().len(), + previous.selected_state_index(), + ), + previous_fingerprint, + "prospective materialization must not overwrite previous A", + ); + assert_eq!( + ( + cause.report().observation().revision(), + cause.report().observation().backing_ptr_for_test(), + cause.report().cells().as_ptr(), + cause.report().cells().len(), + cause + .report() + .cells() + .iter() + .filter(|cell| cell.result().is_violation()) + .count(), + cause.considered_state_count(), + ), + cause_fingerprint, + "prospective materialization must not overwrite cause B", + ); +} + fn exercise_modeled_action( owner: &OwnerV1, session: &mut SessionV1, diff --git a/crates/labcolors-core/src/program_point_causality_tests.rs b/crates/labcolors-core/src/program_point_causality_tests.rs index 22cc6edc..a39aa3fb 100644 --- a/crates/labcolors-core/src/program_point_causality_tests.rs +++ b/crates/labcolors-core/src/program_point_causality_tests.rs @@ -670,32 +670,26 @@ fn finite_program_with_candidates( } #[test] -fn verified_report_storage_does_not_retain_exhaustive_joint_capacity() { - let capacities = |candidate_codes: &[[u8; 3]]| { - let compiled = - finite_program_with_candidates(Srgb8::new(candidate_codes[0]), candidate_codes); - let mut session = compiled.instantiate(STREAM).unwrap(); - let SessionState::Ready { current } = session.commit(observed_backdrop(1, [0; 3])).unwrap() - else { - panic!("the first authored state must verify"); - }; - current.report().storage_capacities_for_test() - }; - let small = [[0; 3], [1; 3]]; +fn verified_report_keeps_one_exhaustive_high_water_arena_but_only_selected_evidence() { let large = (0_u8..64).map(|code| [code; 3]).collect::>(); - let small_capacities = capacities(&small); - let large_capacities = capacities(&large); + let compiled = finite_program_with_candidates(Srgb8::new(large[0]), &large); + let mut session = compiled.instantiate(STREAM).unwrap(); + let SessionState::Ready { current } = session.commit(observed_backdrop(1, [0; 3])).unwrap() + else { + panic!("the first authored state must verify"); + }; + let selected_capacities = current.report().storage_capacities_for_test(); + assert_eq!(current.report().cells().len(), 1); + assert_eq!(current.point_causal_certificates().len(), 1); assert_eq!( - small_capacities, large_capacities, - "a verified report owns one selected state, not exhaustive conflict storage" - ); - assert!( - large_capacities - .into_iter() - .all(|capacity| capacity >= 1 && capacity < large.len()), - "try_reserve_exact may over-allocate, but selected storage must remain non-empty and smaller than the exhaustive candidate set: {large_capacities:?}" + current + .point_causal_certificates() + .map(|certificate| certificate.steps().len()) + .sum::(), + 1 ); + assert_eq!(current.outputs().len(), 1); let compiled = finite_program_with_candidates(Srgb8::new([255; 3]), &large); let mut session = compiled.instantiate(STREAM).unwrap(); @@ -704,11 +698,19 @@ fn verified_report_storage_does_not_retain_exhaustive_joint_capacity() { panic!("none of the authored states may satisfy the exact target"); }; let conflict_capacities = cause.report().storage_capacities_for_test(); - assert!( - conflict_capacities - .into_iter() - .all(|capacity| capacity >= large.len()), - "an exhaustive conflict must own every considered state even if the allocator over-reserves: {conflict_capacities:?}" + assert_eq!( + selected_capacities, conflict_capacities, + "selected and exhaustive outcomes must use the same coordinate-wise max arena" + ); + assert_eq!(cause.report().cells().len(), large.len()); + assert_eq!(cause.considered_state_count(), large.len()); + assert_eq!(cause.considered_point_causal_evidence().len(), large.len()); + assert_eq!( + cause + .considered_point_causal_evidence() + .map(|evidence| evidence.steps().len()) + .sum::(), + large.len() ); } @@ -1090,8 +1092,10 @@ fn every_causal_preflight_reservation_precedes_graph_and_evaluator_work() { } #[test] -fn every_joint_causal_preflight_reservation_is_transactional() { - const FIRST_UNUSED_RESERVATION_INDEX: usize = 7; +fn every_joint_causal_arena_reservation_is_fail_before_work_and_transactional() { + // Joint point-causal оценка заполняет каждую координату arena: constraint + // cells, causal records, replay steps и committed outputs. + const FIRST_UNUSED_RESERVATION_INDEX: usize = 4; for reservation_index in 0..FIRST_UNUSED_RESERVATION_INDEX { let evaluator = CountingProgramWcag22Srgb8V1::default(); diff --git a/crates/labcolors-core/src/program_session.rs b/crates/labcolors-core/src/program_session.rs index 5fce49a3..fb63714c 100644 --- a/crates/labcolors-core/src/program_session.rs +++ b/crates/labcolors-core/src/program_session.rs @@ -52,9 +52,9 @@ use crate::joint::{ }; use crate::lcs_occurrence::{AppearanceContextId, ColorSignal}; use crate::observation::{ - CanonicalObservationSchemaV1, ObservationError, ObservationGroupId, - ObservationSchemaMismatchV1, ObservationStreamId, RevisionBoundObservationV1, - canonicalize_observation_schema, + CanonicalObservationSchemaV1, OBSERVATION_ARENA_SLOT_COUNT_V1, ObservationArenaSlotV1, + ObservationError, ObservationGroupId, ObservationSchemaMismatchV1, ObservationStreamId, + RevisionBoundObservationV1, canonicalize_observation_schema, }; use crate::session::{ Session, SessionDecision, SessionEvidenceV1, SessionObservationBindingPermitV1, SessionPlanV1, @@ -1698,6 +1698,7 @@ where bindings, workspace, presentation_cache, + evaluation_arenas: ProgramEvaluationArenaPoolV1::new(), }, )) } @@ -2084,9 +2085,7 @@ where { content_identity: ProgramContentIdentityV3, observation: RevisionBoundObservationV1, - cells: Vec>, - point_causal_records: Vec, - point_causal_steps: Vec, + arena: ProgramEvaluationArenaLeaseV1, } impl ProgramReportV1 @@ -2104,17 +2103,31 @@ where } pub fn cells(&self) -> &[ProgramConstraintCellV1] { - &self.cells + &self.arena.storage.cells } #[cfg(test)] pub(crate) fn storage_capacities_for_test(&self) -> [usize; 3] { [ - self.cells.capacity(), - self.point_causal_records.capacity(), - self.point_causal_steps.capacity(), + self.arena.storage.cells.capacity(), + self.arena.storage.point_causal_records.capacity(), + self.arena.storage.point_causal_steps.capacity(), ] } + + fn into_arena(self) -> ProgramEvaluationArenaReturnV1 { + let Self { + content_identity: _, + observation, + arena, + } = self; + let slot = observation.arena_slot(); + drop(observation); + ProgramEvaluationArenaReturnV1 { + slot, + storage: arena.storage, + } + } } /// Один encoded Paint из Program, направленный в непрозрачный клиентский slot. @@ -2147,7 +2160,6 @@ where Evaluation: ProgramConstraintEvaluatorSetV1, { report: ProgramReportV1, - outputs: Vec, selected_state_index: Option, } @@ -2174,7 +2186,7 @@ where } pub fn outputs(&self) -> &[ProgramPaintOutputV1] { - &self.outputs + &self.report.arena.storage.outputs } /// Index inside the authored total order. `None` means this Program has no @@ -2190,20 +2202,29 @@ where ProgramPointCausalSelectedStateV1::Fixed, ProgramPointCausalSelectedStateV1::Selected, ); - self.report.point_causal_records.iter().map(move |record| { - debug_assert!(record.considered_state_index.is_none()); - let steps = record - .replay - .get(&self.report.point_causal_steps) - .unwrap_or_else(|| unreachable!("report владеет каноническим replay span")); - ProgramPointCausalEvidenceV1 { - content_identity: self.report.content_identity, - observation: &self.report.observation, - record, - steps, - state, - } - }) + self.report + .arena + .storage + .point_causal_records + .iter() + .map(move |record| { + debug_assert!(record.considered_state_index.is_none()); + let steps = record + .replay + .get(&self.report.arena.storage.point_causal_steps) + .unwrap_or_else(|| unreachable!("report владеет каноническим replay span")); + ProgramPointCausalEvidenceV1 { + content_identity: self.report.content_identity, + observation: &self.report.observation, + record, + steps, + state, + } + }) + } + + fn into_arena(self) -> ProgramEvaluationArenaReturnV1 { + self.report.into_arena() } } @@ -2243,26 +2264,40 @@ where self.considered_state_count } + #[cfg(test)] + pub(crate) fn retained_output_value_count_for_test(&self) -> usize { + self.report.arena.storage.outputs.len() + } + pub(crate) fn considered_point_causal_evidence( &self, ) -> impl ExactSizeIterator> + '_ { - self.report.point_causal_records.iter().map(move |record| { - let state = record.considered_state_index.map_or( - ProgramPointCausalConsideredStateV1::Fixed, - ProgramPointCausalConsideredStateV1::Considered, - ); - let steps = record - .replay - .get(&self.report.point_causal_steps) - .unwrap_or_else(|| unreachable!("report владеет каноническим replay span")); - ProgramPointCausalEvidenceV1 { - content_identity: self.report.content_identity, - observation: &self.report.observation, - record, - steps, - state, - } - }) + self.report + .arena + .storage + .point_causal_records + .iter() + .map(move |record| { + let state = record.considered_state_index.map_or( + ProgramPointCausalConsideredStateV1::Fixed, + ProgramPointCausalConsideredStateV1::Considered, + ); + let steps = record + .replay + .get(&self.report.arena.storage.point_causal_steps) + .unwrap_or_else(|| unreachable!("report владеет каноническим replay span")); + ProgramPointCausalEvidenceV1 { + content_identity: self.report.content_identity, + observation: &self.report.observation, + record, + steps, + state, + } + }) + } + + fn into_arena(self) -> ProgramEvaluationArenaReturnV1 { + self.report.into_arena() } } @@ -2498,6 +2533,11 @@ pub(crate) fn fail_program_preflight_reservation_for_test( } } +#[cfg(test)] +pub(crate) fn program_preflight_failure_remaining_for_test() -> Option { + PROGRAM_PREFLIGHT_FAILURE_AT.with(std::cell::Cell::get) +} + #[cfg(test)] fn injected_program_preflight_failure() -> bool { PROGRAM_PREFLIGHT_FAILURE_AT.with(|failure| match failure.get() { @@ -2517,23 +2557,70 @@ fn try_reserve_program_evaluation_buffer( buffer: &mut Vec, capacity: usize, ) -> Result<(), ()> { + // Нулевая координата не является резервированием и не сдвигает индекс + // fault injection; любая непустая координата учитывается и после прогрева. + if capacity == 0 { + return Ok(()); + } + #[cfg(test)] + let fail_this_coordinate = injected_program_preflight_failure(); + if buffer.capacity() >= capacity { + return Ok(()); + } #[cfg(test)] - if injected_program_preflight_failure() { + if fail_this_coordinate { return Err(()); } buffer.try_reserve_exact(capacity).map_err(|_| ()) } -struct ProgramReportBuffersV1 +#[cfg(test)] +mod program_preflight_reservation_tests { + use super::*; + + #[test] + fn mixed_warm_and_cold_coordinates_keep_stable_failure_indices() { + let mut warm = Vec::::with_capacity(1); + let mut cold = Vec::::new(); + let _failure = fail_program_preflight_reservation_for_test(1); + + assert_eq!(try_reserve_program_evaluation_buffer(&mut warm, 1), Ok(())); + assert_eq!(try_reserve_program_evaluation_buffer(&mut cold, 1), Err(())); + } + + #[test] + fn a_warm_coordinate_consumes_but_cannot_realize_an_allocation_failure() { + let mut warm = Vec::::with_capacity(1); + let mut cold = Vec::::new(); + let _failure = fail_program_preflight_reservation_for_test(0); + + assert_eq!(try_reserve_program_evaluation_buffer(&mut warm, 1), Ok(())); + assert_eq!(try_reserve_program_evaluation_buffer(&mut cold, 1), Ok(())); + assert!(cold.capacity() >= 1); + } + + #[test] + fn an_empty_coordinate_does_not_consume_a_failure_index() { + let mut empty = Vec::::new(); + let mut cold = Vec::::new(); + let _failure = fail_program_preflight_reservation_for_test(0); + + assert_eq!(try_reserve_program_evaluation_buffer(&mut empty, 0), Ok(())); + assert_eq!(try_reserve_program_evaluation_buffer(&mut cold, 1), Err(())); + } +} + +struct ProgramEvaluationArenaV1 where Evaluation: ProgramConstraintEvaluatorSetV1, { cells: Vec>, point_causal_records: Vec, point_causal_steps: Vec, + outputs: Vec, } -impl ProgramReportBuffersV1 +impl ProgramEvaluationArenaV1 where Evaluation: ProgramConstraintEvaluatorSetV1, { @@ -2542,8 +2629,124 @@ where cells: Vec::new(), point_causal_records: Vec::new(), point_causal_steps: Vec::new(), + outputs: Vec::new(), } } + + fn clear(&mut self) { + self.cells.clear(); + self.point_causal_records.clear(); + self.point_causal_steps.clear(); + self.outputs.clear(); + } +} + +/// Move-only storage половина логического arena-слота Session. Return-route +/// остаётся только в observation, поэтому report нельзя привязать к чужому slot. +struct ProgramEvaluationArenaLeaseV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + storage: ProgramEvaluationArenaV1, +} + +/// Единственный return-route появляется при retirement из observation, +/// которая остаётся SSOT общей arena identity на всём lifetime report. +struct ProgramEvaluationArenaReturnV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + slot: ObservationArenaSlotV1, + storage: ProgramEvaluationArenaV1, +} + +struct ProgramEvaluationArenaPoolV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + slots: [Option>; OBSERVATION_ARENA_SLOT_COUNT_V1], +} + +/// Возвращает arena в точный pool-слот при любом выходе, включая unwind из +/// зарегистрированного evaluator или деструктора evidence. +struct ProgramEvaluationArenaGuardV1<'pool, Evaluation> +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + destination: &'pool mut Option>, + lease: Option>, +} + +impl ProgramEvaluationArenaGuardV1<'_, Evaluation> +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + fn storage_mut(&mut self) -> &mut ProgramEvaluationArenaV1 { + &mut self + .lease + .as_mut() + .unwrap_or_else(|| unreachable!("an active guard owns one arena lease")) + .storage + } + + fn into_lease(mut self) -> ProgramEvaluationArenaLeaseV1 { + self.lease + .take() + .unwrap_or_else(|| unreachable!("an active guard owns one arena lease")) + } +} + +impl Drop for ProgramEvaluationArenaGuardV1<'_, Evaluation> +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + fn drop(&mut self) { + let Some(lease) = self.lease.take() else { + return; + }; + // Эксклюзивное заимствование указывает ровно на слот, из которого + // guard забрал storage. Drop не проверяет этот структурный инвариант + // паникой: evaluator unwind иначе мог бы превратиться в abort. + *self.destination = Some(lease.storage); + } +} + +impl ProgramEvaluationArenaPoolV1 +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + fn new() -> Self { + Self { + slots: std::array::from_fn(|_| Some(ProgramEvaluationArenaV1::empty())), + } + } + + fn guard( + &mut self, + slot: ObservationArenaSlotV1, + ) -> Option> { + let destination = self.slots.get_mut(slot.index())?; + let storage = destination.take()?; + Some(ProgramEvaluationArenaGuardV1 { + destination, + lease: Some(ProgramEvaluationArenaLeaseV1 { storage }), + }) + } + + fn restore(&mut self, returned: ProgramEvaluationArenaReturnV1) { + // Маршрут возврата минтится тем же observation, который выбрал слот + // ограниченной арены; внешнего конструктора для такого слота нет. + let destination = self + .slots + .get_mut(returned.slot.index()) + .unwrap_or_else(|| unreachable!("observation minted a bounded arena slot")); + // Move-only lease удерживает storage до единственного retirement, поэтому + // занятый destination означал бы повторный возврат одного владения. + if destination.is_some() { + unreachable!("a move-only Program arena cannot be returned twice"); + } + *destination = Some(returned.storage); + } } // Порядок координат совпадает с физическим владением отчёта: constraint cells, @@ -2583,29 +2786,6 @@ pub(crate) fn selected_program_storage_is_prepared_for_test( selected_program_storage_is_prepared(lengths, capacities, required) } -struct PreparedProgramEvaluationBuffersV1 -where - Evaluation: ProgramConstraintEvaluatorSetV1, -{ - // Раздельное владение оставляет успешному отчёту только выбранное - // доказательство, но сохраняет fail-before-work для возможного конфликта. - selected: ProgramReportBuffersV1, - exhaustive_conflict: ProgramReportBuffersV1, - outputs: Vec, - counts: ProgramEvaluationCardinalityV1, -} - -struct SelectedProgramEvaluationBuffersV1 -where - Evaluation: ProgramConstraintEvaluatorSetV1, -{ - report: ProgramReportBuffersV1, - outputs: Vec, - expected_cell_count: usize, - expected_point_record_count: usize, - expected_replay_step_count: usize, -} - struct ProgramPointCausalBuffersV1<'buffers> { considered_state_index: Option, records: &'buffers mut Vec, @@ -2634,26 +2814,12 @@ where } } -impl PreparedProgramEvaluationBuffersV1 -where - Evaluation: ProgramConstraintEvaluatorSetV1, -{ - fn take_selected(&mut self) -> SelectedProgramEvaluationBuffersV1 { - SelectedProgramEvaluationBuffersV1 { - report: std::mem::replace(&mut self.selected, ProgramReportBuffersV1::empty()), - outputs: std::mem::take(&mut self.outputs), - expected_cell_count: self.counts.selected, - expected_point_record_count: self.counts.selected_point_records, - expected_replay_step_count: self.counts.selected_replay_steps, - } - } -} - -fn prepare_program_evaluation_buffers( +fn prepare_program_evaluation_arena( epoch: &ProgramEpochV1, observation: &RevisionBoundObservationV1, + arena: &mut ProgramEvaluationArenaV1, ) -> Result< - PreparedProgramEvaluationBuffersV1, + ProgramEvaluationCardinalityV1, ProgramSessionEvaluationError>, > where @@ -2664,63 +2830,35 @@ where checked_program_epoch_evaluation_cardinality(epoch, observation.physical_case_count()) .ok_or(ProgramSessionEvaluationError::ResourceExhausted)?; - // Failure-injection tests observe this order by index, so adding or moving - // a reservation must also move the corresponding first-unused boundary. - let mut selected = ProgramReportBuffersV1::empty(); - try_reserve_program_evaluation_buffer(&mut selected.cells, counts.selected) - .map_err(|()| ProgramSessionEvaluationError::ResourceExhausted)?; - - let mut exhaustive_conflict = ProgramReportBuffersV1::empty(); - if epoch.joint_selection.is_some() && counts.exhaustive_conflict != 0 { - try_reserve_program_evaluation_buffer( - &mut exhaustive_conflict.cells, - counts.exhaustive_conflict, - ) - .map_err(|()| ProgramSessionEvaluationError::ResourceExhausted)?; - } - - if counts.selected_point_records != 0 { - try_reserve_program_evaluation_buffer( - &mut selected.point_causal_records, - counts.selected_point_records, - ) - .map_err(|()| ProgramSessionEvaluationError::ResourceExhausted)?; - } - - if epoch.joint_selection.is_some() && counts.exhaustive_point_records != 0 { - try_reserve_program_evaluation_buffer( - &mut exhaustive_conflict.point_causal_records, - counts.exhaustive_point_records, - ) - .map_err(|()| ProgramSessionEvaluationError::ResourceExhausted)?; - } - - if counts.selected_replay_steps != 0 { - try_reserve_program_evaluation_buffer( - &mut selected.point_causal_steps, - counts.selected_replay_steps, - ) - .map_err(|()| ProgramSessionEvaluationError::ResourceExhausted)?; - } - - if epoch.joint_selection.is_some() && counts.exhaustive_replay_steps != 0 { - try_reserve_program_evaluation_buffer( - &mut exhaustive_conflict.point_causal_steps, - counts.exhaustive_replay_steps, - ) - .map_err(|()| ProgramSessionEvaluationError::ResourceExhausted)?; - } - - let mut outputs = Vec::new(); - try_reserve_program_evaluation_buffer(&mut outputs, epoch.outputs.len()) + // Search и exhaustive conflict взаимоисключают друг друга в одном update. + // Покомпонентный максимум резервируется до evaluator work: старые два + // владельца буферов исчезают без ослабления fail-before-work. + arena.clear(); + try_reserve_program_evaluation_buffer( + &mut arena.cells, + counts.selected.max(counts.exhaustive_conflict), + ) + .map_err(|()| ProgramSessionEvaluationError::ResourceExhausted)?; + try_reserve_program_evaluation_buffer( + &mut arena.point_causal_records, + counts + .selected_point_records + .max(counts.exhaustive_point_records), + ) + .map_err(|()| ProgramSessionEvaluationError::ResourceExhausted)?; + try_reserve_program_evaluation_buffer( + &mut arena.point_causal_steps, + counts + .selected_replay_steps + .max(counts.exhaustive_replay_steps), + ) + .map_err(|()| ProgramSessionEvaluationError::ResourceExhausted)?; + // До verdict любой из трёх logical slots может стать новым Ready и потому + // заранее покрывает outputs. Conflict очистит значения; перенос capacity + // потребовал бы второго pool/lease authority вместо одной связанной arena. + try_reserve_program_evaluation_buffer(&mut arena.outputs, epoch.outputs.len()) .map_err(|()| ProgramSessionEvaluationError::ResourceExhausted)?; - - Ok(PreparedProgramEvaluationBuffersV1 { - selected, - exhaustive_conflict, - outputs, - counts, - }) + Ok(counts) } /// Per-Session mutable execution state bound weakly to one immutable compiled @@ -2735,6 +2873,15 @@ where bindings: AdmittedAppearanceBindings, workspace: AppearanceWorkspace, presentation_cache: ProgramPresentationCacheV1, + evaluation_arenas: ProgramEvaluationArenaPoolV1, +} + +/// Mutable execution state не владеет arena: guard удерживает непересекающееся +/// заимствование pool на всём fallible evaluator work. +struct ProgramEvaluationRuntimeV1<'plan> { + bindings: &'plan mut AdmittedAppearanceBindings, + workspace: &'plan mut AppearanceWorkspace, + presentation_cache: &'plan mut ProgramPresentationCacheV1, } impl session_private::PlanSealed for ProgramSessionPlan @@ -2773,6 +2920,19 @@ where ) -> Result, Self::Error> { evaluate_program_session(self, &owner.0, observation) } + + fn retire_verified(&mut self, evidence: Self::Verified) { + self.evaluation_arenas.restore(evidence.into_arena()); + } + + fn retire_violation(&mut self, evidence: Self::Violation) { + self.evaluation_arenas.restore(evidence.into_arena()); + } +} + +enum ProgramEvaluationOutcomeV1 { + Verified { selected_state_index: Option }, + Conflict { considered_state_count: usize }, } fn evaluate_program_session( @@ -2780,49 +2940,107 @@ fn evaluate_program_session( epoch: &ProgramEpochV1, observation: RevisionBoundObservationV1, ) -> ProgramSessionEvaluationResult +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + let slot = observation.arena_slot(); + let ProgramSessionPlan { + owner_generation: _, + bindings, + workspace, + presentation_cache, + evaluation_arenas, + } = plan; + let mut arena = evaluation_arenas + .guard(slot) + .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; + let mut runtime = ProgramEvaluationRuntimeV1 { + bindings, + workspace, + presentation_cache, + }; + let counts = prepare_program_evaluation_arena(epoch, &observation, arena.storage_mut())?; + let outcome = evaluate_program_session_into( + &mut runtime, + epoch, + &observation, + arena.storage_mut(), + counts, + )?; + if matches!(&outcome, ProgramEvaluationOutcomeV1::Conflict { .. }) { + // Conflict не имеет output-authority: сохраняется только capacity для + // следующего prospective update, но ни одно значение не переживает verdict. + arena.storage_mut().outputs.clear(); + } + let report = ProgramReportV1 { + content_identity: epoch.content_identity, + observation, + arena: arena.into_lease(), + }; + Ok(match outcome { + ProgramEvaluationOutcomeV1::Verified { + selected_state_index, + } => SessionDecision::Verified(ProgramVerifiedV1 { + report, + selected_state_index, + }), + ProgramEvaluationOutcomeV1::Conflict { + considered_state_count, + } => SessionDecision::Violation(ProgramConflictV1 { + report, + considered_state_count, + }), + }) +} + +fn evaluate_program_session_into( + runtime: &mut ProgramEvaluationRuntimeV1<'_>, + epoch: &ProgramEpochV1, + observation: &RevisionBoundObservationV1, + arena: &mut ProgramEvaluationArenaV1, + counts: ProgramEvaluationCardinalityV1, +) -> Result< + ProgramEvaluationOutcomeV1, + ProgramSessionEvaluationError>, +> where Evaluation: ProgramConstraintEvaluatorSetV1, ProgramConstraintInvocationOf: Copy, { let Some(selection) = &epoch.joint_selection else { - let mut buffers = prepare_program_evaluation_buffers(epoch, &observation)?; - return collect_program_candidate_into( - plan, - epoch, - observation, - None, - 1, - buffers.take_selected(), - ); + return collect_program_candidate_into(runtime, epoch, observation, None, 1, arena, counts); }; let state_count = selection.order.state_count(); - let mut buffers = prepare_program_evaluation_buffers(epoch, &observation)?; for (state_index, tuple) in selection.order.tuples().enumerate() { - apply_joint_candidate(plan, &epoch.finite_targets, tuple)?; + apply_joint_candidate::(runtime, &epoch.finite_targets, tuple)?; if !scan_program_candidate( - plan, + runtime, epoch, - &observation, + observation, state_index, ProgramEvaluationPhaseV1::Hard, ProgramCandidateCollectionV1::none(), )? { // A selected tuple is never certified from its allocation-free // search pass. Re-apply and collect fresh terminal evidence. - apply_joint_candidate(plan, &epoch.finite_targets, tuple)?; + apply_joint_candidate::(runtime, &epoch.finite_targets, tuple)?; match collect_program_candidate_into( - plan, + runtime, epoch, - observation.clone(), + observation, Some(state_index), state_index + 1, - buffers.take_selected(), + arena, + counts, )? { - SessionDecision::Verified(verified) => { - return Ok(SessionDecision::Verified(verified)); + ProgramEvaluationOutcomeV1::Verified { .. } => { + return Ok(ProgramEvaluationOutcomeV1::Verified { + selected_state_index: Some(state_index), + }); } - SessionDecision::Violation(_) => { + ProgramEvaluationOutcomeV1::Conflict { .. } => { // A selected finite state converts every fresh hard failure // into FinalRecheckViolation inside collect_*. Reaching a // plain Violation here means that contract was broken. @@ -2832,21 +3050,44 @@ where } } + if !selected_program_storage_is_prepared( + [ + arena.cells.len(), + arena.point_causal_records.len(), + arena.point_causal_steps.len(), + arena.outputs.len(), + ], + [ + arena.cells.capacity(), + arena.point_causal_records.capacity(), + arena.point_causal_steps.capacity(), + arena.outputs.capacity(), + ], + [ + counts.exhaustive_conflict, + counts.exhaustive_point_records, + counts.exhaustive_replay_steps, + 0, + ], + ) { + return Err(ProgramSessionEvaluationError::InternalInvariant); + } + for (state_index, tuple) in selection.order.tuples().enumerate() { - apply_joint_candidate(plan, &epoch.finite_targets, tuple)?; + apply_joint_candidate::(runtime, &epoch.finite_targets, tuple)?; if !scan_program_candidate( - plan, + runtime, epoch, - &observation, + observation, state_index, ProgramEvaluationPhaseV1::Hard, ProgramCandidateCollectionV1 { - cells: Some(&mut buffers.exhaustive_conflict.cells), + cells: Some(&mut arena.cells), outputs: None, point_causal: Some(ProgramPointCausalBuffersV1 { considered_state_index: Some(state_index), - records: &mut buffers.exhaustive_conflict.point_causal_records, - steps: &mut buffers.exhaustive_conflict.point_causal_steps, + records: &mut arena.point_causal_records, + steps: &mut arena.point_causal_steps, }), }, )? { @@ -2858,15 +3099,15 @@ where .contains(ProgramEvaluationPhaseV1::ReportOnly) { for (state_index, tuple) in selection.order.tuples().enumerate() { - apply_joint_candidate(plan, &epoch.finite_targets, tuple)?; + apply_joint_candidate::(runtime, &epoch.finite_targets, tuple)?; if scan_program_candidate( - plan, + runtime, epoch, - &observation, + observation, state_index, ProgramEvaluationPhaseV1::ReportOnly, ProgramCandidateCollectionV1 { - cells: Some(&mut buffers.exhaustive_conflict.cells), + cells: Some(&mut arena.cells), outputs: None, point_causal: None, }, @@ -2877,34 +3118,27 @@ where } if !program_report_cardinality_is_exact( [ - buffers.exhaustive_conflict.cells.len(), - buffers.exhaustive_conflict.point_causal_records.len(), - buffers.exhaustive_conflict.point_causal_steps.len(), + arena.cells.len(), + arena.point_causal_records.len(), + arena.point_causal_steps.len(), ], [ - buffers.counts.exhaustive_conflict, - buffers.counts.exhaustive_point_records, - buffers.counts.exhaustive_replay_steps, + counts.exhaustive_conflict, + counts.exhaustive_point_records, + counts.exhaustive_replay_steps, ], ) { return Err(ProgramSessionEvaluationError::InternalInvariant); } - canonicalize_program_report_cells(&mut buffers.exhaustive_conflict.cells); + canonicalize_program_report_cells(&mut arena.cells); - Ok(SessionDecision::Violation(ProgramConflictV1 { - report: ProgramReportV1 { - content_identity: epoch.content_identity, - observation, - cells: buffers.exhaustive_conflict.cells, - point_causal_records: buffers.exhaustive_conflict.point_causal_records, - point_causal_steps: buffers.exhaustive_conflict.point_causal_steps, - }, + Ok(ProgramEvaluationOutcomeV1::Conflict { considered_state_count: state_count, - })) + }) } fn apply_joint_candidate( - plan: &mut ProgramSessionPlan, + runtime: &mut ProgramEvaluationRuntimeV1<'_>, targets: &[CompiledFiniteTargetV1], tuple: &[FiniteDomainOrdinalV1], ) -> Result<(), ProgramSessionEvaluationError>> @@ -2920,7 +3154,8 @@ where .candidates .get(ordinal.index()) .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; - plan.bindings + runtime + .bindings .overwrite_color_at(target.binding, candidate.srgb8()) .map_err(map_program_execution_binding_error)?; } @@ -2928,41 +3163,36 @@ where } fn collect_program_candidate_into( - plan: &mut ProgramSessionPlan, + runtime: &mut ProgramEvaluationRuntimeV1<'_>, epoch: &ProgramEpochV1, - observation: RevisionBoundObservationV1, + observation: &RevisionBoundObservationV1, selected_state_index: Option, considered_state_count: usize, - buffers: SelectedProgramEvaluationBuffersV1, -) -> ProgramSessionEvaluationResult + arena: &mut ProgramEvaluationArenaV1, + counts: ProgramEvaluationCardinalityV1, +) -> Result< + ProgramEvaluationOutcomeV1, + ProgramSessionEvaluationError>, +> where Evaluation: ProgramConstraintEvaluatorSetV1, ProgramConstraintInvocationOf: Copy, { - let SelectedProgramEvaluationBuffersV1 { - report: - ProgramReportBuffersV1 { - mut cells, - mut point_causal_records, - mut point_causal_steps, - }, - mut outputs, - expected_cell_count, - expected_point_record_count, - expected_replay_step_count, - } = buffers; + let expected_cell_count = counts.selected; + let expected_point_record_count = counts.selected_point_records; + let expected_replay_step_count = counts.selected_replay_steps; if !selected_program_storage_is_prepared( [ - cells.len(), - point_causal_records.len(), - point_causal_steps.len(), - outputs.len(), + arena.cells.len(), + arena.point_causal_records.len(), + arena.point_causal_steps.len(), + arena.outputs.len(), ], [ - cells.capacity(), - point_causal_records.capacity(), - point_causal_steps.capacity(), - outputs.capacity(), + arena.cells.capacity(), + arena.point_causal_records.capacity(), + arena.point_causal_steps.capacity(), + arena.outputs.capacity(), ], [ expected_cell_count, @@ -2982,18 +3212,18 @@ where .contains(ProgramEvaluationPhaseV1::ReportOnly); let has_hard_violation = if has_hard_constraints { scan_program_candidate( - plan, + runtime, epoch, - &observation, + observation, candidate_state_index, ProgramEvaluationPhaseV1::Hard, ProgramCandidateCollectionV1 { - cells: Some(&mut cells), - outputs: Some(&mut outputs), + cells: Some(&mut arena.cells), + outputs: Some(&mut arena.outputs), point_causal: Some(ProgramPointCausalBuffersV1 { considered_state_index: None, - records: &mut point_causal_records, - steps: &mut point_causal_steps, + records: &mut arena.point_causal_records, + steps: &mut arena.point_causal_steps, }), }, )? @@ -3003,7 +3233,8 @@ where if let Some(state_index) = selected_state_index.filter(|_| has_hard_violation) { // Search only nominates a finite state. Its fresh hard recheck owns the // terminal verdict, so diagnostics cannot mask or mutate that failure. - let mut violations = cells + let mut violations = arena + .cells .iter() .filter(|cell| cell.is_hard() && cell.result().is_violation()); let first = violations @@ -3021,18 +3252,18 @@ where if has_report_constraints { let point_causal = (!has_hard_constraints).then_some(ProgramPointCausalBuffersV1 { considered_state_index: None, - records: &mut point_causal_records, - steps: &mut point_causal_steps, + records: &mut arena.point_causal_records, + steps: &mut arena.point_causal_steps, }); if scan_program_candidate( - plan, + runtime, epoch, - &observation, + observation, candidate_state_index, ProgramEvaluationPhaseV1::ReportOnly, ProgramCandidateCollectionV1 { - cells: Some(&mut cells), - outputs: (!has_hard_constraints).then_some(&mut outputs), + cells: Some(&mut arena.cells), + outputs: (!has_hard_constraints).then_some(&mut arena.outputs), point_causal, }, )? { @@ -3041,9 +3272,9 @@ where } if !program_report_cardinality_is_exact( [ - cells.len(), - point_causal_records.len(), - point_causal_steps.len(), + arena.cells.len(), + arena.point_causal_records.len(), + arena.point_causal_steps.len(), ], [ expected_cell_count, @@ -3053,25 +3284,15 @@ where ) { return Err(ProgramSessionEvaluationError::InternalInvariant); } - canonicalize_program_report_cells(&mut cells); - let report = ProgramReportV1 { - content_identity: epoch.content_identity, - observation, - cells, - point_causal_records, - point_causal_steps, - }; + canonicalize_program_report_cells(&mut arena.cells); if has_hard_violation { - Ok(SessionDecision::Violation(ProgramConflictV1 { - report, + Ok(ProgramEvaluationOutcomeV1::Conflict { considered_state_count, - })) + }) } else { - Ok(SessionDecision::Verified(ProgramVerifiedV1 { - report, - outputs, + Ok(ProgramEvaluationOutcomeV1::Verified { selected_state_index, - })) + }) } } @@ -3088,7 +3309,7 @@ where } fn scan_program_candidate( - plan: &mut ProgramSessionPlan, + runtime: &mut ProgramEvaluationRuntimeV1<'_>, epoch: &ProgramEpochV1, observation: &RevisionBoundObservationV1, candidate_state_index: usize, @@ -3130,16 +3351,17 @@ where ), )); } - plan.bindings + runtime + .bindings .overwrite_surface_inputs_canonical(schema.as_slice().iter().copied(), |index| { values[index].srgb8() }) .map_err(map_program_execution_binding_error)?; let evaluation = epoch .graph - .evaluate_admitted_into(&plan.bindings, &mut plan.workspace) + .evaluate_admitted_into(runtime.bindings, runtime.workspace) .map_err(map_program_execution_binding_error)?; - plan.presentation_cache.begin_case(phase); + runtime.presentation_cache.begin_case(phase); if let Some(point_causal) = point_causal.as_mut() { // Предварительный расчёт зарезервировал арены целиком. Локальная @@ -3159,7 +3381,7 @@ where } for (presentation_ordinal, presentation) in epoch.point_presentations.iter().enumerate() { - let resolved = plan + let resolved = runtime .presentation_cache .resolve( &evaluation, @@ -3268,7 +3490,7 @@ where if presentation.terminal != terminal { return Err(ProgramSessionEvaluationError::InternalInvariant); } - let resolved = plan + let resolved = runtime .presentation_cache .resolve(&evaluation, presentation_ordinal, presentation, None) .map_err(|()| ProgramSessionEvaluationError::InternalInvariant)?; diff --git a/crates/labcolors-core/src/program_session_tests.rs b/crates/labcolors-core/src/program_session_tests.rs index d0fc1497..4030cedb 100644 --- a/crates/labcolors-core/src/program_session_tests.rs +++ b/crates/labcolors-core/src/program_session_tests.rs @@ -6,9 +6,9 @@ use crate::lcs_occurrence::{ BackgroundLuminanceRatio, ColorSignal, IEC_SRGB_D65_XYZ_FRAME_V1, SurroundProfileId, }; use crate::observation::{ - ObservationGroupId, ObservationHeadViewV1, ObservationPayloadInput, ObservationStreamId, - ObservationUpdateInput, ObservedScenarioSetInput, Revision, ScenarioId, ScenarioInput, - SurfaceInputBinding, + OBSERVATION_ARENA_SLOT_COUNT_V1, ObservationGroupId, ObservationHeadViewV1, + ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, ObservedScenarioSetInput, + Revision, ScenarioId, ScenarioInput, SurfaceInputBinding, }; use crate::program_session::{ CompositionProfile, ConstraintId, ConstraintInvocation, ConstraintSet, ObservationGroup, @@ -786,7 +786,7 @@ fn independently_instantiated_streams_expire_with_their_compiled_owner_generatio } #[test] -fn program_sessions_reuse_the_owner_canonical_schema_handle() { +fn program_sessions_each_prewarm_three_arenas_over_the_owner_canonical_schema() { let compiled = exact_compiled(ConstraintSet::new( vec![ConstraintInvocation::hard( REQUIRED, @@ -799,12 +799,22 @@ fn program_sessions_reuse_the_owner_canonical_schema_handle() { assert_eq!(compiled.observation_schema_strong_count_for_test(), 1); let mut first = compiled.instantiate(STREAM_A).unwrap(); - assert_eq!(compiled.observation_schema_strong_count_for_test(), 1); + assert_eq!( + compiled.observation_schema_strong_count_for_test(), + 1 + OBSERVATION_ARENA_SLOT_COUNT_V1, + ); let second = compiled.instantiate(STREAM_B).unwrap(); - assert_eq!(compiled.observation_schema_strong_count_for_test(), 1); + assert_eq!( + compiled.observation_schema_strong_count_for_test(), + 1 + 2 * OBSERVATION_ARENA_SLOT_COUNT_V1, + ); drop(second); - assert_eq!(compiled.observation_schema_strong_count_for_test(), 1); + let one_session_schema_handle_count = 1 + OBSERVATION_ARENA_SLOT_COUNT_V1; + assert_eq!( + compiled.observation_schema_strong_count_for_test(), + one_session_schema_handle_count, + ); let schema_ptr = { let owner = first.plan().try_acquire_owner().unwrap(); @@ -813,11 +823,14 @@ fn program_sessions_reuse_the_owner_canonical_schema_handle() { .observation_schema(&owner) .backing_ptr_for_test() }; - let report_schema_ptr = match first + let (report_schema_ptr, report_backing_ptr) = match first .commit(observed_update(STREAM_A, 1, &[(1, [0xFF; 3])])) .unwrap() { - SessionState::Ready { current } => current.report().observation().schema_ptr_for_test(), + SessionState::Ready { current } => ( + current.report().observation().schema_ptr_for_test(), + current.report().observation().backing_ptr_for_test(), + ), _ => panic!("the exact Program must verify"), }; assert_eq!(report_schema_ptr, schema_ptr); @@ -825,12 +838,36 @@ fn program_sessions_reuse_the_owner_canonical_schema_handle() { panic!("the raw head must retain the admitted observation"); }; assert_eq!(raw.schema_ptr_for_test(), schema_ptr); - assert_eq!(compiled.observation_schema_strong_count_for_test(), 2); + assert_eq!(raw.backing_ptr_for_test(), report_backing_ptr); + assert_eq!( + compiled.observation_schema_strong_count_for_test(), + one_session_schema_handle_count, + ); - first + let idempotent_report_backing_ptr = match first .commit(observed_update(STREAM_A, 1, &[(1, [0xFF; 3])])) - .unwrap(); - assert_eq!(compiled.observation_schema_strong_count_for_test(), 2); + .unwrap() + { + SessionState::Ready { current } => current.report().observation().backing_ptr_for_test(), + _ => panic!("an exact replay must retain the verified Program report"), + }; + assert_eq!(idempotent_report_backing_ptr, report_backing_ptr); + assert_eq!( + compiled.observation_schema_strong_count_for_test(), + one_session_schema_handle_count, + ); + + let observation_clone = match first.state() { + SessionState::Ready { current } => current.report().observation().clone(), + _ => panic!("the verified Program report must remain current"), + }; + assert_eq!(observation_clone.schema_ptr_for_test(), schema_ptr); + assert_eq!( + compiled.observation_schema_strong_count_for_test(), + one_session_schema_handle_count, + "cloning an observation must not add a canonical schema Rc", + ); + drop(observation_clone); drop(first); assert_eq!(compiled.observation_schema_strong_count_for_test(), 1); @@ -859,6 +896,11 @@ fn multi_case_hard_failure_retains_the_full_matrix_without_outputs() { panic!("each candidate target fails on one admitted physical case"); }; assert!(previous.is_none()); + assert_eq!( + cause.retained_output_value_count_for_test(), + 0, + "conflict evidence must not retain output values without output authority", + ); let cells = cause.report().cells(); assert_eq!( cells.len(), diff --git a/crates/labcolors-core/src/session.rs b/crates/labcolors-core/src/session.rs index 19eba6cf..a6335064 100644 --- a/crates/labcolors-core/src/session.rs +++ b/crates/labcolors-core/src/session.rs @@ -10,8 +10,8 @@ use std::mem; use crate::observation::{ - CanonicalObservationSchemaV1, ObservationError, ObservationHeadViewV1, ObservationOwnerV1, - ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, + CanonicalObservationSchemaV1, ObservationArenaPoolV1, ObservationError, ObservationHeadViewV1, + ObservationOwnerV1, ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, PreparedObservationUpdateV1, Revision, RevisionBoundObservationV1, RevisionBoundUnknownV1, SchemaOrderedScenarioSourceV1, UnknownReasonId, prepare_observation, prepare_schema_ordered_observation, @@ -92,6 +92,17 @@ pub(crate) trait SessionPlanV1: private::PlanSealed { observation: RevisionBoundObservationV1, permit: SessionObservationBindingPermitV1, ) -> Result, Self::Error>; + + /// Возвращает хранилище retired evidence в точный план этой Session. + /// План без reusable evidence storage сохраняет обычный owning drop. + fn retire_verified(&mut self, evidence: Self::Verified) { + drop(evidence); + } + + /// Симметричный retirement несовместимой ветви violation evidence. + fn retire_violation(&mut self, evidence: Self::Violation) { + drop(evidence); + } } /// Evaluator lifecycle. The current raw payload is deliberately not embedded @@ -216,13 +227,69 @@ struct DisplacedSessionState { /// Поля намеренно закрыты. Значение служит только линейным retirement-bundle; /// owner объявлен последним и потому переживает raw/evidence при уничтожении. pub(crate) struct DeferredSessionRetirement { - _retired_raw_head: Option, - _retired_verified: Option, - _retired_violation: Option, - _displaced_placeholder: SessionState, + retired_raw_head: Option, + retired_verified: Option, + retired_violation: Option, + displaced_placeholder: SessionState, _owner: Plan::OwnerLease, } +impl DeferredSessionRetirement { + /// Возвращает reusable storage до уничтожения raw provenance и exact owner. + fn retire_into(mut self, plan: &mut Plan) { + // Owner остаётся внутри `self` до завершения всех evidence/raw + // деструкторов. При unwind порядок полей всё равно освобождает exact + // owner последним и не зависит от порядка локальных переменных. + if let Some(verified) = self.retired_verified.take() { + plan.retire_verified(verified); + } + if let Some(violation) = self.retired_violation.take() { + plan.retire_violation(violation); + } + drop(mem::replace( + &mut self.displaced_placeholder, + SessionState::Waiting, + )); + drop(self.retired_raw_head.take()); + } +} + +/// Abort-guard: prospective evidence возвращает storage тому же Plan, пока +/// exact owner lease ещё жив. Сам Prepared token поэтому не нуждается в Drop. +struct PendingSessionTransitionGuard<'session, Plan: SessionPlanV1> { + plan: &'session mut Plan, + pending: Option>, + owner: Option, +} + +impl PendingSessionTransitionGuard<'_, Plan> { + fn take_parts( + &mut self, + ) -> ( + PendingSessionTransition, + Plan::OwnerLease, + ) { + let pending = self + .pending + .take() + .unwrap_or_else(|| unreachable!("prepared transition owns one pending value")); + let owner = self + .owner + .take() + .unwrap_or_else(|| unreachable!("prepared transition owns one generation lease")); + (pending, owner) + } +} + +impl Drop for PendingSessionTransitionGuard<'_, Plan> { + fn drop(&mut self) { + if let Some(pending) = self.pending.take() { + retire_pending_transition(self.plan, pending); + } + drop(self.owner.take()); + } +} + /// Линейный, полностью вычисленный и ещё не опубликованный переход Session. /// /// Drop отбрасывает только prospective data. Commit поглощает единственное @@ -232,16 +299,19 @@ pub(crate) struct DeferredSessionRetirement { pub(crate) struct PreparedSessionTransition<'session, Plan: SessionPlanV1> { raw_head: &'session mut SessionObservationHeadV1, state: &'session mut SessionState, - pending: PendingSessionTransition, - // Rust уничтожает поля в порядке объявления. Lease остаётся последним, - // чтобы abort уничтожил все prospective evidence при живой generation. - owner: Plan::OwnerLease, + deferred_retirement: &'session mut Option>, + guard: PendingSessionTransitionGuard<'session, Plan>, } impl<'session, Plan: SessionPlanV1> PreparedSessionTransition<'session, Plan> { /// Возвращает fully evaluated prospective disposition без публикации. pub(crate) fn disposition(&self) -> PreparedSessionDispositionV1<'_, Plan> { - match &self.pending { + match self + .guard + .pending + .as_ref() + .unwrap_or_else(|| unreachable!("prepared transition has not been consumed")) + { PendingSessionTransition::Idempotent => PreparedSessionDispositionV1::Idempotent { raw_head: self.raw_head.observation_head(), state: self.state, @@ -265,97 +335,118 @@ impl<'session, Plan: SessionPlanV1> PreparedSessionTransition<'session, Plan> { /// Функция не возвращает ошибку и не выполняет admission, evaluation или /// allocation. Она не утверждает, что внешний sink принял output. pub(crate) fn commit(self) -> SessionView<'session, Plan> { - let (view, retirement) = self.commit_deferred(); - drop(retirement); + let Self { + raw_head, + state, + deferred_retirement: _, + mut guard, + } = self; + // Token получен только после drain и эксклюзивно заимствует retirement + // slot до commit, поэтому повторно проверить или заполнить его нельзя. + let (pending, owner) = guard.take_parts(); + let (view, retirement) = publish_session_transition(raw_head, state, pending, owner); + retirement.retire_into(guard.plan); + drop(guard); view } - /// Публикует пару raw-head/lifecycle, но возвращает всё вытесненное без - /// запуска пользовательских деструкторов. + /// Публикует пару raw-head/lifecycle и паркует всё вытесненное внутри той + /// же Session без запуска пользовательских деструкторов. /// /// После входа в эту функцию выполняются только перемещения и записи в /// уже существующие слоты. Это вариант для imperative shell, который уже /// установил внешний снимок и обязан отложить retirement до следующего /// pre-install участка. - pub(crate) fn commit_deferred( - self, - ) -> (SessionView<'session, Plan>, DeferredSessionRetirement) { + pub(crate) fn commit_deferred(self) -> SessionView<'session, Plan> { let Self { raw_head, state, - pending, - owner, + deferred_retirement, + mut guard, } = self; + // Та же эксклюзивная vacant-slot гарантия, что и у обычного commit. + let (pending, owner) = guard.take_parts(); + let (view, retirement) = publish_session_transition(raw_head, state, pending, owner); + *deferred_retirement = Some(retirement); + drop(guard); + view + } +} - let (retired_raw_head, retired_verified, retired_violation, displaced_placeholder) = - match pending { - PendingSessionTransition::Idempotent => (None, None, None, SessionState::Waiting), - PendingSessionTransition::Unknown(unknown) => { - let DisplacedSessionState { - last_verified, - discarded_violation, - } = displace_session_state(state); - let next_state = match last_verified { - Some(previous) => SessionState::Stale { previous }, - None => SessionState::Waiting, - }; - let retired_raw_head = - mem::replace(raw_head, SessionObservationHeadV1::Unknown(unknown)); - let displaced_placeholder = mem::replace(state, next_state); - ( - Some(retired_raw_head), +fn publish_session_transition<'session, Plan: SessionPlanV1>( + raw_head: &'session mut SessionObservationHeadV1, + state: &'session mut SessionState, + pending: PendingSessionTransition, + owner: Plan::OwnerLease, +) -> (SessionView<'session, Plan>, DeferredSessionRetirement) { + let (retired_raw_head, retired_verified, retired_violation, displaced_placeholder) = + match pending { + PendingSessionTransition::Idempotent => (None, None, None, SessionState::Waiting), + PendingSessionTransition::Unknown(unknown) => { + let DisplacedSessionState { + last_verified, + discarded_violation, + } = displace_session_state(state); + let next_state = match last_verified { + Some(previous) => SessionState::Stale { previous }, + None => SessionState::Waiting, + }; + let retired_raw_head = + mem::replace(raw_head, SessionObservationHeadV1::Unknown(unknown)); + let displaced_placeholder = mem::replace(state, next_state); + ( + Some(retired_raw_head), + None, + discarded_violation, + displaced_placeholder, + ) + } + PendingSessionTransition::Observed { + raw_observation, + decision, + } => { + let DisplacedSessionState { + last_verified, + discarded_violation, + } = displace_session_state(state); + let (next_state, retired_verified) = match decision { + SessionDecision::Verified(current) => { + (SessionState::Ready { current }, last_verified) + } + SessionDecision::Violation(cause) => ( + SessionState::Failed { + cause, + previous: last_verified, + }, None, - discarded_violation, - displaced_placeholder, - ) - } - PendingSessionTransition::Observed { - raw_observation, - decision, - } => { - let DisplacedSessionState { - last_verified, - discarded_violation, - } = displace_session_state(state); - let (next_state, retired_verified) = match decision { - SessionDecision::Verified(current) => { - (SessionState::Ready { current }, last_verified) - } - SessionDecision::Violation(cause) => ( - SessionState::Failed { - cause, - previous: last_verified, - }, - None, - ), - }; - let retired_raw_head = mem::replace( - raw_head, - SessionObservationHeadV1::Observed(raw_observation), - ); - let displaced_placeholder = mem::replace(state, next_state); - ( - Some(retired_raw_head), - retired_verified, - discarded_violation, - displaced_placeholder, - ) - } - }; - - let view = SessionView { - raw_head: raw_head.observation_head(), - state, - }; - let retirement = DeferredSessionRetirement { - _retired_raw_head: retired_raw_head, - _retired_verified: retired_verified, - _retired_violation: retired_violation, - _displaced_placeholder: displaced_placeholder, - _owner: owner, + ), + }; + let retired_raw_head = mem::replace( + raw_head, + SessionObservationHeadV1::Observed(raw_observation), + ); + let displaced_placeholder = mem::replace(state, next_state); + ( + Some(retired_raw_head), + retired_verified, + discarded_violation, + displaced_placeholder, + ) + } }; - (view, retirement) - } + + let view = SessionView { + raw_head: raw_head.observation_head(), + state, + }; + let retirement = DeferredSessionRetirement { + retired_raw_head, + retired_verified, + retired_violation, + displaced_placeholder, + _owner: owner, + }; + (view, retirement) } /// The only production owner of revision admission and evaluator lifecycle. @@ -363,21 +454,37 @@ impl<'session, Plan: SessionPlanV1> PreparedSessionTransition<'session, Plan> { /// A plan may keep only a weak reference to its compiled owner generation; /// every update pins that exact generation before admission and releases it /// after commit or rollback. -#[derive(Debug)] pub(crate) struct Session { stream: ObservationStreamId, plan: Plan, + observation_arenas: ObservationArenaPoolV1, raw_head: SessionObservationHeadV1, state: SessionState, + deferred_retirement: Option>, } impl Session { pub(crate) fn new(stream: ObservationStreamId, plan: Plan) -> Self { + let owner = plan + .try_acquire_owner() + .unwrap_or_else(|| unreachable!("a Session is created from one live compiled owner")); + let observation_arenas = ObservationArenaPoolV1::new(plan.observation_schema(&owner)); + drop(owner); Self { stream, plan, + observation_arenas, raw_head: SessionObservationHeadV1::Empty, state: SessionState::Waiting, + deferred_retirement: None, + } + } + + /// Retirement прошлого terminal install завершается до owner acquisition, + /// admission, evaluator work и новой sink mutation. + fn drain_deferred_retirement(&mut self) { + if let Some(retirement) = self.deferred_retirement.take() { + retirement.retire_into(&mut self.plan); } } @@ -410,15 +517,28 @@ impl Session { &mut self, update: ObservationUpdateInput, ) -> SessionPrepareResult<'_, Plan> { + self.drain_deferred_retirement(); let owner = self .plan .try_acquire_owner() .ok_or(SessionUpdateError::OwnerExpired)?; let schema = self.plan.observation_schema(&owner); - let prepared = prepare_observation(&mut self.raw_head, self.stream, schema, update) - .map_err(SessionUpdateError::Observation)?; + let prepared = prepare_observation( + &mut self.raw_head, + &mut self.observation_arenas, + self.stream, + schema, + update, + ) + .map_err(SessionUpdateError::Observation)?; - prepare_session_transition(&mut self.plan, &mut self.state, owner, prepared) + prepare_session_transition( + &mut self.plan, + &mut self.state, + &mut self.deferred_retirement, + owner, + prepared, + ) } /// Stream-affine `Unknown` admission without re-exporting or duplicating @@ -444,6 +564,7 @@ impl Session { source: &Source, order_scratch: &mut Vec, ) -> SessionPrepareResult<'_, Plan> { + self.drain_deferred_retirement(); let owner = self .plan .try_acquire_owner() @@ -451,6 +572,7 @@ impl Session { let schema = self.plan.observation_schema(&owner); let prepared = prepare_schema_ordered_observation( &mut self.raw_head, + &mut self.observation_arenas, self.stream, schema, revision, @@ -459,13 +581,20 @@ impl Session { ) .map_err(SessionUpdateError::Observation)?; - prepare_session_transition(&mut self.plan, &mut self.state, owner, prepared) + prepare_session_transition( + &mut self.plan, + &mut self.state, + &mut self.deferred_retirement, + owner, + prepared, + ) } } fn prepare_session_transition<'session, Plan: SessionPlanV1>( - plan: &mut Plan, + plan: &'session mut Plan, state: &'session mut SessionState, + deferred_retirement: &'session mut Option>, owner: Plan::OwnerLease, prepared: PreparedObservationUpdateV1<'session, SessionObservationHeadV1>, ) -> SessionPrepareResult<'session, Plan> { @@ -491,6 +620,7 @@ fn prepare_session_transition<'session, Plan: SessionPlanV1>( ) .map_err(SessionUpdateError::Plan)?; if !decision.observation().is_same_binding_as(&raw_observation) { + retire_session_decision(plan, decision); return Err(SessionUpdateError::EvidenceBindingInvariant); } ( @@ -506,11 +636,41 @@ fn prepare_session_transition<'session, Plan: SessionPlanV1>( Ok(PreparedSessionTransition { raw_head, state, - pending, - owner, + deferred_retirement, + guard: PendingSessionTransitionGuard { + plan, + pending: Some(pending), + owner: Some(owner), + }, }) } +fn retire_pending_transition( + plan: &mut Plan, + pending: PendingSessionTransition, +) { + match pending { + PendingSessionTransition::Idempotent | PendingSessionTransition::Unknown(_) => {} + PendingSessionTransition::Observed { + raw_observation, + decision, + } => { + retire_session_decision(plan, decision); + drop(raw_observation); + } + } +} + +fn retire_session_decision( + plan: &mut Plan, + decision: SessionDecision, +) { + match decision { + SessionDecision::Verified(verified) => plan.retire_verified(verified), + SessionDecision::Violation(violation) => plan.retire_violation(violation), + } +} + /// Вытесняет старый lifecycle, не уничтожая evidence до установки следующей /// пары raw-head/state. fn displace_session_state( diff --git a/crates/labcolors-core/src/session_tests.rs b/crates/labcolors-core/src/session_tests.rs index 196ef2ff..ac3ed164 100644 --- a/crates/labcolors-core/src/session_tests.rs +++ b/crates/labcolors-core/src/session_tests.rs @@ -5,10 +5,11 @@ use crate::Srgb8; use crate::appearance::SurfaceInputPortId; use crate::lcs_occurrence::ColorSignal; use crate::observation::{ - CanonicalObservationSchemaV1, ObservationError, ObservationHeadViewV1, ObservationPayloadInput, - ObservationStreamId, ObservationUpdateInput, ObservedScenarioSetInput, Revision, - RevisionBoundObservationV1, ScenarioId, ScenarioInput, SchemaOrderedScenarioSourceV1, - SurfaceInputBinding, UnknownReasonId, canonicalize_observation_schema, + CanonicalObservationSchemaV1, OBSERVATION_ARENA_SLOT_COUNT_V1, ObservationError, + ObservationHeadViewV1, ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, + ObservedScenarioSetInput, Revision, RevisionBoundObservationV1, ScenarioId, ScenarioInput, + SchemaOrderedScenarioSourceV1, SurfaceInputBinding, UnknownReasonId, + canonicalize_observation_schema, }; use crate::session::{ PreparedSessionDispositionV1, Session, SessionDecision, SessionEvidenceV1, @@ -605,14 +606,15 @@ fn exact_replay_is_idempotent_and_never_invokes_the_plan() { } #[test] -fn schema_ordered_admission_shares_only_the_plan_schema_handle() { +fn schema_ordered_admission_reuses_the_prewarmed_canonical_schema_arenas() { let (mut session, control, schema_ptr) = session(); + let session_schema_handle_count = 1 + OBSERVATION_ARENA_SLOT_COUNT_V1; assert_eq!( session .plan() .observation_schema(&()) .strong_count_for_test(), - 1, + session_schema_handle_count, ); let source = OneOrderedScenario { id: ScenarioId::new(1), @@ -627,26 +629,60 @@ fn schema_ordered_admission_shares_only_the_plan_schema_handle() { panic!("white sentinel input must verify"); }; assert_eq!(current.observation.schema_ptr_for_test(), schema_ptr); + let observation_backing_ptr = current.observation.backing_ptr_for_test(); assert_eq!( session .plan() .observation_schema(&()) .strong_count_for_test(), - 2, + session_schema_handle_count, ); assert_eq!(control.evaluation_count(), 1); - session + let idempotent_observation_backing_ptr = match session .commit_schema_ordered(Revision::new(1), &source, &mut order_scratch) - .unwrap(); + .unwrap() + { + SessionState::Ready { current } => current.observation.backing_ptr_for_test(), + _ => panic!("an exact schema-ordered replay must retain Ready"), + }; + assert_eq!(idempotent_observation_backing_ptr, observation_backing_ptr); assert_eq!( session .plan() .observation_schema(&()) .strong_count_for_test(), - 2, + session_schema_handle_count, ); assert_eq!(control.evaluation_count(), 1); + + let observation_clone = match session.state() { + SessionState::Ready { current } => current.observation.clone(), + _ => panic!("the verified observation must remain current"), + }; + assert_eq!(observation_clone.schema_ptr_for_test(), schema_ptr); + assert_eq!( + session + .plan() + .observation_schema(&()) + .strong_count_for_test(), + session_schema_handle_count, + "cloning an observation must not clone the canonical schema Rc", + ); + drop(observation_clone); + + let schema_probe = session.plan().observation_schema(&()).clone(); + assert_eq!( + schema_probe.strong_count_for_test(), + session_schema_handle_count + 1, + ); + drop(session); + assert_eq!(schema_probe.backing_ptr_for_test(), schema_ptr); + assert_eq!( + schema_probe.strong_count_for_test(), + 1, + "dropping the Session must release all three persistent arena schema handles", + ); } #[test] @@ -804,13 +840,13 @@ fn deferred_commit_returns_before_hostile_retirement_destructor_runs() { panic_on_next_drop.set(true); let committed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| prepared.commit_deferred())); - let (view, retirement) = committed.expect("deferred commit must only move retirement"); + let view = committed.expect("deferred commit must only park retirement"); assert_eq!(view.raw_head().revision(), Some(Revision::new(2))); assert!(matches!(view.state(), SessionState::Ready { .. })); assert_eq!(drops.get(), 0); let retirement = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - drop(retirement); + let _ = session.prepare_update(observed_update(3, [255; 3])); })); assert!( retirement.is_err(), @@ -851,10 +887,15 @@ fn deferred_retirement_keeps_its_exact_owner_alive_through_old_evidence_drop() { drop(generation); assert!(alive.get()); - let (_view, retirement) = prepared.commit_deferred(); + let view = prepared.commit_deferred(); assert!(events.borrow().is_empty()); assert!(alive.get()); - drop(retirement); + assert_eq!(view.raw_head().revision(), Some(Revision::new(2))); + + assert!(matches!( + session.prepare_update(observed_update(3, [255; 3])), + Err(SessionUpdateError::OwnerExpired) + )); assert!(!alive.get()); assert!(generation_weak.upgrade().is_none()); diff --git a/packages/colors/test/release-contract.test.mjs b/packages/colors/test/release-contract.test.mjs index dd32feac..7d1056b0 100644 --- a/packages/colors/test/release-contract.test.mjs +++ b/packages/colors/test/release-contract.test.mjs @@ -482,6 +482,215 @@ test("MSRV and packaged Rust crate gates are executable CI contracts", () => { assert.match(ci, /DEPS_DIR="\$RUNNER_TEMP\/chrome-deps-\$GITHUB_JOB"/); assert.match(ci, /APT_LISTS="\$DEPS_DIR\/apt-lists"/); assert.match(ci, /APT_CACHE="\$DEPS_DIR\/apt-cache"/); + const chromeInstallStep = "name: install Chrome + dependencies (Chrome for Testing + apt-get download)"; + const assertAptSourceIsolation = (workflow) => { + const active = workflowRunScript(workflow, chromeInstallStep) + .split("\n") + .filter((line) => !line.trimStart().startsWith("#")) + .join("\n"); + const activeLines = active.split("\n").map((line) => line.trim()); + assert.equal( + activeLines[0], + "set -euo pipefail", + "Chrome dependency install must start in fail-closed shell mode", + ); + assert.deepEqual( + activeLines.filter((line) => /\bset\b/u.test(line)), + ["set -euo pipefail"], + "Chrome dependency install cannot weaken fail-closed shell mode", + ); + assert.equal( + active.match(/^readonly APT_SOURCES="\$DEPS_DIR\/apt-sources"$/gmu)?.length, + 1, + "Chrome APT source root must have one active job-local authority", + ); + assert.equal( + active.match(/\bAPT_SOURCES(?:\[[^\]\n]*\])?\s*\+?=/gu)?.length, + 1, + "Chrome APT source authority must be assigned exactly once", + ); + assert.doesNotMatch(active, /\bunset\b[^\n;]*\bAPT_SOURCES\b/gu); + const optionArrays = [...active.matchAll( + /^[ \t]*APT_OPTIONS=\(\n(?(?:[ \t]+[^\n]*\n)+)^[ \t]*\)$/gmu, + )]; + assert.equal(optionArrays.length, 1, "Chrome step must have one active APT_OPTIONS array"); + assert.equal( + active.match(/\bAPT_OPTIONS(?:\[[^\]\n]*\])?\s*\+?=/gu)?.length, + 1, + "Chrome APT options must be assigned exactly once", + ); + assert.equal(active.match(/^readonly APT_OPTIONS$/gmu)?.length, 1); + assert.doesNotMatch(active, /\bunset\b[^\n;]*\bAPT_OPTIONS\b/gu); + const optionBody = optionArrays[0].groups?.body ?? ""; + for (const option of [ + '-o "Dir::Etc::sourcelist=$APT_SOURCES/sources.list"', + '-o "Dir::Etc::sourceparts=$APT_SOURCES/sources.list.d"', + ]) { + assert.equal( + optionBody.split("\n").filter((line) => line.trim() === option).length, + 1, + `missing active isolated-source option: ${option}`, + ); + } + assert.match(active, /^: "\$\{ID:\?missing distro ID\}"$/mu); + assert.match(active, /^: "\$\{VERSION_CODENAME:\?missing distro codename\}"$/mu); + assert.match(active, /^case "\$ID:\$VERSION_CODENAME" in$/mu); + assert.match( + active, + /^\s*debian:bookworm\|ubuntu:jammy\)\n\s+ALSA_PACKAGE=libasound2\n\s+;;$/mu, + ); + assert.match( + active, + /^\s*debian:trixie\|ubuntu:noble\)\n\s+ALSA_PACKAGE=libasound2t64\n\s+;;$/mu, + ); + assert.match( + active, + /^\s*\*\)\n\s+echo "unsupported Chrome dependency release: \$ID:\$VERSION_CODENAME" >&2\n\s+exit 1\n\s+;;$/mu, + ); + assert.deepEqual( + activeLines.filter((line) => /\bALSA_PACKAGE\b/u.test(line)), + [ + "ALSA_PACKAGE=libasound2", + "ALSA_PACKAGE=libasound2t64", + "readonly ALSA_PACKAGE", + '(cd "$DEBS_DIR" && apt-get "${APT_OPTIONS[@]}" download libnspr4 libnss3 "$ALSA_PACKAGE" libgbm1 2>&1)', + ], + "the release matrix must be the sole ALSA package authority", + ); + const trustedSourceLines = [ + '"deb [signed-by=$DISTRO_KEYRING] https://deb.debian.org/debian $VERSION_CODENAME main" \\', + '"deb [signed-by=$DISTRO_KEYRING] https://deb.debian.org/debian $VERSION_CODENAME-updates main" \\', + '"deb [signed-by=$DISTRO_KEYRING] https://security.debian.org/debian-security $VERSION_CODENAME-security main" \\', + '"deb [signed-by=$DISTRO_KEYRING] https://archive.ubuntu.com/ubuntu $VERSION_CODENAME main" \\', + '"deb [signed-by=$DISTRO_KEYRING] https://archive.ubuntu.com/ubuntu $VERSION_CODENAME-updates main" \\', + '"deb [signed-by=$DISTRO_KEYRING] https://security.ubuntu.com/ubuntu $VERSION_CODENAME-security main" \\', + ]; + assert.deepEqual( + activeLines.filter((line) => /(?:^|["'])deb\s/u.test(line)), + trustedSourceLines, + "the generated source inventory must contain only the six exact distro sources", + ); + assert.deepEqual( + activeLines.filter((line) => /\$\{?APT_SOURCES\}?\/sources\.list/u.test(line)), + [ + '"$APT_SOURCES/sources.list.d"', + '> "$APT_SOURCES/sources.list"', + '> "$APT_SOURCES/sources.list"', + '-o "Dir::Etc::sourcelist=$APT_SOURCES/sources.list"', + '-o "Dir::Etc::sourceparts=$APT_SOURCES/sources.list.d"', + ], + "the isolated source inventory must have one closed set of readers and writers", + ); + assert.deepEqual( + activeLines.filter((line) => line.startsWith("DISTRO_KEYRING=")), + [ + "DISTRO_KEYRING=/usr/share/keyrings/debian-archive-keyring.gpg", + "DISTRO_KEYRING=/usr/share/keyrings/ubuntu-archive-keyring.gpg", + ], + "each distro branch must bind its official archive keyring exactly once", + ); + assert.equal(active.match(/^readonly DISTRO_KEYRING$/gmu)?.length, 1); + assert.equal(active.match(/^\s*test -r "\$DISTRO_KEYRING"$/gmu)?.length, 2); + assert.match( + active, + /^\s*\*\)\n\s+echo "unsupported Chrome dependency distro: \$ID" >&2\n\s+exit 1\n\s+;;$/mu, + ); + + const optionsEnd = optionArrays[0].index + optionArrays[0][0].length; + const afterOptions = active.slice(optionsEnd); + assert.match(afterOptions, /^\nreadonly APT_OPTIONS\napt-get /u); + const update = active.indexOf('apt-get "${APT_OPTIONS[@]}" update', optionsEnd); + const download = active.indexOf('apt-get "${APT_OPTIONS[@]}" download', update); + assert.ok(optionsEnd < update && update < download); + assert.deepEqual( + active + .split("\n") + .map((line) => line.trim()) + .filter((line) => /(?:^|[\s;&(|])apt(?:-get)?(?=\s)/u.test(line)), + [ + 'apt-get "${APT_OPTIONS[@]}" update', + '(cd "$DEBS_DIR" && apt-get "${APT_OPTIONS[@]}" download libnspr4 libnss3 "$ALSA_PACKAGE" libgbm1 2>&1)', + ], + "every APT invocation must use the one immutable isolated option set", + ); + }; + assertAptSourceIsolation(ci); + for (const mutant of [ + ci.replace( + ' -o "Dir::Etc::sourcelist=$APT_SOURCES/sources.list"', + ' # -o "Dir::Etc::sourcelist=$APT_SOURCES/sources.list"', + ), + ci.replace( + ' apt-get "${APT_OPTIONS[@]}" update', + ' APT_OPTIONS=()\n apt-get "${APT_OPTIONS[@]}" update', + ), + ci.replace( + ' apt-get "${APT_OPTIONS[@]}" update', + ' APT_SOURCES=/etc/apt\n apt-get "${APT_OPTIONS[@]}" update', + ), + ci.replace( + ' apt-get "${APT_OPTIONS[@]}" update', + ' :; APT_OPTIONS=()\n apt-get "${APT_OPTIONS[@]}" update', + ), + ci.replace( + ' (cd "$DEBS_DIR" && apt-get', + ' unset APT_OPTIONS\n (cd "$DEBS_DIR" && apt-get', + ), + ci.replace( + ' apt-get "${APT_OPTIONS[@]}" update', + ' apt-get download libnss3\n apt-get "${APT_OPTIONS[@]}" update', + ), + ci.replace( + "debian:bookworm|ubuntu:jammy)", + "debian:bookworm|ubuntu:noble)", + ), + ci.replace( + "https://deb.debian.org/debian $VERSION_CODENAME main", + "https://example.invalid/debian $VERSION_CODENAME main", + ), + ci.replace( + "[signed-by=$DISTRO_KEYRING] https://archive.ubuntu.com/ubuntu", + "[signed-by=/tmp/forged.gpg] https://archive.ubuntu.com/ubuntu", + ), + ci.replace( + "DISTRO_KEYRING=/usr/share/keyrings/debian-archive-keyring.gpg", + "DISTRO_KEYRING=/tmp/forged.gpg", + ), + ci.replace( + "https://security.ubuntu.com/ubuntu $VERSION_CODENAME-security main", + "https://security.ubuntu.com/ubuntu stable-security main", + ), + ci.replace( + ' readonly ALSA_PACKAGE\n case "$ID" in', + ' if [[ "$ID:$VERSION_CODENAME" == ubuntu:jammy ]]; then ALSA_PACKAGE=libasound2t64; fi\n' + + ' readonly ALSA_PACKAGE\n case "$ID" in', + ), + ci.replace( + " readonly DISTRO_KEYRING\n", + " readonly DISTRO_KEYRING\n" + + ' echo "deb [trusted=yes] https://deb.debian.org/debian sid main" >> "$APT_SOURCES/sources.list"\n', + ), + ci.replace( + " run: |\n set -euo pipefail\n # -- CfT chrome + chromedriver --", + " run: |\n set +e\n # -- CfT chrome + chromedriver --", + ), + ci.replace( + ' readonly ALSA_PACKAGE\n case "$ID" in', + ' ALSA_PACKAGE+=t64\n readonly ALSA_PACKAGE\n case "$ID" in', + ), + ci.replace( + " readonly ALSA_PACKAGE\n", + " readonly ALSA_PACKAGE\n :; set +e\n", + ), + ci.replace( + " readonly DISTRO_KEYRING\n", + " readonly DISTRO_KEYRING\n" + + ' echo "deb https://example.invalid/debian sid main" >> $APT_SOURCES/sources.list\n', + ), + ]) { + assert.notEqual(mutant, ci, "hostile APT mutation must bite"); + assert.throws(() => assertAptSourceIsolation(mutant)); + } assert.match(ci, /Dir::State::lists=\$APT_LISTS/); assert.match(ci, /Dir::State::status=\/var\/lib\/dpkg\/status/); assert.match(ci, /Dir::Cache=\$APT_CACHE/); diff --git a/scripts/test_point_support_surplus_source_binding.py b/scripts/test_point_support_surplus_source_binding.py index 9e43f10d..62bab329 100644 --- a/scripts/test_point_support_surplus_source_binding.py +++ b/scripts/test_point_support_surplus_source_binding.py @@ -55,7 +55,7 @@ def test_complete_production_dependency_cone_is_bound(self) -> None: ), ( self.observation_path, - b" self.backing.set.values(case_index)\n", + b" self.backing.set().values(case_index)\n", b" None\n", ), ( diff --git a/scripts/test_program_public_surface.py b/scripts/test_program_public_surface.py index 6b1b423c..cd04ede5 100755 --- a/scripts/test_program_public_surface.py +++ b/scripts/test_program_public_surface.py @@ -53,6 +53,73 @@ def test_arbitrary_root_reexport_alias_is_rejected_by_origin(self) -> None: self.assertEqual(count, 1) self.assertEqual([leak.public_item for leak in leaks], ["struct.AnyClientName.html"]) + def test_arbitrary_session_alias_is_rejected_by_origin(self) -> None: + self.write_all("struct.SessionAlias.html") + self.write_item( + "struct.SessionAlias.html", + 'Source', + ) + _, leaks = program_public_surface(self.crate) + self.assertEqual(len(leaks), 1) + + def test_public_page_inside_forbidden_module_is_rejected(self) -> None: + for module in ("observation", "program", "program_session", "session"): + with self.subTest(module=module): + item = f"{module}/struct.InternalItem.html" + self.write_all(item) + self.write_item( + item, + ( + 'Source' + ), + ) + count, leaks = program_public_surface(self.crate) + self.assertEqual(count, 1) + self.assertEqual(len(leaks), 1) + self.assertEqual(leaks[0].public_item, item) + self.assertEqual( + leaks[0].route, + f"labcolors_core/{item}", + ) + + def test_similar_module_name_is_not_rejected(self) -> None: + self.write_all("sessionish/struct.ClientItem.html") + self.write_item( + "sessionish/struct.ClientItem.html", + ( + 'Source' + ), + ) + count, leaks = program_public_surface(self.crate) + self.assertEqual(count, 1) + self.assertEqual(leaks, []) + + def test_arbitrary_program_session_alias_is_rejected_by_origin(self) -> None: + self.write_all("struct.ProgramSessionAlias.html") + self.write_item( + "struct.ProgramSessionAlias.html", + ( + 'Source' + ), + ) + _, leaks = program_public_surface(self.crate) + self.assertEqual(len(leaks), 1) + + def test_arbitrary_observation_alias_is_rejected_by_origin(self) -> None: + self.write_all("struct.ObservationAlias.html") + self.write_item( + "struct.ObservationAlias.html", + ( + 'Source' + ), + ) + _, leaks = program_public_surface(self.crate) + self.assertEqual(len(leaks), 1) + def test_nested_alias_is_rejected_without_a_name_allowlist(self) -> None: self.write_all("facade/struct.UnrelatedName.html") self.write_item( diff --git a/scripts/verify_point_support_surplus.py b/scripts/verify_point_support_surplus.py index c8c82c43..89604b05 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 = ( - "bc676c172691493be0b2a2a0910505a9b6047ca02e3e49d39fb4ffa8dbd92e4f" + "472d7e40538fc8864b66ddc65ab45728183b3a8cd309efa49011f3ce2b9f6619" ) EXPECTED_Q55_PROOF_SHA256 = ( "ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd" @@ -198,8 +198,8 @@ def verify_source_binding() -> tuple[str, int]: (POINT_SOURCE, b" if !observation.shares_schema_backing_with(&plan.surface_schema) {\n", b" if observation.shares_schema_backing_with(&plan.surface_schema) {\n"), (POINT_SOURCE, b" _permit: SessionObservationBindingPermitV1,\n", b" _permit: (),\n"), (POINT_SOURCE, b"use crate::wcag22::{Wcag22CriterionV1, Wcag22MeasurementV1, measure_wcag22_srgb8};", b"use crate::wcag22::{Wcag22CriterionV1, Wcag22MeasurementV1, measure_wcag22_srgb8 as canonical_measure_wcag22_srgb8};\nfn measure_wcag22_srgb8(foreground: [u8; 3], background: [u8; 3]) -> Wcag22MeasurementV1 { canonical_measure_wcag22_srgb8(background, foreground) }"), - (OBSERVATION_SOURCE, b" self.backing.set.values(case_index)\n", b" None\n"), - (OBSERVATION_SOURCE, b" values.extend(bindings.iter().map(|binding| binding.value));\n", b" values.extend(bindings.iter().map(|_| Srgb8::new([0, 0, 0])));\n"), + (OBSERVATION_SOURCE, b" self.backing.set().values(case_index)\n", b" None\n"), + (OBSERVATION_SOURCE, b" .extend(bindings.iter().map(|binding| binding.value));\n", b" .extend(bindings.iter().map(|_| ColorSignal::from_srgb8(Srgb8::new([0, 0, 0]))));\n"), (OBSERVATION_SOURCE, b" Rc::ptr_eq(&self.0, &other.0)\n", b" self == other\n"), (OBSERVATION_SOURCE, b" Self(Rc::clone(&self.0))\n", b" Self(Rc::from(self.as_slice()))\n"), (OBSERVATION_SOURCE, b"if expected_input != actual_input", b"if expected_input == actual_input"), @@ -209,11 +209,11 @@ def verify_source_binding() -> tuple[str, int]: (LCS_OCCURRENCE_SOURCE, b" pub(crate) const fn srgb8(self) -> Srgb8 {\n self.srgb8\n }", b" pub(crate) const fn srgb8(self) -> Srgb8 {\n Srgb8::new([0, 0, 0])\n }"), (SESSION_SOURCE, b" Self::Observed(observation) => ObservationHeadViewV1::Observed(observation),\n", b" Self::Observed(_) => ObservationHeadViewV1::Empty,\n"), (SESSION_SOURCE, b" let (raw_head, observation) = prepared.into_parts();\n let raw_observation = observation.clone();\n", b" let (raw_head, observation) = prepared.into_parts();\n *raw_head = SessionObservationHeadV1::Observed(observation.clone());\n let raw_observation = observation.clone();\n"), - (SESSION_SOURCE, b" let retired_raw_head = mem::replace(\n raw_head,\n SessionObservationHeadV1::Observed(raw_observation),\n );\n", b" let retired_raw_head = mem::replace(\n raw_head,\n SessionObservationHeadV1::Empty,\n );\n"), + (SESSION_SOURCE, b" let retired_raw_head = mem::replace(\n raw_head,\n SessionObservationHeadV1::Observed(raw_observation),\n );\n", b" let retired_raw_head = mem::replace(\n raw_head,\n SessionObservationHeadV1::Empty,\n );\n"), (SESSION_SOURCE, b" Some(previous) => SessionState::Stale { previous },", b" Some(_) => SessionState::Waiting,"), (SESSION_SOURCE, b" SessionObservationBindingPermitV1::mint(),", b" SessionObservationBindingPermitV1::for_test(),"), - (SESSION_SOURCE, b" SessionDecision::Verified(current) => {\n (SessionState::Ready { current }, last_verified)\n }\n", b" SessionDecision::Verified(current) => {\n (SessionState::Stale { previous: current }, last_verified)\n }\n"), - (SESSION_SOURCE, b" SessionDecision::Violation(cause) => (\n SessionState::Failed {\n cause,\n previous: last_verified,\n },\n None,\n ),\n", b" SessionDecision::Violation(_) => (\n SessionState::Waiting,\n last_verified,\n ),\n"), + (SESSION_SOURCE, b" SessionDecision::Verified(current) => {\n (SessionState::Ready { current }, last_verified)\n }\n", b" SessionDecision::Verified(current) => {\n (SessionState::Stale { previous: current }, last_verified)\n }\n"), + (SESSION_SOURCE, b" SessionDecision::Violation(cause) => (\n SessionState::Failed {\n cause,\n previous: last_verified,\n },\n None,\n ),\n", b" SessionDecision::Violation(_) => (\n SessionState::Waiting,\n last_verified,\n ),\n"), (SESSION_SOURCE, b" return Err(SessionUpdateError::EvidenceBindingInvariant);\n", b" unreachable!();\n"), (NUMERICS_SOURCE, b"proof_ids: [NumericalProofIdV2::PointSupportReferenceSurplusIntegerV1],\n bound_status: Available", b"proof_ids: [NumericalProofIdV2::PointSupportReferenceSurplusIntegerV1],\n bound_status: Unavailable"), (COMPOSITION_SOURCE, b"f64::from(backdrop) + alpha * (f64::from(tint) - f64::from(backdrop))", b"f64::from(tint)"), diff --git a/scripts/verify_program_public_surface.py b/scripts/verify_program_public_surface.py index 6a9c78f6..01bd559b 100755 --- a/scripts/verify_program_public_surface.py +++ b/scripts/verify_program_public_surface.py @@ -129,14 +129,22 @@ def public_item_pages(crate_doc_root: Path) -> list[tuple[str, Path]]: def program_public_surface(crate_doc_root: Path) -> tuple[int, list[ProgramLeak]]: crate_doc_root = crate_doc_root.resolve() docs_root = crate_doc_root.parent - forbidden_source = (docs_root / "src/labcolors_core/program.rs.html").resolve() - forbidden_source_dir = (docs_root / "src/labcolors_core/program").resolve() - forbidden_module = (crate_doc_root / "program").resolve() + forbidden_sources = tuple( + (docs_root / f"src/labcolors_core/{source}.rs.html").resolve() + for source in ("observation", "program", "program_session", "session") + ) + forbidden_source_dirs = ( + (docs_root / "src/labcolors_core/program").resolve(), + ) + forbidden_modules = tuple( + (crate_doc_root / module).resolve() + for module in ("observation", "program", "program_session", "session") + ) pages = public_item_pages(crate_doc_root) leaks: list[ProgramLeak] = [] for public_item, page in pages: - if _inside(page, forbidden_module): + if any(_inside(page, module) for module in forbidden_modules): leaks.append(ProgramLeak(public_item, str(page.relative_to(docs_root)))) continue @@ -151,9 +159,9 @@ def program_public_surface(crate_doc_root: Path) -> tuple[int, list[ProgramLeak] if "src" in classes: source_links += 1 if ( - route == forbidden_source - or _inside(route, forbidden_source_dir) - or _inside(route, forbidden_module) + route in forbidden_sources + or any(_inside(route, source_dir) for source_dir in forbidden_source_dirs) + or any(_inside(route, module) for module in forbidden_modules) ): leaks.append(ProgramLeak(public_item, href)) break