diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bac26c3..c4dcaffa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,28 +235,65 @@ jobs: text=True, )) packages = {package["name"]: package for package in metadata["packages"]} + consumers = ( + "labcolors-wasm", + "labcolors-ffi", + "labcolors-conformance", + ) core = packages["labcolors-core"] if core["features"].get("default") != ["wcag22-feasibility"]: raise SystemExit("labcolors-core default capability set drifted") - dependencies = [ + protocol_core = [ dependency - for dependency in packages["labcolors-wasm"]["dependencies"] + for dependency in packages["labcolors-protocol"]["dependencies"] if dependency["name"] == "labcolors-core" and dependency["kind"] is None ] - if len(dependencies) != 1: - raise SystemExit("labcolors-wasm must have one normal labcolors-core edge") - dependency = dependencies[0] - if dependency["uses_default_features"] or dependency["features"]: - raise SystemExit("labcolors-wasm enabled an unprojected core capability") - PY + if len(protocol_core) != 1: + raise SystemExit("labcolors-protocol must have one normal labcolors-core edge") + protocol_core = protocol_core[0] + if protocol_core["uses_default_features"]: + raise SystemExit("labcolors-protocol must disable Core default capabilities") + if protocol_core["features"] != ["wcag22-feasibility"]: + raise SystemExit( + "labcolors-protocol must own exactly wcag22-feasibility, got " + f"{protocol_core['features']}" + ) - feature_tree="$(cargo tree -p labcolors-wasm --edges normal -e features)" - if grep -Fq 'labcolors-core feature "wcag22-feasibility"' <<<"$feature_tree"; then - echo "labcolors-wasm resolved an unprojected core capability" >&2 - exit 1 - fi - echo "core capability projection: PASS" + for consumer in consumers: + core_dependencies = [ + dependency + for dependency in packages[consumer]["dependencies"] + if dependency["name"] == "labcolors-core" and dependency["kind"] is None + ] + if len(core_dependencies) != 1: + raise SystemExit(f"{consumer} must have one normal labcolors-core edge") + core_dependency = core_dependencies[0] + if core_dependency["uses_default_features"]: + raise SystemExit(f"{consumer} must disable Core default capabilities") + if core_dependency["features"]: + raise SystemExit( + f"{consumer} direct Core edge must own no optional capability, got " + f"{core_dependency['features']}" + ) + protocol_dependencies = [ + dependency + for dependency in packages[consumer]["dependencies"] + if dependency["name"] == "labcolors-protocol" and dependency["kind"] is None + ] + if len(protocol_dependencies) != 1: + raise SystemExit(f"{consumer} must have one normal labcolors-protocol edge") + feature_tree = subprocess.check_output( + ["cargo", "tree", "-p", consumer, "--edges", "normal", "-e", "features"], + text=True, + ) + if 'labcolors-core feature "wcag22-feasibility"' not in feature_tree: + raise SystemExit( + f"{consumer} did not resolve protocol-owned wcag22-feasibility" + ) + + print("core capability projection: PASS") + PY - name: reject WCAG22 source-route redirects and proof vacuums run: python3 scripts/test_wcag22_source_binding.py - name: prove WCAG22 sRGB8 Q55 artifact over the full finite domain @@ -278,13 +315,25 @@ jobs: --admit-package-version 0.2.0 --admit-sample-count 5 ) - if test "$GITHUB_EVENT_NAME" = pull_request; then + historical_snapshot=6001cf41e0a8364f25543e7955ceaf64d50129b4 + historical_root="$RUNNER_TEMP/wcag22-feasibility-slice-a-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + git worktree add --detach "$historical_root" "$historical_snapshot" + cleanup_history() { + git worktree remove --force "$historical_root" + } + trap cleanup_history EXIT + ( + cd "$historical_root" python3 scripts/check_wcag22_feasibility_benchmark.py \ - "$artifact" "${protocol[@]}" --self-test - fi - python3 scripts/check_wcag22_feasibility_benchmark.py \ - "$artifact" "${protocol[@]}" \ - --verify-current-subjects \ + "$artifact" "${protocol[@]}" \ + --verify-current-subjects \ + --artifact-sha256 7e9ffcbdd9d5d50fe681f511c34fc5c5dd270e9c475ce23ae56e9776922a3c5e \ + --self-test + ) + cleanup_history + trap - EXIT + python3 scripts/check_wcag22_feasibility_applicability.py \ + "$artifact" \ --artifact-sha256 7e9ffcbdd9d5d50fe681f511c34fc5c5dd270e9c475ce23ae56e9776922a3c5e \ --self-test @@ -392,10 +441,32 @@ jobs: node-version: ${{ env.NODE_TOOLCHAIN }} cache: npm cache-dependency-path: packages/colors/package-lock.json + - name: "verify committed #295 canonical whole-call WASM boundary evidence" + # The admitted JSON binds exact deterministic bytes/shapes and the + # reviewed Uint8Array package root to this built Linux x64 WASM. Fresh + # latency/maxRSS/pages remain observations only. + working-directory: packages/colors + run: >- + node bench/wcag22-feasibility-boundary.bench.mjs + --verify + - name: independently fingerprint the exact #295 WASM + run: | + bytes="$(wc -c < packages/colors/pkg/labcolors_bg.wasm | tr -d '[:space:]')" + sha256="$(sha256sum packages/colors/pkg/labcolors_bg.wasm | cut -d ' ' -f1)" + echo "canonical candidate raw=${bytes}B sha256=${sha256}" + - name: "upload exact #295 verified whole-call evidence" + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: wcag22-feasibility-wasm-boundary-${{ github.sha }}-attempt-${{ github.run_attempt }} + path: | + packages/colors/bench/wcag22-feasibility-wasm-boundary-v1.json + packages/colors/pkg/labcolors_bg.wasm + if-no-files-found: error + retention-days: 30 - name: enforce measured WASM raw-byte budget - # Issue #284 owns the immutable measured ceiling baseline. The checker - # separately binds this current Linux x64 artifact's exact SHA-256 and - # rejects growth above that ceiling. gzip is transport diagnostics only. + # Issue #295 owns the current exact Linux x64 measurement and zero-headroom + # ceiling. Issue #284 remains the immutable V1 build-recipe/baseline input. + # gzip is transport diagnostics only. run: node scripts/check-wasm-size-budget.mjs - name: "@labpics/colors: typecheck + runtime tests" # Now that pkg/ exists (built above), the package's public types resolve diff --git a/.github/workflows/native-conformance.yml b/.github/workflows/native-conformance.yml index ea75e40d..cb44ecb6 100644 --- a/.github/workflows/native-conformance.yml +++ b/.github/workflows/native-conformance.yml @@ -51,11 +51,29 @@ jobs: # $GITHUB_WORKSPACE монтируется read-only; сборка идёт в /work в контейнере. - name: swift conformance in swift:6.1.3 container run: | + evidence_dir="$RUNNER_TEMP/labcolors-swift-evidence" + rm -rf "$evidence_dir" + mkdir -p "$evidence_dir" + subject_sha=$(git rev-parse HEAD) docker run --rm \ -v "$GITHUB_WORKSPACE":/src:ro \ + -v "$evidence_dir":/evidence \ + -e LABCOLORS_SUBJECT_SHA="$subject_sha" \ + -e LABCOLORS_SWIFT_CONTAINER="$SWIFT_CONTAINER" \ + -e LABCOLORS_SWIFT_EVIDENCE_DIR=/evidence \ + -e LABCOLORS_EVIDENCE_MODE=canonical-ci \ "$SWIFT_CONTAINER" \ bash /src/bindings/swift/ci/run-conformance.sh + - name: upload versioned UniFFI/Swift observation + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: uniffi-swift-observation-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/labcolors-swift-evidence + if-no-files-found: ignore + retention-days: 14 + # ССЫЛОЧНАЯ джоба (НЕ активный гейт): нативный macOS/arm64. Требует ПЛАТНЫХ # GitHub-hosted macOS-минут, которые владелец исключил навсегда — поэтому # гейтирована `workflow_dispatch` и НЕ запускается на PR/push (не блокирует). diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3bf67f..4fd8db9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,11 @@ Rust различаются, потому что это разные delivery su ## [Unreleased] -Атомарная numerical-decision граница (#292) и exact WCAG 2.2 evaluator для -финальной sRGB8-пары (#284). Существующая цветовая эмиссия, config fingerprint -и adaptive runtime не меняются, но Rust/npm capability API и conformance pack -изменены; следующий release обязан получить согласованный 0.x version bump. +Атомарная numerical-decision граница (#292), exact WCAG 2.2 evaluator для +финальной sRGB8-пары (#284) и bounded complete-feasibility compiler (#295). +Существующая цветовая эмиссия, config fingerprint и adaptive runtime не +меняются, но Rust/npm/Swift transport API и conformance pack изменены; +следующий release обязан получить согласованный 0.x version bump. Migration-note: [exact alpha / typed Glow](docs/migrations/exact-alpha-glow.md), дополнение ADR-0004 от 2026-07-12. @@ -22,6 +23,15 @@ Migration-note: [exact alpha / typed Glow](docs/migrations/exact-alpha-glow.md), source bindings и SHA-256 live typed registry admission-row. - npm package несёт byte-exact profile/table/proof в `evidence/`; release verifier и clean-install gate повторно проверяют их хэши и содержимое. +- `labcolors-protocol` задаёт единственную versioned bytes→Core→wire границу + complete-feasibility. npm принимает только `Uint8Array`, Swift — `Data` или + `[UInt8]`; + обе поверхности сохраняют `Success(Feasible | Infeasible | NotEvaluated)` + либо typed `Failure` и не воспроизводят математику Core. +- Conformance pack 5.0.0 добавляет ровно одно семейство + `wcag22-feasibility.json`: exact 7/2/0/92/59, mixed/all NotApplicable, + typed conflict/resource failures и opaque-ID law. Шесть прежних family + остаются byte-identical. ### Breaking (Rust API) @@ -60,14 +70,16 @@ Migration-note: [exact alpha / typed Glow](docs/migrations/exact-alpha-glow.md), capsules. Profile V1, proof ID `wcag22-srgb8-full-domain-q55-v1` и package path `evidence/wcag22-srgb8-q55-proof-v1.json` не меняются: это отдельные version domains, а доказанная математика и finite artifact прежние. -- WASM size contract сохраняет immutable измерение #284 как происхождение - точного raw-byte ceiling и отдельно аттестует SHA текущего воспроизводимого - артефакта. Proof-only recertification больше не переписывает baseline задним - числом; ceiling остаётся `454385 B` без запаса. -- Conformance pack 4.0.0 добавляет `wcag22.json`; `packDigest` закономерно - изменён. `manifest.numericalCapabilities` зеркалит single public V2 core - manifest (coverage `migrated-sites-only-v1`, FNV-1a-32 drift-checksum над - canonical length-prefixed preimage). +- WASM size history стала append-only: immutable V1 сохраняет допуск #284 + (`454385 B`), а V2 допускает полный transport #295 как точные `521240 B` / + `d37841…9ca0` с нулевым headroom и ссылкой на неизменяемый V1 build recipe. + Canonical whole-call artifact фиксирует 10 крайних форм × 5 свежих процессов; + latency, process maxRSS и WASM pages остаются наблюдениями, не SLO. +- Conformance pack 4.0.0 добавил `wcag22.json`; pack 5.0.0 добавляет только + versioned complete-feasibility transport family, поэтому `packDigest` + закономерно изменён. `manifest.numericalCapabilities` зеркалит single public + V2 core manifest (coverage `migrated-sites-only-v1`, FNV-1a-32 + drift-checksum над canonical length-prefixed preimage). - Release manifest schema v2: секция `numericalSites` заменена на `numericalCapabilities`; release verifier и Swift conformance-тесты пересчитывают capability checksum независимо от Rust-кода. diff --git a/Cargo.lock b/Cargo.lock index 204c1c69..bd2faf41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -498,6 +498,7 @@ name = "labcolors-conformance" version = "0.2.0" dependencies = [ "labcolors-core", + "labcolors-protocol", "serde", "serde_json", ] @@ -518,10 +519,20 @@ name = "labcolors-ffi" version = "0.2.0" dependencies = [ "labcolors-core", + "labcolors-protocol", "thiserror", "uniffi", ] +[[package]] +name = "labcolors-protocol" +version = "0.2.0" +dependencies = [ + "labcolors-core", + "serde", + "serde_json", +] + [[package]] name = "labcolors-wasm" version = "0.2.0" @@ -531,6 +542,7 @@ dependencies = [ "js-sys", "labcolors-conformance", "labcolors-core", + "labcolors-protocol", "serde", "serde_json", "thiserror", diff --git a/README.md b/README.md index c78002a8..679fd744 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ NamedRoleTable source-over/screen операции несут проверяемый профиль и `bit-exact` сертификат; Glow требует явный decision profile и может завершиться типизированным `Indeterminate` без CSS fallback. +- **Полная проверка конечного домена.** Клиент может объявить opaque relations, + точные соседние sRGB8-цвета и критерии WCAG 2.2; Core полностью перечислит + зарегистрированную neutral-axis и вернёт packed feasible partition с + доказательством. Это проверка выполнимости, а не скрытая политика выбора. - **Непрерывные семейства.** `ColorCurve` и реализации `NeutralCurve`/`AccentCurve` доступны как низкоуровневые вычислительные примитивы. - **Браузерное применение.** `applyTheme`, `watchTheme`, `adaptTheme` и `effectiveBackground` связывают результат WASM с локальной областью DOM. @@ -308,6 +312,7 @@ Roadmap, текущий SHA и активный PR хранятся только ```text crates/ ├── labcolors-core — математика, конфиг, resolve и результаты +├── labcolors-protocol — единая versioned bytes→Core→wire граница ├── labcolors-wasm — WASM-граница ├── labcolors-ffi — нативная FFI-граница └── labcolors-conformance — общие тест-векторы diff --git a/bindings/swift/.gitignore b/bindings/swift/.gitignore index ca35b4f6..24395834 100644 --- a/bindings/swift/.gitignore +++ b/bindings/swift/.gitignore @@ -1,6 +1,8 @@ # Сгенерированные UniFFI-биндинги — производный артефакт, генерируется в CI -# (uniffi-bindgen) ПЕРЕД `swift test`. В репозитории каталоги несут лишь .gitkeep. +# (uniffi-bindgen) ПЕРЕД `swift test`. Единственный отслеживаемый Swift-source +# ниже — hand-written protocol consumer. Sources/LabColors/*.swift +!Sources/LabColors/Wcag22FeasibilityProtocol.swift Sources/labcolorsFFI/*.h Sources/labcolorsFFI/module.modulemap Sources/labcolorsFFI/*.modulemap diff --git a/bindings/swift/Package.swift b/bindings/swift/Package.swift index 4f872f86..8f87fe9d 100644 --- a/bindings/swift/Package.swift +++ b/bindings/swift/Package.swift @@ -5,8 +5,9 @@ // Swift-контейнере на Linux x86_64; ручной macOS/arm64 path не является // достигнутой аттестацией. Файлы в `Sources/LabColors` и // `Sources/labcolorsFFI` генерирует uniffi-bindgen ПЕРЕД `swift test` (см. -// .github/workflows/native-conformance.yml). В репозитории эти каталоги несут -// лишь .gitkeep — сгенерированное не коммитится. +// .github/workflows/native-conformance.yml). Единственный hand-written source +// в `Sources/LabColors` — exhaustive protocol wrapper +// `Wcag22FeasibilityProtocol.swift`; сгенерированное не коммитится. import PackageDescription let package = Package( @@ -19,7 +20,7 @@ let package = Package( // labcolorsFFI.h + labcolorsFFI.modulemap → переименовывается в // module.modulemap в CI). Экспортирует extern-C FFI-символы ядра. .systemLibrary(name: "labcolorsFFI", path: "Sources/labcolorsFFI"), - // Swift-обёртка (сгенерированный labcolors.swift) поверх системного модуля. + // Сгенерированный labcolors.swift + hand-written typed byte wrapper. .target( name: "LabColors", dependencies: ["labcolorsFFI"], diff --git a/bindings/swift/README.md b/bindings/swift/README.md index 4e306709..022b6c0c 100644 --- a/bindings/swift/README.md +++ b/bindings/swift/README.md @@ -3,8 +3,8 @@ Swift-поверхность динамического Rust-ядра через UniFFI: сгенерированный Swift вызывает `labcolors-core` в runtime, а не сериализует токены на сборке. Экспортируется рантайм-контраст-ядро (см. `crates/labcolors-ffi`): контраст, -резолв, лестницы, подложка→α, legacy-координата `muddiness` и low-level Glow -point decision с обязательным numerical profile. +резолв, лестницы, подложка→α, legacy-координата `muddiness`, low-level Glow +point decision и versioned WCAG 2.2 feasibility byte protocol. Исторически названная `muddiness` поверхность — это `experimental compatibility proxy`: она сохраняет прежний числовой API и его @@ -20,9 +20,10 @@ Linux x86_64. Оно не является аттестацией Apple ABI, mac - `Package.swift` — SwiftPM: системный модуль (`labcolorsFFI`) + Swift-обёртка (`LabColors`) + conformance-тесты. -- `Sources/LabColors`, `Sources/labcolorsFFI` — **генерируются в CI** - (uniffi-bindgen), в репозитории лишь `.gitkeep`. Коммитить сгенерированное не - нужно — оно производно от `crates/labcolors-ffi`. +- `Sources/LabColors/Wcag22FeasibilityProtocol.swift` — hand-written строгая + `Codable`-проекция `Success(feasibility) / Failure(error)` и host-preflight; + остальные Swift/C sources **генерируются в CI** (uniffi-bindgen) и не + коммитятся. - `Tests/LabColorsConformanceTests` — прогон закоммиченного пака `conformance/vectors/*.json` против выхода FFI. @@ -46,7 +47,7 @@ docker run --rm -v "$PWD":/src:ro \ Историческая аттестация pack `1.0.0` (числа, версии, платформа): `docs/conformance/local-swift-attestation.md`; она явно superseded для текущего -pack `2.0.0` и не подменяет новый CI-прогон. Нативный macOS/arm64 path сейчас +pack `5.0.0` и не подменяет новый CI-прогон. Нативный macOS/arm64 path сейчас представлен только ручной reference-джобой `swift-conformance-macos-reference` (`workflow_dispatch`): это **не gate** PR, `main` или release. Полная platform/runtime-матрица остаётся отдельной работой @@ -89,3 +90,35 @@ provenance и target outcome. `indeterminate` является stable отказ успешной валидации, неизвестный численный variant или новый `Unreachable` считаются `ColorError.IncompatibleCoreContract`; adapter не выдаёт им выдуманный fallback-code. + +## Feasibility byte protocol + +`evaluateWcag22Feasibility(_:)` принимает только `Data` или `[UInt8]`. Swift +сравнивает точный byte count с protocol-owned ceiling до сырой UniFFI-копии; +для oversize вызывает scalar helper, а Rust повторяет authoritative limit на +каждом raw-вызове. Результат — исчерпывающий `Codable` sum +`Wcag22FeasibilityOutcomeV1`: semantic input/Core error остаётся +`Failure(error)` data, а не exception, fallback или цветовым решением. + +Swift не вычисляет WCAG, не сортирует отношения и не строит partition. Он +декодирует Core-owned ordered domain, canonical relations, candidate-major +LSB0 failure matrix и proof. Pack 5 replay независимо проверяет 256 triples, +`32E`-byte matrix, 32-byte partition, LSB0 query law и opaque-ID invariance. + +Linux gate дополнительно публикует emit-first +`uniffi-swift-observation-v1.json`: exact checkout SHA, pinned container, +manifest/family/benchmark/FFI hashes, четыре extreme shape, +request/output/submitted bytes, limit+1 preflight и peak RSS. Sample count и +profile limits читаются из Core admission artifact, а не повторяются. Статус +намеренно `observed-not-admitted`, latency/memory thresholds равны `null`; +локальные macOS-числа не становятся evidence. Структурный checker и +отрицательные мутации запрещают дрейф provenance/copy law, потерю shape и +ложный admission. После первого exact-head CI JSON можно отдельно заморозить +как durable Linux observation, не превращая измерение в научную константу. + +Latency начинается после построения request envelope и покрывает +Swift-wrapper → UniFFI → protocol/Core → typed outcome decode. Peak RSS — +максимум двух отдельных focused `swift test` процессов; он включает +SwiftPM/XCTest и заранее построенные extreme envelopes. Эти scope-строки — +обязательная часть проверяемого JSON, поэтому измерение нельзя молча выдать за +чистое время Core или за incremental allocation одного вызова. diff --git a/bindings/swift/Sources/LabColors/.gitkeep b/bindings/swift/Sources/LabColors/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/bindings/swift/Sources/LabColors/Wcag22FeasibilityProtocol.swift b/bindings/swift/Sources/LabColors/Wcag22FeasibilityProtocol.swift new file mode 100644 index 00000000..c1c4e917 --- /dev/null +++ b/bindings/swift/Sources/LabColors/Wcag22FeasibilityProtocol.swift @@ -0,0 +1,886 @@ +import Foundation + +/// Exact non-negative `u64` carried by the V1 JSON protocol as canonical +/// decimal text, so JavaScript and native consumers share one integer law. +public struct DecimalU64V1: Codable, Equatable, Hashable, Sendable { + public let value: UInt64 + + public init(_ value: UInt64) { + self.value = value + } + + public init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + guard let value = UInt64(raw), String(value) == raw else { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "expected canonical decimal UInt64 text")) + } + self.value = value + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(String(value)) + } +} + +/// One exact final encoded-sRGB8 colour. +public struct Srgb8BytesV1: Codable, Equatable, Hashable, Sendable { + public let red: UInt8 + public let green: UInt8 + public let blue: UInt8 + + public init(red: UInt8, green: UInt8, blue: UInt8) { + self.red = red + self.green = green + self.blue = blue + } + + public var bytes: [UInt8] { [red, green, blue] } + + public init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + red = try container.decode(UInt8.self) + green = try container.decode(UInt8.self) + blue = try container.decode(UInt8.self) + guard container.isAtEnd else { + throw DecodingError.dataCorruptedError( + in: container, debugDescription: "sRGB8 value must contain exactly three bytes") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(red) + try container.encode(green) + try container.encode(blue) + } +} + +/// One exact SHA-256 digest or 256-bit LSB0 partition. +public struct Bytes32V1: Codable, Equatable, Hashable, Sendable { + public let bytes: [UInt8] + + public init?(exactBytes: [UInt8]) { + guard exactBytes.count == 32 else { return nil } + bytes = exactBytes + } + + public init(from decoder: Decoder) throws { + let bytes = try decoder.singleValueContainer().decode([UInt8].self) + guard bytes.count == 32 else { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "expected exactly 32 bytes")) + } + self.bytes = bytes + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(bytes) + } +} + +public enum Wcag22FeasibilityDomainIdV1: String, Codable, Sendable { + case srgb8NeutralAxisV1 = "srgb8-neutral-axis-v1" +} + +public enum Wcag22FeasibilityResourceProfileIdV1: String, Codable, Sendable { + case compileV1 = "compile-v1" +} + +public enum Wcag22FeasibilityCriterionV1: String, Codable, Sendable { + case sc143TextDefault = "sc-1.4.3-text-default" + case sc143TextLargeScale = "sc-1.4.3-text-large-scale" + case sc1411UiComponentOrState = "sc-1.4.11-ui-component-or-state" + case sc1411GraphicalObject = "sc-1.4.11-graphical-object" +} + +public enum Wcag22FeasibilityRelationV1: Codable, Equatable, Sendable { + case applicable( + relationId: String, + occurrenceId: String, + criterion: Wcag22FeasibilityCriterionV1, + adjacent: [Srgb8BytesV1] + ) + case notApplicable(relationId: String, occurrenceId: String, reasonId: String) + + private enum CodingKeys: String, CodingKey { + case relationId, occurrenceId, kind, criterion, adjacent, reasonId + } + + private enum Kind: String, Codable { + case applicable + case notApplicable + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Kind.self, forKey: .kind) { + case .applicable: + self = try .applicable( + relationId: container.decode(String.self, forKey: .relationId), + occurrenceId: container.decode(String.self, forKey: .occurrenceId), + criterion: container.decode( + Wcag22FeasibilityCriterionV1.self, forKey: .criterion), + adjacent: container.decode([Srgb8BytesV1].self, forKey: .adjacent)) + case .notApplicable: + self = try .notApplicable( + relationId: container.decode(String.self, forKey: .relationId), + occurrenceId: container.decode(String.self, forKey: .occurrenceId), + reasonId: container.decode(String.self, forKey: .reasonId)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .applicable(relationId, occurrenceId, criterion, adjacent): + try container.encode(Kind.applicable, forKey: .kind) + try container.encode(relationId, forKey: .relationId) + try container.encode(occurrenceId, forKey: .occurrenceId) + try container.encode(criterion, forKey: .criterion) + try container.encode(adjacent, forKey: .adjacent) + case let .notApplicable(relationId, occurrenceId, reasonId): + try container.encode(Kind.notApplicable, forKey: .kind) + try container.encode(relationId, forKey: .relationId) + try container.encode(occurrenceId, forKey: .occurrenceId) + try container.encode(reasonId, forKey: .reasonId) + } + } +} + +public enum Wcag22FeasibilityProfileIdV1: String, Codable, Sendable { + case wcag22Srgb8ContrastV1 = "wcag22-srgb8-contrast-v1" +} + +public enum Wcag22FeasibilityArtifactIdV1: String, Codable, Sendable { + case wcag22Srgb8LuminanceQ55V1 = "wcag22-srgb8-luminance-q55-v1" +} + +public enum Wcag22FeasibilityBoundIdV1: String, Codable, Sendable { + case wcag22Srgb8OutwardQ55V1 = "wcag22-srgb8-outward-q55-v1" +} + +public enum Wcag22FeasibilityProofIdV1: String, Codable, Sendable { + case wcag22Srgb8FullDomainQ55V1 = "wcag22-srgb8-full-domain-q55-v1" +} + +public struct Wcag22FeasibilityProofV1: Codable, Equatable, Sendable { + public let evaluationId: Bytes32V1 + public let resourceProfileId: Wcag22FeasibilityResourceProfileIdV1 + public let domainId: Wcag22FeasibilityDomainIdV1 + public let domainDigest: Bytes32V1 + public let domainCount: DecimalU64V1 + public let domainFirst: Srgb8BytesV1 + public let domainLast: Srgb8BytesV1 + public let relationSetDigest: Bytes32V1 + public let canonicalRelations: DecimalU64V1 + public let applicableRelations: DecimalU64V1 + public let notApplicableRelations: DecimalU64V1 + public let applicableEdges: DecimalU64V1 + public let logicalAssessments: DecimalU64V1 + public let matrixDigest: Bytes32V1 + public let partition: Bytes32V1 + public let wcag22ProfileId: Wcag22FeasibilityProfileIdV1 + public let artifactId: Wcag22FeasibilityArtifactIdV1 + public let boundId: Wcag22FeasibilityBoundIdV1 + public let proofId: Wcag22FeasibilityProofIdV1 + public let proofSha256: Bytes32V1 +} + +public struct Wcag22FeasibilityEvaluatedV1: Codable, Equatable, Sendable { + public let domain: [Srgb8BytesV1] + public let relations: [Wcag22FeasibilityRelationV1] + public let failureMatrix: [UInt8] + public let proof: Wcag22FeasibilityProofV1 +} + +public struct Wcag22FeasibilityNotEvaluatedV1: Codable, Equatable, Sendable { + public let domainId: Wcag22FeasibilityDomainIdV1 + public let domainDigest: Bytes32V1 + public let relationSetDigest: Bytes32V1 + public let resourceProfileId: Wcag22FeasibilityResourceProfileIdV1 + public let relations: [Wcag22FeasibilityRelationV1] +} + +public enum Wcag22FeasibilityV1: Codable, Equatable, Sendable { + case feasible(Wcag22FeasibilityEvaluatedV1) + case infeasible(Wcag22FeasibilityEvaluatedV1) + case notEvaluated(Wcag22FeasibilityNotEvaluatedV1) + + private enum CodingKeys: String, CodingKey { case status, result } + private enum Status: String, Codable { case feasible, infeasible, notEvaluated } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Status.self, forKey: .status) { + case .feasible: + self = try .feasible( + container.decode(Wcag22FeasibilityEvaluatedV1.self, forKey: .result)) + case .infeasible: + self = try .infeasible( + container.decode(Wcag22FeasibilityEvaluatedV1.self, forKey: .result)) + case .notEvaluated: + self = try .notEvaluated( + container.decode(Wcag22FeasibilityNotEvaluatedV1.self, forKey: .result)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .feasible(value): + try container.encode(Status.feasible, forKey: .status) + try container.encode(value, forKey: .result) + case let .infeasible(value): + try container.encode(Status.infeasible, forKey: .status) + try container.encode(value, forKey: .result) + case let .notEvaluated(value): + try container.encode(Status.notEvaluated, forKey: .status) + try container.encode(value, forKey: .result) + } + } + + fileprivate func validate(codingPath: [CodingKey]) throws { + func corrupted(_ message: String) -> DecodingError { + .dataCorrupted(.init(codingPath: codingPath, debugDescription: message)) + } + func relationCounts( + _ relations: [Wcag22FeasibilityRelationV1] + ) throws -> (applicable: Int, notApplicable: Int, edges: Int) { + var applicable = 0 + var notApplicable = 0 + var edges = 0 + for relation in relations { + switch relation { + case let .applicable(_, _, _, adjacent): + applicable += 1 + let (next, overflow) = edges.addingReportingOverflow(adjacent.count) + guard !overflow else { throw corrupted("applicable edge count overflow") } + edges = next + case .notApplicable: + notApplicable += 1 + } + } + return (applicable, notApplicable, edges) + } + func validateEvaluated( + _ value: Wcag22FeasibilityEvaluatedV1, + expectsFeasible: Bool + ) throws { + guard value.domain.count == 256 else { + throw corrupted("evaluated domain must contain exactly 256 candidates") + } + for (index, candidate) in value.domain.enumerated() { + guard let channel = UInt8(exactly: index), + candidate == Srgb8BytesV1(red: channel, green: channel, blue: channel) + else { + throw corrupted("evaluated domain differs from registered candidate order") + } + } + let counts = try relationCounts(value.relations) + guard counts.applicable > 0, counts.edges > 0 else { + throw corrupted("evaluated terminal must contain an applicable edge") + } + let (matrixBytes, matrixOverflow) = counts.edges.multipliedReportingOverflow(by: 32) + let (logicalAssessments, logicalOverflow) = + counts.edges.multipliedReportingOverflow(by: 256) + guard !matrixOverflow, value.failureMatrix.count == matrixBytes else { + throw corrupted("failure matrix length does not equal 32E") + } + guard !logicalOverflow else { + throw corrupted("logical assessment count overflow") + } + guard value.proof.domainCount.value == UInt64(value.domain.count), + value.proof.domainFirst == value.domain.first, + value.proof.domainLast == value.domain.last, + value.proof.canonicalRelations.value == UInt64(value.relations.count), + value.proof.applicableRelations.value == UInt64(counts.applicable), + value.proof.notApplicableRelations.value == UInt64(counts.notApplicable), + value.proof.applicableEdges.value == UInt64(counts.edges), + value.proof.logicalAssessments.value == UInt64(logicalAssessments) + else { + throw corrupted("proof counts do not match transported domain/relations") + } + + func lsb0(_ bytes: [UInt8], _ index: Int) -> Bool { + (bytes[index / 8] & (UInt8(1) << UInt8(index % 8))) != 0 + } + var hasFeasibleCandidate = false + for candidate in 0..<256 { + var rowFailed = false + for edge in 0.. UInt64 + let evaluateRaw: (Data) throws -> Data + let envelopeTooLarge: (UInt64) throws -> Data + + static let live = Self( + maxRequestBytes: wcag22FeasibilityMaxRequestBytesV1, + evaluateRaw: { try evaluateWcag22FeasibilityRawV1(request: $0) }, + envelopeTooLarge: { + try wcag22FeasibilityEnvelopeTooLargeV1(requestedBytes: $0) + }) +} + +/// Exact protocol-owned request byte ceiling. +public func wcag22FeasibilityMaxBytes() -> UInt64 { + Wcag22FeasibilityBridge.live.maxRequestBytes() +} + +/// Evaluate one exact V1 request carried as `Data`. +public func evaluateWcag22Feasibility( + _ request: Data +) throws -> Wcag22FeasibilityOutcomeV1 { + try evaluateWcag22Feasibility(request, using: .live) +} + +/// Evaluate one exact V1 request carried as bytes. The size comparison occurs +/// before `Data` materialization and therefore before the raw UniFFI copy. +public func evaluateWcag22Feasibility( + _ request: [UInt8] +) throws -> Wcag22FeasibilityOutcomeV1 { + let bridge = Wcag22FeasibilityBridge.live + return try evaluateWcag22Feasibility( + requestedBytes: UInt64(request.count), + using: bridge, + evaluateAdmitted: { try bridge.evaluateRaw(Data(request)) }) +} + +func evaluateWcag22Feasibility( + _ request: Data, + using bridge: Wcag22FeasibilityBridge +) throws -> Wcag22FeasibilityOutcomeV1 { + try evaluateWcag22Feasibility( + requestedBytes: UInt64(request.count), + using: bridge, + evaluateAdmitted: { try bridge.evaluateRaw(request) }) +} + +private func evaluateWcag22Feasibility( + requestedBytes: UInt64, + using bridge: Wcag22FeasibilityBridge, + evaluateAdmitted: () throws -> Data +) throws -> Wcag22FeasibilityOutcomeV1 { + let encoded: Data + if requestedBytes > bridge.maxRequestBytes() { + encoded = try bridge.envelopeTooLarge(requestedBytes) + } else { + encoded = try evaluateAdmitted() + } + return try JSONDecoder().decode(Wcag22FeasibilityOutcomeV1.self, from: encoded) +} diff --git a/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift b/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift index 5247bb8e..7a146527 100644 --- a/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift +++ b/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift @@ -32,6 +32,17 @@ final class ConformanceTests: XCTestCase { return try JSONDecoder().decode(T.self, from: data) } + func loadFeasibilityBenchmarkContract() throws -> FeasibilityBenchmarkContract { + let repoRoot = Self.vectorsDir + .deletingLastPathComponent() + .deletingLastPathComponent() + let url = repoRoot + .appendingPathComponent("crates/labcolors-core/contracts") + .appendingPathComponent("wcag22-feasibility-benchmark-v1.json") + return try JSONDecoder().decode( + FeasibilityBenchmarkContract.self, from: Data(contentsOf: url)) + } + // MARK: - Вспомогательные мапперы словаря func theme(_ s: String) -> Theme { @@ -468,6 +479,551 @@ final class ConformanceTests: XCTestCase { XCTAssertEqual(got.evidence.profileSha256, vector.profileSha256) } } + + // MARK: - WCAG 2.2 finite-domain feasibility protocol + + func testWcag22FeasibilityPackReplaysThroughRawAndTypedSwiftSurfaces() throws { + let vectors = try load( + "wcag22-feasibility.json", as: [Wcag22FeasibilityVector].self) + XCTAssertEqual(vectors.count, 13, "pack 5 family cardinality") + + for vector in vectors { + let request = Array(vector.requestJson.utf8) + let raw = try evaluateWcag22FeasibilityRawV1(request: Data(request)) + XCTAssertEqual( + raw, Data(vector.outcomeJson.utf8), + "canonical FFI bytes \(vector.caseId)") + + let typed = try evaluateWcag22Feasibility(Data(request)) + let expected = try JSONDecoder().decode( + Wcag22FeasibilityOutcomeV1.self, from: Data(vector.outcomeJson.utf8)) + XCTAssertEqual(typed, expected, "typed outcome \(vector.caseId)") + } + } + + func testWcag22FeasibilityDomainPackingAndOpaqueIdentityLaws() throws { + let vectors = try load( + "wcag22-feasibility.json", as: [Wcag22FeasibilityVector].self) + var evaluatedById: [String: Wcag22FeasibilityEvaluatedV1] = [:] + var feasibleCounts: [String: Int] = [:] + var sawNotEvaluated = false + var sawConflict = false + var sawRawResourceRejection = false + + func lsb0(_ bytes: [UInt8], _ index: Int) -> Bool { + (bytes[index / 8] & (UInt8(1) << UInt8(index % 8))) != 0 + } + + for vector in vectors { + let outcome = try evaluateWcag22Feasibility(Array(vector.requestJson.utf8)) + switch outcome { + case let .success(feasibility): + switch feasibility { + case let .feasible(value), let .infeasible(value): + XCTAssertEqual(value.domain.count, 256, vector.caseId) + for (index, candidate) in value.domain.enumerated() { + XCTAssertEqual( + candidate.bytes, + [UInt8(index), UInt8(index), UInt8(index)], + "Core-owned domain order \(vector.caseId) @ \(index)") + } + + let edgeCount = value.relations.reduce(into: 0) { count, relation in + if case let .applicable(_, _, _, adjacent) = relation { + count += adjacent.count + } + } + XCTAssertEqual(value.proof.applicableEdges.value, UInt64(edgeCount)) + XCTAssertEqual(value.failureMatrix.count, 32 * edgeCount) + XCTAssertEqual(value.proof.partition.bytes.count, 32) + XCTAssertEqual( + value.proof.logicalAssessments.value, UInt64(256 * edgeCount)) + + var feasibleCount = 0 + for candidate in 0..<256 { + var rowFailed = false + for edge in 0.. Void + ) throws -> Data { + var root = try XCTUnwrap( + JSONSerialization.jsonObject(with: canonicalData) as? [String: Any]) + var feasibility = try XCTUnwrap(root["feasibility"] as? [String: Any]) + var result = try XCTUnwrap(feasibility["result"] as? [String: Any]) + var domain = try XCTUnwrap(result["domain"] as? [[Int]]) + var matrix = try XCTUnwrap(result["failureMatrix"] as? [Int]) + var proof = try XCTUnwrap(result["proof"] as? [String: Any]) + try change(&domain, &matrix, &proof) + result["domain"] = domain + result["failureMatrix"] = matrix + result["proof"] = proof + feasibility["result"] = result + root["feasibility"] = feasibility + return try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys]) + } + + let wrongFirstBit = try mutated { _, matrix, _ in matrix[0] ^= 1 } + XCTAssertThrowsError( + try JSONDecoder().decode(Wcag22FeasibilityOutcomeV1.self, from: wrongFirstBit)) + + let truncated = try mutated { _, matrix, _ in matrix.removeLast() } + XCTAssertThrowsError( + try JSONDecoder().decode(Wcag22FeasibilityOutcomeV1.self, from: truncated)) + + let transposedDomain = try mutated { domain, _, _ in domain.swapAt(0, 1) } + XCTAssertThrowsError( + try JSONDecoder().decode(Wcag22FeasibilityOutcomeV1.self, from: transposedDomain)) + + let interiorTransposedDomain = try mutated { domain, _, _ in domain.swapAt(1, 2) } + XCTAssertThrowsError( + try JSONDecoder().decode( + Wcag22FeasibilityOutcomeV1.self, from: interiorTransposedDomain)) + + func reversedBits(_ byte: Int) -> Int { + var source = byte + var result = 0 + for _ in 0..<8 { + result = (result << 1) | (source & 1) + source >>= 1 + } + return result + } + let msb0 = try mutated { _, matrix, _ in matrix = matrix.map(reversedBits) } + XCTAssertThrowsError( + try JSONDecoder().decode(Wcag22FeasibilityOutcomeV1.self, from: msb0)) + + var forgedRoot = try XCTUnwrap( + JSONSerialization.jsonObject(with: canonicalData) as? [String: Any]) + var forgedFeasibility = try XCTUnwrap(forgedRoot["feasibility"] as? [String: Any]) + forgedFeasibility["status"] = "infeasible" + forgedRoot["feasibility"] = forgedFeasibility + let forgedTerminal = try JSONSerialization.data( + withJSONObject: forgedRoot, options: [.sortedKeys]) + XCTAssertThrowsError( + try JSONDecoder().decode(Wcag22FeasibilityOutcomeV1.self, from: forgedTerminal)) + + let changedIdentity = try mutated { _, _, proof in + var digest = try XCTUnwrap(proof["relationSetDigest"] as? [Int]) + let first = try XCTUnwrap(digest.indices.first) + digest[first] ^= 1 + proof["relationSetDigest"] = digest + } + let changedIdentityOutcome = try JSONDecoder().decode( + Wcag22FeasibilityOutcomeV1.self, from: changedIdentity) + XCTAssertNotEqual( + changedIdentityOutcome, canonical, + "pack equality must reject an identity mutation even when shape remains lawful") + } + + func testWcag22FeasibilitySwiftFailureAlgebraRoundTripsExhaustively() throws { + let one = DecimalU64V1(1) + let two = DecimalU64V1(2) + let rgb = Srgb8BytesV1(red: 1, green: 2, blue: 3) + let transport: [Wcag22FeasibilityTransportErrorV1] = [ + .envelopeTooLarge(requestedBytes: two, limitBytes: one), + .invalidUtf8, + .malformedEnvelope(.syntax), + .malformedEnvelope(.shape), + .malformedEnvelope(.endOfInput), + .malformedEnvelope(.io), + .unsupportedSchemaVersion(2), + .unsupportedDomainId("future"), + .unsupportedResourceProfileId("future"), + .unsupportedCriterion("future"), + .emptyNotApplicableReason, + ] + let invalid: [Wcag22FeasibilityInvalidRequestV1] = [ + .emptyRelationId, + .emptyOccurrenceId, + .emptyRelations, + .emptyAdjacentSet(relationId: "r"), + .conflictingRelationId(relationId: "r"), + .arithmeticOverflow, + ] + let atomic: [Wcag22FeasibilityAtomicErrorV1] = [ + .invalidSrgb8(field: "foreground", reason: "fixture"), + .emptyNotApplicableReason, + .artifactInvariantViolation( + criterion: .sc143TextDefault, foreground: rgb, background: rgb), + .evidenceRegistryMismatch(message: "fixture"), + ] + var evaluator: [Wcag22FeasibilityEvaluatorInvariantV1] = atomic.map { .source($0) } + evaluator += [ + .unexpectedNotEvaluated, .inputMismatch, .criterionMismatch, .evidenceMismatch, + ] + let compiler: [Wcag22FeasibilityCompilerInvariantV1] = [ + .layoutMismatch, + .assessmentCardinalityMismatch(expected: one, observed: two), + .candidateCardinalityMismatch(expected: one, observed: two), + .decisionStorageRejectedCell, + .decisionStorageRejectedPartition, + .completeResultMismatch, + ] + var core: [Wcag22FeasibilityCoreErrorV1] = invalid.map { .invalidRequest($0) } + core += [ + .resourceLimitExceeded( + profileId: .compileV1, + dimension: .rawRelations, + requested: two, + limit: one), + .allocationFailed(profileId: .compileV1, requestedBytes: two), + ] + core += evaluator.map { + .evaluatorInvariantViolation( + candidate: rgb, relationId: "r", adjacent: rgb, violation: $0) + } + core += compiler.map { .compilerInvariantViolation($0) } + + let protocolErrors = transport.map { Wcag22FeasibilityProtocolErrorV1.transport($0) } + + core.map { Wcag22FeasibilityProtocolErrorV1.core($0) } + + [.incompatibleCoreContract] + XCTAssertEqual(protocolErrors.count, 34, "new branch requires an explicit test fixture") + for error in protocolErrors { + let outcome = Wcag22FeasibilityOutcomeV1.failure(error) + let encoded = try JSONEncoder().encode(outcome) + let decoded = try JSONDecoder().decode( + Wcag22FeasibilityOutcomeV1.self, from: encoded) + XCTAssertEqual(decoded, outcome) + } + } + + func testWcag22FeasibilityWholeCallObservation() throws { + let vectors = try load( + "wcag22-feasibility.json", as: [Wcag22FeasibilityVector].self) + let benchmark = try loadFeasibilityBenchmarkContract() + guard benchmark.sampleCount > 0 else { + return XCTFail("benchmark sampleCount must remain positive") + } + let fixture = try XCTUnwrap(vectors.first { $0.caseId == "text-default-two" }) + let request = Data(fixture.requestJson.utf8) + var elapsedNanoseconds: [UInt64] = [] + + for _ in 0.. Data { + try JSONSerialization.data(withJSONObject: [ + "schemaVersion": 1, + "domainId": "srgb8-neutral-axis-v1", + "resourceProfileId": "compile-v1", + "relations": relations, + ], options: [.sortedKeys]) + } + + let relations2047x1: [[String: Any]] = (0..> 8) & 255, 0] + } + let relation1x2047: [[String: Any]] = [[ + "relationId": "r", + "occurrenceId": "o", + "kind": "applicable", + "criterion": "sc-1.4.11-ui-component-or-state", + "adjacent": adjacent2047, + ]] + let opaqueShare = opaqueUtf8Limit / 3 + let maxOpaque: [[String: Any]] = [[ + "relationId": String(repeating: "r", count: opaqueShare), + "occurrenceId": String(repeating: "o", count: opaqueShare), + "kind": "notApplicable", + "reasonId": String( + repeating: "n", + count: opaqueUtf8Limit - 2 * opaqueShare), + ]] + let allNotApplicable: [[String: Any]] = + (0.. Shape { + guard let scenario = scenarios.first(where: { $0.name == name }) else { + throw NSError( + domain: "LabColorsConformance", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "missing benchmark scenario: \(name)"]) + } + return scenario.shape + } +} + struct ContrastVec: Codable { let fg: String let bg: String diff --git a/bindings/swift/ci/check-observation.sh b/bindings/swift/ci/check-observation.sh new file mode 100755 index 00000000..70e3d0dc --- /dev/null +++ b/bindings/swift/ci/check-observation.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# Structural verifier for emit-first UniFFI/Swift whole-call evidence. +# It validates provenance and exact copy/shape laws only. Latency and memory +# remain observations: this checker deliberately rejects invented thresholds. +set -euo pipefail + +readonly evidence=${1:?usage: check-observation.sh [--mutation-test]} +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +readonly script_dir +repo_root=$(cd -- "$script_dir/../../.." && pwd) +readonly repo_root +readonly manifest="$repo_root/conformance/vectors/manifest.json" +readonly feasibility_family="$repo_root/conformance/vectors/wcag22-feasibility.json" +readonly benchmark="$repo_root/crates/labcolors-core/contracts/wcag22-feasibility-benchmark-v1.json" +readonly ffi_subject=${LABCOLORS_FFI_SUBJECT:-"$repo_root/target/debug/liblabcolors.so"} +readonly expected_mode=${LABCOLORS_EVIDENCE_MODE:-local-diagnostic} + +live_pack_digest=$(jq -r .packDigest "$manifest") +readonly live_pack_digest +live_manifest_sha=$(sha256sum "$manifest" | awk '{print $1}') +readonly live_manifest_sha +live_family_sha=$(sha256sum "$feasibility_family" | awk '{print $1}') +readonly live_family_sha +live_benchmark_sha=$(sha256sum "$benchmark" | awk '{print $1}') +readonly live_benchmark_sha +live_ffi_sha=$(sha256sum "$ffi_subject" | awk '{print $1}') +readonly live_ffi_sha +live_sample_count=$(jq -r .sampleCount "$benchmark") +readonly live_sample_count +# Independent transport oracle: the compact V1 grammar contributes 101 fixed +# bytes, at most 115 bytes per raw relation, 14 per adjacent triple and six +# per opaque UTF-8 byte. Production owns the same ceiling in +# labcolors-protocol; this checker intentionally derives it from the public +# profile limits instead of trusting the observed Swift scalar. +live_max_request_bytes=$(jq -er ' + .profileLimits + | 101 + + 115 * .rawRelations + + 14 * .rawAdjacentEntries + + 6 * .opaqueUtf8Bytes +' "$benchmark") +readonly live_max_request_bytes +live_max_relation_raw=$(jq -r ' + .scenarios[] | select(.name == "maximum-canonical-applicable-relations") + | .shape.rawRelations +' "$benchmark") +readonly live_max_relation_raw +live_max_relation_adjacent=$(jq -r ' + .scenarios[] | select(.name == "maximum-canonical-applicable-relations") + | .shape.rawAdjacentEntries +' "$benchmark") +readonly live_max_relation_adjacent +live_max_edge_raw=$(jq -r ' + .scenarios[] | select(.name == "maximum-applicable-edges") + | .shape.rawRelations +' "$benchmark") +readonly live_max_edge_raw +live_max_edges=$(jq -r ' + .scenarios[] | select(.name == "maximum-applicable-edges") + | .shape.rawAdjacentEntries +' "$benchmark") +readonly live_max_edges +live_max_opaque=$(jq -r ' + .scenarios[] | select(.name == "maximum-opaque-utf8-bytes") + | .shape.opaqueUtf8Bytes +' "$benchmark") +readonly live_max_opaque +live_max_not_applicable_raw=$(jq -r ' + .scenarios[] | select(.name == "maximum-canonical-not-applicable-relations") + | .shape.rawRelations +' "$benchmark") +readonly live_max_not_applicable_raw + +jq -e \ + --arg expectedMode "$expected_mode" \ + --arg packDigest "$live_pack_digest" \ + --arg manifestSha256 "$live_manifest_sha" \ + --arg feasibilityFamilySha256 "$live_family_sha" \ + --arg benchmarkSha256 "$live_benchmark_sha" \ + --arg ffiSha256 "$live_ffi_sha" \ + --argjson sampleCount "$live_sample_count" \ + --argjson maxRequestBytes "$live_max_request_bytes" \ + --argjson maxRelationRaw "$live_max_relation_raw" \ + --argjson maxRelationAdjacent "$live_max_relation_adjacent" \ + --argjson maxEdgeRaw "$live_max_edge_raw" \ + --argjson maxEdges "$live_max_edges" \ + --argjson maxOpaque "$live_max_opaque" \ + --argjson maxNotApplicableRaw "$live_max_not_applicable_raw" ' + def exact_keys($expected): (keys | sort) == ($expected | sort); + exact_keys([ + "artifacts", "contract", "evidenceClass", "extremeShapes", "mode", + "oversizePreflight", "process", "schemaVersion", "status", "subject", + "toolchain", "wholeCall" + ]) and + .schemaVersion == 1 and + .evidenceClass == "uniffi-swift-whole-call-observation-v1" and + .mode == $expectedMode and + .status == "observed-not-admitted" and + (.subject | exact_keys(["bound", "gitSha"])) and + (.subject.bound | type == "boolean") and + (if $expectedMode == "canonical-ci" + then .subject.bound == true and (.subject.gitSha | test("^[0-9a-f]{40}$")) + elif $expectedMode == "local-diagnostic" + then .subject.bound == false and .subject.gitSha == "local-unbound" + else false + end) and + (.toolchain | exact_keys([ + "runnerKernel", "rustToolchain", "swiftContainer", "swiftContainerBound", + "swiftVersion", "target" + ])) and + (.toolchain.swiftContainerBound | type == "boolean") and + (if $expectedMode == "canonical-ci" + then .toolchain.swiftContainerBound == true and + (.toolchain.swiftContainer | test("@sha256:[0-9a-f]{64}$")) + else .toolchain.swiftContainerBound == false and + .toolchain.swiftContainer == "local-unbound" + end) and + (.toolchain.swiftVersion | type == "string" and length > 0) and + (.toolchain.runnerKernel | type == "string" and length > 0) and + .toolchain.rustToolchain == "1.96.0" and + .toolchain.target == "x86_64-unknown-linux-gnu" and + (.artifacts | exact_keys([ + "benchmarkSha256", "feasibilityFamilySha256", "ffiSha256", + "manifestSha256", "packDigest" + ])) and + .artifacts.packDigest == $packDigest and + .artifacts.manifestSha256 == $manifestSha256 and + .artifacts.feasibilityFamilySha256 == $feasibilityFamilySha256 and + .artifacts.benchmarkSha256 == $benchmarkSha256 and + .artifacts.ffiSha256 == $ffiSha256 and + (.contract | exact_keys([ + "copyObservation", "latencyThresholdNs", "peakRssScope", + "peakRssThresholdKib", "wholeCallLatencyScope" + ])) and + .contract.latencyThresholdNs == null and + .contract.peakRssThresholdKib == null and + .contract.copyObservation == + "one-admitted-submission-zero-oversize-submission" and + .contract.wholeCallLatencyScope == + "after-envelope-construction-through-typed-outcome-decode" and + .contract.peakRssScope == + "max-of-two-focused-swift-test-processes-including-swiftpm-xctest-and-preconstructed-envelopes" and + (.wholeCall | length == 1) and + (.wholeCall[0] | exact_keys([ + "maxRequestBytes", "medianNs", "requestBytes", "sampleCount", "samplesNs" + ])) and + .wholeCall[0].sampleCount == $sampleCount and + (.wholeCall[0].samplesNs | length == $sampleCount) and + all(.wholeCall[0].samplesNs[]; (type == "number") and . > 0) and + (.wholeCall[0] as $call | + ($call.samplesNs | sort | .[($sampleCount / 2 | floor)]) == $call.medianNs and + ($call.requestBytes | type == "number") and $call.requestBytes > 0 and + ($call.maxRequestBytes | type == "number") and + $call.maxRequestBytes == $maxRequestBytes and + $call.maxRequestBytes >= $call.requestBytes) and + (.oversizePreflight | length == 1) and + (.oversizePreflight[0] | exact_keys([ + "ffiSubmittedBytes", "maxRequestBytes", "outputBytes", "rawCalls", + "requestedBytes", "scalarCalls" + ])) and + (.oversizePreflight[0] as $oversize | + $oversize.maxRequestBytes == $maxRequestBytes and + $oversize.requestedBytes == ($maxRequestBytes + 1) and + $oversize.ffiSubmittedBytes == 0 and + $oversize.outputBytes > 0 and + $oversize.rawCalls == 0 and + $oversize.scalarCalls == 1) and + (.process | exact_keys(["peakRssKib"])) and + (.process.peakRssKib | numbers) and .process.peakRssKib > 0 and + (.extremeShapes | length == 4) and + ([.extremeShapes[].shape] | sort) == + ([ + "maximum-applicable-edges", + "maximum-canonical-applicable-relations", + "maximum-canonical-not-applicable-relations", + "maximum-opaque-utf8-bytes" + ] | sort) and + all(.extremeShapes[]; + exact_keys([ + "ffiSubmittedBytes", "outputBytes", "rawCalls", "requestBytes", + "rawAdjacentEntries", "rawRelations", "opaqueUtf8Bytes", "scalarCalls", + "shape", "wholeCallNs" + ]) and + (.rawRelations | type == "number") and .rawRelations > 0 and + (.rawAdjacentEntries | type == "number") and .rawAdjacentEntries >= 0 and + (.opaqueUtf8Bytes | type == "number") and .opaqueUtf8Bytes > 0 and + (.wholeCallNs | numbers) and .wholeCallNs > 0 and + (.requestBytes | numbers) and .requestBytes > 0 and + .requestBytes <= $maxRequestBytes and + (.outputBytes | numbers) and .outputBytes > 0 and + .ffiSubmittedBytes == .requestBytes and + .rawCalls == 1 and + .scalarCalls == 0) and + (.extremeShapes[] | select( + .shape == "maximum-canonical-applicable-relations" + ) | .rawRelations == $maxRelationRaw and + .rawAdjacentEntries == $maxRelationAdjacent) and + (.extremeShapes[] | select(.shape == "maximum-applicable-edges") + | .rawRelations == $maxEdgeRaw and .rawAdjacentEntries == $maxEdges) and + (.extremeShapes[] | select(.shape == "maximum-opaque-utf8-bytes") + | .rawRelations == 1 and .rawAdjacentEntries == 0 and + .opaqueUtf8Bytes == $maxOpaque) and + (.extremeShapes[] | select( + .shape == "maximum-canonical-not-applicable-relations" + ) | .rawRelations == $maxNotApplicableRaw and .rawAdjacentEntries == 0) +' "$evidence" >/dev/null + +if [[ ${2:-} == "--mutation-test" ]]; then + tmp_dir=$(mktemp -d) + trap 'rm -rf "$tmp_dir"' EXIT + + reject_mutation() { + local name=$1 + local filter=$2 + local mutated="$tmp_dir/$name.json" + jq "$filter" "$evidence" >"$mutated" + if "$0" "$mutated" >/dev/null 2>&1; then + echo "observation checker accepted mutation: $name" >&2 + exit 1 + fi + } + + reject_mutation wrong-schema '.schemaVersion = 2' + reject_mutation invented-latency-threshold '.contract.latencyThresholdNs = 1' + reject_mutation latency-scope '.contract.wholeCallLatencyScope = "includes-construction"' + reject_mutation copy-drift '.extremeShapes[0].ffiSubmittedBytes += 1' + reject_mutation oversize-copy '.oversizePreflight[0].rawCalls = 1' + reject_mutation derived-ceiling-drift \ + '.wholeCall[0].maxRequestBytes += 1 | .oversizePreflight[0].maxRequestBytes += 1 | .oversizePreflight[0].requestedBytes += 1' + reject_mutation extreme-over-ceiling \ + '.extremeShapes[0].requestBytes = (.wholeCall[0].maxRequestBytes + 1) | .extremeShapes[0].ffiSubmittedBytes = .extremeShapes[0].requestBytes' + reject_mutation sample-count '.wholeCall[0].sampleCount += 1' + reject_mutation shape-count '.extremeShapes[0].rawRelations += 1' + reject_mutation missing-shape '.extremeShapes |= .[0:3]' + reject_mutation family-binding '.artifacts.feasibilityFamilySha256 = ("0" * 64)' + reject_mutation extra-field '.contract.extra = true' + reject_mutation provenance-bound-flip '.subject.bound = (.subject.bound | not)' + reject_mutation false-admission '.status = "admitted"' + reject_mutation zero-peak '.process.peakRssKib = 0' +fi diff --git a/bindings/swift/ci/run-conformance.sh b/bindings/swift/ci/run-conformance.sh index 3cd45abe..a6ebce1f 100644 --- a/bindings/swift/ci/run-conformance.sh +++ b/bindings/swift/ci/run-conformance.sh @@ -22,7 +22,7 @@ swift --version echo "==================== УСТАНОВКА RUST ====================" export DEBIAN_FRONTEND=noninteractive apt-get update -qq -apt-get install -y -qq curl build-essential pkg-config >/dev/null +apt-get install -y -qq curl build-essential jq pkg-config time >/dev/null curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- \ -y --default-toolchain "$RUST_TOOLCHAIN" --profile minimal # shellcheck disable=SC1091 @@ -57,6 +57,7 @@ mkdir -p bindings/swift/Sources/LabColors bindings/swift/Sources/labcolorsFFI cp "$gen/labcolors.swift" bindings/swift/Sources/LabColors/labcolors.swift cp "$gen/labcolorsFFI.h" bindings/swift/Sources/labcolorsFFI/labcolorsFFI.h cp "$gen/labcolorsFFI.modulemap" bindings/swift/Sources/labcolorsFFI/module.modulemap +test -f bindings/swift/Sources/LabColors/Wcag22FeasibilityProtocol.swift echo "--- module.modulemap ---"; cat bindings/swift/Sources/labcolorsFFI/module.modulemap echo "==================== SWIFT TEST (пак против FFI) ====================" @@ -66,4 +67,153 @@ cd bindings/swift export LD_LIBRARY_PATH="/work/target/debug:${LD_LIBRARY_PATH:-}" swift test -Xlinker -L/work/target/debug +echo "==================== OBSERVATIONAL WHOLE-CALL EVIDENCE ====================" +# No latency or memory threshold is invented here. The test uses the Core-owned +# benchmark sample count for end-to-end Swift → UniFFI → protocol → Core calls; +# GNU time records process peak RSS for the already-built focused replay. +# Correctness and the no-oversize-copy law are executable assertions above. +evidence_dir=${LABCOLORS_SWIFT_EVIDENCE_DIR:-/work/target/swift-evidence} +mkdir -p "$evidence_dir" +whole_log="$evidence_dir/whole-call.log" +whole_time="$evidence_dir/whole-call.time" +extreme_log="$evidence_dir/extreme-shapes.log" +extreme_time="$evidence_dir/extreme-shapes.time" + +/usr/bin/time -v -o "$whole_time" \ + swift test --skip-build \ + --filter ConformanceTests.testWcag22FeasibilityWholeCallObservation \ + -Xlinker -L/work/target/debug 2>&1 | tee "$whole_log" +/usr/bin/time -v -o "$extreme_time" \ + swift test --skip-build \ + --filter ConformanceTests.testWcag22FeasibilityExtremeShapeObservation \ + -Xlinker -L/work/target/debug 2>&1 | tee "$extreme_log" + +whole_json=$(jq -Rn ' + def fields: + split(" ")[1:] | map(split("=") | {(.[0]): .[1]}) | add; + [inputs + | select(startswith("LABCOLORS_FEASIBILITY_OBSERVATION ")) + | fields + | { + medianNs: (.whole_call_median_ns | tonumber), + sampleCount: (.sample_count | tonumber), + samplesNs: (.samples | split(",") | map(tonumber)), + requestBytes: (.request_bytes | tonumber), + maxRequestBytes: (.max_request_bytes | tonumber) + }] +' <"$whole_log") +oversize_json=$(jq -Rn ' + def fields: + split(" ")[1:] | map(split("=") | {(.[0]): .[1]}) | add; + [inputs + | select(startswith("LABCOLORS_FEASIBILITY_OVERSIZE ")) + | fields + | { + requestedBytes: (.requested_bytes | tonumber), + maxRequestBytes: (.max_request_bytes | tonumber), + ffiSubmittedBytes: (.ffi_submitted_bytes | tonumber), + outputBytes: (.output_bytes | tonumber), + rawCalls: (.raw_calls | tonumber), + scalarCalls: (.scalar_calls | tonumber) + }] +' <"$whole_log") +extreme_json=$(jq -Rn ' + def fields: + split(" ")[1:] | map(split("=") | {(.[0]): .[1]}) | add; + [inputs + | select(startswith("LABCOLORS_FEASIBILITY_EXTREME ")) + | fields + | { + shape: .shape, + rawRelations: (.raw_relations | tonumber), + rawAdjacentEntries: (.raw_adjacent_entries | tonumber), + opaqueUtf8Bytes: (.opaque_utf8_bytes | tonumber), + wholeCallNs: (.whole_call_ns | tonumber), + requestBytes: (.request_bytes | tonumber), + ffiSubmittedBytes: (.ffi_submitted_bytes | tonumber), + outputBytes: (.output_bytes | tonumber), + rawCalls: (.raw_calls | tonumber), + scalarCalls: (.scalar_calls | tonumber) + }] +' <"$extreme_log") + +peak_whole=$(awk -F: '/Maximum resident set size \(kbytes\)/ { + gsub(/[[:space:]]/, "", $2); print $2 +}' "$whole_time") +peak_extreme=$(awk -F: '/Maximum resident set size \(kbytes\)/ { + gsub(/[[:space:]]/, "", $2); print $2 +}' "$extreme_time") +peak_rss_kib=$peak_whole +if ((peak_extreme > peak_rss_kib)); then + peak_rss_kib=$peak_extreme +fi + +subject_sha=${LABCOLORS_SUBJECT_SHA:-local-unbound} +subject_bound=false +if [[ $subject_sha =~ ^[0-9a-f]{40}$ ]]; then + subject_bound=true +fi +swift_container=${LABCOLORS_SWIFT_CONTAINER:-local-unbound} +swift_container_bound=false +if [[ $swift_container =~ @sha256:[0-9a-f]{64}$ ]]; then + swift_container_bound=true +fi +evidence_mode=${LABCOLORS_EVIDENCE_MODE:-local-diagnostic} + +evidence_json="$evidence_dir/uniffi-swift-observation-v1.json" +jq -n \ + --arg mode "$evidence_mode" \ + --arg subjectSha "$subject_sha" \ + --argjson subjectBound "$subject_bound" \ + --arg swiftContainer "$swift_container" \ + --argjson swiftContainerBound "$swift_container_bound" \ + --arg swiftVersion "$(swift --version | sed -n '1p')" \ + --arg rustToolchain "$RUST_TOOLCHAIN" \ + --arg packDigest "$(jq -r .packDigest ../../conformance/vectors/manifest.json)" \ + --arg manifestSha256 "$(sha256sum ../../conformance/vectors/manifest.json | awk '{print $1}')" \ + --arg feasibilityFamilySha256 "$(sha256sum ../../conformance/vectors/wcag22-feasibility.json | awk '{print $1}')" \ + --arg benchmarkSha256 "$(sha256sum ../../crates/labcolors-core/contracts/wcag22-feasibility-benchmark-v1.json | awk '{print $1}')" \ + --arg ffiSha256 "$(sha256sum ../../target/debug/liblabcolors.so | awk '{print $1}')" \ + --arg runnerKernel "$(uname -a)" \ + --argjson peakRssKib "$peak_rss_kib" \ + --argjson wholeCall "$whole_json" \ + --argjson oversizePreflight "$oversize_json" \ + --argjson extremeShapes "$extreme_json" \ + '{ + schemaVersion: 1, + evidenceClass: "uniffi-swift-whole-call-observation-v1", + mode: $mode, + status: "observed-not-admitted", + subject: {gitSha: $subjectSha, bound: $subjectBound}, + toolchain: { + swiftContainer: $swiftContainer, + swiftContainerBound: $swiftContainerBound, + swiftVersion: $swiftVersion, + rustToolchain: $rustToolchain, + target: "x86_64-unknown-linux-gnu", + runnerKernel: $runnerKernel + }, + artifacts: { + packDigest: $packDigest, + manifestSha256: $manifestSha256, + feasibilityFamilySha256: $feasibilityFamilySha256, + benchmarkSha256: $benchmarkSha256, + ffiSha256: $ffiSha256 + }, + contract: { + latencyThresholdNs: null, + peakRssThresholdKib: null, + copyObservation: "one-admitted-submission-zero-oversize-submission", + wholeCallLatencyScope: "after-envelope-construction-through-typed-outcome-decode", + peakRssScope: "max-of-two-focused-swift-test-processes-including-swiftpm-xctest-and-preconstructed-envelopes" + }, + wholeCall: $wholeCall, + oversizePreflight: $oversizePreflight, + extremeShapes: $extremeShapes, + process: {peakRssKib: $peakRssKib} + }' >"$evidence_json" + +bash ci/check-observation.sh "$evidence_json" --mutation-test +cat "$evidence_json" + echo "==================== ГОТОВО: SWIFT CONFORMANCE ЗЕЛЁНЫЙ ====================" diff --git a/conformance/README.md b/conformance/README.md index 783dd0a4..8ec1ba04 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -13,8 +13,11 @@ ## Версионирование -- **Версия пака** (`manifest.packVersion`, сейчас `4.0.0`) — семантическая - версия СХЕМЫ и состава векторов. Bump 3.0.0 → 4.0.0 перевёл +- **Версия пака** (`manifest.packVersion`, сейчас `5.0.0`) — семантическая + версия СХЕМЫ и состава векторов. Bump 4.0.0 → 5.0.0 добавил ровно одно + семейство `wcag22-feasibility`: канонические versioned request/outcome JSON + для полного перечисления neutral-axis с packed evidence. Байты шести прежних + семейств сохранены. Предыдущий bump 3.0.0 → 4.0.0 перевёл `numericalCapabilities` на proof-capable schema V2 и добавил семейство `wcag22`; поэтому состав семейств и `packDigest` изменились. Предыдущий bump 2.0.0 → 3.0.0 менял только схему манифеста @@ -25,8 +28,9 @@ ровно для этой версии ядра; при легитимной смене канона (значения якорей/ручек, формулы) генератор перегенерирует векторы и `coreVersion` сдвигается. -- **Дайджест** (`manifest.packDigest`) — FNV-1a-32 над сырыми байтами шести - семейств (в порядке `contrasts, ladders, alpha, solve, muddiness, wcag22`). +- **Дайджест** (`manifest.packDigest`) — FNV-1a-32 над сырыми байтами семи + семейств (в порядке `contrasts, ladders, alpha, solve, muddiness, wcag22, + wcag22-feasibility`). Отпечаток КОНКРЕТНОГО закоммиченного артефакта. Зависит от платформы генерации (последний ULP f64 в сериализации) — не кросс-платформенный инвариант, а якорь целостности файлов. @@ -41,8 +45,38 @@ | `solve.json` | резолв контракта | `{bg, contract, theme, outcome}` | | `muddiness.json` | замороженная legacy-координата `muddiness` | `{hex, score}` | | `wcag22.json` | финальная sRGB8-пара и явно выбранный критерий WCAG 2.2 | `{foreground, background, criterion, decision, *Q55, evidence*}` | +| `wcag22-feasibility.json` | bounded request и полный compiler outcome | `{caseId, requestJson, outcomeJson}` | | `manifest.json` | метаданные и capability manifest численных решений | `{packVersion, coreVersion, packDigest, counts, numericalCapabilities}` | +`wcag22-feasibility.json` не определяет собственную transport-схему. Поля +`requestJson` и `outcomeJson` — точные compact UTF-8 байты публичных canonical +encoders `labcolors-protocol`. Outcome сохраняет вложенную алгебру +`Success(feasibility) | Failure(error)`; evaluated result содержит упорядоченный +домен и канонические relations по одному разу, candidate-major LSB0 +`failureMatrix` и 32-byte partition. В нём нет `256 × E` cells/assessments, +списков feasible/infeasible candidates или локально пересобранного proof. + +Числа feasibility ниже — не параметры solver-а, а точные мощности допустимых +подмножеств полной 256-точечной оси `#000000…#FFFFFF`: + +- для normal text 4.5:1 против `#767676` проходят 7 кандидатов: + `#000000…#040404` и `#FEFEFE…#FFFFFF`; против black+white проходят только + `#757575…#767676` (2), а добавление `#767676` к black+white даёт пустое + пересечение (0); +- для каждого из трёх критериев с отношением 3:1 против `#767676` проходят + `#000000…#2D2D2D` и `#D2D2D2…#FFFFFF` (92); против black+white — + `#5A5A5A…#949494` (59). + +Любое изменение adjacent bytes или нормативного отношения пересчитывает набор, +а не сохраняет эти числа как константы. Независимый exact-rational oracle — +`scripts/verify_wcag22_neutral_axis.py`; его content-bound результат — +`crates/labcolors-core/contracts/wcag22-neutral-axis-oracle-v1.json`. Production +differential и mutation tests убивают any/first-only, пропуск adjacent и +пропуск endpoint. Corpus также фиксирует mixed/all NotApplicable, conflicting +relation ID, raw resource rejection и пару запросов с несвязанными opaque IDs. +Последняя пара обязана иметь одинаковые физические packed decisions и разные +declared identity. + `muddiness.json` — это `experimental compatibility proxy`: corpus доказывает воспроизводимость исторического числового API, но не валидированный на наблюдателях человеческий вердикт clean/dirty и не пригодность для production @@ -59,7 +93,8 @@ decision. Legacy-идентификаторы сохранены только д - `alpha.json` начиная с pack `2.0.0` обязательно содержит точный byte-reference half-tie `#C0B2FA @ 0.122` над `#000000` → `#17161F`. Это mutation-killer старого пути `(byte/255) · alpha · 255`, который выбирал соседний LSB. -- `manifest.numericalCapabilities` в pack `4.0.0` генерируется из +- `manifest.numericalCapabilities`, введённый в pack `3.0.0`, переведённый на + proof-capable schema V2 в pack `4.0.0` и сохранённый в `5.0.0`, генерируется из proof-capable core-owned `numerical_capability_manifest_v2()`. До появления внешних клиентов промежуточная Glow-only capability-схема V1 удалена из public API: один `numericalCapabilityManifest()` сразу возвращает V2, без @@ -109,6 +144,10 @@ labels (канон labui): роли `icon` в словаре нет. - **`solve.outcome.hex`** — в пределах **±1 LSB на канал**. Это квантование трансцендентного резолва: у границы 8-бит-ячейки libm-шум может качнуть результат на один шаг. +- **`wcag22-feasibility.requestJson/outcomeJson`** — БАЙТ-ТОЧНО. Это + canonical integer/byte/string protocol без libm: terminal algebra, decimal + u64 strings, identities, упакованная LSB0 matrix и partition сравниваются как + точные UTF-8 байты. Внутренняя ошибка core и неизвестный forward-вариант `Unreachable` не являются solve-векторами: `Pack::generate()` возвращает `PackGenerationError` и не пишет @@ -119,8 +158,9 @@ solve-векторами: `Pack::generate()` возвращает `PackGeneratio ## Референс: ядро само себя проходит `crates/labcolors-conformance` несёт генератор (`--bin gen`) и раннер-референс -(`tests/reference_runner.rs`). Раннер — CI-гейт: ядро воспроизводит каждый -вектор в пределах толерантности, дайджест сходится с сырыми байтами, а +(`tests/reference_runner.rs`). Раннер — CI-гейт: ядро и общий protocol boundary +воспроизводят каждый вектор в пределах толерантности, дайджест сходится с +сырыми байтами, а опубликованные WCAG-якоря (21:1, граница `#767676`) держатся. Раннер входит в `cargo test --workspace` на Linux x86_64. Активный Swift/UniFFI gate также прогоняет все перечисленные manifest-ом семейства в pinned Linux x86_64 @@ -147,5 +187,7 @@ cargo run -p labcolors-conformance --bin gen ``` Пишет `vectors/*.json` + `manifest.json` детерминированно. Раннер-референс -падёт, если закоммиченные векторы разошлись с ядром за пределами толерантности — -тогда перегенерируй и обнови `coreVersion`. +падёт, если старые численные векторы разошлись с ядром за пределами +толерантности или feasibility JSON разошёлся с protocol encoder хотя бы на один +байт. Перегенерация допустима только при принятом изменении соответствующего +контракта; `coreVersion` обновляется лишь вместе с версией ядра. diff --git a/conformance/vectors/manifest.json b/conformance/vectors/manifest.json index 6967fdf8..57e7640b 100644 --- a/conformance/vectors/manifest.json +++ b/conformance/vectors/manifest.json @@ -1,7 +1,7 @@ { - "packVersion": "4.0.0", + "packVersion": "5.0.0", "coreVersion": "0.2.0", - "packDigest": "8faec2af", + "packDigest": "48d00cd5", "counts": { "contrasts": 40, "ladders": 25, @@ -9,7 +9,8 @@ "solve": 6, "muddiness": 4, "wcag22": 6, - "total": 88 + "wcag22Feasibility": 13, + "total": 101 }, "numericalCapabilities": { "schemaVersion": 2, diff --git a/conformance/vectors/wcag22-feasibility.json b/conformance/vectors/wcag22-feasibility.json new file mode 100644 index 00000000..78cd14de --- /dev/null +++ b/conformance/vectors/wcag22-feasibility.json @@ -0,0 +1,67 @@ +[ + { + "caseId": "text-default-seven", + "requestJson": "{\"schemaVersion\":1,\"domainId\":\"srgb8-neutral-axis-v1\",\"resourceProfileId\":\"compile-v1\",\"relations\":[{\"relationId\":\"relation-7\",\"occurrenceId\":\"occurrence-7\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-default\",\"adjacent\":[[118,118,118]]}]}", + "outcomeJson": "{\"schemaVersion\":1,\"outcome\":\"success\",\"feasibility\":{\"status\":\"feasible\",\"result\":{\"domain\":[[0,0,0],[1,1,1],[2,2,2],[3,3,3],[4,4,4],[5,5,5],[6,6,6],[7,7,7],[8,8,8],[9,9,9],[10,10,10],[11,11,11],[12,12,12],[13,13,13],[14,14,14],[15,15,15],[16,16,16],[17,17,17],[18,18,18],[19,19,19],[20,20,20],[21,21,21],[22,22,22],[23,23,23],[24,24,24],[25,25,25],[26,26,26],[27,27,27],[28,28,28],[29,29,29],[30,30,30],[31,31,31],[32,32,32],[33,33,33],[34,34,34],[35,35,35],[36,36,36],[37,37,37],[38,38,38],[39,39,39],[40,40,40],[41,41,41],[42,42,42],[43,43,43],[44,44,44],[45,45,45],[46,46,46],[47,47,47],[48,48,48],[49,49,49],[50,50,50],[51,51,51],[52,52,52],[53,53,53],[54,54,54],[55,55,55],[56,56,56],[57,57,57],[58,58,58],[59,59,59],[60,60,60],[61,61,61],[62,62,62],[63,63,63],[64,64,64],[65,65,65],[66,66,66],[67,67,67],[68,68,68],[69,69,69],[70,70,70],[71,71,71],[72,72,72],[73,73,73],[74,74,74],[75,75,75],[76,76,76],[77,77,77],[78,78,78],[79,79,79],[80,80,80],[81,81,81],[82,82,82],[83,83,83],[84,84,84],[85,85,85],[86,86,86],[87,87,87],[88,88,88],[89,89,89],[90,90,90],[91,91,91],[92,92,92],[93,93,93],[94,94,94],[95,95,95],[96,96,96],[97,97,97],[98,98,98],[99,99,99],[100,100,100],[101,101,101],[102,102,102],[103,103,103],[104,104,104],[105,105,105],[106,106,106],[107,107,107],[108,108,108],[109,109,109],[110,110,110],[111,111,111],[112,112,112],[113,113,113],[114,114,114],[115,115,115],[116,116,116],[117,117,117],[118,118,118],[119,119,119],[120,120,120],[121,121,121],[122,122,122],[123,123,123],[124,124,124],[125,125,125],[126,126,126],[127,127,127],[128,128,128],[129,129,129],[130,130,130],[131,131,131],[132,132,132],[133,133,133],[134,134,134],[135,135,135],[136,136,136],[137,137,137],[138,138,138],[139,139,139],[140,140,140],[141,141,141],[142,142,142],[143,143,143],[144,144,144],[145,145,145],[146,146,146],[147,147,147],[148,148,148],[149,149,149],[150,150,150],[151,151,151],[152,152,152],[153,153,153],[154,154,154],[155,155,155],[156,156,156],[157,157,157],[158,158,158],[159,159,159],[160,160,160],[161,161,161],[162,162,162],[163,163,163],[164,164,164],[165,165,165],[166,166,166],[167,167,167],[168,168,168],[169,169,169],[170,170,170],[171,171,171],[172,172,172],[173,173,173],[174,174,174],[175,175,175],[176,176,176],[177,177,177],[178,178,178],[179,179,179],[180,180,180],[181,181,181],[182,182,182],[183,183,183],[184,184,184],[185,185,185],[186,186,186],[187,187,187],[188,188,188],[189,189,189],[190,190,190],[191,191,191],[192,192,192],[193,193,193],[194,194,194],[195,195,195],[196,196,196],[197,197,197],[198,198,198],[199,199,199],[200,200,200],[201,201,201],[202,202,202],[203,203,203],[204,204,204],[205,205,205],[206,206,206],[207,207,207],[208,208,208],[209,209,209],[210,210,210],[211,211,211],[212,212,212],[213,213,213],[214,214,214],[215,215,215],[216,216,216],[217,217,217],[218,218,218],[219,219,219],[220,220,220],[221,221,221],[222,222,222],[223,223,223],[224,224,224],[225,225,225],[226,226,226],[227,227,227],[228,228,228],[229,229,229],[230,230,230],[231,231,231],[232,232,232],[233,233,233],[234,234,234],[235,235,235],[236,236,236],[237,237,237],[238,238,238],[239,239,239],[240,240,240],[241,241,241],[242,242,242],[243,243,243],[244,244,244],[245,245,245],[246,246,246],[247,247,247],[248,248,248],[249,249,249],[250,250,250],[251,251,251],[252,252,252],[253,253,253],[254,254,254],[255,255,255]],\"relations\":[{\"relationId\":\"relation-7\",\"occurrenceId\":\"occurrence-7\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-default\",\"adjacent\":[[118,118,118]]}],\"failureMatrix\":[224,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,63],\"proof\":{\"evaluationId\":[251,238,109,146,65,174,222,178,244,71,81,4,3,140,16,138,95,250,53,165,47,215,254,59,252,214,253,123,19,101,236,249],\"resourceProfileId\":\"compile-v1\",\"domainId\":\"srgb8-neutral-axis-v1\",\"domainDigest\":[150,52,172,50,105,121,178,60,33,3,255,205,146,162,184,144,66,126,168,145,74,151,178,100,176,199,52,9,100,15,132,102],\"domainCount\":\"256\",\"domainFirst\":[0,0,0],\"domainLast\":[255,255,255],\"relationSetDigest\":[215,150,231,125,35,15,215,56,17,44,222,177,242,219,121,42,91,236,123,109,77,2,25,65,67,29,95,188,254,71,2,184],\"canonicalRelations\":\"1\",\"applicableRelations\":\"1\",\"notApplicableRelations\":\"0\",\"applicableEdges\":\"1\",\"logicalAssessments\":\"256\",\"matrixDigest\":[178,86,216,35,22,195,81,11,1,181,78,150,37,207,7,251,190,115,205,159,188,251,22,33,206,237,152,232,64,10,93,223],\"partition\":[31,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192],\"wcag22ProfileId\":\"wcag22-srgb8-contrast-v1\",\"artifactId\":\"wcag22-srgb8-luminance-q55-v1\",\"boundId\":\"wcag22-srgb8-outward-q55-v1\",\"proofId\":\"wcag22-srgb8-full-domain-q55-v1\",\"proofSha256\":[210,105,233,222,104,144,9,187,149,87,136,191,135,98,252,229,102,128,191,97,111,192,69,155,101,38,163,103,135,90,106,8]}}}}" + }, + { + "caseId": "text-default-two", + "requestJson": "{\"schemaVersion\":1,\"domainId\":\"srgb8-neutral-axis-v1\",\"resourceProfileId\":\"compile-v1\",\"relations\":[{\"relationId\":\"relation-2\",\"occurrenceId\":\"occurrence-2\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-default\",\"adjacent\":[[0,0,0],[255,255,255]]}]}", + "outcomeJson": "{\"schemaVersion\":1,\"outcome\":\"success\",\"feasibility\":{\"status\":\"feasible\",\"result\":{\"domain\":[[0,0,0],[1,1,1],[2,2,2],[3,3,3],[4,4,4],[5,5,5],[6,6,6],[7,7,7],[8,8,8],[9,9,9],[10,10,10],[11,11,11],[12,12,12],[13,13,13],[14,14,14],[15,15,15],[16,16,16],[17,17,17],[18,18,18],[19,19,19],[20,20,20],[21,21,21],[22,22,22],[23,23,23],[24,24,24],[25,25,25],[26,26,26],[27,27,27],[28,28,28],[29,29,29],[30,30,30],[31,31,31],[32,32,32],[33,33,33],[34,34,34],[35,35,35],[36,36,36],[37,37,37],[38,38,38],[39,39,39],[40,40,40],[41,41,41],[42,42,42],[43,43,43],[44,44,44],[45,45,45],[46,46,46],[47,47,47],[48,48,48],[49,49,49],[50,50,50],[51,51,51],[52,52,52],[53,53,53],[54,54,54],[55,55,55],[56,56,56],[57,57,57],[58,58,58],[59,59,59],[60,60,60],[61,61,61],[62,62,62],[63,63,63],[64,64,64],[65,65,65],[66,66,66],[67,67,67],[68,68,68],[69,69,69],[70,70,70],[71,71,71],[72,72,72],[73,73,73],[74,74,74],[75,75,75],[76,76,76],[77,77,77],[78,78,78],[79,79,79],[80,80,80],[81,81,81],[82,82,82],[83,83,83],[84,84,84],[85,85,85],[86,86,86],[87,87,87],[88,88,88],[89,89,89],[90,90,90],[91,91,91],[92,92,92],[93,93,93],[94,94,94],[95,95,95],[96,96,96],[97,97,97],[98,98,98],[99,99,99],[100,100,100],[101,101,101],[102,102,102],[103,103,103],[104,104,104],[105,105,105],[106,106,106],[107,107,107],[108,108,108],[109,109,109],[110,110,110],[111,111,111],[112,112,112],[113,113,113],[114,114,114],[115,115,115],[116,116,116],[117,117,117],[118,118,118],[119,119,119],[120,120,120],[121,121,121],[122,122,122],[123,123,123],[124,124,124],[125,125,125],[126,126,126],[127,127,127],[128,128,128],[129,129,129],[130,130,130],[131,131,131],[132,132,132],[133,133,133],[134,134,134],[135,135,135],[136,136,136],[137,137,137],[138,138,138],[139,139,139],[140,140,140],[141,141,141],[142,142,142],[143,143,143],[144,144,144],[145,145,145],[146,146,146],[147,147,147],[148,148,148],[149,149,149],[150,150,150],[151,151,151],[152,152,152],[153,153,153],[154,154,154],[155,155,155],[156,156,156],[157,157,157],[158,158,158],[159,159,159],[160,160,160],[161,161,161],[162,162,162],[163,163,163],[164,164,164],[165,165,165],[166,166,166],[167,167,167],[168,168,168],[169,169,169],[170,170,170],[171,171,171],[172,172,172],[173,173,173],[174,174,174],[175,175,175],[176,176,176],[177,177,177],[178,178,178],[179,179,179],[180,180,180],[181,181,181],[182,182,182],[183,183,183],[184,184,184],[185,185,185],[186,186,186],[187,187,187],[188,188,188],[189,189,189],[190,190,190],[191,191,191],[192,192,192],[193,193,193],[194,194,194],[195,195,195],[196,196,196],[197,197,197],[198,198,198],[199,199,199],[200,200,200],[201,201,201],[202,202,202],[203,203,203],[204,204,204],[205,205,205],[206,206,206],[207,207,207],[208,208,208],[209,209,209],[210,210,210],[211,211,211],[212,212,212],[213,213,213],[214,214,214],[215,215,215],[216,216,216],[217,217,217],[218,218,218],[219,219,219],[220,220,220],[221,221,221],[222,222,222],[223,223,223],[224,224,224],[225,225,225],[226,226,226],[227,227,227],[228,228,228],[229,229,229],[230,230,230],[231,231,231],[232,232,232],[233,233,233],[234,234,234],[235,235,235],[236,236,236],[237,237,237],[238,238,238],[239,239,239],[240,240,240],[241,241,241],[242,242,242],[243,243,243],[244,244,244],[245,245,245],[246,246,246],[247,247,247],[248,248,248],[249,249,249],[250,250,250],[251,251,251],[252,252,252],[253,253,253],[254,254,254],[255,255,255]],\"relations\":[{\"relationId\":\"relation-2\",\"occurrenceId\":\"occurrence-2\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-default\",\"adjacent\":[[0,0,0],[255,255,255]]}],\"failureMatrix\":[85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,129,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170],\"proof\":{\"evaluationId\":[11,4,161,119,22,120,154,57,114,161,65,247,85,126,17,205,115,146,174,208,150,44,240,246,113,76,195,99,254,249,21,251],\"resourceProfileId\":\"compile-v1\",\"domainId\":\"srgb8-neutral-axis-v1\",\"domainDigest\":[150,52,172,50,105,121,178,60,33,3,255,205,146,162,184,144,66,126,168,145,74,151,178,100,176,199,52,9,100,15,132,102],\"domainCount\":\"256\",\"domainFirst\":[0,0,0],\"domainLast\":[255,255,255],\"relationSetDigest\":[124,168,166,28,145,19,164,28,93,97,67,28,82,21,56,244,60,244,142,58,141,84,102,201,120,98,20,53,83,28,234,109],\"canonicalRelations\":\"1\",\"applicableRelations\":\"1\",\"notApplicableRelations\":\"0\",\"applicableEdges\":\"2\",\"logicalAssessments\":\"512\",\"matrixDigest\":[174,35,247,72,171,140,62,149,94,155,207,169,44,190,77,63,113,76,180,142,225,13,233,129,65,145,88,241,255,127,61,136],\"partition\":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,96,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],\"wcag22ProfileId\":\"wcag22-srgb8-contrast-v1\",\"artifactId\":\"wcag22-srgb8-luminance-q55-v1\",\"boundId\":\"wcag22-srgb8-outward-q55-v1\",\"proofId\":\"wcag22-srgb8-full-domain-q55-v1\",\"proofSha256\":[210,105,233,222,104,144,9,187,149,87,136,191,135,98,252,229,102,128,191,97,111,192,69,155,101,38,163,103,135,90,106,8]}}}}" + }, + { + "caseId": "text-default-zero", + "requestJson": "{\"schemaVersion\":1,\"domainId\":\"srgb8-neutral-axis-v1\",\"resourceProfileId\":\"compile-v1\",\"relations\":[{\"relationId\":\"relation-0\",\"occurrenceId\":\"occurrence-0\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-default\",\"adjacent\":[[0,0,0],[255,255,255],[118,118,118]]}]}", + "outcomeJson": "{\"schemaVersion\":1,\"outcome\":\"success\",\"feasibility\":{\"status\":\"infeasible\",\"result\":{\"domain\":[[0,0,0],[1,1,1],[2,2,2],[3,3,3],[4,4,4],[5,5,5],[6,6,6],[7,7,7],[8,8,8],[9,9,9],[10,10,10],[11,11,11],[12,12,12],[13,13,13],[14,14,14],[15,15,15],[16,16,16],[17,17,17],[18,18,18],[19,19,19],[20,20,20],[21,21,21],[22,22,22],[23,23,23],[24,24,24],[25,25,25],[26,26,26],[27,27,27],[28,28,28],[29,29,29],[30,30,30],[31,31,31],[32,32,32],[33,33,33],[34,34,34],[35,35,35],[36,36,36],[37,37,37],[38,38,38],[39,39,39],[40,40,40],[41,41,41],[42,42,42],[43,43,43],[44,44,44],[45,45,45],[46,46,46],[47,47,47],[48,48,48],[49,49,49],[50,50,50],[51,51,51],[52,52,52],[53,53,53],[54,54,54],[55,55,55],[56,56,56],[57,57,57],[58,58,58],[59,59,59],[60,60,60],[61,61,61],[62,62,62],[63,63,63],[64,64,64],[65,65,65],[66,66,66],[67,67,67],[68,68,68],[69,69,69],[70,70,70],[71,71,71],[72,72,72],[73,73,73],[74,74,74],[75,75,75],[76,76,76],[77,77,77],[78,78,78],[79,79,79],[80,80,80],[81,81,81],[82,82,82],[83,83,83],[84,84,84],[85,85,85],[86,86,86],[87,87,87],[88,88,88],[89,89,89],[90,90,90],[91,91,91],[92,92,92],[93,93,93],[94,94,94],[95,95,95],[96,96,96],[97,97,97],[98,98,98],[99,99,99],[100,100,100],[101,101,101],[102,102,102],[103,103,103],[104,104,104],[105,105,105],[106,106,106],[107,107,107],[108,108,108],[109,109,109],[110,110,110],[111,111,111],[112,112,112],[113,113,113],[114,114,114],[115,115,115],[116,116,116],[117,117,117],[118,118,118],[119,119,119],[120,120,120],[121,121,121],[122,122,122],[123,123,123],[124,124,124],[125,125,125],[126,126,126],[127,127,127],[128,128,128],[129,129,129],[130,130,130],[131,131,131],[132,132,132],[133,133,133],[134,134,134],[135,135,135],[136,136,136],[137,137,137],[138,138,138],[139,139,139],[140,140,140],[141,141,141],[142,142,142],[143,143,143],[144,144,144],[145,145,145],[146,146,146],[147,147,147],[148,148,148],[149,149,149],[150,150,150],[151,151,151],[152,152,152],[153,153,153],[154,154,154],[155,155,155],[156,156,156],[157,157,157],[158,158,158],[159,159,159],[160,160,160],[161,161,161],[162,162,162],[163,163,163],[164,164,164],[165,165,165],[166,166,166],[167,167,167],[168,168,168],[169,169,169],[170,170,170],[171,171,171],[172,172,172],[173,173,173],[174,174,174],[175,175,175],[176,176,176],[177,177,177],[178,178,178],[179,179,179],[180,180,180],[181,181,181],[182,182,182],[183,183,183],[184,184,184],[185,185,185],[186,186,186],[187,187,187],[188,188,188],[189,189,189],[190,190,190],[191,191,191],[192,192,192],[193,193,193],[194,194,194],[195,195,195],[196,196,196],[197,197,197],[198,198,198],[199,199,199],[200,200,200],[201,201,201],[202,202,202],[203,203,203],[204,204,204],[205,205,205],[206,206,206],[207,207,207],[208,208,208],[209,209,209],[210,210,210],[211,211,211],[212,212,212],[213,213,213],[214,214,214],[215,215,215],[216,216,216],[217,217,217],[218,218,218],[219,219,219],[220,220,220],[221,221,221],[222,222,222],[223,223,223],[224,224,224],[225,225,225],[226,226,226],[227,227,227],[228,228,228],[229,229,229],[230,230,230],[231,231,231],[232,232,232],[233,233,233],[234,234,234],[235,235,235],[236,236,236],[237,237,237],[238,238,238],[239,239,239],[240,240,240],[241,241,241],[242,242,242],[243,243,243],[244,244,244],[245,245,245],[246,246,246],[247,247,247],[248,248,248],[249,249,249],[250,250,250],[251,251,251],[252,252,252],[253,253,253],[254,254,254],[255,255,255]],\"relations\":[{\"relationId\":\"relation-0\",\"occurrenceId\":\"occurrence-0\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-default\",\"adjacent\":[[0,0,0],[118,118,118],[255,255,255]]}],\"failureMatrix\":[73,146,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,54,201,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,219,182,109,147],\"proof\":{\"evaluationId\":[249,255,32,246,129,191,88,134,245,192,33,49,139,189,51,235,113,142,0,149,38,53,155,1,98,223,209,143,165,168,38,59],\"resourceProfileId\":\"compile-v1\",\"domainId\":\"srgb8-neutral-axis-v1\",\"domainDigest\":[150,52,172,50,105,121,178,60,33,3,255,205,146,162,184,144,66,126,168,145,74,151,178,100,176,199,52,9,100,15,132,102],\"domainCount\":\"256\",\"domainFirst\":[0,0,0],\"domainLast\":[255,255,255],\"relationSetDigest\":[49,18,198,45,237,186,81,17,201,19,16,180,107,105,55,118,96,34,162,104,127,76,125,134,27,93,51,61,174,143,216,227],\"canonicalRelations\":\"1\",\"applicableRelations\":\"1\",\"notApplicableRelations\":\"0\",\"applicableEdges\":\"3\",\"logicalAssessments\":\"768\",\"matrixDigest\":[14,230,177,69,93,181,107,109,102,219,11,79,85,38,55,12,204,83,54,35,201,57,22,48,173,92,140,115,26,129,131,28],\"partition\":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],\"wcag22ProfileId\":\"wcag22-srgb8-contrast-v1\",\"artifactId\":\"wcag22-srgb8-luminance-q55-v1\",\"boundId\":\"wcag22-srgb8-outward-q55-v1\",\"proofId\":\"wcag22-srgb8-full-domain-q55-v1\",\"proofSha256\":[210,105,233,222,104,144,9,187,149,87,136,191,135,98,252,229,102,128,191,97,111,192,69,155,101,38,163,103,135,90,106,8]}}}}" + }, + { + "caseId": "text-large-scale-ninety-two", + "requestJson": "{\"schemaVersion\":1,\"domainId\":\"srgb8-neutral-axis-v1\",\"resourceProfileId\":\"compile-v1\",\"relations\":[{\"relationId\":\"ratio-three-relation\",\"occurrenceId\":\"ratio-three-occurrence\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-large-scale\",\"adjacent\":[[118,118,118]]}]}", + "outcomeJson": "{\"schemaVersion\":1,\"outcome\":\"success\",\"feasibility\":{\"status\":\"feasible\",\"result\":{\"domain\":[[0,0,0],[1,1,1],[2,2,2],[3,3,3],[4,4,4],[5,5,5],[6,6,6],[7,7,7],[8,8,8],[9,9,9],[10,10,10],[11,11,11],[12,12,12],[13,13,13],[14,14,14],[15,15,15],[16,16,16],[17,17,17],[18,18,18],[19,19,19],[20,20,20],[21,21,21],[22,22,22],[23,23,23],[24,24,24],[25,25,25],[26,26,26],[27,27,27],[28,28,28],[29,29,29],[30,30,30],[31,31,31],[32,32,32],[33,33,33],[34,34,34],[35,35,35],[36,36,36],[37,37,37],[38,38,38],[39,39,39],[40,40,40],[41,41,41],[42,42,42],[43,43,43],[44,44,44],[45,45,45],[46,46,46],[47,47,47],[48,48,48],[49,49,49],[50,50,50],[51,51,51],[52,52,52],[53,53,53],[54,54,54],[55,55,55],[56,56,56],[57,57,57],[58,58,58],[59,59,59],[60,60,60],[61,61,61],[62,62,62],[63,63,63],[64,64,64],[65,65,65],[66,66,66],[67,67,67],[68,68,68],[69,69,69],[70,70,70],[71,71,71],[72,72,72],[73,73,73],[74,74,74],[75,75,75],[76,76,76],[77,77,77],[78,78,78],[79,79,79],[80,80,80],[81,81,81],[82,82,82],[83,83,83],[84,84,84],[85,85,85],[86,86,86],[87,87,87],[88,88,88],[89,89,89],[90,90,90],[91,91,91],[92,92,92],[93,93,93],[94,94,94],[95,95,95],[96,96,96],[97,97,97],[98,98,98],[99,99,99],[100,100,100],[101,101,101],[102,102,102],[103,103,103],[104,104,104],[105,105,105],[106,106,106],[107,107,107],[108,108,108],[109,109,109],[110,110,110],[111,111,111],[112,112,112],[113,113,113],[114,114,114],[115,115,115],[116,116,116],[117,117,117],[118,118,118],[119,119,119],[120,120,120],[121,121,121],[122,122,122],[123,123,123],[124,124,124],[125,125,125],[126,126,126],[127,127,127],[128,128,128],[129,129,129],[130,130,130],[131,131,131],[132,132,132],[133,133,133],[134,134,134],[135,135,135],[136,136,136],[137,137,137],[138,138,138],[139,139,139],[140,140,140],[141,141,141],[142,142,142],[143,143,143],[144,144,144],[145,145,145],[146,146,146],[147,147,147],[148,148,148],[149,149,149],[150,150,150],[151,151,151],[152,152,152],[153,153,153],[154,154,154],[155,155,155],[156,156,156],[157,157,157],[158,158,158],[159,159,159],[160,160,160],[161,161,161],[162,162,162],[163,163,163],[164,164,164],[165,165,165],[166,166,166],[167,167,167],[168,168,168],[169,169,169],[170,170,170],[171,171,171],[172,172,172],[173,173,173],[174,174,174],[175,175,175],[176,176,176],[177,177,177],[178,178,178],[179,179,179],[180,180,180],[181,181,181],[182,182,182],[183,183,183],[184,184,184],[185,185,185],[186,186,186],[187,187,187],[188,188,188],[189,189,189],[190,190,190],[191,191,191],[192,192,192],[193,193,193],[194,194,194],[195,195,195],[196,196,196],[197,197,197],[198,198,198],[199,199,199],[200,200,200],[201,201,201],[202,202,202],[203,203,203],[204,204,204],[205,205,205],[206,206,206],[207,207,207],[208,208,208],[209,209,209],[210,210,210],[211,211,211],[212,212,212],[213,213,213],[214,214,214],[215,215,215],[216,216,216],[217,217,217],[218,218,218],[219,219,219],[220,220,220],[221,221,221],[222,222,222],[223,223,223],[224,224,224],[225,225,225],[226,226,226],[227,227,227],[228,228,228],[229,229,229],[230,230,230],[231,231,231],[232,232,232],[233,233,233],[234,234,234],[235,235,235],[236,236,236],[237,237,237],[238,238,238],[239,239,239],[240,240,240],[241,241,241],[242,242,242],[243,243,243],[244,244,244],[245,245,245],[246,246,246],[247,247,247],[248,248,248],[249,249,249],[250,250,250],[251,251,251],[252,252,252],[253,253,253],[254,254,254],[255,255,255]],\"relations\":[{\"relationId\":\"ratio-three-relation\",\"occurrenceId\":\"ratio-three-occurrence\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-large-scale\",\"adjacent\":[[118,118,118]]}],\"failureMatrix\":[0,0,0,0,0,192,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,3,0,0,0,0,0],\"proof\":{\"evaluationId\":[208,183,197,172,1,201,200,122,209,52,5,143,175,249,8,203,82,96,14,134,162,0,115,116,55,159,62,0,131,114,86,224],\"resourceProfileId\":\"compile-v1\",\"domainId\":\"srgb8-neutral-axis-v1\",\"domainDigest\":[150,52,172,50,105,121,178,60,33,3,255,205,146,162,184,144,66,126,168,145,74,151,178,100,176,199,52,9,100,15,132,102],\"domainCount\":\"256\",\"domainFirst\":[0,0,0],\"domainLast\":[255,255,255],\"relationSetDigest\":[51,192,137,146,75,16,68,97,116,93,164,4,90,139,196,82,239,158,149,236,122,185,151,1,93,76,167,248,193,170,14,240],\"canonicalRelations\":\"1\",\"applicableRelations\":\"1\",\"notApplicableRelations\":\"0\",\"applicableEdges\":\"1\",\"logicalAssessments\":\"256\",\"matrixDigest\":[66,141,117,172,101,177,148,128,188,167,186,94,170,206,26,195,64,98,168,14,94,194,181,41,157,245,231,238,80,254,26,5],\"partition\":[255,255,255,255,255,63,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,252,255,255,255,255,255],\"wcag22ProfileId\":\"wcag22-srgb8-contrast-v1\",\"artifactId\":\"wcag22-srgb8-luminance-q55-v1\",\"boundId\":\"wcag22-srgb8-outward-q55-v1\",\"proofId\":\"wcag22-srgb8-full-domain-q55-v1\",\"proofSha256\":[210,105,233,222,104,144,9,187,149,87,136,191,135,98,252,229,102,128,191,97,111,192,69,155,101,38,163,103,135,90,106,8]}}}}" + }, + { + "caseId": "ui-component-ninety-two", + "requestJson": "{\"schemaVersion\":1,\"domainId\":\"srgb8-neutral-axis-v1\",\"resourceProfileId\":\"compile-v1\",\"relations\":[{\"relationId\":\"ratio-three-relation\",\"occurrenceId\":\"ratio-three-occurrence\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.11-ui-component-or-state\",\"adjacent\":[[118,118,118]]}]}", + "outcomeJson": "{\"schemaVersion\":1,\"outcome\":\"success\",\"feasibility\":{\"status\":\"feasible\",\"result\":{\"domain\":[[0,0,0],[1,1,1],[2,2,2],[3,3,3],[4,4,4],[5,5,5],[6,6,6],[7,7,7],[8,8,8],[9,9,9],[10,10,10],[11,11,11],[12,12,12],[13,13,13],[14,14,14],[15,15,15],[16,16,16],[17,17,17],[18,18,18],[19,19,19],[20,20,20],[21,21,21],[22,22,22],[23,23,23],[24,24,24],[25,25,25],[26,26,26],[27,27,27],[28,28,28],[29,29,29],[30,30,30],[31,31,31],[32,32,32],[33,33,33],[34,34,34],[35,35,35],[36,36,36],[37,37,37],[38,38,38],[39,39,39],[40,40,40],[41,41,41],[42,42,42],[43,43,43],[44,44,44],[45,45,45],[46,46,46],[47,47,47],[48,48,48],[49,49,49],[50,50,50],[51,51,51],[52,52,52],[53,53,53],[54,54,54],[55,55,55],[56,56,56],[57,57,57],[58,58,58],[59,59,59],[60,60,60],[61,61,61],[62,62,62],[63,63,63],[64,64,64],[65,65,65],[66,66,66],[67,67,67],[68,68,68],[69,69,69],[70,70,70],[71,71,71],[72,72,72],[73,73,73],[74,74,74],[75,75,75],[76,76,76],[77,77,77],[78,78,78],[79,79,79],[80,80,80],[81,81,81],[82,82,82],[83,83,83],[84,84,84],[85,85,85],[86,86,86],[87,87,87],[88,88,88],[89,89,89],[90,90,90],[91,91,91],[92,92,92],[93,93,93],[94,94,94],[95,95,95],[96,96,96],[97,97,97],[98,98,98],[99,99,99],[100,100,100],[101,101,101],[102,102,102],[103,103,103],[104,104,104],[105,105,105],[106,106,106],[107,107,107],[108,108,108],[109,109,109],[110,110,110],[111,111,111],[112,112,112],[113,113,113],[114,114,114],[115,115,115],[116,116,116],[117,117,117],[118,118,118],[119,119,119],[120,120,120],[121,121,121],[122,122,122],[123,123,123],[124,124,124],[125,125,125],[126,126,126],[127,127,127],[128,128,128],[129,129,129],[130,130,130],[131,131,131],[132,132,132],[133,133,133],[134,134,134],[135,135,135],[136,136,136],[137,137,137],[138,138,138],[139,139,139],[140,140,140],[141,141,141],[142,142,142],[143,143,143],[144,144,144],[145,145,145],[146,146,146],[147,147,147],[148,148,148],[149,149,149],[150,150,150],[151,151,151],[152,152,152],[153,153,153],[154,154,154],[155,155,155],[156,156,156],[157,157,157],[158,158,158],[159,159,159],[160,160,160],[161,161,161],[162,162,162],[163,163,163],[164,164,164],[165,165,165],[166,166,166],[167,167,167],[168,168,168],[169,169,169],[170,170,170],[171,171,171],[172,172,172],[173,173,173],[174,174,174],[175,175,175],[176,176,176],[177,177,177],[178,178,178],[179,179,179],[180,180,180],[181,181,181],[182,182,182],[183,183,183],[184,184,184],[185,185,185],[186,186,186],[187,187,187],[188,188,188],[189,189,189],[190,190,190],[191,191,191],[192,192,192],[193,193,193],[194,194,194],[195,195,195],[196,196,196],[197,197,197],[198,198,198],[199,199,199],[200,200,200],[201,201,201],[202,202,202],[203,203,203],[204,204,204],[205,205,205],[206,206,206],[207,207,207],[208,208,208],[209,209,209],[210,210,210],[211,211,211],[212,212,212],[213,213,213],[214,214,214],[215,215,215],[216,216,216],[217,217,217],[218,218,218],[219,219,219],[220,220,220],[221,221,221],[222,222,222],[223,223,223],[224,224,224],[225,225,225],[226,226,226],[227,227,227],[228,228,228],[229,229,229],[230,230,230],[231,231,231],[232,232,232],[233,233,233],[234,234,234],[235,235,235],[236,236,236],[237,237,237],[238,238,238],[239,239,239],[240,240,240],[241,241,241],[242,242,242],[243,243,243],[244,244,244],[245,245,245],[246,246,246],[247,247,247],[248,248,248],[249,249,249],[250,250,250],[251,251,251],[252,252,252],[253,253,253],[254,254,254],[255,255,255]],\"relations\":[{\"relationId\":\"ratio-three-relation\",\"occurrenceId\":\"ratio-three-occurrence\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.11-ui-component-or-state\",\"adjacent\":[[118,118,118]]}],\"failureMatrix\":[0,0,0,0,0,192,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,3,0,0,0,0,0],\"proof\":{\"evaluationId\":[209,140,33,54,133,210,90,253,243,189,103,216,68,198,207,217,7,88,226,207,236,116,114,210,228,84,202,246,80,171,70,179],\"resourceProfileId\":\"compile-v1\",\"domainId\":\"srgb8-neutral-axis-v1\",\"domainDigest\":[150,52,172,50,105,121,178,60,33,3,255,205,146,162,184,144,66,126,168,145,74,151,178,100,176,199,52,9,100,15,132,102],\"domainCount\":\"256\",\"domainFirst\":[0,0,0],\"domainLast\":[255,255,255],\"relationSetDigest\":[26,6,182,65,48,1,71,163,17,161,202,206,237,209,215,190,95,66,128,147,73,135,156,31,215,83,131,215,171,170,132,102],\"canonicalRelations\":\"1\",\"applicableRelations\":\"1\",\"notApplicableRelations\":\"0\",\"applicableEdges\":\"1\",\"logicalAssessments\":\"256\",\"matrixDigest\":[66,141,117,172,101,177,148,128,188,167,186,94,170,206,26,195,64,98,168,14,94,194,181,41,157,245,231,238,80,254,26,5],\"partition\":[255,255,255,255,255,63,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,252,255,255,255,255,255],\"wcag22ProfileId\":\"wcag22-srgb8-contrast-v1\",\"artifactId\":\"wcag22-srgb8-luminance-q55-v1\",\"boundId\":\"wcag22-srgb8-outward-q55-v1\",\"proofId\":\"wcag22-srgb8-full-domain-q55-v1\",\"proofSha256\":[210,105,233,222,104,144,9,187,149,87,136,191,135,98,252,229,102,128,191,97,111,192,69,155,101,38,163,103,135,90,106,8]}}}}" + }, + { + "caseId": "graphical-object-ninety-two", + "requestJson": "{\"schemaVersion\":1,\"domainId\":\"srgb8-neutral-axis-v1\",\"resourceProfileId\":\"compile-v1\",\"relations\":[{\"relationId\":\"ratio-three-relation\",\"occurrenceId\":\"ratio-three-occurrence\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.11-graphical-object\",\"adjacent\":[[118,118,118]]}]}", + "outcomeJson": "{\"schemaVersion\":1,\"outcome\":\"success\",\"feasibility\":{\"status\":\"feasible\",\"result\":{\"domain\":[[0,0,0],[1,1,1],[2,2,2],[3,3,3],[4,4,4],[5,5,5],[6,6,6],[7,7,7],[8,8,8],[9,9,9],[10,10,10],[11,11,11],[12,12,12],[13,13,13],[14,14,14],[15,15,15],[16,16,16],[17,17,17],[18,18,18],[19,19,19],[20,20,20],[21,21,21],[22,22,22],[23,23,23],[24,24,24],[25,25,25],[26,26,26],[27,27,27],[28,28,28],[29,29,29],[30,30,30],[31,31,31],[32,32,32],[33,33,33],[34,34,34],[35,35,35],[36,36,36],[37,37,37],[38,38,38],[39,39,39],[40,40,40],[41,41,41],[42,42,42],[43,43,43],[44,44,44],[45,45,45],[46,46,46],[47,47,47],[48,48,48],[49,49,49],[50,50,50],[51,51,51],[52,52,52],[53,53,53],[54,54,54],[55,55,55],[56,56,56],[57,57,57],[58,58,58],[59,59,59],[60,60,60],[61,61,61],[62,62,62],[63,63,63],[64,64,64],[65,65,65],[66,66,66],[67,67,67],[68,68,68],[69,69,69],[70,70,70],[71,71,71],[72,72,72],[73,73,73],[74,74,74],[75,75,75],[76,76,76],[77,77,77],[78,78,78],[79,79,79],[80,80,80],[81,81,81],[82,82,82],[83,83,83],[84,84,84],[85,85,85],[86,86,86],[87,87,87],[88,88,88],[89,89,89],[90,90,90],[91,91,91],[92,92,92],[93,93,93],[94,94,94],[95,95,95],[96,96,96],[97,97,97],[98,98,98],[99,99,99],[100,100,100],[101,101,101],[102,102,102],[103,103,103],[104,104,104],[105,105,105],[106,106,106],[107,107,107],[108,108,108],[109,109,109],[110,110,110],[111,111,111],[112,112,112],[113,113,113],[114,114,114],[115,115,115],[116,116,116],[117,117,117],[118,118,118],[119,119,119],[120,120,120],[121,121,121],[122,122,122],[123,123,123],[124,124,124],[125,125,125],[126,126,126],[127,127,127],[128,128,128],[129,129,129],[130,130,130],[131,131,131],[132,132,132],[133,133,133],[134,134,134],[135,135,135],[136,136,136],[137,137,137],[138,138,138],[139,139,139],[140,140,140],[141,141,141],[142,142,142],[143,143,143],[144,144,144],[145,145,145],[146,146,146],[147,147,147],[148,148,148],[149,149,149],[150,150,150],[151,151,151],[152,152,152],[153,153,153],[154,154,154],[155,155,155],[156,156,156],[157,157,157],[158,158,158],[159,159,159],[160,160,160],[161,161,161],[162,162,162],[163,163,163],[164,164,164],[165,165,165],[166,166,166],[167,167,167],[168,168,168],[169,169,169],[170,170,170],[171,171,171],[172,172,172],[173,173,173],[174,174,174],[175,175,175],[176,176,176],[177,177,177],[178,178,178],[179,179,179],[180,180,180],[181,181,181],[182,182,182],[183,183,183],[184,184,184],[185,185,185],[186,186,186],[187,187,187],[188,188,188],[189,189,189],[190,190,190],[191,191,191],[192,192,192],[193,193,193],[194,194,194],[195,195,195],[196,196,196],[197,197,197],[198,198,198],[199,199,199],[200,200,200],[201,201,201],[202,202,202],[203,203,203],[204,204,204],[205,205,205],[206,206,206],[207,207,207],[208,208,208],[209,209,209],[210,210,210],[211,211,211],[212,212,212],[213,213,213],[214,214,214],[215,215,215],[216,216,216],[217,217,217],[218,218,218],[219,219,219],[220,220,220],[221,221,221],[222,222,222],[223,223,223],[224,224,224],[225,225,225],[226,226,226],[227,227,227],[228,228,228],[229,229,229],[230,230,230],[231,231,231],[232,232,232],[233,233,233],[234,234,234],[235,235,235],[236,236,236],[237,237,237],[238,238,238],[239,239,239],[240,240,240],[241,241,241],[242,242,242],[243,243,243],[244,244,244],[245,245,245],[246,246,246],[247,247,247],[248,248,248],[249,249,249],[250,250,250],[251,251,251],[252,252,252],[253,253,253],[254,254,254],[255,255,255]],\"relations\":[{\"relationId\":\"ratio-three-relation\",\"occurrenceId\":\"ratio-three-occurrence\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.11-graphical-object\",\"adjacent\":[[118,118,118]]}],\"failureMatrix\":[0,0,0,0,0,192,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,3,0,0,0,0,0],\"proof\":{\"evaluationId\":[171,87,148,244,165,252,170,254,133,38,115,246,114,13,182,204,197,81,169,31,100,173,22,36,116,238,48,51,70,24,16,214],\"resourceProfileId\":\"compile-v1\",\"domainId\":\"srgb8-neutral-axis-v1\",\"domainDigest\":[150,52,172,50,105,121,178,60,33,3,255,205,146,162,184,144,66,126,168,145,74,151,178,100,176,199,52,9,100,15,132,102],\"domainCount\":\"256\",\"domainFirst\":[0,0,0],\"domainLast\":[255,255,255],\"relationSetDigest\":[222,194,26,12,211,115,227,154,201,168,114,4,85,170,241,243,227,1,26,123,183,59,29,145,43,69,194,88,121,155,154,214],\"canonicalRelations\":\"1\",\"applicableRelations\":\"1\",\"notApplicableRelations\":\"0\",\"applicableEdges\":\"1\",\"logicalAssessments\":\"256\",\"matrixDigest\":[66,141,117,172,101,177,148,128,188,167,186,94,170,206,26,195,64,98,168,14,94,194,181,41,157,245,231,238,80,254,26,5],\"partition\":[255,255,255,255,255,63,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,252,255,255,255,255,255],\"wcag22ProfileId\":\"wcag22-srgb8-contrast-v1\",\"artifactId\":\"wcag22-srgb8-luminance-q55-v1\",\"boundId\":\"wcag22-srgb8-outward-q55-v1\",\"proofId\":\"wcag22-srgb8-full-domain-q55-v1\",\"proofSha256\":[210,105,233,222,104,144,9,187,149,87,136,191,135,98,252,229,102,128,191,97,111,192,69,155,101,38,163,103,135,90,106,8]}}}}" + }, + { + "caseId": "ui-component-fifty-nine", + "requestJson": "{\"schemaVersion\":1,\"domainId\":\"srgb8-neutral-axis-v1\",\"resourceProfileId\":\"compile-v1\",\"relations\":[{\"relationId\":\"relation-59\",\"occurrenceId\":\"occurrence-59\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.11-ui-component-or-state\",\"adjacent\":[[0,0,0],[255,255,255]]}]}", + "outcomeJson": "{\"schemaVersion\":1,\"outcome\":\"success\",\"feasibility\":{\"status\":\"feasible\",\"result\":{\"domain\":[[0,0,0],[1,1,1],[2,2,2],[3,3,3],[4,4,4],[5,5,5],[6,6,6],[7,7,7],[8,8,8],[9,9,9],[10,10,10],[11,11,11],[12,12,12],[13,13,13],[14,14,14],[15,15,15],[16,16,16],[17,17,17],[18,18,18],[19,19,19],[20,20,20],[21,21,21],[22,22,22],[23,23,23],[24,24,24],[25,25,25],[26,26,26],[27,27,27],[28,28,28],[29,29,29],[30,30,30],[31,31,31],[32,32,32],[33,33,33],[34,34,34],[35,35,35],[36,36,36],[37,37,37],[38,38,38],[39,39,39],[40,40,40],[41,41,41],[42,42,42],[43,43,43],[44,44,44],[45,45,45],[46,46,46],[47,47,47],[48,48,48],[49,49,49],[50,50,50],[51,51,51],[52,52,52],[53,53,53],[54,54,54],[55,55,55],[56,56,56],[57,57,57],[58,58,58],[59,59,59],[60,60,60],[61,61,61],[62,62,62],[63,63,63],[64,64,64],[65,65,65],[66,66,66],[67,67,67],[68,68,68],[69,69,69],[70,70,70],[71,71,71],[72,72,72],[73,73,73],[74,74,74],[75,75,75],[76,76,76],[77,77,77],[78,78,78],[79,79,79],[80,80,80],[81,81,81],[82,82,82],[83,83,83],[84,84,84],[85,85,85],[86,86,86],[87,87,87],[88,88,88],[89,89,89],[90,90,90],[91,91,91],[92,92,92],[93,93,93],[94,94,94],[95,95,95],[96,96,96],[97,97,97],[98,98,98],[99,99,99],[100,100,100],[101,101,101],[102,102,102],[103,103,103],[104,104,104],[105,105,105],[106,106,106],[107,107,107],[108,108,108],[109,109,109],[110,110,110],[111,111,111],[112,112,112],[113,113,113],[114,114,114],[115,115,115],[116,116,116],[117,117,117],[118,118,118],[119,119,119],[120,120,120],[121,121,121],[122,122,122],[123,123,123],[124,124,124],[125,125,125],[126,126,126],[127,127,127],[128,128,128],[129,129,129],[130,130,130],[131,131,131],[132,132,132],[133,133,133],[134,134,134],[135,135,135],[136,136,136],[137,137,137],[138,138,138],[139,139,139],[140,140,140],[141,141,141],[142,142,142],[143,143,143],[144,144,144],[145,145,145],[146,146,146],[147,147,147],[148,148,148],[149,149,149],[150,150,150],[151,151,151],[152,152,152],[153,153,153],[154,154,154],[155,155,155],[156,156,156],[157,157,157],[158,158,158],[159,159,159],[160,160,160],[161,161,161],[162,162,162],[163,163,163],[164,164,164],[165,165,165],[166,166,166],[167,167,167],[168,168,168],[169,169,169],[170,170,170],[171,171,171],[172,172,172],[173,173,173],[174,174,174],[175,175,175],[176,176,176],[177,177,177],[178,178,178],[179,179,179],[180,180,180],[181,181,181],[182,182,182],[183,183,183],[184,184,184],[185,185,185],[186,186,186],[187,187,187],[188,188,188],[189,189,189],[190,190,190],[191,191,191],[192,192,192],[193,193,193],[194,194,194],[195,195,195],[196,196,196],[197,197,197],[198,198,198],[199,199,199],[200,200,200],[201,201,201],[202,202,202],[203,203,203],[204,204,204],[205,205,205],[206,206,206],[207,207,207],[208,208,208],[209,209,209],[210,210,210],[211,211,211],[212,212,212],[213,213,213],[214,214,214],[215,215,215],[216,216,216],[217,217,217],[218,218,218],[219,219,219],[220,220,220],[221,221,221],[222,222,222],[223,223,223],[224,224,224],[225,225,225],[226,226,226],[227,227,227],[228,228,228],[229,229,229],[230,230,230],[231,231,231],[232,232,232],[233,233,233],[234,234,234],[235,235,235],[236,236,236],[237,237,237],[238,238,238],[239,239,239],[240,240,240],[241,241,241],[242,242,242],[243,243,243],[244,244,244],[245,245,245],[246,246,246],[247,247,247],[248,248,248],[249,249,249],[250,250,250],[251,251,251],[252,252,252],[253,253,253],[254,254,254],[255,255,255]],\"relations\":[{\"relationId\":\"relation-59\",\"occurrenceId\":\"occurrence-59\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.11-ui-component-or-state\",\"adjacent\":[[0,0,0],[255,255,255]]}],\"failureMatrix\":[85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,85,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,168,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170],\"proof\":{\"evaluationId\":[224,71,230,106,4,128,242,64,139,40,47,231,197,223,156,112,252,212,24,33,55,59,2,6,33,143,227,29,109,97,219,187],\"resourceProfileId\":\"compile-v1\",\"domainId\":\"srgb8-neutral-axis-v1\",\"domainDigest\":[150,52,172,50,105,121,178,60,33,3,255,205,146,162,184,144,66,126,168,145,74,151,178,100,176,199,52,9,100,15,132,102],\"domainCount\":\"256\",\"domainFirst\":[0,0,0],\"domainLast\":[255,255,255],\"relationSetDigest\":[162,123,143,170,206,129,26,224,84,48,181,40,224,14,98,232,157,55,33,131,74,61,239,120,205,175,15,36,115,66,181,234],\"canonicalRelations\":\"1\",\"applicableRelations\":\"1\",\"notApplicableRelations\":\"0\",\"applicableEdges\":\"2\",\"logicalAssessments\":\"512\",\"matrixDigest\":[130,159,17,17,18,252,106,219,175,9,137,66,200,232,127,72,72,18,249,4,122,50,203,197,63,251,172,242,106,152,102,16],\"partition\":[0,0,0,0,0,0,0,0,0,0,0,252,255,255,255,255,255,255,31,0,0,0,0,0,0,0,0,0,0,0,0,0],\"wcag22ProfileId\":\"wcag22-srgb8-contrast-v1\",\"artifactId\":\"wcag22-srgb8-luminance-q55-v1\",\"boundId\":\"wcag22-srgb8-outward-q55-v1\",\"proofId\":\"wcag22-srgb8-full-domain-q55-v1\",\"proofSha256\":[210,105,233,222,104,144,9,187,149,87,136,191,135,98,252,229,102,128,191,97,111,192,69,155,101,38,163,103,135,90,106,8]}}}}" + }, + { + "caseId": "mixed-not-applicable", + "requestJson": "{\"schemaVersion\":1,\"domainId\":\"srgb8-neutral-axis-v1\",\"resourceProfileId\":\"compile-v1\",\"relations\":[{\"relationId\":\"mixed-applicable\",\"occurrenceId\":\"shared-occurrence\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-default\",\"adjacent\":[[118,118,118]]},{\"relationId\":\"mixed-declaration\",\"occurrenceId\":\"shared-occurrence\",\"kind\":\"notApplicable\",\"reasonId\":\"client-declared-out-of-scope\"}]}", + "outcomeJson": "{\"schemaVersion\":1,\"outcome\":\"success\",\"feasibility\":{\"status\":\"feasible\",\"result\":{\"domain\":[[0,0,0],[1,1,1],[2,2,2],[3,3,3],[4,4,4],[5,5,5],[6,6,6],[7,7,7],[8,8,8],[9,9,9],[10,10,10],[11,11,11],[12,12,12],[13,13,13],[14,14,14],[15,15,15],[16,16,16],[17,17,17],[18,18,18],[19,19,19],[20,20,20],[21,21,21],[22,22,22],[23,23,23],[24,24,24],[25,25,25],[26,26,26],[27,27,27],[28,28,28],[29,29,29],[30,30,30],[31,31,31],[32,32,32],[33,33,33],[34,34,34],[35,35,35],[36,36,36],[37,37,37],[38,38,38],[39,39,39],[40,40,40],[41,41,41],[42,42,42],[43,43,43],[44,44,44],[45,45,45],[46,46,46],[47,47,47],[48,48,48],[49,49,49],[50,50,50],[51,51,51],[52,52,52],[53,53,53],[54,54,54],[55,55,55],[56,56,56],[57,57,57],[58,58,58],[59,59,59],[60,60,60],[61,61,61],[62,62,62],[63,63,63],[64,64,64],[65,65,65],[66,66,66],[67,67,67],[68,68,68],[69,69,69],[70,70,70],[71,71,71],[72,72,72],[73,73,73],[74,74,74],[75,75,75],[76,76,76],[77,77,77],[78,78,78],[79,79,79],[80,80,80],[81,81,81],[82,82,82],[83,83,83],[84,84,84],[85,85,85],[86,86,86],[87,87,87],[88,88,88],[89,89,89],[90,90,90],[91,91,91],[92,92,92],[93,93,93],[94,94,94],[95,95,95],[96,96,96],[97,97,97],[98,98,98],[99,99,99],[100,100,100],[101,101,101],[102,102,102],[103,103,103],[104,104,104],[105,105,105],[106,106,106],[107,107,107],[108,108,108],[109,109,109],[110,110,110],[111,111,111],[112,112,112],[113,113,113],[114,114,114],[115,115,115],[116,116,116],[117,117,117],[118,118,118],[119,119,119],[120,120,120],[121,121,121],[122,122,122],[123,123,123],[124,124,124],[125,125,125],[126,126,126],[127,127,127],[128,128,128],[129,129,129],[130,130,130],[131,131,131],[132,132,132],[133,133,133],[134,134,134],[135,135,135],[136,136,136],[137,137,137],[138,138,138],[139,139,139],[140,140,140],[141,141,141],[142,142,142],[143,143,143],[144,144,144],[145,145,145],[146,146,146],[147,147,147],[148,148,148],[149,149,149],[150,150,150],[151,151,151],[152,152,152],[153,153,153],[154,154,154],[155,155,155],[156,156,156],[157,157,157],[158,158,158],[159,159,159],[160,160,160],[161,161,161],[162,162,162],[163,163,163],[164,164,164],[165,165,165],[166,166,166],[167,167,167],[168,168,168],[169,169,169],[170,170,170],[171,171,171],[172,172,172],[173,173,173],[174,174,174],[175,175,175],[176,176,176],[177,177,177],[178,178,178],[179,179,179],[180,180,180],[181,181,181],[182,182,182],[183,183,183],[184,184,184],[185,185,185],[186,186,186],[187,187,187],[188,188,188],[189,189,189],[190,190,190],[191,191,191],[192,192,192],[193,193,193],[194,194,194],[195,195,195],[196,196,196],[197,197,197],[198,198,198],[199,199,199],[200,200,200],[201,201,201],[202,202,202],[203,203,203],[204,204,204],[205,205,205],[206,206,206],[207,207,207],[208,208,208],[209,209,209],[210,210,210],[211,211,211],[212,212,212],[213,213,213],[214,214,214],[215,215,215],[216,216,216],[217,217,217],[218,218,218],[219,219,219],[220,220,220],[221,221,221],[222,222,222],[223,223,223],[224,224,224],[225,225,225],[226,226,226],[227,227,227],[228,228,228],[229,229,229],[230,230,230],[231,231,231],[232,232,232],[233,233,233],[234,234,234],[235,235,235],[236,236,236],[237,237,237],[238,238,238],[239,239,239],[240,240,240],[241,241,241],[242,242,242],[243,243,243],[244,244,244],[245,245,245],[246,246,246],[247,247,247],[248,248,248],[249,249,249],[250,250,250],[251,251,251],[252,252,252],[253,253,253],[254,254,254],[255,255,255]],\"relations\":[{\"relationId\":\"mixed-applicable\",\"occurrenceId\":\"shared-occurrence\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-default\",\"adjacent\":[[118,118,118]]},{\"relationId\":\"mixed-declaration\",\"occurrenceId\":\"shared-occurrence\",\"kind\":\"notApplicable\",\"reasonId\":\"client-declared-out-of-scope\"}],\"failureMatrix\":[224,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,63],\"proof\":{\"evaluationId\":[57,180,189,29,30,223,186,121,66,29,197,253,43,88,96,70,91,182,27,170,24,199,103,9,26,209,180,120,16,159,17,3],\"resourceProfileId\":\"compile-v1\",\"domainId\":\"srgb8-neutral-axis-v1\",\"domainDigest\":[150,52,172,50,105,121,178,60,33,3,255,205,146,162,184,144,66,126,168,145,74,151,178,100,176,199,52,9,100,15,132,102],\"domainCount\":\"256\",\"domainFirst\":[0,0,0],\"domainLast\":[255,255,255],\"relationSetDigest\":[204,63,80,227,192,197,181,192,11,211,4,95,247,191,213,238,9,154,178,147,70,175,11,62,2,51,190,21,6,95,26,114],\"canonicalRelations\":\"2\",\"applicableRelations\":\"1\",\"notApplicableRelations\":\"1\",\"applicableEdges\":\"1\",\"logicalAssessments\":\"256\",\"matrixDigest\":[178,86,216,35,22,195,81,11,1,181,78,150,37,207,7,251,190,115,205,159,188,251,22,33,206,237,152,232,64,10,93,223],\"partition\":[31,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192],\"wcag22ProfileId\":\"wcag22-srgb8-contrast-v1\",\"artifactId\":\"wcag22-srgb8-luminance-q55-v1\",\"boundId\":\"wcag22-srgb8-outward-q55-v1\",\"proofId\":\"wcag22-srgb8-full-domain-q55-v1\",\"proofSha256\":[210,105,233,222,104,144,9,187,149,87,136,191,135,98,252,229,102,128,191,97,111,192,69,155,101,38,163,103,135,90,106,8]}}}}" + }, + { + "caseId": "all-not-applicable", + "requestJson": "{\"schemaVersion\":1,\"domainId\":\"srgb8-neutral-axis-v1\",\"resourceProfileId\":\"compile-v1\",\"relations\":[{\"relationId\":\"declaration-only\",\"occurrenceId\":\"opaque-occurrence\",\"kind\":\"notApplicable\",\"reasonId\":\"client-declared-out-of-scope\"}]}", + "outcomeJson": "{\"schemaVersion\":1,\"outcome\":\"success\",\"feasibility\":{\"status\":\"notEvaluated\",\"result\":{\"domainId\":\"srgb8-neutral-axis-v1\",\"domainDigest\":[150,52,172,50,105,121,178,60,33,3,255,205,146,162,184,144,66,126,168,145,74,151,178,100,176,199,52,9,100,15,132,102],\"relationSetDigest\":[116,119,210,149,233,116,117,191,9,64,11,77,60,152,91,136,75,26,156,63,49,2,153,11,155,202,161,60,26,162,222,190],\"resourceProfileId\":\"compile-v1\",\"relations\":[{\"relationId\":\"declaration-only\",\"occurrenceId\":\"opaque-occurrence\",\"kind\":\"notApplicable\",\"reasonId\":\"client-declared-out-of-scope\"}]}}}" + }, + { + "caseId": "conflicting-relation-id", + "requestJson": "{\"schemaVersion\":1,\"domainId\":\"srgb8-neutral-axis-v1\",\"resourceProfileId\":\"compile-v1\",\"relations\":[{\"relationId\":\"same-id\",\"occurrenceId\":\"first-occurrence\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-default\",\"adjacent\":[[0,0,0]]},{\"relationId\":\"same-id\",\"occurrenceId\":\"second-occurrence\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-default\",\"adjacent\":[[0,0,0]]}]}", + "outcomeJson": "{\"schemaVersion\":1,\"outcome\":\"failure\",\"error\":{\"source\":\"core\",\"error\":{\"code\":\"invalidRequest\",\"details\":{\"code\":\"conflictingRelationId\",\"relationId\":\"same-id\"}}}}" + }, + { + "caseId": "raw-adjacent-resource-rejection", + "requestJson": "{\"schemaVersion\":1,\"domainId\":\"srgb8-neutral-axis-v1\",\"resourceProfileId\":\"compile-v1\",\"relations\":[{\"relationId\":\"resource-relation\",\"occurrenceId\":\"resource-occurrence\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.11-ui-component-or-state\",\"adjacent\":[[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0]]}]}", + "outcomeJson": "{\"schemaVersion\":1,\"outcome\":\"failure\",\"error\":{\"source\":\"core\",\"error\":{\"code\":\"resourceLimitExceeded\",\"details\":{\"profileId\":\"compile-v1\",\"dimension\":\"rawAdjacentEntries\",\"requested\":\"2048\",\"limit\":\"2047\"}}}}" + }, + { + "caseId": "opaque-identity-a", + "requestJson": "{\"schemaVersion\":1,\"domainId\":\"srgb8-neutral-axis-v1\",\"resourceProfileId\":\"compile-v1\",\"relations\":[{\"relationId\":\"r:7f3a\",\"occurrenceId\":\"o:17\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-default\",\"adjacent\":[[118,118,118]]}]}", + "outcomeJson": "{\"schemaVersion\":1,\"outcome\":\"success\",\"feasibility\":{\"status\":\"feasible\",\"result\":{\"domain\":[[0,0,0],[1,1,1],[2,2,2],[3,3,3],[4,4,4],[5,5,5],[6,6,6],[7,7,7],[8,8,8],[9,9,9],[10,10,10],[11,11,11],[12,12,12],[13,13,13],[14,14,14],[15,15,15],[16,16,16],[17,17,17],[18,18,18],[19,19,19],[20,20,20],[21,21,21],[22,22,22],[23,23,23],[24,24,24],[25,25,25],[26,26,26],[27,27,27],[28,28,28],[29,29,29],[30,30,30],[31,31,31],[32,32,32],[33,33,33],[34,34,34],[35,35,35],[36,36,36],[37,37,37],[38,38,38],[39,39,39],[40,40,40],[41,41,41],[42,42,42],[43,43,43],[44,44,44],[45,45,45],[46,46,46],[47,47,47],[48,48,48],[49,49,49],[50,50,50],[51,51,51],[52,52,52],[53,53,53],[54,54,54],[55,55,55],[56,56,56],[57,57,57],[58,58,58],[59,59,59],[60,60,60],[61,61,61],[62,62,62],[63,63,63],[64,64,64],[65,65,65],[66,66,66],[67,67,67],[68,68,68],[69,69,69],[70,70,70],[71,71,71],[72,72,72],[73,73,73],[74,74,74],[75,75,75],[76,76,76],[77,77,77],[78,78,78],[79,79,79],[80,80,80],[81,81,81],[82,82,82],[83,83,83],[84,84,84],[85,85,85],[86,86,86],[87,87,87],[88,88,88],[89,89,89],[90,90,90],[91,91,91],[92,92,92],[93,93,93],[94,94,94],[95,95,95],[96,96,96],[97,97,97],[98,98,98],[99,99,99],[100,100,100],[101,101,101],[102,102,102],[103,103,103],[104,104,104],[105,105,105],[106,106,106],[107,107,107],[108,108,108],[109,109,109],[110,110,110],[111,111,111],[112,112,112],[113,113,113],[114,114,114],[115,115,115],[116,116,116],[117,117,117],[118,118,118],[119,119,119],[120,120,120],[121,121,121],[122,122,122],[123,123,123],[124,124,124],[125,125,125],[126,126,126],[127,127,127],[128,128,128],[129,129,129],[130,130,130],[131,131,131],[132,132,132],[133,133,133],[134,134,134],[135,135,135],[136,136,136],[137,137,137],[138,138,138],[139,139,139],[140,140,140],[141,141,141],[142,142,142],[143,143,143],[144,144,144],[145,145,145],[146,146,146],[147,147,147],[148,148,148],[149,149,149],[150,150,150],[151,151,151],[152,152,152],[153,153,153],[154,154,154],[155,155,155],[156,156,156],[157,157,157],[158,158,158],[159,159,159],[160,160,160],[161,161,161],[162,162,162],[163,163,163],[164,164,164],[165,165,165],[166,166,166],[167,167,167],[168,168,168],[169,169,169],[170,170,170],[171,171,171],[172,172,172],[173,173,173],[174,174,174],[175,175,175],[176,176,176],[177,177,177],[178,178,178],[179,179,179],[180,180,180],[181,181,181],[182,182,182],[183,183,183],[184,184,184],[185,185,185],[186,186,186],[187,187,187],[188,188,188],[189,189,189],[190,190,190],[191,191,191],[192,192,192],[193,193,193],[194,194,194],[195,195,195],[196,196,196],[197,197,197],[198,198,198],[199,199,199],[200,200,200],[201,201,201],[202,202,202],[203,203,203],[204,204,204],[205,205,205],[206,206,206],[207,207,207],[208,208,208],[209,209,209],[210,210,210],[211,211,211],[212,212,212],[213,213,213],[214,214,214],[215,215,215],[216,216,216],[217,217,217],[218,218,218],[219,219,219],[220,220,220],[221,221,221],[222,222,222],[223,223,223],[224,224,224],[225,225,225],[226,226,226],[227,227,227],[228,228,228],[229,229,229],[230,230,230],[231,231,231],[232,232,232],[233,233,233],[234,234,234],[235,235,235],[236,236,236],[237,237,237],[238,238,238],[239,239,239],[240,240,240],[241,241,241],[242,242,242],[243,243,243],[244,244,244],[245,245,245],[246,246,246],[247,247,247],[248,248,248],[249,249,249],[250,250,250],[251,251,251],[252,252,252],[253,253,253],[254,254,254],[255,255,255]],\"relations\":[{\"relationId\":\"r:7f3a\",\"occurrenceId\":\"o:17\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-default\",\"adjacent\":[[118,118,118]]}],\"failureMatrix\":[224,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,63],\"proof\":{\"evaluationId\":[198,237,87,212,16,3,44,173,85,205,75,119,38,250,14,157,96,21,6,222,47,119,79,242,219,23,115,198,89,235,36,45],\"resourceProfileId\":\"compile-v1\",\"domainId\":\"srgb8-neutral-axis-v1\",\"domainDigest\":[150,52,172,50,105,121,178,60,33,3,255,205,146,162,184,144,66,126,168,145,74,151,178,100,176,199,52,9,100,15,132,102],\"domainCount\":\"256\",\"domainFirst\":[0,0,0],\"domainLast\":[255,255,255],\"relationSetDigest\":[220,115,178,90,131,117,153,255,137,85,101,209,107,91,97,231,8,248,170,38,19,63,178,21,171,61,22,194,34,96,75,136],\"canonicalRelations\":\"1\",\"applicableRelations\":\"1\",\"notApplicableRelations\":\"0\",\"applicableEdges\":\"1\",\"logicalAssessments\":\"256\",\"matrixDigest\":[178,86,216,35,22,195,81,11,1,181,78,150,37,207,7,251,190,115,205,159,188,251,22,33,206,237,152,232,64,10,93,223],\"partition\":[31,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192],\"wcag22ProfileId\":\"wcag22-srgb8-contrast-v1\",\"artifactId\":\"wcag22-srgb8-luminance-q55-v1\",\"boundId\":\"wcag22-srgb8-outward-q55-v1\",\"proofId\":\"wcag22-srgb8-full-domain-q55-v1\",\"proofSha256\":[210,105,233,222,104,144,9,187,149,87,136,191,135,98,252,229,102,128,191,97,111,192,69,155,101,38,163,103,135,90,106,8]}}}}" + }, + { + "caseId": "opaque-identity-b", + "requestJson": "{\"schemaVersion\":1,\"domainId\":\"srgb8-neutral-axis-v1\",\"resourceProfileId\":\"compile-v1\",\"relations\":[{\"relationId\":\"z:λ9\",\"occurrenceId\":\"q:海\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-default\",\"adjacent\":[[118,118,118]]}]}", + "outcomeJson": "{\"schemaVersion\":1,\"outcome\":\"success\",\"feasibility\":{\"status\":\"feasible\",\"result\":{\"domain\":[[0,0,0],[1,1,1],[2,2,2],[3,3,3],[4,4,4],[5,5,5],[6,6,6],[7,7,7],[8,8,8],[9,9,9],[10,10,10],[11,11,11],[12,12,12],[13,13,13],[14,14,14],[15,15,15],[16,16,16],[17,17,17],[18,18,18],[19,19,19],[20,20,20],[21,21,21],[22,22,22],[23,23,23],[24,24,24],[25,25,25],[26,26,26],[27,27,27],[28,28,28],[29,29,29],[30,30,30],[31,31,31],[32,32,32],[33,33,33],[34,34,34],[35,35,35],[36,36,36],[37,37,37],[38,38,38],[39,39,39],[40,40,40],[41,41,41],[42,42,42],[43,43,43],[44,44,44],[45,45,45],[46,46,46],[47,47,47],[48,48,48],[49,49,49],[50,50,50],[51,51,51],[52,52,52],[53,53,53],[54,54,54],[55,55,55],[56,56,56],[57,57,57],[58,58,58],[59,59,59],[60,60,60],[61,61,61],[62,62,62],[63,63,63],[64,64,64],[65,65,65],[66,66,66],[67,67,67],[68,68,68],[69,69,69],[70,70,70],[71,71,71],[72,72,72],[73,73,73],[74,74,74],[75,75,75],[76,76,76],[77,77,77],[78,78,78],[79,79,79],[80,80,80],[81,81,81],[82,82,82],[83,83,83],[84,84,84],[85,85,85],[86,86,86],[87,87,87],[88,88,88],[89,89,89],[90,90,90],[91,91,91],[92,92,92],[93,93,93],[94,94,94],[95,95,95],[96,96,96],[97,97,97],[98,98,98],[99,99,99],[100,100,100],[101,101,101],[102,102,102],[103,103,103],[104,104,104],[105,105,105],[106,106,106],[107,107,107],[108,108,108],[109,109,109],[110,110,110],[111,111,111],[112,112,112],[113,113,113],[114,114,114],[115,115,115],[116,116,116],[117,117,117],[118,118,118],[119,119,119],[120,120,120],[121,121,121],[122,122,122],[123,123,123],[124,124,124],[125,125,125],[126,126,126],[127,127,127],[128,128,128],[129,129,129],[130,130,130],[131,131,131],[132,132,132],[133,133,133],[134,134,134],[135,135,135],[136,136,136],[137,137,137],[138,138,138],[139,139,139],[140,140,140],[141,141,141],[142,142,142],[143,143,143],[144,144,144],[145,145,145],[146,146,146],[147,147,147],[148,148,148],[149,149,149],[150,150,150],[151,151,151],[152,152,152],[153,153,153],[154,154,154],[155,155,155],[156,156,156],[157,157,157],[158,158,158],[159,159,159],[160,160,160],[161,161,161],[162,162,162],[163,163,163],[164,164,164],[165,165,165],[166,166,166],[167,167,167],[168,168,168],[169,169,169],[170,170,170],[171,171,171],[172,172,172],[173,173,173],[174,174,174],[175,175,175],[176,176,176],[177,177,177],[178,178,178],[179,179,179],[180,180,180],[181,181,181],[182,182,182],[183,183,183],[184,184,184],[185,185,185],[186,186,186],[187,187,187],[188,188,188],[189,189,189],[190,190,190],[191,191,191],[192,192,192],[193,193,193],[194,194,194],[195,195,195],[196,196,196],[197,197,197],[198,198,198],[199,199,199],[200,200,200],[201,201,201],[202,202,202],[203,203,203],[204,204,204],[205,205,205],[206,206,206],[207,207,207],[208,208,208],[209,209,209],[210,210,210],[211,211,211],[212,212,212],[213,213,213],[214,214,214],[215,215,215],[216,216,216],[217,217,217],[218,218,218],[219,219,219],[220,220,220],[221,221,221],[222,222,222],[223,223,223],[224,224,224],[225,225,225],[226,226,226],[227,227,227],[228,228,228],[229,229,229],[230,230,230],[231,231,231],[232,232,232],[233,233,233],[234,234,234],[235,235,235],[236,236,236],[237,237,237],[238,238,238],[239,239,239],[240,240,240],[241,241,241],[242,242,242],[243,243,243],[244,244,244],[245,245,245],[246,246,246],[247,247,247],[248,248,248],[249,249,249],[250,250,250],[251,251,251],[252,252,252],[253,253,253],[254,254,254],[255,255,255]],\"relations\":[{\"relationId\":\"z:λ9\",\"occurrenceId\":\"q:海\",\"kind\":\"applicable\",\"criterion\":\"sc-1.4.3-text-default\",\"adjacent\":[[118,118,118]]}],\"failureMatrix\":[224,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,63],\"proof\":{\"evaluationId\":[154,51,8,138,97,242,8,232,199,196,4,131,118,79,191,244,196,178,179,179,61,168,85,122,224,153,168,190,223,137,194,156],\"resourceProfileId\":\"compile-v1\",\"domainId\":\"srgb8-neutral-axis-v1\",\"domainDigest\":[150,52,172,50,105,121,178,60,33,3,255,205,146,162,184,144,66,126,168,145,74,151,178,100,176,199,52,9,100,15,132,102],\"domainCount\":\"256\",\"domainFirst\":[0,0,0],\"domainLast\":[255,255,255],\"relationSetDigest\":[85,199,73,184,31,92,52,232,86,189,74,69,93,236,179,255,178,236,204,30,250,39,21,251,255,80,179,128,241,30,96,169],\"canonicalRelations\":\"1\",\"applicableRelations\":\"1\",\"notApplicableRelations\":\"0\",\"applicableEdges\":\"1\",\"logicalAssessments\":\"256\",\"matrixDigest\":[178,86,216,35,22,195,81,11,1,181,78,150,37,207,7,251,190,115,205,159,188,251,22,33,206,237,152,232,64,10,93,223],\"partition\":[31,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,192],\"wcag22ProfileId\":\"wcag22-srgb8-contrast-v1\",\"artifactId\":\"wcag22-srgb8-luminance-q55-v1\",\"boundId\":\"wcag22-srgb8-outward-q55-v1\",\"proofId\":\"wcag22-srgb8-full-domain-q55-v1\",\"proofSha256\":[210,105,233,222,104,144,9,187,149,87,136,191,135,98,252,229,102,128,191,97,111,192,69,155,101,38,163,103,135,90,106,8]}}}}" + } +] diff --git a/crates/labcolors-conformance/Cargo.toml b/crates/labcolors-conformance/Cargo.toml index ffd4023e..936a6603 100644 --- a/crates/labcolors-conformance/Cargo.toml +++ b/crates/labcolors-conformance/Cargo.toml @@ -10,9 +10,14 @@ rust-version.workspace = true description = "Платформо-нейтральный conformance-пак движка labcolors: детерминированные тест-векторы канона (лестницы, подложка→α, контрасты, резолв) плюс Rust-раннер-референс. Любой биндинг (WASM, Swift, …) обязан их воспроизвести." [dependencies] -# Единственная предметная зависимость — само ядро. Векторы ДЕРИВИРУЮТСЯ из его -# публичного API, а не вписываются руками: канон один, генератор — его тень. -labcolors-core = { path = "../labcolors-core" } +# Численные семейства ДЕРИВИРУЮТСЯ из public Core API; versioned transport +# family — из общего public protocol API. Канонические значения и wire algebra +# не вписываются руками: генератор остаётся тенью двух своих authority. +labcolors-core = { path = "../labcolors-core", default-features = false } +# Request construction, byte preflight and wire algebra stay in one protocol +# authority shared with both adapters. This crate only records its canonical +# request/outcome bytes; it does not mirror transport DTOs. +labcolors-protocol = { path = "../labcolors-protocol" } # serde/serde_json УЖЕ в дереве воркспейса (граница конфига WASM). Переиспользуем # тот же пин — ноль новой supply-chain-поверхности для `cargo audit`. serde = { version = "1", features = ["derive"] } diff --git a/crates/labcolors-conformance/src/bin/gen.rs b/crates/labcolors-conformance/src/bin/gen.rs index 4c8cdf2d..ace64c97 100644 --- a/crates/labcolors-conformance/src/bin/gen.rs +++ b/crates/labcolors-conformance/src/bin/gen.rs @@ -45,7 +45,7 @@ fn main() -> Result<(), Box> { let c = pack.counts(); println!( - "готово: {} векторов (contrasts={}, ladders={}, alpha={}, solve={}, muddiness={}, wcag22={}), дайджест={}", + "готово: {} векторов (contrasts={}, ladders={}, alpha={}, solve={}, muddiness={}, wcag22={}, wcag22-feasibility={}), дайджест={}", c.total, c.contrasts, c.ladders, @@ -53,6 +53,7 @@ fn main() -> Result<(), Box> { c.solve, c.muddiness, c.wcag22, + c.wcag22_feasibility, pack.digest() ); Ok(()) diff --git a/crates/labcolors-conformance/src/lib.rs b/crates/labcolors-conformance/src/lib.rs index fd8a9bac..590ce47f 100644 --- a/crates/labcolors-conformance/src/lib.rs +++ b/crates/labcolors-conformance/src/lib.rs @@ -28,6 +28,7 @@ //! | `solve.json` | (bg, контракт, тема) → резолв или честный отказ | `solve` | //! | `muddiness.json` | hex → замороженная legacy-координата | `cleanliness::muddiness_from_hex` | //! | `wcag22.json` | final sRGB8 pair + criterion → exact assessment | `wcag22::evaluate_wcag22_hex` | +//! | `wcag22-feasibility.json` | versioned request → packed compiler algebra | `labcolors-protocol` | //! | `manifest.json` | версии, дайджест, счётчики, capability manifest | `numerical_capability_manifest_v2` | //! //! `muddiness.json` фиксирует только воспроизводимость исторического числового @@ -54,7 +55,7 @@ use labcolors_core::{ /// Семантическая версия conformance-пака. Меняется при изменении СХЕМЫ или /// состава векторов; значения векторов при этом диктует канон ядра. -pub const PACK_VERSION: &str = "4.0.0"; +pub const PACK_VERSION: &str = "5.0.0"; /// Версия ядра, к которой привязан пак. Все крейты воркспейса делят одну версию /// (`version.workspace = true`), поэтому собственная `CARGO_PKG_VERSION` этого @@ -309,6 +310,14 @@ pub enum PackGenerationError { /// The core introduced an `Unreachable` variant this pack does not know how /// to encode. The adapter must be upgraded before regenerating artifacts. IncompatibleCoreContract { reason: String }, + /// Public protocol builders or canonical encoders rejected a controlled + /// fixture. Generation stops instead of inventing a local wire schema. + IncompatibleProtocolContract { + /// Stable vector identifier. + case_id: String, + /// Upstream protocol error. + reason: String, + }, } impl core::fmt::Display for PackGenerationError { @@ -320,6 +329,9 @@ impl core::fmt::Display for PackGenerationError { Self::IncompatibleCoreContract { reason } => { write!(f, "incompatible core contract: {reason}") } + Self::IncompatibleProtocolContract { case_id, reason } => { + write!(f, "incompatible protocol contract in {case_id}: {reason}") + } } } } @@ -603,6 +615,254 @@ pub fn generate_wcag22() -> Result, PackGenerationError> { .collect() } +// ───────────────────────────────────────────────────────────────────────────── +// Семейство: WCAG 2.2 feasibility transport +// ───────────────────────────────────────────────────────────────────────────── + +/// One end-to-end vector for the versioned feasibility protocol. +/// +/// Both JSON strings are emitted by `labcolors-protocol` canonical encoders. +/// The conformance crate deliberately owns neither a duplicate request DTO nor +/// an outcome projection. In particular, the encoded outcome keeps only the +/// packed failure matrix and partition supplied by Core; it never expands the +/// `256 × E` logical assessment graph. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Wcag22FeasibilityVector { + /// Stable corpus identity, outside the protocol algebra. + pub case_id: String, + /// Exact compact UTF-8 request produced by `encode_request_v1`. + pub request_json: String, + /// Exact compact UTF-8 nested `Success/Failure` outcome produced by + /// `encode_outcome_v1`. + pub outcome_json: String, +} + +fn protocol_fixture( + case_id: &str, + result: Result, +) -> Result { + result.map_err(|error| PackGenerationError::IncompatibleProtocolContract { + case_id: case_id.to_string(), + reason: format!("{error:?}"), + }) +} + +fn applicable_relation( + case_id: &str, + relation_id: &str, + occurrence_id: &str, + criterion: labcolors_protocol::Wcag22CriterionV1, + adjacent: Vec<[u8; 3]>, +) -> Result { + protocol_fixture( + case_id, + labcolors_protocol::RelationV1::applicable(relation_id, occurrence_id, criterion, adjacent), + ) +} + +fn not_applicable_relation( + case_id: &str, + relation_id: &str, + occurrence_id: &str, + reason: &str, +) -> Result { + protocol_fixture( + case_id, + labcolors_protocol::RelationV1::not_applicable(relation_id, occurrence_id, reason), + ) +} + +fn feasibility_vector( + case_id: &str, + relations: Vec, +) -> Result { + use labcolors_protocol::{DomainIdV1, RequestV1, ResourceProfileIdV1}; + + let request = protocol_fixture( + case_id, + RequestV1::try_new( + DomainIdV1::Srgb8NeutralAxis, + relations, + ResourceProfileIdV1::Compile, + ), + )?; + let request_bytes = protocol_fixture(case_id, labcolors_protocol::encode_request_v1(&request))?; + let outcome = labcolors_protocol::evaluate_wcag22_feasibility_v1(&request_bytes); + let outcome_bytes = protocol_fixture(case_id, labcolors_protocol::encode_outcome_v1(&outcome))?; + let request_json = protocol_fixture(case_id, String::from_utf8(request_bytes))?; + let outcome_json = protocol_fixture(case_id, String::from_utf8(outcome_bytes))?; + Ok(Wcag22FeasibilityVector { + case_id: case_id.to_string(), + request_json, + outcome_json, + }) +} + +/// Generate the complete pack-5 feasibility corpus exclusively through the +/// public protocol construction and canonical encoding boundary. +pub fn generate_wcag22_feasibility() -> Result, PackGenerationError> { + use labcolors_protocol::Wcag22CriterionV1 as Criterion; + + let mut vectors = Vec::with_capacity(13); + + let case_id = "text-default-seven"; + vectors.push(feasibility_vector( + case_id, + vec![applicable_relation( + case_id, + "relation-7", + "occurrence-7", + Criterion::Sc143TextDefault, + vec![[0x76; 3]], + )?], + )?); + + let case_id = "text-default-two"; + vectors.push(feasibility_vector( + case_id, + vec![applicable_relation( + case_id, + "relation-2", + "occurrence-2", + Criterion::Sc143TextDefault, + vec![[0; 3], [255; 3]], + )?], + )?); + + let case_id = "text-default-zero"; + vectors.push(feasibility_vector( + case_id, + vec![applicable_relation( + case_id, + "relation-0", + "occurrence-0", + Criterion::Sc143TextDefault, + vec![[0; 3], [255; 3], [0x76; 3]], + )?], + )?); + + for (case_id, criterion) in [ + ( + "text-large-scale-ninety-two", + Criterion::Sc143TextLargeScale, + ), + ( + "ui-component-ninety-two", + Criterion::Sc1411UiComponentOrState, + ), + ( + "graphical-object-ninety-two", + Criterion::Sc1411GraphicalObject, + ), + ] { + vectors.push(feasibility_vector( + case_id, + vec![applicable_relation( + case_id, + "ratio-three-relation", + "ratio-three-occurrence", + criterion, + vec![[0x76; 3]], + )?], + )?); + } + + let case_id = "ui-component-fifty-nine"; + vectors.push(feasibility_vector( + case_id, + vec![applicable_relation( + case_id, + "relation-59", + "occurrence-59", + Criterion::Sc1411UiComponentOrState, + vec![[0; 3], [255; 3]], + )?], + )?); + + let case_id = "mixed-not-applicable"; + vectors.push(feasibility_vector( + case_id, + vec![ + applicable_relation( + case_id, + "mixed-applicable", + "shared-occurrence", + Criterion::Sc143TextDefault, + vec![[0x76; 3]], + )?, + not_applicable_relation( + case_id, + "mixed-declaration", + "shared-occurrence", + "client-declared-out-of-scope", + )?, + ], + )?); + + let case_id = "all-not-applicable"; + vectors.push(feasibility_vector( + case_id, + vec![not_applicable_relation( + case_id, + "declaration-only", + "opaque-occurrence", + "client-declared-out-of-scope", + )?], + )?); + + let case_id = "conflicting-relation-id"; + vectors.push(feasibility_vector( + case_id, + vec![ + applicable_relation( + case_id, + "same-id", + "first-occurrence", + Criterion::Sc143TextDefault, + vec![[0; 3]], + )?, + applicable_relation( + case_id, + "same-id", + "second-occurrence", + Criterion::Sc143TextDefault, + vec![[0; 3]], + )?, + ], + )?); + + let case_id = "raw-adjacent-resource-rejection"; + vectors.push(feasibility_vector( + case_id, + vec![applicable_relation( + case_id, + "resource-relation", + "resource-occurrence", + Criterion::Sc1411UiComponentOrState, + vec![[0; 3]; 2_048], + )?], + )?); + + for (case_id, relation_id, occurrence_id) in [ + ("opaque-identity-a", "r:7f3a", "o:17"), + ("opaque-identity-b", "z:\u{03bb}9", "q:\u{6d77}"), + ] { + vectors.push(feasibility_vector( + case_id, + vec![applicable_relation( + case_id, + relation_id, + occurrence_id, + Criterion::Sc143TextDefault, + vec![[0x76; 3]], + )?], + )?); + } + + Ok(vectors) +} + // ───────────────────────────────────────────────────────────────────────────── // Агрегат пака + сериализация + дайджест // ───────────────────────────────────────────────────────────────────────────── @@ -612,13 +872,14 @@ pub const MANIFEST_FILE: &str = "manifest.json"; /// Имена файлов семейств в КАНОНИЧЕСКОМ порядке — единый источник порядка для /// генератора, дайджеста и раннера-референса (дайджест зависит от порядка). -pub const FAMILY_FILES: [&str; 6] = [ +pub const FAMILY_FILES: [&str; 7] = [ "contrasts.json", "ladders.json", "alpha.json", "solve.json", "muddiness.json", "wcag22.json", + "wcag22-feasibility.json", ]; /// Каноническая толерантность сравнения f64 для conformance- @@ -648,6 +909,8 @@ pub struct Counts { pub muddiness: usize, /// Exact WCAG 2.2 vectors. pub wcag22: usize, + /// Versioned WCAG 2.2 feasibility protocol vectors. + pub wcag22_feasibility: usize, /// Итого. pub total: usize, } @@ -770,6 +1033,8 @@ pub struct Pack { pub muddiness: Vec, /// Exact final-sRGB8 WCAG 2.2 vectors. pub wcag22: Vec, + /// Bounded compiler protocol vectors with packed evidence only. + pub wcag22_feasibility: Vec, } impl Pack { @@ -782,6 +1047,7 @@ impl Pack { solve: generate_solve()?, muddiness: generate_muddiness(), wcag22: generate_wcag22()?, + wcag22_feasibility: generate_wcag22_feasibility()?, }) } @@ -794,6 +1060,7 @@ impl Pack { let solve = self.solve.len(); let muddiness = self.muddiness.len(); let wcag22 = self.wcag22.len(); + let wcag22_feasibility = self.wcag22_feasibility.len(); Counts { contrasts, ladders, @@ -801,7 +1068,8 @@ impl Pack { solve, muddiness, wcag22, - total: contrasts + ladders + alpha + solve + muddiness + wcag22, + wcag22_feasibility, + total: contrasts + ladders + alpha + solve + muddiness + wcag22 + wcag22_feasibility, } } @@ -841,6 +1109,7 @@ impl Pack { (FAMILY_FILES[3], to_canonical_json(&self.solve)), (FAMILY_FILES[4], to_canonical_json(&self.muddiness)), (FAMILY_FILES[5], to_canonical_json(&self.wcag22)), + (FAMILY_FILES[6], to_canonical_json(&self.wcag22_feasibility)), ] } } @@ -909,7 +1178,13 @@ mod tests { assert!(c.total > 0, "пустой пак бессмыслен"); assert_eq!( c.total, - c.contrasts + c.ladders + c.alpha + c.solve + c.muddiness + c.wcag22, + c.contrasts + + c.ladders + + c.alpha + + c.solve + + c.muddiness + + c.wcag22 + + c.wcag22_feasibility, "итог не сходится с семействами" ); // Лестниц ровно столько, сколько канонических позиций. @@ -952,19 +1227,22 @@ mod tests { } #[test] - fn pack_v4_contains_prior_half_tie_and_new_wcag22_family() { + fn pack_v5_inherits_old_families_and_adds_feasibility_protocol() { // ADR-0004 делает этот байтовый шов частью breaking conformance-контракта: // нормализованный `(byte/255) * alpha * 255` путь ошибочно отдавал - // соседний LSB. Обязательство унаследовано pack v4; v4 добавляет exact - // WCAG22 family. Проверка одновременно убивает вакуумные + // соседний LSB. Обязательство унаследовано pack v5; v5 добавляет ровно + // одну WCAG22-feasibility family. Проверка одновременно убивает вакуумные // изменения версии/счётчика без обязательного доказательного вектора. let pack = Pack::generate().expect("canonical pack generation"); let manifest = pack.manifest(); - assert_eq!(PACK_VERSION, "4.0.0", "WCAG22 family обязана быть pack v4"); + assert_eq!( + PACK_VERSION, "5.0.0", + "WCAG22 feasibility family обязана быть pack v5" + ); assert_eq!(manifest.pack_version, PACK_VERSION); assert_eq!( manifest.core_version, "0.2.0", - "pack v4 наследует прежние семейства и добавляет WCAG22 на core 0.2.0" + "pack v5 наследует прежние семейства на core 0.2.0" ); assert_eq!( pack.alpha.len(), @@ -973,10 +1251,11 @@ mod tests { ); assert_eq!(manifest.counts.alpha, pack.alpha.len()); assert_eq!( - manifest.counts.total, 88, + manifest.counts.total, 101, "состав векторных семейств изменился" ); assert_eq!(manifest.counts.wcag22, 6); + assert_eq!(manifest.counts.wcag22_feasibility, 13); let half_tie = pack .alpha @@ -1005,6 +1284,10 @@ mod tests { to_canonical_json(&pack.solve), to_canonical_json(&pack.solve) ); + assert_eq!( + to_canonical_json(&pack.wcag22_feasibility), + to_canonical_json(&pack.wcag22_feasibility) + ); // Разбор валиден структурно (форма контракта), даже если последний ULP // f64 может отличаться — семантическую точность держит tolerance пака. let _parsed: Vec = diff --git a/crates/labcolors-conformance/tests/pack_v5_contract.rs b/crates/labcolors-conformance/tests/pack_v5_contract.rs new file mode 100644 index 00000000..b7b27053 --- /dev/null +++ b/crates/labcolors-conformance/tests/pack_v5_contract.rs @@ -0,0 +1,292 @@ +//! RED contract for #295 Slice B: pack 5 adds exactly one feasibility family +//! while preserving every byte of the six admitted pack-4 families. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use labcolors_conformance::{FAMILY_FILES, MANIFEST_FILE, PACK_VERSION}; + +#[allow(dead_code)] +#[path = "../../labcolors-core/src/sha256.rs"] +mod sha256; + +const LEGACY_FAMILY_SHA256: [(&str, &str); 6] = [ + ( + "contrasts.json", + "57d99bb3138edba769a185af5589651ab1cd3140f92e5cf493be2f998b2f1145", + ), + ( + "ladders.json", + "496f562e55ad8110aeb8a07042b1964ec9ff4d0f1e8c09e362d1b2d14c513036", + ), + ( + "alpha.json", + "b9c71e26c96c977c51cb2ffc98ff8f24a24705105c1962479e72e687b1b05bb1", + ), + ( + "solve.json", + "64acfc4a8c613a4b11e4e83c52a33ecf308320abc6ab18fde20853a7f2399f06", + ), + ( + "muddiness.json", + "3c5497b251f04c089d33452b9bf0bfba7f4ef9a72dc496180ff42aad08377aa3", + ), + ( + "wcag22.json", + "6e234fa3a0d4e2b21f515b8f4e6be76f223768821e0308e774c31a5ce7a1d826", + ), +]; + +const REQUIRED_CASES: [&str; 13] = [ + "text-default-seven", + "text-default-two", + "text-default-zero", + "text-large-scale-ninety-two", + "ui-component-ninety-two", + "graphical-object-ninety-two", + "ui-component-fifty-nine", + "mixed-not-applicable", + "all-not-applicable", + "conflicting-relation-id", + "raw-adjacent-resource-rejection", + "opaque-identity-a", + "opaque-identity-b", +]; + +fn vectors_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("conformance") + .join("vectors") +} + +fn read(path: impl AsRef) -> Vec { + std::fs::read(path.as_ref()) + .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.as_ref().display())) +} + +fn feasibility_vectors() -> Vec { + serde_json::from_slice(&read(vectors_dir().join("wcag22-feasibility.json"))) + .expect("valid feasibility family JSON") +} + +fn vector<'a>(vectors: &'a [serde_json::Value], case_id: &str) -> &'a serde_json::Value { + vectors + .iter() + .find(|vector| vector["caseId"] == case_id) + .unwrap_or_else(|| panic!("missing case {case_id}")) +} + +fn outcome(vector: &serde_json::Value) -> serde_json::Value { + serde_json::from_str( + vector["outcomeJson"] + .as_str() + .expect("canonical outcome JSON string"), + ) + .expect("outcome protocol JSON") +} + +fn evaluated_result(outcome: &serde_json::Value) -> &serde_json::Value { + assert_eq!(outcome["outcome"], "success"); + assert!(matches!( + outcome["feasibility"]["status"].as_str(), + Some("feasible" | "infeasible") + )); + &outcome["feasibility"]["result"] +} + +fn partition_count(outcome: &serde_json::Value) -> u32 { + evaluated_result(outcome)["proof"]["partition"] + .as_array() + .expect("packed partition") + .iter() + .map(|byte| { + u8::try_from(byte.as_u64().expect("partition byte")) + .expect("partition byte range") + .count_ones() + }) + .sum() +} + +#[test] +fn pack_v5_adds_only_the_feasibility_family_and_preserves_legacy_bytes() { + assert_eq!(PACK_VERSION, "5.0.0"); + assert_eq!( + FAMILY_FILES.as_slice(), + [ + "contrasts.json", + "ladders.json", + "alpha.json", + "solve.json", + "muddiness.json", + "wcag22.json", + "wcag22-feasibility.json", + ] + .as_slice() + ); + + let dir = vectors_dir(); + for (name, expected) in LEGACY_FAMILY_SHA256 { + assert_eq!( + sha256::digest(&read(dir.join(name))).to_hex(), + expected, + "pack-4 family bytes drifted: {name}" + ); + } + + let manifest: serde_json::Value = + serde_json::from_slice(&read(dir.join(MANIFEST_FILE))).expect("valid manifest JSON"); + assert_eq!(manifest["packVersion"], "5.0.0"); + assert_eq!(manifest["counts"]["wcag22Feasibility"], 13); + assert_eq!(manifest["counts"]["total"], 101); +} + +#[test] +fn feasibility_family_is_canonical_protocol_json_without_cell_graphs() { + let vectors = feasibility_vectors(); + assert_eq!(vectors.len(), REQUIRED_CASES.len()); + + let actual: BTreeSet<_> = vectors + .iter() + .map(|vector| { + let object = vector.as_object().expect("vector object"); + assert_eq!(object.len(), 3, "vector schema must stay compact"); + let request = object["requestJson"] + .as_str() + .expect("canonical request JSON string"); + let outcome = object["outcomeJson"] + .as_str() + .expect("canonical outcome JSON string"); + serde_json::from_str::(request).expect("request protocol JSON"); + serde_json::from_str::(outcome).expect("outcome protocol JSON"); + for forbidden in [ + "feasibleCandidates", + "infeasibleCandidates", + "cells", + "assessments", + ] { + assert!( + !outcome.contains(forbidden), + "outcome contains forbidden proportional view {forbidden}" + ); + } + object["caseId"].as_str().expect("stable case id") + }) + .collect(); + assert_eq!(actual, BTreeSet::from(REQUIRED_CASES)); +} + +#[test] +fn feasibility_corpus_pins_terminals_counts_packing_and_opaque_identity_law() { + let vectors = feasibility_vectors(); + for (case_id, expected) in [ + ("text-default-seven", 7), + ("text-default-two", 2), + ("text-default-zero", 0), + ("text-large-scale-ninety-two", 92), + ("ui-component-ninety-two", 92), + ("graphical-object-ninety-two", 92), + ("ui-component-fifty-nine", 59), + ] { + assert_eq!( + partition_count(&outcome(vector(&vectors, case_id))), + expected, + "wrong feasible count in {case_id}" + ); + } + assert_eq!( + outcome(vector(&vectors, "text-default-zero"))["feasibility"]["status"], + "infeasible" + ); + + for value in &vectors { + let outcome = outcome(value); + if outcome["outcome"] != "success" || outcome["feasibility"]["status"] == "notEvaluated" { + continue; + } + let result = evaluated_result(&outcome); + assert_eq!(result["domain"].as_array().expect("domain").len(), 256); + let proof = &result["proof"]; + let edges: usize = proof["applicableEdges"] + .as_str() + .expect("decimal edge count") + .parse() + .expect("numeric edge count"); + assert_eq!( + result["failureMatrix"] + .as_array() + .expect("packed matrix") + .len(), + 32 * edges + ); + assert_eq!( + proof["partition"] + .as_array() + .expect("packed partition") + .len(), + 32 + ); + assert_eq!( + proof["logicalAssessments"] + .as_str() + .expect("decimal assessment count") + .parse::() + .expect("numeric assessment count"), + 256 * edges + ); + } + + let mixed = outcome(vector(&vectors, "mixed-not-applicable")); + assert_eq!(partition_count(&mixed), 7); + assert_eq!( + mixed["feasibility"]["result"]["proof"]["applicableRelations"], + "1" + ); + assert_eq!( + mixed["feasibility"]["result"]["proof"]["notApplicableRelations"], + "1" + ); + let all_na = outcome(vector(&vectors, "all-not-applicable")); + assert_eq!(all_na["outcome"], "success"); + assert_eq!(all_na["feasibility"]["status"], "notEvaluated"); + assert!(all_na["feasibility"]["result"].get("proof").is_none()); + assert!( + all_na["feasibility"]["result"] + .get("failureMatrix") + .is_none() + ); + + let conflict = outcome(vector(&vectors, "conflicting-relation-id")); + assert_eq!(conflict["outcome"], "failure"); + assert!(conflict.get("feasibility").is_none()); + assert_eq!(conflict["error"]["source"], "core"); + assert_eq!(conflict["error"]["error"]["code"], "invalidRequest"); + assert_eq!( + conflict["error"]["error"]["details"]["code"], + "conflictingRelationId" + ); + let resource = outcome(vector(&vectors, "raw-adjacent-resource-rejection")); + assert_eq!(resource["outcome"], "failure"); + assert!(resource.get("feasibility").is_none()); + assert_eq!(resource["error"]["error"]["code"], "resourceLimitExceeded"); + assert_eq!( + resource["error"]["error"]["details"]["dimension"], + "rawAdjacentEntries" + ); + assert_eq!(resource["error"]["error"]["details"]["requested"], "2048"); + assert_eq!(resource["error"]["error"]["details"]["limit"], "2047"); + + let opaque_a = outcome(vector(&vectors, "opaque-identity-a")); + let opaque_b = outcome(vector(&vectors, "opaque-identity-b")); + let a = evaluated_result(&opaque_a); + let b = evaluated_result(&opaque_b); + assert_eq!(a["failureMatrix"], b["failureMatrix"]); + assert_eq!(a["proof"]["partition"], b["proof"]["partition"]); + assert_eq!(a["proof"]["matrixDigest"], b["proof"]["matrixDigest"]); + assert_ne!( + a["proof"]["relationSetDigest"], + b["proof"]["relationSetDigest"] + ); + assert_ne!(a["proof"]["evaluationId"], b["proof"]["evaluationId"]); +} diff --git a/crates/labcolors-conformance/tests/reference_runner.rs b/crates/labcolors-conformance/tests/reference_runner.rs index 5a07fc1c..eaadedc6 100644 --- a/crates/labcolors-conformance/tests/reference_runner.rs +++ b/crates/labcolors-conformance/tests/reference_runner.rs @@ -29,8 +29,9 @@ use std::path::PathBuf; use labcolors_conformance::{ AlphaVector, ContrastVector, DRIFT_TOL, FAMILY_FILES, LadderVector, MANIFEST_FILE, Manifest, - MuddinessVector, Pack, SolveOutcome, SolveVector, Wcag22Vector, generate_alpha, - generate_contrasts, generate_ladders, generate_muddiness, generate_solve, generate_wcag22, + MuddinessVector, Pack, SolveOutcome, SolveVector, Wcag22FeasibilityVector, Wcag22Vector, + generate_alpha, generate_contrasts, generate_ladders, generate_muddiness, generate_solve, + generate_wcag22, generate_wcag22_feasibility, }; use labcolors_core::fnv1a_32; @@ -206,6 +207,27 @@ fn core_reproduces_committed_wcag22_exactly() { })); } +#[test] +fn protocol_reproduces_committed_wcag22_feasibility_exactly() { + let committed: Vec = parse("wcag22-feasibility.json"); + let fresh = generate_wcag22_feasibility().expect("canonical feasibility vectors"); + assert_eq!(committed, fresh, "wcag22-feasibility.json drifted"); + + for vector in committed { + let outcome = + labcolors_protocol::evaluate_wcag22_feasibility_v1(vector.request_json.as_bytes()); + let encoded = labcolors_protocol::encode_outcome_v1(&outcome).unwrap_or_else(|error| { + panic!("{} outcome encoding failed: {error:?}", vector.case_id) + }); + assert_eq!( + encoded, + vector.outcome_json.as_bytes(), + "{} canonical outcome bytes drifted", + vector.case_id + ); + } +} + // ── Слой 2: метаданные манифеста ────────────────────────────────────────────── #[test] diff --git a/crates/labcolors-ffi/Cargo.toml b/crates/labcolors-ffi/Cargo.toml index 03dff7d9..02138431 100644 --- a/crates/labcolors-ffi/Cargo.toml +++ b/crates/labcolors-ffi/Cargo.toml @@ -27,7 +27,8 @@ name = "uniffi-bindgen" path = "src/bin/uniffi_bindgen.rs" [dependencies] -labcolors-core = { path = "../labcolors-core" } +labcolors-core = { path = "../labcolors-core", default-features = false } +labcolors-protocol = { path = "../labcolors-protocol" } # Нативная FFI-обвязка. Библиотечная зависимость БЕЗ фичи cli (иначе clap течёт # в артефакт); cli включается только через passthrough-фичу ниже на генерации. uniffi = { version = "0.32", default-features = false } diff --git a/crates/labcolors-ffi/src/lib.rs b/crates/labcolors-ffi/src/lib.rs index ab1a9fc5..c77a8d08 100644 --- a/crates/labcolors-ffi/src/lib.rs +++ b/crates/labcolors-ffi/src/lib.rs @@ -24,6 +24,7 @@ //! | [`composite`] / [`min_alpha`] | `alpha` | `alpha::composite_hex` / `alpha::min_alpha_hex` | //! | [`muddiness`] | `muddiness` legacy compatibility vectors | `cleanliness::muddiness_from_hex` | //! | [`evaluate_wcag22`] | `wcag22` | exact final-sRGB8 WCAG 2.2 evaluator | +//! | [`evaluate_wcag22_feasibility_raw_v1`] | `wcag22-feasibility` | `labcolors-protocol` bytes → Core → canonical outcome bytes | //! | [`core_version`] | `manifest` | версия ядра | //! //! [`solve_glow_point`] — отдельный low-level contract test нативной границы: @@ -384,6 +385,14 @@ pub enum ColorError { /// Человекочитаемая причина internal/core-surface несовместимости. reason: String, }, + /// Sealed protocol outcome could not be encoded for the native ABI. + /// Public-input and Core failures never use this branch: they remain + /// canonical `ProtocolOutcomeV1::Failure` bytes. + #[error("не удалось закодировать protocol outcome: {reason}")] + ProtocolEncodingFailed { + /// Internal serialization diagnostic; never a colour decision. + reason: String, + }, } /// Стабильный код недостижимости — тождественен маппингу WASM-границы @@ -420,6 +429,62 @@ pub fn core_version() -> String { env!("CARGO_PKG_VERSION").to_string() } +fn encode_feasibility_outcome( + outcome: labcolors_protocol::ProtocolOutcomeV1, +) -> Result, ColorError> { + labcolors_protocol::encode_outcome_v1(&outcome).map_err(|error| { + ColorError::ProtocolEncodingFailed { + reason: error.to_string(), + } + }) +} + +/// Exact protocol-owned V1 request-byte ceiling. +/// +/// Swift reads this scalar before converting `Data` into the UniFFI byte +/// buffer. Rust still repeats the authoritative check for every raw call. +#[uniffi::export] +#[must_use] +pub const fn wcag22_feasibility_max_request_bytes_v1() -> u64 { + labcolors_protocol::MAX_ENVELOPE_BYTES_V1 +} + +/// Evaluate one exact V1 request byte envelope through the sole protocol +/// compiler and return its canonical outcome bytes. +/// +/// Public-input and Core failures are successful ABI calls carrying +/// `ProtocolOutcomeV1::Failure`; only an internal canonical-encoding failure +/// returns [`ColorError::ProtocolEncodingFailed`]. +/// +/// # Errors +/// +/// [`ColorError::ProtocolEncodingFailed`] only if the sealed protocol outcome +/// cannot be serialized. +#[uniffi::export] +pub fn evaluate_wcag22_feasibility_raw_v1(request: Vec) -> Result, ColorError> { + encode_feasibility_outcome(labcolors_protocol::evaluate_wcag22_feasibility_v1(&request)) +} + +/// Construct canonical oversize failure bytes from a scalar byte count. +/// +/// Host wrappers call this only after proving the request exceeds +/// [`wcag22_feasibility_max_request_bytes_v1`], so the rejected raw buffer is +/// never copied into UniFFI. Callers that bypass the wrapper remain protected +/// by the authoritative raw function above. +/// +/// # Errors +/// +/// [`ColorError::ProtocolEncodingFailed`] only if the sealed protocol outcome +/// cannot be serialized. +#[uniffi::export] +pub fn wcag22_feasibility_envelope_too_large_v1( + requested_bytes: u64, +) -> Result, ColorError> { + encode_feasibility_outcome(labcolors_protocol::envelope_too_large_outcome_v1( + requested_bytes, + )) +} + /// Контраст одного переднего плана на фоне под темой — `(Lc, WCAG)`. Дешёвый /// пер-кадровый примитив: один forward перцептивной модели для фона плюс один /// для переднего плана. @@ -855,6 +920,63 @@ pub fn muddiness(hex: String) -> Result { mod tests { use super::*; + #[test] + fn feasibility_boundary_exposes_the_protocol_byte_ceiling() { + assert_eq!( + wcag22_feasibility_max_request_bytes_v1(), + labcolors_protocol::MAX_ENVELOPE_BYTES_V1 + ); + } + + #[test] + fn feasibility_boundary_is_a_mechanical_protocol_byte_shell() { + let request = br#"{"schemaVersion":1,"domainId":"srgb8-neutral-axis-v1","resourceProfileId":"compile-v1","relations":[{"relationId":"relation","occurrenceId":"occurrence","kind":"applicable","criterion":"sc-1.4.3-text-default","adjacent":[[118,118,118]]}]}"#; + let expected = labcolors_protocol::encode_outcome_v1( + &labcolors_protocol::evaluate_wcag22_feasibility_v1(request), + ) + .unwrap(); + + assert_eq!( + evaluate_wcag22_feasibility_raw_v1(request.to_vec()).unwrap(), + expected + ); + } + + #[test] + fn feasibility_semantic_input_errors_remain_protocol_failure_data() { + let request = br#"{"schemaVersion":2}"#; + let encoded = evaluate_wcag22_feasibility_raw_v1(request.to_vec()).unwrap(); + let expected = labcolors_protocol::encode_outcome_v1( + &labcolors_protocol::evaluate_wcag22_feasibility_v1(request), + ) + .unwrap(); + + assert_eq!(encoded, expected); + assert!( + std::str::from_utf8(&encoded) + .unwrap() + .contains(r#""outcome":"failure""#), + "public-input failure must be outcome data, not a thrown FFI error" + ); + } + + #[test] + fn feasibility_oversize_scalar_helper_matches_authoritative_raw_recheck() { + let requested = labcolors_protocol::MAX_ENVELOPE_BYTES_V1 + 1; + let from_scalar = wcag22_feasibility_envelope_too_large_v1(requested).unwrap(); + let oversized = vec![b' '; usize::try_from(requested).unwrap()]; + let from_raw = evaluate_wcag22_feasibility_raw_v1(oversized).unwrap(); + + assert_eq!(from_scalar, from_raw); + assert_eq!( + from_scalar, + labcolors_protocol::encode_outcome_v1( + &labcolors_protocol::envelope_too_large_outcome_v1(requested) + ) + .unwrap() + ); + } + #[test] fn wcag22_transport_preserves_core_decision_and_evidence() { let assessment = evaluate_wcag22( diff --git a/crates/labcolors-protocol/Cargo.toml b/crates/labcolors-protocol/Cargo.toml new file mode 100644 index 00000000..69579aa4 --- /dev/null +++ b/crates/labcolors-protocol/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "labcolors-protocol" +publish = false +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Bounded versioned transport protocol for Lab Colors compiler operations." + +[dependencies] +labcolors-core = { path = "../labcolors-core", default-features = false, features = ["wcag22-feasibility"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/labcolors-protocol/src/lib.rs b/crates/labcolors-protocol/src/lib.rs new file mode 100644 index 00000000..ae40ba27 --- /dev/null +++ b/crates/labcolors-protocol/src/lib.rs @@ -0,0 +1,1969 @@ +//! Versioned bounded transport contracts shared by Lab Colors adapters. +//! +//! This crate is the only owner of feasibility wire parsing and Core-to-wire +//! projection. It performs no colour mathematics or canonicalization. + +#![forbid(unsafe_code)] + +use core::fmt; + +use labcolors_core::Srgb8; +use labcolors_core::numerics::{ + NumericalArtifactIdV2 as CoreNumericalArtifactIdV2, + NumericalErrorBoundIdV2 as CoreNumericalErrorBoundIdV2, + NumericalProofIdV2 as CoreNumericalProofIdV2, +}; +use labcolors_core::wcag22::{ + Wcag22ClientDeclaredNotApplicableV1 as CoreNotApplicableV1, + Wcag22CriterionV1 as CoreWcag22CriterionV1, + Wcag22EvaluationErrorV1 as CoreWcag22EvaluationErrorV1, + Wcag22ProfileIdV1 as CoreWcag22ProfileIdV1, +}; +use labcolors_core::wcag22_feasibility::{ + self as core_feasibility, CompilerInvariantV1 as CoreCompilerInvariantV1, + DomainIdV1 as CoreDomainIdV1, ErrorV1 as CoreFeasibilityErrorV1, + EvaluatedV1 as CoreEvaluatedV1, EvaluatorInvariantV1 as CoreEvaluatorInvariantV1, + FeasibilityV1 as CoreFeasibilityV1, InvalidRequestV1 as CoreInvalidRequestSourceV1, + NotEvaluatedV1 as CoreNotEvaluatedV1, OccurrenceId as CoreOccurrenceId, + RelationId as CoreRelationId, RelationV1 as CoreRelationV1, RequestV1 as CoreRequestV1, + ResourceDimensionV1 as CoreResourceDimensionV1, ResourceProfileIdV1 as CoreResourceProfileIdV1, +}; +use serde::ser::SerializeStruct; +use serde::{Deserialize, Serialize, Serializer}; + +/// Exact schema version of the request and outcome envelopes. +pub const SCHEMA_VERSION_V1: u32 = 1; + +const FIXED_ENVELOPE_BYTES_V1: u64 = b"{\"schemaVersion\":1,\"domainId\":\"".len() as u64 + + CoreDomainIdV1::Srgb8NeutralAxis.key().len() as u64 + + b"\",\"resourceProfileId\":\"".len() as u64 + + CoreResourceProfileIdV1::Compile.key().len() as u64 + + b"\",\"relations\":[".len() as u64 + + b"]}".len() as u64; +const APPLICABLE_SKELETON_BYTES_V1: u64 = + b"{\"relationId\":\"\",\"occurrenceId\":\"\",\"kind\":\"applicable\",\"criterion\":\"".len() + as u64 + + CoreWcag22CriterionV1::Sc1411UiComponentOrState.key().len() as u64 + + b"\",\"adjacent\":[]}".len() as u64; +const NOT_APPLICABLE_SKELETON_BYTES_V1: u64 = + b"{\"relationId\":\"\",\"occurrenceId\":\"\",\"kind\":\"notApplicable\",\"reasonId\":\"\"}" + .len() as u64; +const MAX_RGB_TRIPLE_BYTES_V1: u64 = b"[255,255,255]".len() as u64; +const MAX_JSON_ESCAPE_BYTES_PER_OPAQUE_BYTE_V1: u64 = b"\\u0000".len() as u64; +const RAW_RELATION_LIMIT_V1: u64 = + CoreResourceProfileIdV1::Compile.limit(CoreResourceDimensionV1::RawRelations); +const RAW_ADJACENT_LIMIT_V1: u64 = + CoreResourceProfileIdV1::Compile.limit(CoreResourceDimensionV1::RawAdjacentEntries); +const OPAQUE_UTF8_LIMIT_V1: u64 = + CoreResourceProfileIdV1::Compile.limit(CoreResourceDimensionV1::OpaqueUtf8Bytes); +const MAX_APPLICABLE_RELATIONS_V1: u64 = if RAW_RELATION_LIMIT_V1 < RAW_ADJACENT_LIMIT_V1 { + RAW_RELATION_LIMIT_V1 +} else { + RAW_ADJACENT_LIMIT_V1 +}; +const MAX_NOT_APPLICABLE_RELATIONS_V1: u64 = RAW_RELATION_LIMIT_V1 - MAX_APPLICABLE_RELATIONS_V1; +const MAX_RELATION_SEPARATORS_V1: u64 = RAW_RELATION_LIMIT_V1.saturating_sub(1); +const MAX_ADJACENT_SEPARATORS_V1: u64 = RAW_ADJACENT_LIMIT_V1 - MAX_APPLICABLE_RELATIONS_V1; + +/// Tight maximum byte length of a compact, Core-admissible V1 request. +/// +/// The expression is derived from the literal JSON grammar above and the +/// public Core `Compile` profile. It has no discretionary headroom. +pub const MAX_ENVELOPE_BYTES_V1: u64 = FIXED_ENVELOPE_BYTES_V1 + + APPLICABLE_SKELETON_BYTES_V1 * MAX_APPLICABLE_RELATIONS_V1 + + NOT_APPLICABLE_SKELETON_BYTES_V1 * MAX_NOT_APPLICABLE_RELATIONS_V1 + + MAX_RELATION_SEPARATORS_V1 + + MAX_RGB_TRIPLE_BYTES_V1 * RAW_ADJACENT_LIMIT_V1 + + MAX_ADJACENT_SEPARATORS_V1 + + MAX_JSON_ESCAPE_BYTES_PER_OPAQUE_BYTE_V1 * OPAQUE_UTF8_LIMIT_V1; + +/// Registered finite candidate domain exposed by protocol V1. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DomainIdV1 { + /// Exact ascending encoded-sRGB8 neutral axis. + Srgb8NeutralAxis, +} + +impl DomainIdV1 { + /// Stable Core-owned wire key. + pub const fn key(self) -> &'static str { + CoreDomainIdV1::Srgb8NeutralAxis.key() + } + + fn parse(key: &str) -> Option { + (key == CoreDomainIdV1::Srgb8NeutralAxis.key()).then_some(Self::Srgb8NeutralAxis) + } +} + +impl Serialize for DomainIdV1 { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.key()) + } +} + +/// Bounded operational resource profile exposed by protocol V1. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResourceProfileIdV1 { + /// Offline compile profile. + Compile, +} + +impl ResourceProfileIdV1 { + /// Stable Core-owned wire key. + pub const fn key(self) -> &'static str { + CoreResourceProfileIdV1::Compile.key() + } + + fn parse(key: &str) -> Option { + (key == CoreResourceProfileIdV1::Compile.key()).then_some(Self::Compile) + } +} + +impl Serialize for ResourceProfileIdV1 { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.key()) + } +} + +/// Exact WCAG occurrence criterion exposed by protocol V1. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Wcag22CriterionV1 { + /// SC 1.4.3 ordinary text. + Sc143TextDefault, + /// SC 1.4.3 explicitly declared large-scale text. + Sc143TextLargeScale, + /// SC 1.4.11 UI component or state. + Sc1411UiComponentOrState, + /// SC 1.4.11 graphical object. + Sc1411GraphicalObject, +} + +impl Serialize for Wcag22CriterionV1 { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.key()) + } +} + +impl Wcag22CriterionV1 { + /// Stable Core-owned wire key. + pub const fn key(self) -> &'static str { + match self { + Self::Sc143TextDefault => CoreWcag22CriterionV1::Sc143TextDefault.key(), + Self::Sc143TextLargeScale => CoreWcag22CriterionV1::Sc143TextLargeScale.key(), + Self::Sc1411UiComponentOrState => CoreWcag22CriterionV1::Sc1411UiComponentOrState.key(), + Self::Sc1411GraphicalObject => CoreWcag22CriterionV1::Sc1411GraphicalObject.key(), + } + } + + fn parse(key: &str) -> Option { + match CoreWcag22CriterionV1::parse(key)? { + CoreWcag22CriterionV1::Sc143TextDefault => Some(Self::Sc143TextDefault), + CoreWcag22CriterionV1::Sc143TextLargeScale => Some(Self::Sc143TextLargeScale), + CoreWcag22CriterionV1::Sc1411UiComponentOrState => Some(Self::Sc1411UiComponentOrState), + CoreWcag22CriterionV1::Sc1411GraphicalObject => Some(Self::Sc1411GraphicalObject), + _ => None, + } + } + + const fn into_core(self) -> CoreWcag22CriterionV1 { + match self { + Self::Sc143TextDefault => CoreWcag22CriterionV1::Sc143TextDefault, + Self::Sc143TextLargeScale => CoreWcag22CriterionV1::Sc143TextLargeScale, + Self::Sc1411UiComponentOrState => CoreWcag22CriterionV1::Sc1411UiComponentOrState, + Self::Sc1411GraphicalObject => CoreWcag22CriterionV1::Sc1411GraphicalObject, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RelationKindV1 { + Applicable { + criterion: Wcag22CriterionV1, + adjacent: Vec<[u8; 3]>, + }, + NotApplicable { + reason_id: String, + }, +} + +/// One client-declared occurrence relation in the canonical V1 wire shape. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelationV1 { + relation_id: String, + occurrence_id: String, + kind: RelationKindV1, +} + +impl RelationV1 { + /// Construct a locally valid applicable relation. + pub fn applicable( + relation_id: impl Into, + occurrence_id: impl Into, + criterion: Wcag22CriterionV1, + adjacent: Vec<[u8; 3]>, + ) -> Result { + let relation_id = relation_id.into(); + let occurrence_id = occurrence_id.into(); + validate_relation_id(&relation_id)?; + validate_occurrence_id(&occurrence_id)?; + if adjacent.is_empty() { + return Err(core_invalid(CoreInvalidRequestV1::EmptyAdjacentSet { + relation_id, + })); + } + Ok(Self { + relation_id, + occurrence_id, + kind: RelationKindV1::Applicable { + criterion, + adjacent, + }, + }) + } + + /// Construct a locally valid client-declared NotApplicable relation. + pub fn not_applicable( + relation_id: impl Into, + occurrence_id: impl Into, + reason_id: impl Into, + ) -> Result { + let relation_id = relation_id.into(); + let occurrence_id = occurrence_id.into(); + let reason_id = reason_id.into(); + validate_relation_id(&relation_id)?; + validate_occurrence_id(&occurrence_id)?; + if reason_id.is_empty() { + return Err(ProtocolErrorV1::Transport( + TransportErrorV1::EmptyNotApplicableReason, + )); + } + Ok(Self { + relation_id, + occurrence_id, + kind: RelationKindV1::NotApplicable { reason_id }, + }) + } + + /// Opaque relation identity. + pub fn relation_id(&self) -> &str { + &self.relation_id + } + + /// Opaque occurrence identity. + pub fn occurrence_id(&self) -> &str { + &self.occurrence_id + } + + /// Applicable criterion and exact adjacent byte triples, if applicable. + pub fn as_applicable(&self) -> Option<(Wcag22CriterionV1, &[[u8; 3]])> { + match &self.kind { + RelationKindV1::Applicable { + criterion, + adjacent, + } => Some((*criterion, adjacent)), + RelationKindV1::NotApplicable { .. } => None, + } + } + + /// Opaque client reason, if declared NotApplicable. + pub fn as_not_applicable(&self) -> Option<&str> { + match &self.kind { + RelationKindV1::Applicable { .. } => None, + RelationKindV1::NotApplicable { reason_id } => Some(reason_id), + } + } + + fn into_core(self) -> Result { + let relation_id = CoreRelationId::try_new(self.relation_id).map_err(map_core_invalid)?; + let occurrence_id = + CoreOccurrenceId::try_new(self.occurrence_id).map_err(map_core_invalid)?; + match self.kind { + RelationKindV1::Applicable { + criterion, + adjacent, + } => CoreRelationV1::applicable( + relation_id, + occurrence_id, + criterion.into_core(), + adjacent.into_iter().map(Srgb8::new).collect(), + ) + .map_err(map_core_invalid), + RelationKindV1::NotApplicable { reason_id } => { + let declaration = CoreNotApplicableV1::try_new(reason_id).map_err(|error| { + if matches!(error, CoreWcag22EvaluationErrorV1::EmptyNotApplicableReason) { + ProtocolErrorV1::Transport(TransportErrorV1::EmptyNotApplicableReason) + } else { + ProtocolErrorV1::IncompatibleCoreContract + } + })?; + Ok(CoreRelationV1::not_applicable( + relation_id, + occurrence_id, + declaration, + )) + } + } + } +} + +impl Serialize for RelationV1 { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match &self.kind { + RelationKindV1::Applicable { + criterion, + adjacent, + } => { + let mut state = serializer.serialize_struct("RelationV1", 5)?; + state.serialize_field("relationId", &self.relation_id)?; + state.serialize_field("occurrenceId", &self.occurrence_id)?; + state.serialize_field("kind", "applicable")?; + state.serialize_field("criterion", criterion)?; + state.serialize_field("adjacent", adjacent)?; + state.end() + } + RelationKindV1::NotApplicable { reason_id } => { + let mut state = serializer.serialize_struct("RelationV1", 4)?; + state.serialize_field("relationId", &self.relation_id)?; + state.serialize_field("occurrenceId", &self.occurrence_id)?; + state.serialize_field("kind", "notApplicable")?; + state.serialize_field("reasonId", reason_id)?; + state.end() + } + } + } +} + +/// Validated V1 request that the canonical encoder can serialize. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RequestV1 { + schema_version: u32, + domain_id: DomainIdV1, + resource_profile_id: ResourceProfileIdV1, + relations: Vec, +} + +impl RequestV1 { + /// Construct a locally valid request. Aggregate resource and conflict laws + /// remain Core-owned and are evaluated only after transport preflight. + pub fn try_new( + domain_id: DomainIdV1, + relations: Vec, + resource_profile_id: ResourceProfileIdV1, + ) -> Result { + if relations.is_empty() { + return Err(core_invalid(CoreInvalidRequestV1::EmptyRelations)); + } + Ok(Self { + schema_version: SCHEMA_VERSION_V1, + domain_id, + resource_profile_id, + relations, + }) + } + + /// Registered domain. + pub const fn domain_id(&self) -> DomainIdV1 { + self.domain_id + } + + /// Operational resource profile. + pub const fn resource_profile_id(&self) -> ResourceProfileIdV1 { + self.resource_profile_id + } + + /// Declared relations in caller order. + pub fn relations(&self) -> &[RelationV1] { + &self.relations + } + + fn into_core(self) -> Result { + let relations = self + .relations + .into_iter() + .map(RelationV1::into_core) + .collect::, _>>()?; + CoreRequestV1::try_new( + match self.domain_id { + DomainIdV1::Srgb8NeutralAxis => CoreDomainIdV1::Srgb8NeutralAxis, + }, + relations, + match self.resource_profile_id { + ResourceProfileIdV1::Compile => CoreResourceProfileIdV1::Compile, + }, + ) + .map_err(map_core_invalid) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawRequestV1 { + schema_version: u32, + domain_id: String, + resource_profile_id: String, + relations: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +enum RawRelationV1 { + Applicable { + #[serde(rename = "relationId")] + relation_id: String, + #[serde(rename = "occurrenceId")] + occurrence_id: String, + criterion: String, + adjacent: Vec<[u8; 3]>, + }, + NotApplicable { + #[serde(rename = "relationId")] + relation_id: String, + #[serde(rename = "occurrenceId")] + occurrence_id: String, + #[serde(rename = "reasonId")] + reason_id: String, + }, +} + +/// Stable category for malformed JSON without exposing parser prose as ABI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum MalformedEnvelopeClassV1 { + /// JSON token/grammar error. + Syntax, + /// Struct shape, duplicate, unknown or value-domain error. + Shape, + /// Unexpected end of input. + EndOfInput, + /// Underlying reader failure; unreachable for in-memory bytes but total. + Io, +} + +/// Typed transport failures. No variant is a feasibility terminal. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde( + tag = "code", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum TransportErrorV1 { + /// Raw bytes exceed the derived V1 ceiling. + EnvelopeTooLarge { + /// Supplied raw bytes. + #[serde(serialize_with = "serialize_u64_decimal")] + requested_bytes: u64, + /// Exact V1 byte ceiling. + #[serde(serialize_with = "serialize_u64_decimal")] + limit_bytes: u64, + }, + /// Raw bytes are not UTF-8 JSON text. + InvalidUtf8, + /// JSON syntax or strict schema shape was rejected. + MalformedEnvelope { + /// Stable parser category. + class: MalformedEnvelopeClassV1, + }, + /// Envelope schema version is unsupported. + UnsupportedSchemaVersion { + /// Received schema version. + received: u32, + }, + /// Domain key is not registered in V1. + UnsupportedDomainId { + /// Exact rejected key. + received: String, + }, + /// Resource-profile key is not registered in V1. + UnsupportedResourceProfileId { + /// Exact rejected key. + received: String, + }, + /// Criterion key is not admitted by Core. + UnsupportedCriterion { + /// Exact rejected key. + received: String, + }, + /// A NotApplicable declaration had no client reason identity. + EmptyNotApplicableReason, +} + +/// Canonical JSON encoding failure. This is not a colour or feasibility result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtocolEncodingErrorV1 { + /// A sealed protocol value could not be serialized by the JSON backend. + SerializationFailed, +} + +impl fmt::Display for ProtocolEncodingErrorV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SerializationFailed => formatter.write_str("protocol JSON serialization failed"), + } + } +} + +impl std::error::Error for ProtocolEncodingErrorV1 {} + +/// Exact Core resource dimension projected into protocol errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ResourceDimensionV1 { + /// Relations before canonical duplicate removal. + RawRelations, + /// Adjacent entries before per-relation deduplication. + RawAdjacentEntries, + /// Aggregate opaque identity bytes. + OpaqueUtf8Bytes, + /// Relations after canonical duplicate removal. + CanonicalRelations, + /// Canonical applicable edges. + ApplicableEdges, + /// Candidate-edge assessment count. + LogicalAssessments, + /// Packed matrix plus partition bytes. + PackedResultBytes, +} + +/// Invalid or contradictory request projected exactly from Core. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde( + tag = "code", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum CoreInvalidRequestV1 { + /// Empty relation identity. + EmptyRelationId, + /// Empty occurrence identity. + EmptyOccurrenceId, + /// No declared relations. + EmptyRelations, + /// Applicable relation had no adjacency. + EmptyAdjacentSet { + /// Opaque relation identity. + relation_id: String, + }, + /// One relation identity described contradictory declarations. + ConflictingRelationId { + /// Opaque relation identity. + relation_id: String, + }, + /// Checked cardinality arithmetic overflowed. + ArithmeticOverflow, +} + +/// Atomic WCAG evaluator error projected without collapsing its cause. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde( + tag = "code", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum Wcag22EvaluationErrorV1 { + /// A direct atomic input was not exact sRGB8. + InvalidSrgb8 { + /// Input field identity. + field: String, + /// Core parser diagnostic. + reason: String, + }, + /// A NotApplicable declaration had no reason identity. + EmptyNotApplicableReason, + /// Finite bounds failed to separate a supported threshold. + ArtifactInvariantViolation { + /// Declared occurrence criterion. + criterion: Wcag22CriterionV1, + /// Exact foreground bytes. + foreground: [u8; 3], + /// Exact background bytes. + background: [u8; 3], + }, + /// Atomic evidence and registry identity drifted. + EvidenceRegistryMismatch { + /// Core diagnostic. + message: String, + }, +} + +/// Proof-bound evaluator invariant projected from Core. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "code", content = "details", rename_all = "camelCase")] +pub enum EvaluatorInvariantV1 { + /// Atomic source evaluator failed closed. + Source(Wcag22EvaluationErrorV1), + /// Applicable call returned NotEvaluated. + UnexpectedNotEvaluated, + /// Returned byte inputs differed from the call. + InputMismatch, + /// Returned criterion differed from the declaration. + CriterionMismatch, + /// Returned atomic evidence differed from the registry binding. + EvidenceMismatch, +} + +/// Compiler-owned completeness invariant projected from Core. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde( + tag = "code", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum CompilerInvariantV1 { + /// Derived layout counts disagreed. + LayoutMismatch, + /// Observed assessment count differed from exact preflight work. + AssessmentCardinalityMismatch { + /// Exact expected count. + #[serde(serialize_with = "serialize_u64_decimal")] + expected: u64, + /// Exact observed count. + #[serde(serialize_with = "serialize_u64_decimal")] + observed: u64, + }, + /// Observed domain count differed from the registered domain. + CandidateCardinalityMismatch { + /// Exact expected count. + #[serde(serialize_with = "serialize_u64_decimal")] + expected: u64, + /// Exact observed count. + #[serde(serialize_with = "serialize_u64_decimal")] + observed: u64, + }, + /// Packed storage rejected an addressable cell. + DecisionStorageRejectedCell, + /// Packed storage rejected the fixed domain partition. + DecisionStorageRejectedPartition, + /// Completed matrix, partition or proof state disagreed. + CompleteResultMismatch, +} + +/// Complete Core failure algebra projected as data. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde( + tag = "code", + content = "details", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum CoreErrorV1 { + /// Invalid or contradictory request. + InvalidRequest(CoreInvalidRequestV1), + /// One exact resource dimension exceeded its profile. + ResourceLimitExceeded { + /// Admitting profile. + profile_id: ResourceProfileIdV1, + /// Rejected dimension. + dimension: ResourceDimensionV1, + /// Requested exact count. + #[serde(serialize_with = "serialize_u64_decimal")] + requested: u64, + /// Admitted exact count. + #[serde(serialize_with = "serialize_u64_decimal")] + limit: u64, + }, + /// Exact packed allocation failed before evaluation. + AllocationFailed { + /// Admitting profile. + profile_id: ResourceProfileIdV1, + /// Requested allocation bytes. + #[serde(serialize_with = "serialize_u64_decimal")] + requested_bytes: u64, + }, + /// Atomic evaluator violated its proof-bound contract. + EvaluatorInvariantViolation { + /// Registered candidate bytes. + candidate: [u8; 3], + /// Opaque relation identity. + relation_id: String, + /// Exact adjacent bytes. + adjacent: [u8; 3], + /// Exact invariant cause. + violation: EvaluatorInvariantV1, + }, + /// Compiler completeness state was inconsistent. + CompilerInvariantViolation(CompilerInvariantV1), +} + +/// Failure source at the public protocol boundary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "source", content = "error", rename_all = "camelCase")] +pub enum ProtocolErrorV1 { + /// Raw envelope or schema failure. + Transport(TransportErrorV1), + /// Exact Core failure. + Core(CoreErrorV1), + /// A future non-exhaustive Core variant is not represented by this schema. + IncompatibleCoreContract, +} + +/// Typed canonical domain content identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct DomainDigestV1([u8; 32]); + +impl DomainDigestV1 { + /// Exact SHA-256 bytes. + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +/// Typed canonical relation-set content identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct RelationSetDigestV1([u8; 32]); + +impl RelationSetDigestV1 { + /// Exact SHA-256 bytes. + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +/// Typed complete-evaluation identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct EvaluationIdV1([u8; 32]); + +impl EvaluationIdV1 { + /// Exact SHA-256 bytes. + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +/// Atomic WCAG evaluator profile identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Wcag22ProfileIdV1 { + /// Exact WCAG 2.2 final-sRGB8 contrast profile. + Wcag22Srgb8Contrast, +} + +impl Wcag22ProfileIdV1 { + /// Stable Core-owned key. + pub fn key(self) -> &'static str { + CoreWcag22ProfileIdV1::Wcag22Srgb8ContrastV1.key() + } +} + +impl Serialize for Wcag22ProfileIdV1 { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.key()) + } +} + +/// Canonical finite numerical artifact identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NumericalArtifactIdV2 { + /// WCAG sRGB8 relative-luminance Q55 table. + Wcag22Srgb8LuminanceQ55, +} + +impl NumericalArtifactIdV2 { + /// Stable Core-owned key. + pub fn key(self) -> &'static str { + CoreNumericalArtifactIdV2::Wcag22Srgb8LuminanceQ55V1.key() + } +} + +impl Serialize for NumericalArtifactIdV2 { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.key()) + } +} + +/// Numerical bound-law identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NumericalErrorBoundIdV2 { + /// WCAG sRGB8 outward-Q55 threshold law. + Wcag22Srgb8OutwardQ55, +} + +impl NumericalErrorBoundIdV2 { + /// Stable Core-owned key. + pub fn key(self) -> &'static str { + CoreNumericalErrorBoundIdV2::Wcag22Srgb8OutwardQ55V1.key() + } +} + +impl Serialize for NumericalErrorBoundIdV2 { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.key()) + } +} + +/// Replayable numerical proof identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NumericalProofIdV2 { + /// Full finite sRGB8 WCAG Q55 proof. + Wcag22Srgb8FullDomainQ55, +} + +impl NumericalProofIdV2 { + /// Stable Core-owned key. + pub fn key(self) -> &'static str { + CoreNumericalProofIdV2::Wcag22Srgb8FullDomainQ55V1.key() + } +} + +impl Serialize for NumericalProofIdV2 { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.key()) + } +} + +/// Sealed, fully projected complete-enumeration proof. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EvaluationProofV1 { + evaluation_id: EvaluationIdV1, + resource_profile_id: ResourceProfileIdV1, + domain_id: DomainIdV1, + domain_digest: DomainDigestV1, + #[serde(serialize_with = "serialize_u64_decimal")] + domain_count: u64, + domain_first: [u8; 3], + domain_last: [u8; 3], + relation_set_digest: RelationSetDigestV1, + #[serde(serialize_with = "serialize_u64_decimal")] + canonical_relations: u64, + #[serde(serialize_with = "serialize_u64_decimal")] + applicable_relations: u64, + #[serde(serialize_with = "serialize_u64_decimal")] + not_applicable_relations: u64, + #[serde(serialize_with = "serialize_u64_decimal")] + applicable_edges: u64, + #[serde(serialize_with = "serialize_u64_decimal")] + logical_assessments: u64, + matrix_digest: [u8; 32], + partition: [u8; 32], + wcag22_profile_id: Wcag22ProfileIdV1, + artifact_id: NumericalArtifactIdV2, + bound_id: NumericalErrorBoundIdV2, + proof_id: NumericalProofIdV2, + proof_sha256: [u8; 32], +} + +impl EvaluationProofV1 { + /// Semantic evaluation identity. + pub const fn evaluation_id(&self) -> EvaluationIdV1 { + self.evaluation_id + } + + /// Resource profile that admitted the computation. + pub const fn resource_profile_id(&self) -> ResourceProfileIdV1 { + self.resource_profile_id + } + + /// Registered domain identity. + pub const fn domain_id(&self) -> DomainIdV1 { + self.domain_id + } + + /// Canonical domain digest. + pub const fn domain_digest(&self) -> DomainDigestV1 { + self.domain_digest + } + + /// Exact registered-domain cardinality. + pub const fn domain_count(&self) -> u64 { + self.domain_count + } + + /// First domain byte triple. + pub const fn domain_first(&self) -> [u8; 3] { + self.domain_first + } + + /// Last domain byte triple. + pub const fn domain_last(&self) -> [u8; 3] { + self.domain_last + } + + /// Canonical relation-set digest. + pub const fn relation_set_digest(&self) -> RelationSetDigestV1 { + self.relation_set_digest + } + + /// Canonical relation count. + pub const fn canonical_relations(&self) -> u64 { + self.canonical_relations + } + + /// Canonical applicable-relation count. + pub const fn applicable_relations(&self) -> u64 { + self.applicable_relations + } + + /// Canonical NotApplicable relation count. + pub const fn not_applicable_relations(&self) -> u64 { + self.not_applicable_relations + } + + /// Flattened canonical edge count. + pub const fn applicable_edges(&self) -> u64 { + self.applicable_edges + } + + /// Exact complete assessment count. + pub const fn logical_assessments(&self) -> u64 { + self.logical_assessments + } + + /// SHA-256 of the packed failure matrix. + pub const fn matrix_digest(&self) -> &[u8; 32] { + &self.matrix_digest + } + + /// Exact 256-bit feasible partition, candidate-index LSB0. + pub const fn partition(&self) -> &[u8; 32] { + &self.partition + } + + /// Atomic WCAG profile. + pub const fn wcag22_profile_id(&self) -> Wcag22ProfileIdV1 { + self.wcag22_profile_id + } + + /// Atomic finite artifact. + pub const fn artifact_id(&self) -> NumericalArtifactIdV2 { + self.artifact_id + } + + /// Atomic numerical bound law. + pub const fn bound_id(&self) -> NumericalErrorBoundIdV2 { + self.bound_id + } + + /// Atomic complete-domain proof identity. + pub const fn proof_id(&self) -> NumericalProofIdV2 { + self.proof_id + } + + /// Exact proof-file SHA-256 bytes. + pub const fn proof_sha256(&self) -> &[u8; 32] { + &self.proof_sha256 + } +} + +/// Packed evaluated payload shared by Feasible and Infeasible terminals. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EvaluatedV1 { + domain: Vec<[u8; 3]>, + relations: Vec, + failure_matrix: Vec, + proof: EvaluationProofV1, +} + +impl EvaluatedV1 { + /// Exact registered domain in Core order, transported once. + pub fn domain(&self) -> &[[u8; 3]] { + &self.domain + } + + /// Canonical declarations, transported once. + pub fn relations(&self) -> &[RelationV1] { + &self.relations + } + + /// Candidate-major LSB0 packed failure matrix. + pub fn failure_matrix(&self) -> &[u8] { + &self.failure_matrix + } + + /// Sealed complete-evaluation proof. + pub const fn proof(&self) -> &EvaluationProofV1 { + &self.proof + } +} + +/// Declaration-only terminal payload with no fabricated numerical proof. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NotEvaluatedV1 { + domain_id: DomainIdV1, + domain_digest: DomainDigestV1, + relation_set_digest: RelationSetDigestV1, + resource_profile_id: ResourceProfileIdV1, + relations: Vec, +} + +impl NotEvaluatedV1 { + /// Registered domain identity. + pub const fn domain_id(&self) -> DomainIdV1 { + self.domain_id + } + + /// Canonical domain digest. + pub const fn domain_digest(&self) -> DomainDigestV1 { + self.domain_digest + } + + /// Canonical declaration-set digest. + pub const fn relation_set_digest(&self) -> RelationSetDigestV1 { + self.relation_set_digest + } + + /// Admitting operational profile. + pub const fn resource_profile_id(&self) -> ResourceProfileIdV1 { + self.resource_profile_id + } + + /// Canonical declarations, transported once. + pub fn relations(&self) -> &[RelationV1] { + &self.relations + } +} + +/// Successful feasibility terminal. Failure is represented only by +/// [`ProtocolOutcomeV1::Failure`], never by a fourth variant here. +/// +/// Evaluated terminal payloads are variant-sealed: an external adapter may +/// inspect or clone the evidence, but cannot rewrap genuine Core evidence with +/// the opposite terminal. +/// +/// ```compile_fail +/// use labcolors_protocol::{ +/// EvaluatedV1, FeasibilityV1, NotEvaluatedV1, ProtocolOutcomeV1, +/// }; +/// +/// fn rewrap_feasible(value: EvaluatedV1) -> FeasibilityV1 { +/// FeasibilityV1::Feasible(value) +/// } +/// +/// fn rewrap_infeasible(value: EvaluatedV1) -> FeasibilityV1 { +/// FeasibilityV1::Infeasible(value) +/// } +/// +/// fn rewrap_not_evaluated(value: NotEvaluatedV1) -> FeasibilityV1 { +/// FeasibilityV1::NotEvaluated(value) +/// } +/// +/// fn flip_terminal(outcome: ProtocolOutcomeV1) -> ProtocolOutcomeV1 { +/// let evaluated = outcome +/// .feasibility() +/// .and_then(FeasibilityV1::evaluated) +/// .expect("evaluated Core outcome") +/// .clone(); +/// ProtocolOutcomeV1::Success { +/// feasibility: FeasibilityV1::Infeasible(evaluated), +/// } +/// } +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "status", content = "result", rename_all = "camelCase")] +#[non_exhaustive] +pub enum FeasibilityV1 { + /// Complete evaluation found a non-empty feasible partition. + #[non_exhaustive] + Feasible(EvaluatedV1), + /// Complete evaluation proved the registered partition empty. + #[non_exhaustive] + Infeasible(EvaluatedV1), + /// Every canonical relation was client-declared NotApplicable. + #[non_exhaustive] + NotEvaluated(NotEvaluatedV1), +} + +impl FeasibilityV1 { + /// Whether complete evaluation found at least one feasible candidate. + pub const fn is_feasible(&self) -> bool { + matches!(self, Self::Feasible(..)) + } + + /// Whether complete evaluation proved the feasible partition empty. + pub const fn is_infeasible(&self) -> bool { + matches!(self, Self::Infeasible(..)) + } + + /// Whether every canonical relation was declared NotApplicable. + pub const fn is_not_evaluated(&self) -> bool { + matches!(self, Self::NotEvaluated(..)) + } + + /// Borrow an evaluated payload from either evaluated terminal. + pub const fn evaluated(&self) -> Option<&EvaluatedV1> { + match self { + Self::Feasible(value) | Self::Infeasible(value) => Some(value), + Self::NotEvaluated(_) => None, + } + } + + /// Borrow the declaration-only payload. + pub const fn not_evaluated(&self) -> Option<&NotEvaluatedV1> { + match self { + Self::NotEvaluated(value) => Some(value), + Self::Feasible(..) | Self::Infeasible(..) => None, + } + } +} + +/// Total public protocol result: success with one feasibility terminal, or +/// failure with typed transport/Core error data. +/// +/// The type deliberately does not implement `Deserialize`: proof-bearing +/// terminals can be obtained only by evaluating Core, not by parsing forged +/// JSON. +/// +/// ```compile_fail +/// let _: labcolors_protocol::ProtocolOutcomeV1 = +/// serde_json::from_str(r#"{"schemaVersion":1,"outcome":"success"}"#).unwrap(); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +// The successful terminal keeps bounded owned-buffer headers inline, avoiding +// one heap allocation, OOM edge and indirection on every compiler call. The +// layout test below admits exactly the largest payload plus its required tag +// slot, with no discretionary stack headroom; this is not wire ABI. +#[allow(clippy::large_enum_variant)] +pub enum ProtocolOutcomeV1 { + /// Successful complete feasibility operation. + Success { + /// Exact feasibility terminal. + feasibility: FeasibilityV1, + }, + /// Failed operation with no partial feasibility terminal. + Failure { + /// Typed failure data. + error: ProtocolErrorV1, + }, +} + +impl ProtocolOutcomeV1 { + /// Borrow the successful feasibility terminal. + pub const fn feasibility(&self) -> Option<&FeasibilityV1> { + match self { + Self::Success { feasibility } => Some(feasibility), + Self::Failure { .. } => None, + } + } + + /// Borrow typed failure data. + pub const fn error(&self) -> Option<&ProtocolErrorV1> { + match self { + Self::Success { .. } => None, + Self::Failure { error } => Some(error), + } + } + + fn failure(error: ProtocolErrorV1) -> Self { + Self::Failure { error } + } +} + +impl Serialize for ProtocolOutcomeV1 { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Success { feasibility } => { + let mut state = serializer.serialize_struct("ProtocolOutcomeV1", 3)?; + state.serialize_field("schemaVersion", &SCHEMA_VERSION_V1)?; + state.serialize_field("outcome", "success")?; + state.serialize_field("feasibility", feasibility)?; + state.end() + } + Self::Failure { error } => { + let mut state = serializer.serialize_struct("ProtocolOutcomeV1", 3)?; + state.serialize_field("schemaVersion", &SCHEMA_VERSION_V1)?; + state.serialize_field("outcome", "failure")?; + state.serialize_field("error", error)?; + state.end() + } + } + } +} + +fn serialize_u64_decimal(value: &u64, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +fn usize_as_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +fn core_invalid(error: CoreInvalidRequestV1) -> ProtocolErrorV1 { + ProtocolErrorV1::Core(CoreErrorV1::InvalidRequest(error)) +} + +fn validate_relation_id(value: &str) -> Result<(), ProtocolErrorV1> { + if value.is_empty() { + Err(core_invalid(CoreInvalidRequestV1::EmptyRelationId)) + } else { + Ok(()) + } +} + +fn validate_occurrence_id(value: &str) -> Result<(), ProtocolErrorV1> { + if value.is_empty() { + Err(core_invalid(CoreInvalidRequestV1::EmptyOccurrenceId)) + } else { + Ok(()) + } +} + +/// Encode one validated request as deterministic compact UTF-8 JSON bytes. +pub fn encode_request_v1(request: &RequestV1) -> Result, ProtocolEncodingErrorV1> { + serde_json::to_vec(request).map_err(|_| ProtocolEncodingErrorV1::SerializationFailed) +} + +/// Encode one sealed outcome as deterministic compact UTF-8 JSON bytes. +pub fn encode_outcome_v1(outcome: &ProtocolOutcomeV1) -> Result, ProtocolEncodingErrorV1> { + serde_json::to_vec(outcome).map_err(|_| ProtocolEncodingErrorV1::SerializationFailed) +} + +/// Construct the canonical typed failure used by host-side byte preflight. +/// +/// Adapters call this scalar helper only after proving their raw byte length is +/// greater than [`MAX_ENVELOPE_BYTES_V1`]. The authoritative Rust byte path +/// uses the same constructor. +pub fn envelope_too_large_outcome_v1(requested_bytes: u64) -> ProtocolOutcomeV1 { + ProtocolOutcomeV1::failure(ProtocolErrorV1::Transport( + TransportErrorV1::EnvelopeTooLarge { + requested_bytes, + limit_bytes: MAX_ENVELOPE_BYTES_V1, + }, + )) +} + +/// Evaluate one exact raw UTF-8 JSON envelope through the sole Core compiler. +/// +/// Raw length is rejected before UTF-8 validation, JSON parsing, nested +/// allocation or Core work. Every public-input and Core error becomes a typed +/// [`ProtocolOutcomeV1::Failure`]; no `Result`, panic or fallback escapes. +pub fn evaluate_wcag22_feasibility_v1(raw: &[u8]) -> ProtocolOutcomeV1 { + evaluate_with_decoder(raw, decode_request_v1) +} + +fn evaluate_with_decoder(raw: &[u8], decoder: F) -> ProtocolOutcomeV1 +where + F: FnOnce(&str) -> Result, +{ + let requested_bytes = usize_as_u64(raw.len()); + if requested_bytes > MAX_ENVELOPE_BYTES_V1 { + return envelope_too_large_outcome_v1(requested_bytes); + } + let text = match core::str::from_utf8(raw) { + Ok(text) => text, + Err(_) => { + return ProtocolOutcomeV1::failure(ProtocolErrorV1::Transport( + TransportErrorV1::InvalidUtf8, + )); + } + }; + let request = match decoder(text) { + Ok(request) => request, + Err(error) => return ProtocolOutcomeV1::failure(error), + }; + match core_feasibility::evaluate(request) { + Ok(feasibility) => match project_feasibility(&feasibility) { + Ok(feasibility) => ProtocolOutcomeV1::Success { feasibility }, + Err(error) => ProtocolOutcomeV1::failure(error), + }, + Err(error) => ProtocolOutcomeV1::failure(project_core_error(error)), + } +} + +fn decode_request_v1(text: &str) -> Result { + let raw: RawRequestV1 = serde_json::from_str(text).map_err(|error| { + let class = match error.classify() { + serde_json::error::Category::Io => MalformedEnvelopeClassV1::Io, + serde_json::error::Category::Syntax => MalformedEnvelopeClassV1::Syntax, + serde_json::error::Category::Data => MalformedEnvelopeClassV1::Shape, + serde_json::error::Category::Eof => MalformedEnvelopeClassV1::EndOfInput, + }; + ProtocolErrorV1::Transport(TransportErrorV1::MalformedEnvelope { class }) + })?; + if raw.schema_version != SCHEMA_VERSION_V1 { + return Err(ProtocolErrorV1::Transport( + TransportErrorV1::UnsupportedSchemaVersion { + received: raw.schema_version, + }, + )); + } + let domain_id = match DomainIdV1::parse(&raw.domain_id) { + Some(domain_id) => domain_id, + None => { + return Err(ProtocolErrorV1::Transport( + TransportErrorV1::UnsupportedDomainId { + received: raw.domain_id, + }, + )); + } + }; + let resource_profile_id = match ResourceProfileIdV1::parse(&raw.resource_profile_id) { + Some(resource_profile_id) => resource_profile_id, + None => { + return Err(ProtocolErrorV1::Transport( + TransportErrorV1::UnsupportedResourceProfileId { + received: raw.resource_profile_id, + }, + )); + } + }; + let relations = raw + .relations + .into_iter() + .map(|relation| match relation { + RawRelationV1::Applicable { + relation_id, + occurrence_id, + criterion, + adjacent, + } => { + let criterion = Wcag22CriterionV1::parse(&criterion).ok_or({ + ProtocolErrorV1::Transport(TransportErrorV1::UnsupportedCriterion { + received: criterion, + }) + })?; + RelationV1::applicable(relation_id, occurrence_id, criterion, adjacent) + } + RawRelationV1::NotApplicable { + relation_id, + occurrence_id, + reason_id, + } => RelationV1::not_applicable(relation_id, occurrence_id, reason_id), + }) + .collect::, _>>()?; + RequestV1::try_new(domain_id, relations, resource_profile_id)?.into_core() +} + +fn project_feasibility(value: &CoreFeasibilityV1) -> Result { + if let Some(evaluated) = value.evaluated() { + let evaluated = project_evaluated(evaluated)?; + if value.is_feasible() { + return Ok(FeasibilityV1::Feasible(evaluated)); + } + if value.is_infeasible() { + return Ok(FeasibilityV1::Infeasible(evaluated)); + } + return Err(ProtocolErrorV1::IncompatibleCoreContract); + } + if let Some(not_evaluated) = value.not_evaluated() { + if value.is_not_evaluated() { + return Ok(FeasibilityV1::NotEvaluated(project_not_evaluated( + not_evaluated, + )?)); + } + } + Err(ProtocolErrorV1::IncompatibleCoreContract) +} + +fn project_evaluated(value: &CoreEvaluatedV1) -> Result { + let domain_id = project_domain_id(value.domain_id())?; + let domain = value.domain_id().candidates().map(Srgb8::bytes).collect(); + let relations = project_relations(value.relations())?; + Ok(EvaluatedV1 { + domain, + relations, + failure_matrix: value.failure_matrix().to_vec(), + proof: project_proof(value.proof(), domain_id)?, + }) +} + +fn project_not_evaluated(value: &CoreNotEvaluatedV1) -> Result { + Ok(NotEvaluatedV1 { + domain_id: project_domain_id(value.domain_id())?, + domain_digest: DomainDigestV1(*value.domain_digest().as_bytes()), + relation_set_digest: RelationSetDigestV1(*value.relation_set_digest().as_bytes()), + resource_profile_id: project_resource_profile_id(value.resource_profile_id())?, + relations: project_relations(value.relations())?, + }) +} + +fn project_relations(values: &[CoreRelationV1]) -> Result, ProtocolErrorV1> { + values.iter().map(project_relation).collect() +} + +fn project_relation(value: &CoreRelationV1) -> Result { + if let Some((criterion, adjacent)) = value.as_applicable() { + return RelationV1::applicable( + value.relation_id().as_str(), + value.occurrence_id().as_str(), + project_criterion(criterion)?, + adjacent.iter().copied().map(Srgb8::bytes).collect(), + ); + } + if let Some(declaration) = value.as_not_applicable() { + return RelationV1::not_applicable( + value.relation_id().as_str(), + value.occurrence_id().as_str(), + declaration.reason_id(), + ); + } + Err(ProtocolErrorV1::IncompatibleCoreContract) +} + +fn project_proof( + value: &core_feasibility::EvaluationProofV1, + domain_id: DomainIdV1, +) -> Result { + Ok(EvaluationProofV1 { + evaluation_id: EvaluationIdV1(*value.evaluation_id().as_bytes()), + resource_profile_id: project_resource_profile_id(value.resource_profile_id())?, + domain_id, + domain_digest: DomainDigestV1(*value.domain_digest().as_bytes()), + domain_count: value.domain_count(), + domain_first: value.domain_first().bytes(), + domain_last: value.domain_last().bytes(), + relation_set_digest: RelationSetDigestV1(*value.relation_set_digest().as_bytes()), + canonical_relations: value.canonical_relations(), + applicable_relations: value.applicable_relations(), + not_applicable_relations: value.not_applicable_relations(), + applicable_edges: value.applicable_edges(), + logical_assessments: value.logical_assessments(), + matrix_digest: *value.matrix_digest(), + partition: *value.partition(), + wcag22_profile_id: project_wcag22_profile_id(value.profile_id())?, + artifact_id: project_artifact_id(value.artifact_id())?, + bound_id: project_bound_id(value.bound_id())?, + proof_id: project_proof_id(value.proof_id())?, + proof_sha256: *value.proof_sha256(), + }) +} + +fn project_domain_id(value: CoreDomainIdV1) -> Result { + match value { + CoreDomainIdV1::Srgb8NeutralAxis => Ok(DomainIdV1::Srgb8NeutralAxis), + _ => Err(ProtocolErrorV1::IncompatibleCoreContract), + } +} + +fn project_resource_profile_id( + value: CoreResourceProfileIdV1, +) -> Result { + match value { + CoreResourceProfileIdV1::Compile => Ok(ResourceProfileIdV1::Compile), + _ => Err(ProtocolErrorV1::IncompatibleCoreContract), + } +} + +fn project_criterion(value: CoreWcag22CriterionV1) -> Result { + match value { + CoreWcag22CriterionV1::Sc143TextDefault => Ok(Wcag22CriterionV1::Sc143TextDefault), + CoreWcag22CriterionV1::Sc143TextLargeScale => Ok(Wcag22CriterionV1::Sc143TextLargeScale), + CoreWcag22CriterionV1::Sc1411UiComponentOrState => { + Ok(Wcag22CriterionV1::Sc1411UiComponentOrState) + } + CoreWcag22CriterionV1::Sc1411GraphicalObject => { + Ok(Wcag22CriterionV1::Sc1411GraphicalObject) + } + _ => Err(ProtocolErrorV1::IncompatibleCoreContract), + } +} + +fn project_wcag22_profile_id( + value: CoreWcag22ProfileIdV1, +) -> Result { + match value { + CoreWcag22ProfileIdV1::Wcag22Srgb8ContrastV1 => Ok(Wcag22ProfileIdV1::Wcag22Srgb8Contrast), + _ => Err(ProtocolErrorV1::IncompatibleCoreContract), + } +} + +fn project_artifact_id( + value: CoreNumericalArtifactIdV2, +) -> Result { + match value { + CoreNumericalArtifactIdV2::Wcag22Srgb8LuminanceQ55V1 => { + Ok(NumericalArtifactIdV2::Wcag22Srgb8LuminanceQ55) + } + _ => Err(ProtocolErrorV1::IncompatibleCoreContract), + } +} + +fn project_bound_id( + value: CoreNumericalErrorBoundIdV2, +) -> Result { + match value { + CoreNumericalErrorBoundIdV2::Wcag22Srgb8OutwardQ55V1 => { + Ok(NumericalErrorBoundIdV2::Wcag22Srgb8OutwardQ55) + } + _ => Err(ProtocolErrorV1::IncompatibleCoreContract), + } +} + +fn project_proof_id(value: CoreNumericalProofIdV2) -> Result { + match value { + CoreNumericalProofIdV2::Wcag22Srgb8FullDomainQ55V1 => { + Ok(NumericalProofIdV2::Wcag22Srgb8FullDomainQ55) + } + _ => Err(ProtocolErrorV1::IncompatibleCoreContract), + } +} + +fn map_core_invalid(error: CoreInvalidRequestSourceV1) -> ProtocolErrorV1 { + match project_core_invalid(error) { + Ok(error) => core_invalid(error), + Err(()) => ProtocolErrorV1::IncompatibleCoreContract, + } +} + +fn project_core_invalid(error: CoreInvalidRequestSourceV1) -> Result { + match error { + CoreInvalidRequestSourceV1::EmptyRelationId => Ok(CoreInvalidRequestV1::EmptyRelationId), + CoreInvalidRequestSourceV1::EmptyOccurrenceId => { + Ok(CoreInvalidRequestV1::EmptyOccurrenceId) + } + CoreInvalidRequestSourceV1::EmptyRelations => Ok(CoreInvalidRequestV1::EmptyRelations), + CoreInvalidRequestSourceV1::EmptyAdjacentSet { relation_id } => { + Ok(CoreInvalidRequestV1::EmptyAdjacentSet { + relation_id: relation_id.as_str().to_string(), + }) + } + CoreInvalidRequestSourceV1::ConflictingRelationId { relation_id } => { + Ok(CoreInvalidRequestV1::ConflictingRelationId { + relation_id: relation_id.as_str().to_string(), + }) + } + CoreInvalidRequestSourceV1::ArithmeticOverflow => { + Ok(CoreInvalidRequestV1::ArithmeticOverflow) + } + _ => Err(()), + } +} + +fn project_core_error(error: CoreFeasibilityErrorV1) -> ProtocolErrorV1 { + let projected = match error { + CoreFeasibilityErrorV1::InvalidRequest(error) => { + project_core_invalid(error).map(CoreErrorV1::InvalidRequest) + } + CoreFeasibilityErrorV1::ResourceLimitExceeded { + profile_id, + dimension, + requested, + limit, + } => Ok(CoreErrorV1::ResourceLimitExceeded { + profile_id: match project_resource_profile_id(profile_id) { + Ok(value) => value, + Err(_) => return ProtocolErrorV1::IncompatibleCoreContract, + }, + dimension: match project_resource_dimension(dimension) { + Ok(value) => value, + Err(()) => return ProtocolErrorV1::IncompatibleCoreContract, + }, + requested, + limit, + }), + CoreFeasibilityErrorV1::AllocationFailed { + profile_id, + requested_bytes, + } => Ok(CoreErrorV1::AllocationFailed { + profile_id: match project_resource_profile_id(profile_id) { + Ok(value) => value, + Err(_) => return ProtocolErrorV1::IncompatibleCoreContract, + }, + requested_bytes, + }), + CoreFeasibilityErrorV1::EvaluatorInvariantViolation { + candidate, + relation_id, + adjacent, + violation, + } => Ok(CoreErrorV1::EvaluatorInvariantViolation { + candidate: candidate.bytes(), + relation_id: relation_id.as_str().to_string(), + adjacent: adjacent.bytes(), + violation: match project_evaluator_invariant(violation) { + Ok(value) => value, + Err(()) => return ProtocolErrorV1::IncompatibleCoreContract, + }, + }), + CoreFeasibilityErrorV1::CompilerInvariantViolation(violation) => { + project_compiler_invariant(violation).map(CoreErrorV1::CompilerInvariantViolation) + } + _ => Err(()), + }; + match projected { + Ok(error) => ProtocolErrorV1::Core(error), + Err(()) => ProtocolErrorV1::IncompatibleCoreContract, + } +} + +fn project_resource_dimension(value: CoreResourceDimensionV1) -> Result { + match value { + CoreResourceDimensionV1::RawRelations => Ok(ResourceDimensionV1::RawRelations), + CoreResourceDimensionV1::RawAdjacentEntries => Ok(ResourceDimensionV1::RawAdjacentEntries), + CoreResourceDimensionV1::OpaqueUtf8Bytes => Ok(ResourceDimensionV1::OpaqueUtf8Bytes), + CoreResourceDimensionV1::CanonicalRelations => Ok(ResourceDimensionV1::CanonicalRelations), + CoreResourceDimensionV1::ApplicableEdges => Ok(ResourceDimensionV1::ApplicableEdges), + CoreResourceDimensionV1::LogicalAssessments => Ok(ResourceDimensionV1::LogicalAssessments), + CoreResourceDimensionV1::PackedResultBytes => Ok(ResourceDimensionV1::PackedResultBytes), + _ => Err(()), + } +} + +fn project_evaluator_invariant( + value: CoreEvaluatorInvariantV1, +) -> Result { + match value { + CoreEvaluatorInvariantV1::Source(error) => { + project_wcag22_error(error).map(EvaluatorInvariantV1::Source) + } + CoreEvaluatorInvariantV1::UnexpectedNotEvaluated => { + Ok(EvaluatorInvariantV1::UnexpectedNotEvaluated) + } + CoreEvaluatorInvariantV1::InputMismatch => Ok(EvaluatorInvariantV1::InputMismatch), + CoreEvaluatorInvariantV1::CriterionMismatch => Ok(EvaluatorInvariantV1::CriterionMismatch), + CoreEvaluatorInvariantV1::EvidenceMismatch => Ok(EvaluatorInvariantV1::EvidenceMismatch), + _ => Err(()), + } +} + +fn project_wcag22_error(value: CoreWcag22EvaluationErrorV1) -> Result { + match value { + CoreWcag22EvaluationErrorV1::InvalidSrgb8 { field, reason } => { + Ok(Wcag22EvaluationErrorV1::InvalidSrgb8 { + field: field.to_string(), + reason, + }) + } + CoreWcag22EvaluationErrorV1::EmptyNotApplicableReason => { + Ok(Wcag22EvaluationErrorV1::EmptyNotApplicableReason) + } + CoreWcag22EvaluationErrorV1::ArtifactInvariantViolation { + criterion, + foreground, + background, + } => Ok(Wcag22EvaluationErrorV1::ArtifactInvariantViolation { + criterion: match project_criterion(criterion) { + Ok(value) => value, + Err(_) => return Err(()), + }, + foreground, + background, + }), + CoreWcag22EvaluationErrorV1::EvidenceRegistryMismatch(message) => { + Ok(Wcag22EvaluationErrorV1::EvidenceRegistryMismatch { message }) + } + _ => Err(()), + } +} + +fn project_compiler_invariant(value: CoreCompilerInvariantV1) -> Result { + match value { + CoreCompilerInvariantV1::LayoutMismatch => Ok(CompilerInvariantV1::LayoutMismatch), + CoreCompilerInvariantV1::AssessmentCardinalityMismatch { expected, observed } => { + Ok(CompilerInvariantV1::AssessmentCardinalityMismatch { expected, observed }) + } + CoreCompilerInvariantV1::CandidateCardinalityMismatch { expected, observed } => { + Ok(CompilerInvariantV1::CandidateCardinalityMismatch { expected, observed }) + } + CoreCompilerInvariantV1::DecisionStorageRejectedCell => { + Ok(CompilerInvariantV1::DecisionStorageRejectedCell) + } + CoreCompilerInvariantV1::DecisionStorageRejectedPartition => { + Ok(CompilerInvariantV1::DecisionStorageRejectedPartition) + } + CoreCompilerInvariantV1::CompleteResultMismatch => { + Ok(CompilerInvariantV1::CompleteResultMismatch) + } + _ => Err(()), + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + + fn one_core_request() -> CoreRequestV1 { + let relation = CoreRelationV1::applicable( + CoreRelationId::try_new("relation").unwrap(), + CoreOccurrenceId::try_new("occurrence").unwrap(), + CoreWcag22CriterionV1::Sc143TextDefault, + vec![Srgb8::new([0x76; 3])], + ) + .unwrap(); + CoreRequestV1::try_new( + CoreDomainIdV1::Srgb8NeutralAxis, + vec![relation], + CoreResourceProfileIdV1::Compile, + ) + .unwrap() + } + + #[test] + fn raw_byte_preflight_calls_no_decoder_above_limit_and_one_at_limit() { + let calls = Cell::new(0_u32); + let over = vec![b' '; usize::try_from(MAX_ENVELOPE_BYTES_V1 + 1).unwrap()]; + let outcome = evaluate_with_decoder(&over, |_| { + calls.set(calls.get() + 1); + Ok(one_core_request()) + }); + assert_eq!(calls.get(), 0); + assert_eq!( + outcome, + envelope_too_large_outcome_v1(MAX_ENVELOPE_BYTES_V1 + 1) + ); + + let at_limit = vec![b' '; usize::try_from(MAX_ENVELOPE_BYTES_V1).unwrap()]; + let outcome = evaluate_with_decoder(&at_limit, |_| { + calls.set(calls.get() + 1); + Ok(one_core_request()) + }); + assert_eq!(calls.get(), 1); + assert!(matches!( + outcome, + ProtocolOutcomeV1::Success { + feasibility: FeasibilityV1::Feasible(_) + } + )); + } + + #[test] + fn inline_outcome_header_has_no_discretionary_stack_headroom() { + let largest_terminal_payload = + core::mem::size_of::().max(core::mem::size_of::()); + let required_terminal_tag_slot = core::mem::align_of::(); + let exact_terminal_header = largest_terminal_payload + required_terminal_tag_slot; + + assert_eq!( + core::mem::size_of::(), + exact_terminal_header, + "terminal layout gained bytes beyond its largest payload and aligned tag slot" + ); + assert!( + core::mem::size_of::() <= exact_terminal_header, + "failure payload became the largest outcome payload" + ); + assert_eq!( + core::mem::size_of::(), + exact_terminal_header, + "outer outcome tag introduced an additional stack slot" + ); + } + + #[test] + fn every_current_core_error_branch_has_a_typed_projection() { + let relation_id = CoreRelationId::try_new("relation").unwrap(); + let invalid = [ + CoreInvalidRequestSourceV1::EmptyRelationId, + CoreInvalidRequestSourceV1::EmptyOccurrenceId, + CoreInvalidRequestSourceV1::EmptyRelations, + CoreInvalidRequestSourceV1::EmptyAdjacentSet { + relation_id: relation_id.clone(), + }, + CoreInvalidRequestSourceV1::ConflictingRelationId { + relation_id: relation_id.clone(), + }, + CoreInvalidRequestSourceV1::ArithmeticOverflow, + ]; + for error in invalid { + assert!(matches!( + project_core_error(CoreFeasibilityErrorV1::InvalidRequest(error)), + ProtocolErrorV1::Core(CoreErrorV1::InvalidRequest(_)) + )); + } + + let dimensions = [ + CoreResourceDimensionV1::RawRelations, + CoreResourceDimensionV1::RawAdjacentEntries, + CoreResourceDimensionV1::OpaqueUtf8Bytes, + CoreResourceDimensionV1::CanonicalRelations, + CoreResourceDimensionV1::ApplicableEdges, + CoreResourceDimensionV1::LogicalAssessments, + CoreResourceDimensionV1::PackedResultBytes, + ]; + for dimension in dimensions { + assert!(matches!( + project_core_error(CoreFeasibilityErrorV1::ResourceLimitExceeded { + profile_id: CoreResourceProfileIdV1::Compile, + dimension, + requested: 2, + limit: 1, + }), + ProtocolErrorV1::Core(CoreErrorV1::ResourceLimitExceeded { .. }) + )); + } + assert!(matches!( + project_core_error(CoreFeasibilityErrorV1::AllocationFailed { + profile_id: CoreResourceProfileIdV1::Compile, + requested_bytes: 32, + }), + ProtocolErrorV1::Core(CoreErrorV1::AllocationFailed { .. }) + )); + + let source_errors = [ + CoreWcag22EvaluationErrorV1::InvalidSrgb8 { + field: "foreground", + reason: "invalid".to_string(), + }, + CoreWcag22EvaluationErrorV1::EmptyNotApplicableReason, + CoreWcag22EvaluationErrorV1::ArtifactInvariantViolation { + criterion: CoreWcag22CriterionV1::Sc143TextDefault, + foreground: [0; 3], + background: [255; 3], + }, + CoreWcag22EvaluationErrorV1::EvidenceRegistryMismatch("drift".to_string()), + ]; + for source in source_errors { + assert!(matches!( + project_core_error(CoreFeasibilityErrorV1::EvaluatorInvariantViolation { + candidate: Srgb8::new([0; 3]), + relation_id: relation_id.clone(), + adjacent: Srgb8::new([255; 3]), + violation: CoreEvaluatorInvariantV1::Source(source), + }), + ProtocolErrorV1::Core(CoreErrorV1::EvaluatorInvariantViolation { .. }) + )); + } + let evaluator_invariants = [ + CoreEvaluatorInvariantV1::UnexpectedNotEvaluated, + CoreEvaluatorInvariantV1::InputMismatch, + CoreEvaluatorInvariantV1::CriterionMismatch, + CoreEvaluatorInvariantV1::EvidenceMismatch, + ]; + for violation in evaluator_invariants { + assert!(matches!( + project_core_error(CoreFeasibilityErrorV1::EvaluatorInvariantViolation { + candidate: Srgb8::new([0; 3]), + relation_id: relation_id.clone(), + adjacent: Srgb8::new([255; 3]), + violation, + }), + ProtocolErrorV1::Core(CoreErrorV1::EvaluatorInvariantViolation { .. }) + )); + } + + let compiler_invariants = [ + CoreCompilerInvariantV1::LayoutMismatch, + CoreCompilerInvariantV1::AssessmentCardinalityMismatch { + expected: 2, + observed: 1, + }, + CoreCompilerInvariantV1::CandidateCardinalityMismatch { + expected: 256, + observed: 255, + }, + CoreCompilerInvariantV1::DecisionStorageRejectedCell, + CoreCompilerInvariantV1::DecisionStorageRejectedPartition, + CoreCompilerInvariantV1::CompleteResultMismatch, + ]; + for violation in compiler_invariants { + assert!(matches!( + project_core_error(CoreFeasibilityErrorV1::CompilerInvariantViolation( + violation + )), + ProtocolErrorV1::Core(CoreErrorV1::CompilerInvariantViolation(_)) + )); + } + } + + #[test] + fn serialized_stable_ids_are_sourced_from_core_keys() { + assert_eq!( + serde_json::to_string(&DomainIdV1::Srgb8NeutralAxis).unwrap(), + serde_json::to_string(CoreDomainIdV1::Srgb8NeutralAxis.key()).unwrap() + ); + assert_eq!( + serde_json::to_string(&ResourceProfileIdV1::Compile).unwrap(), + serde_json::to_string(CoreResourceProfileIdV1::Compile.key()).unwrap() + ); + let criteria = [ + Wcag22CriterionV1::Sc143TextDefault, + Wcag22CriterionV1::Sc143TextLargeScale, + Wcag22CriterionV1::Sc1411UiComponentOrState, + Wcag22CriterionV1::Sc1411GraphicalObject, + ]; + for (protocol, core) in criteria.into_iter().zip(CoreWcag22CriterionV1::ALL) { + assert_eq!( + serde_json::to_string(&protocol).unwrap(), + serde_json::to_string(core.key()).unwrap() + ); + } + assert_eq!( + serde_json::to_string(&Wcag22ProfileIdV1::Wcag22Srgb8Contrast).unwrap(), + serde_json::to_string(CoreWcag22ProfileIdV1::Wcag22Srgb8ContrastV1.key()).unwrap() + ); + assert_eq!( + serde_json::to_string(&NumericalArtifactIdV2::Wcag22Srgb8LuminanceQ55).unwrap(), + serde_json::to_string(CoreNumericalArtifactIdV2::Wcag22Srgb8LuminanceQ55V1.key()) + .unwrap() + ); + assert_eq!( + serde_json::to_string(&NumericalErrorBoundIdV2::Wcag22Srgb8OutwardQ55).unwrap(), + serde_json::to_string(CoreNumericalErrorBoundIdV2::Wcag22Srgb8OutwardQ55V1.key()) + .unwrap() + ); + assert_eq!( + serde_json::to_string(&NumericalProofIdV2::Wcag22Srgb8FullDomainQ55).unwrap(), + serde_json::to_string(CoreNumericalProofIdV2::Wcag22Srgb8FullDomainQ55V1.key()) + .unwrap() + ); + } + + #[test] + fn every_public_u64_error_field_serializes_as_exact_decimal_text() { + let resource = serde_json::to_value(CoreErrorV1::ResourceLimitExceeded { + profile_id: ResourceProfileIdV1::Compile, + dimension: ResourceDimensionV1::RawRelations, + requested: u64::MAX, + limit: 2_047, + }) + .unwrap(); + assert_eq!( + resource + .pointer("/details/requested") + .and_then(|v| v.as_str()), + Some("18446744073709551615") + ); + assert_eq!( + resource.pointer("/details/limit").and_then(|v| v.as_str()), + Some("2047") + ); + + let allocation = serde_json::to_value(CoreErrorV1::AllocationFailed { + profile_id: ResourceProfileIdV1::Compile, + requested_bytes: u64::MAX, + }) + .unwrap(); + assert_eq!( + allocation + .pointer("/details/requestedBytes") + .and_then(|v| v.as_str()), + Some("18446744073709551615") + ); + + let compiler = serde_json::to_value(CompilerInvariantV1::AssessmentCardinalityMismatch { + expected: u64::MAX, + observed: 1, + }) + .unwrap(); + assert_eq!( + compiler.get("expected").and_then(|v| v.as_str()), + Some("18446744073709551615") + ); + assert_eq!(compiler.get("observed").and_then(|v| v.as_str()), Some("1")); + } +} diff --git a/crates/labcolors-protocol/tests/protocol.rs b/crates/labcolors-protocol/tests/protocol.rs new file mode 100644 index 00000000..e4ef5301 --- /dev/null +++ b/crates/labcolors-protocol/tests/protocol.rs @@ -0,0 +1,461 @@ +use labcolors_protocol::{ + CoreErrorV1, CoreInvalidRequestV1, DomainIdV1, FeasibilityV1, MAX_ENVELOPE_BYTES_V1, + ProtocolErrorV1, ProtocolOutcomeV1, RelationV1, RequestV1, ResourceDimensionV1, + ResourceProfileIdV1, TransportErrorV1, Wcag22CriterionV1, encode_outcome_v1, encode_request_v1, + envelope_too_large_outcome_v1, evaluate_wcag22_feasibility_v1, +}; + +fn applicable( + id: &str, + occurrence: &str, + criterion: Wcag22CriterionV1, + adjacent: Vec<[u8; 3]>, +) -> RelationV1 { + RelationV1::applicable(id, occurrence, criterion, adjacent).expect("valid relation") +} + +fn not_applicable(id: &str, occurrence: &str, reason: &str) -> RelationV1 { + RelationV1::not_applicable(id, occurrence, reason).expect("valid declaration") +} + +fn request(relations: Vec) -> RequestV1 { + RequestV1::try_new( + DomainIdV1::Srgb8NeutralAxis, + relations, + ResourceProfileIdV1::Compile, + ) + .expect("valid request") +} + +fn evaluate(request: &RequestV1) -> ProtocolOutcomeV1 { + let encoded = encode_request_v1(request).expect("canonical request encoding"); + evaluate_wcag22_feasibility_v1(&encoded) +} + +fn evaluated(outcome: &ProtocolOutcomeV1) -> &labcolors_protocol::EvaluatedV1 { + outcome + .feasibility() + .and_then(FeasibilityV1::evaluated) + .expect("successful evaluated feasibility terminal") +} + +fn feasible_count(outcome: &ProtocolOutcomeV1) -> u32 { + evaluated(outcome) + .proof() + .partition() + .iter() + .map(|byte| byte.count_ones()) + .sum() +} + +#[test] +fn exact_oracle_counts_cross_the_protocol_without_adapter_math() { + let default = Wcag22CriterionV1::Sc143TextDefault; + let ui = Wcag22CriterionV1::Sc1411UiComponentOrState; + + let seven = evaluate(&request(vec![applicable( + "seven", + "occurrence", + default, + vec![[0x76; 3]], + )])); + assert!(seven.feasibility().is_some_and(FeasibilityV1::is_feasible)); + assert_eq!(feasible_count(&seven), 7); + + let two = evaluate(&request(vec![applicable( + "two", + "occurrence", + default, + vec![[0; 3], [255; 3]], + )])); + assert!(two.feasibility().is_some_and(FeasibilityV1::is_feasible)); + assert_eq!(feasible_count(&two), 2); + + let zero = evaluate(&request(vec![applicable( + "zero", + "occurrence", + default, + vec![[0; 3], [255; 3], [0x76; 3]], + )])); + assert!(zero.feasibility().is_some_and(FeasibilityV1::is_infeasible)); + assert_eq!(feasible_count(&zero), 0); + + let ninety_two = evaluate(&request(vec![applicable( + "ninety-two", + "occurrence", + ui, + vec![[0x76; 3]], + )])); + assert_eq!(feasible_count(&ninety_two), 92); + + let fifty_nine = evaluate(&request(vec![applicable( + "fifty-nine", + "occurrence", + ui, + vec![[0; 3], [255; 3]], + )])); + assert_eq!(feasible_count(&fifty_nine), 59); +} + +#[test] +fn all_three_ratio_three_criteria_share_the_exact_ninety_two_partition() { + let criteria = [ + Wcag22CriterionV1::Sc143TextLargeScale, + Wcag22CriterionV1::Sc1411UiComponentOrState, + Wcag22CriterionV1::Sc1411GraphicalObject, + ]; + let outcomes: Vec<_> = criteria + .into_iter() + .map(|criterion| { + evaluate(&request(vec![applicable( + "ratio-three", + "occurrence", + criterion, + vec![[0x76; 3]], + )])) + }) + .collect(); + for outcome in &outcomes { + assert_eq!(feasible_count(outcome), 92); + } + assert_eq!( + evaluated(&outcomes[0]).proof().partition(), + evaluated(&outcomes[1]).proof().partition() + ); + assert_eq!( + evaluated(&outcomes[1]).proof().partition(), + evaluated(&outcomes[2]).proof().partition() + ); +} + +#[test] +fn mixed_and_all_not_applicable_keep_declarations_without_fabricated_evidence() { + let mixed = evaluate(&request(vec![ + applicable( + "applicable", + "shared-occurrence", + Wcag22CriterionV1::Sc143TextDefault, + vec![[0x76; 3]], + ), + not_applicable("declared-na", "shared-occurrence", "client-reason"), + ])); + let mixed_value = evaluated(&mixed); + assert_eq!(mixed_value.relations().len(), 2); + assert_eq!(mixed_value.proof().applicable_relations(), 1); + assert_eq!(mixed_value.proof().not_applicable_relations(), 1); + + let all_na = evaluate(&request(vec![not_applicable( + "declared-na", + "occurrence", + "client-reason", + )])); + let declaration = all_na + .feasibility() + .and_then(FeasibilityV1::not_evaluated) + .unwrap_or_else(|| panic!("expected successful NotEvaluated, got {all_na:?}")); + assert_eq!(declaration.relations().len(), 1); +} + +#[test] +fn conflicting_relation_and_resource_rejection_remain_typed() { + let conflicting = evaluate(&request(vec![ + applicable( + "same-id", + "first", + Wcag22CriterionV1::Sc143TextDefault, + vec![[0; 3]], + ), + applicable( + "same-id", + "second", + Wcag22CriterionV1::Sc143TextDefault, + vec![[0; 3]], + ), + ])); + assert!(matches!( + conflicting.error(), + Some(ProtocolErrorV1::Core(CoreErrorV1::InvalidRequest( + CoreInvalidRequestV1::ConflictingRelationId { relation_id } + ))) if relation_id == "same-id" + )); + + let repeated = applicable( + "duplicate", + "occurrence", + Wcag22CriterionV1::Sc143TextDefault, + vec![[0; 3]], + ); + let too_many = evaluate(&request(vec![repeated; 2_048])); + assert!(matches!( + too_many.error(), + Some(ProtocolErrorV1::Core(CoreErrorV1::ResourceLimitExceeded { + profile_id: ResourceProfileIdV1::Compile, + dimension: ResourceDimensionV1::RawRelations, + requested: 2_048, + limit: 2_047, + })) + )); +} + +#[test] +fn opaque_identity_changes_identity_but_not_physical_decisions() { + let first = evaluate(&request(vec![applicable( + "first-id", + "occurrence", + Wcag22CriterionV1::Sc143TextDefault, + vec![[0x76; 3]], + )])); + let second = evaluate(&request(vec![applicable( + "second-id", + "occurrence", + Wcag22CriterionV1::Sc143TextDefault, + vec![[0x76; 3]], + )])); + let first = evaluated(&first); + let second = evaluated(&second); + assert_eq!(first.failure_matrix(), second.failure_matrix()); + assert_eq!(first.proof().partition(), second.proof().partition()); + assert_eq!( + first.proof().matrix_digest(), + second.proof().matrix_digest() + ); + assert_ne!( + first.proof().relation_set_digest(), + second.proof().relation_set_digest() + ); + assert_ne!( + first.proof().evaluation_id(), + second.proof().evaluation_id() + ); +} + +#[test] +fn evaluated_wire_contains_only_canonical_relations_and_packed_evidence() { + let outcome = evaluate(&request(vec![applicable( + "packed", + "occurrence", + Wcag22CriterionV1::Sc143TextDefault, + vec![[0; 3], [255; 3]], + )])); + let value = evaluated(&outcome); + let edges = value.proof().applicable_edges() as usize; + assert_eq!(value.failure_matrix().len(), 32 * edges); + assert_eq!(value.proof().partition().len(), 32); + assert_eq!(value.proof().logical_assessments(), 256 * edges as u64); + assert_eq!(value.domain().len(), 256); + assert_eq!(value.domain().first(), Some(&[0, 0, 0])); + assert_eq!(value.domain().last(), Some(&[255, 255, 255])); + + let canonical = encode_outcome_v1(&outcome).expect("canonical outcome encoding"); + let json: serde_json::Value = + serde_json::from_slice(&canonical).expect("canonical outcome JSON"); + let object = json.as_object().expect("tagged outcome object"); + assert_eq!( + object.get("schemaVersion").and_then(|value| value.as_u64()), + Some(1) + ); + assert_eq!( + object.get("outcome").and_then(|value| value.as_str()), + Some("success") + ); + assert!(object.get("error").is_none()); + assert_eq!( + object + .get("feasibility") + .and_then(|value| value.get("status")) + .and_then(|value| value.as_str()), + Some("feasible") + ); + let encoded = json.to_string(); + assert_eq!(encoded.matches("\"domain\":").count(), 1); + for forbidden in [ + "feasibleCandidates", + "infeasibleCandidates", + "cells", + "assessments", + ] { + assert!( + !encoded.contains(forbidden), + "forbidden proportional view: {forbidden}" + ); + } + + let proof = object + .get("feasibility") + .and_then(|value| value.get("result")) + .and_then(|value| value.get("proof")) + .expect("evaluated proof"); + for field in [ + "domainCount", + "canonicalRelations", + "applicableRelations", + "notApplicableRelations", + "applicableEdges", + "logicalAssessments", + ] { + assert!( + proof.get(field).is_some_and(serde_json::Value::is_string), + "{field} must be an exact decimal string" + ); + } + + assert_eq!( + canonical, + encode_outcome_v1(&outcome).expect("canonical encoding is byte-stable") + ); +} + +#[test] +fn errors_are_failure_outcomes_not_a_fourth_feasibility_terminal() { + let outcome = evaluate_wcag22_feasibility_v1(br#"{}"#); + assert!(outcome.feasibility().is_none()); + assert!(outcome.error().is_some()); + + let canonical = encode_outcome_v1(&outcome).expect("canonical failure encoding"); + let json: serde_json::Value = + serde_json::from_slice(&canonical).expect("canonical failure JSON"); + assert_eq!( + json.get("schemaVersion").and_then(|value| value.as_u64()), + Some(1) + ); + assert_eq!( + json.get("outcome").and_then(|value| value.as_str()), + Some("failure") + ); + assert!(json.get("error").is_some()); + assert!(json.get("feasibility").is_none()); +} + +#[test] +fn schema_and_input_failures_are_strict_and_typed() { + let cases = [ + ( + br#"{"schemaVersion":2,"domainId":"srgb8-neutral-axis-v1","resourceProfileId":"compile-v1","relations":[]}"#.as_slice(), + "version", + ), + ( + br#"{"schemaVersion":1,"domainId":"other","resourceProfileId":"compile-v1","relations":[]}"#.as_slice(), + "domain", + ), + ( + br#"{"schemaVersion":1,"domainId":"srgb8-neutral-axis-v1","resourceProfileId":"other","relations":[]}"#.as_slice(), + "profile", + ), + ( + br#"{"schemaVersion":1,"domainId":"srgb8-neutral-axis-v1","resourceProfileId":"compile-v1","relations":[{"relationId":"r","occurrenceId":"o","kind":"applicable","criterion":"other","adjacent":[[0,0,0]]}]}"#.as_slice(), + "criterion", + ), + ]; + for (raw, expected) in cases { + let outcome = evaluate_wcag22_feasibility_v1(raw); + let error = outcome.error().expect("typed error"); + match (expected, error) { + ( + "version", + ProtocolErrorV1::Transport(TransportErrorV1::UnsupportedSchemaVersion { .. }), + ) + | ( + "domain", + ProtocolErrorV1::Transport(TransportErrorV1::UnsupportedDomainId { .. }), + ) + | ( + "profile", + ProtocolErrorV1::Transport(TransportErrorV1::UnsupportedResourceProfileId { + .. + }), + ) + | ( + "criterion", + ProtocolErrorV1::Transport(TransportErrorV1::UnsupportedCriterion { .. }), + ) => {} + _ => panic!("unexpected {expected} error: {error:?}"), + } + } + + for raw in [ + br#"{"schemaVersion":1,"domainId":"srgb8-neutral-axis-v1","resourceProfileId":"compile-v1","relations":[],"extra":true}"#.as_slice(), + br#"{"schemaVersion":1,"schemaVersion":1,"domainId":"srgb8-neutral-axis-v1","resourceProfileId":"compile-v1","relations":[]}"#.as_slice(), + br#"{"schemaVersion":1,"domainId":"srgb8-neutral-axis-v1","resourceProfileId":"compile-v1","relations":[{"relationId":"r","occurrenceId":"o","kind":"applicable","criterion":"sc-1.4.3-text-default","adjacent":[[0,0,0]],"extra":true}]}"#.as_slice(), + br#"{"schemaVersion":1,"domainId":"srgb8-neutral-axis-v1","resourceProfileId":"compile-v1","relations":[{"relationId":"r","relationId":"r","occurrenceId":"o","kind":"applicable","criterion":"sc-1.4.3-text-default","adjacent":[[0,0,0]]}]}"#.as_slice(), + br#"{"schemaVersion":1,"domainId":"srgb8-neutral-axis-v1","resourceProfileId":"compile-v1","relations":[{"relationId":"r","occurrenceId":"o","kind":"applicable","criterion":"sc-1.4.3-text-default","adjacent":[[0,0]]}]}"#.as_slice(), + br#"{"schemaVersion":1,"domainId":"srgb8-neutral-axis-v1","resourceProfileId":"compile-v1","relations":[{"relationId":"r","occurrenceId":"o","kind":"applicable","criterion":"sc-1.4.3-text-default","adjacent":[[256,0,0]]}]}"#.as_slice(), + ] { + assert!(matches!( + evaluate_wcag22_feasibility_v1(raw).error(), + Some(ProtocolErrorV1::Transport(TransportErrorV1::MalformedEnvelope { .. })) + )); + } + + assert!(matches!( + evaluate_wcag22_feasibility_v1(&[0xff]).error(), + Some(ProtocolErrorV1::Transport(TransportErrorV1::InvalidUtf8)) + )); +} + +#[test] +fn exact_compact_ceiling_is_derived_and_attainable() { + const RELATIONS: usize = 2_047; + const OPAQUE_BYTES: usize = 65_536; + const ESCAPE_SIX_ALPHABET: [u8; 27] = [ + 0, 1, 2, 3, 4, 5, 6, 7, 11, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, + 30, 31, + ]; + + let relation_id_bytes = RELATIONS * 3; + let first_occurrence_bytes = OPAQUE_BYTES - relation_id_bytes - (RELATIONS - 1); + let mut relations = Vec::with_capacity(RELATIONS); + for index in 0..RELATIONS { + let id = String::from_utf8(vec![ + ESCAPE_SIX_ALPHABET[(index / (27 * 27)) % 27], + ESCAPE_SIX_ALPHABET[(index / 27) % 27], + ESCAPE_SIX_ALPHABET[index % 27], + ]) + .expect("ASCII controls are valid UTF-8"); + let occurrence = if index == 0 { + "\0".repeat(first_occurrence_bytes) + } else { + "\0".to_string() + }; + relations.push(applicable( + &id, + &occurrence, + Wcag22CriterionV1::Sc1411UiComponentOrState, + vec![[255; 3]], + )); + } + let encoded = encode_request_v1(&request(relations)).expect("exact-limit encoding"); + assert_eq!(encoded.len() as u64, MAX_ENVELOPE_BYTES_V1); + + let at_limit_outcome = evaluate_wcag22_feasibility_v1(&encoded); + assert!( + at_limit_outcome + .feasibility() + .is_some_and(FeasibilityV1::is_feasible), + "the generated exact-limit maximizer must decode to a complete non-empty partition" + ); + + let mut over = encoded; + over.push(b' '); + let over_outcome = evaluate_wcag22_feasibility_v1(&over); + assert!(matches!( + over_outcome.error(), + Some(ProtocolErrorV1::Transport(TransportErrorV1::EnvelopeTooLarge { + requested_bytes, + limit_bytes: MAX_ENVELOPE_BYTES_V1, + })) if *requested_bytes == MAX_ENVELOPE_BYTES_V1 + 1 + )); + let encoded_error = encode_outcome_v1(&over_outcome).expect("canonical limit failure"); + assert_eq!( + encoded_error, + encode_outcome_v1(&envelope_too_large_outcome_v1(MAX_ENVELOPE_BYTES_V1 + 1,)) + .expect("host-preflight limit failure") + ); + assert_eq!( + String::from_utf8(encoded_error).expect("UTF-8 JSON"), + concat!( + "{\"schemaVersion\":1,\"outcome\":\"failure\",\"error\":", + "{\"source\":\"transport\",\"error\":{\"code\":\"envelopeTooLarge\",", + "\"requestedBytes\":\"657381\",\"limitBytes\":\"657380\"}}}" + ) + ); +} diff --git a/crates/labcolors-wasm/Cargo.toml b/crates/labcolors-wasm/Cargo.toml index 0e843828..ce030846 100644 --- a/crates/labcolors-wasm/Cargo.toml +++ b/crates/labcolors-wasm/Cargo.toml @@ -15,12 +15,14 @@ description = "WASM bindings for the labcolors-core contrast engine, packaged as crate-type = ["cdylib", "rlib"] [dependencies] -# Compile only capabilities this adapter projects. A core capability is enabled -# here atomically with its public transport and conformance surface. +# Direct Core use stays isolated from optional compiler capabilities. The +# versioned protocol crate is the single owner of WCAG22 feasibility. labcolors-core = { path = "../labcolors-core", default-features = false } +labcolors-protocol = { path = "../labcolors-protocol" } wasm-bindgen = { workspace = true } -# js-sys ships with the wasm-bindgen toolchain (no new third-party tree); used -# to build the result object at the boundary without a serde dependency. +# js-sys ships with the wasm-bindgen toolchain (no new third-party tree). It is +# used by existing JavaScript boundary code and to parse protocol-owned JSON; +# the protocol crate, not this adapter, owns serde projection. js-sys = "0.3" # Derive-only: thiserror is a proc-macro with no runtime code, so it adds # nothing to the WASM bundle while giving matchable, well-described errors. diff --git a/crates/labcolors-wasm/src/lib.rs b/crates/labcolors-wasm/src/lib.rs index 3388dc42..b8a5c803 100644 --- a/crates/labcolors-wasm/src/lib.rs +++ b/crates/labcolors-wasm/src/lib.rs @@ -499,6 +499,222 @@ export interface Wcag22AssessmentV1 { }; } +/** + * Exact non-negative `u64` emitted as canonical decimal JSON text. + * + * Output-only branding prevents TypeScript from pretending that an arbitrary + * integer-looking string has passed the Rust range/canonicality check. + */ +declare const decimalU64V1Brand: unique symbol; +export type DecimalU64V1 = string & { + readonly [decimalU64V1Brand]: "DecimalU64V1"; +}; + +/** One exact final encoded-sRGB8 colour. */ +export type Srgb8BytesV1 = readonly [number, number, number]; + +/** One exact SHA-256 digest or 256-bit LSB0 partition. */ +export type Bytes32V1 = readonly [ + number, number, number, number, number, number, number, number, + number, number, number, number, number, number, number, number, + number, number, number, number, number, number, number, number, + number, number, number, number, number, number, number, number, +]; + +export interface Wcag22FeasibilityApplicableRelationV1 { + readonly relationId: string; + readonly occurrenceId: string; + readonly kind: "applicable"; + readonly criterion: Wcag22CriterionV1; + readonly adjacent: ReadonlyArray; +} + +export interface Wcag22FeasibilityNotApplicableRelationV1 { + readonly relationId: string; + readonly occurrenceId: string; + readonly kind: "notApplicable"; + readonly reasonId: string; +} + +export type Wcag22FeasibilityRelationV1 = + | Wcag22FeasibilityApplicableRelationV1 + | Wcag22FeasibilityNotApplicableRelationV1; + +/** Strict decoded form of the UTF-8 JSON accepted by the byte API. */ +export interface Wcag22FeasibilityRequestV1 { + readonly schemaVersion: 1; + readonly domainId: "srgb8-neutral-axis-v1"; + readonly resourceProfileId: "compile-v1"; + readonly relations: ReadonlyArray; +} + +export interface Wcag22FeasibilityProofV1 { + readonly evaluationId: Bytes32V1; + readonly resourceProfileId: "compile-v1"; + readonly domainId: "srgb8-neutral-axis-v1"; + readonly domainDigest: Bytes32V1; + readonly domainCount: DecimalU64V1; + readonly domainFirst: Srgb8BytesV1; + readonly domainLast: Srgb8BytesV1; + readonly relationSetDigest: Bytes32V1; + readonly canonicalRelations: DecimalU64V1; + readonly applicableRelations: DecimalU64V1; + readonly notApplicableRelations: DecimalU64V1; + readonly applicableEdges: DecimalU64V1; + readonly logicalAssessments: DecimalU64V1; + readonly matrixDigest: Bytes32V1; + /** Exact 256-bit candidate partition, candidate-index LSB0. */ + readonly partition: Bytes32V1; + readonly wcag22ProfileId: "wcag22-srgb8-contrast-v1"; + readonly artifactId: "wcag22-srgb8-luminance-q55-v1"; + readonly boundId: "wcag22-srgb8-outward-q55-v1"; + readonly proofId: "wcag22-srgb8-full-domain-q55-v1"; + readonly proofSha256: Bytes32V1; +} + +export interface Wcag22FeasibilityEvaluatedV1 { + /** The complete registered domain in Core-owned candidate order, once. */ + readonly domain: ReadonlyArray; + /** Canonical declarations, once; no per-cell relation duplication. */ + readonly relations: ReadonlyArray; + /** Candidate-major failure bits at `candidate * E + edge`, packed LSB0. */ + readonly failureMatrix: ReadonlyArray; + readonly proof: Wcag22FeasibilityProofV1; +} + +export interface Wcag22FeasibilityNotEvaluatedResultV1 { + readonly domainId: "srgb8-neutral-axis-v1"; + readonly domainDigest: Bytes32V1; + readonly relationSetDigest: Bytes32V1; + readonly resourceProfileId: "compile-v1"; + readonly relations: ReadonlyArray; +} + +export type Wcag22FeasibilityV1 = + | { readonly status: "feasible"; readonly result: Wcag22FeasibilityEvaluatedV1 } + | { readonly status: "infeasible"; readonly result: Wcag22FeasibilityEvaluatedV1 } + | { readonly status: "notEvaluated"; readonly result: Wcag22FeasibilityNotEvaluatedResultV1 }; + +export type Wcag22FeasibilityTransportErrorV1 = + | { + readonly code: "envelopeTooLarge"; + readonly requestedBytes: DecimalU64V1; + readonly limitBytes: DecimalU64V1; + } + | { readonly code: "invalidUtf8" } + | { + readonly code: "malformedEnvelope"; + readonly class: "syntax" | "shape" | "endOfInput" | "io"; + } + | { readonly code: "unsupportedSchemaVersion"; readonly received: number } + | { readonly code: "unsupportedDomainId"; readonly received: string } + | { readonly code: "unsupportedResourceProfileId"; readonly received: string } + | { readonly code: "unsupportedCriterion"; readonly received: string } + | { readonly code: "emptyNotApplicableReason" }; + +export type Wcag22FeasibilityInvalidRequestV1 = + | { readonly code: "emptyRelationId" } + | { readonly code: "emptyOccurrenceId" } + | { readonly code: "emptyRelations" } + | { readonly code: "emptyAdjacentSet"; readonly relationId: string } + | { readonly code: "conflictingRelationId"; readonly relationId: string } + | { readonly code: "arithmeticOverflow" }; + +export type Wcag22FeasibilityAtomicErrorV1 = + | { readonly code: "invalidSrgb8"; readonly field: string; readonly reason: string } + | { readonly code: "emptyNotApplicableReason" } + | { + readonly code: "artifactInvariantViolation"; + readonly criterion: Wcag22CriterionV1; + readonly foreground: Srgb8BytesV1; + readonly background: Srgb8BytesV1; + } + | { readonly code: "evidenceRegistryMismatch"; readonly message: string }; + +export type Wcag22FeasibilityEvaluatorInvariantV1 = + | { readonly code: "source"; readonly details: Wcag22FeasibilityAtomicErrorV1 } + | { readonly code: "unexpectedNotEvaluated" } + | { readonly code: "inputMismatch" } + | { readonly code: "criterionMismatch" } + | { readonly code: "evidenceMismatch" }; + +export type Wcag22FeasibilityCompilerInvariantV1 = + | { readonly code: "layoutMismatch" } + | { + readonly code: "assessmentCardinalityMismatch"; + readonly expected: DecimalU64V1; + readonly observed: DecimalU64V1; + } + | { + readonly code: "candidateCardinalityMismatch"; + readonly expected: DecimalU64V1; + readonly observed: DecimalU64V1; + } + | { readonly code: "decisionStorageRejectedCell" } + | { readonly code: "decisionStorageRejectedPartition" } + | { readonly code: "completeResultMismatch" }; + +export type Wcag22FeasibilityResourceDimensionV1 = + | "rawRelations" + | "rawAdjacentEntries" + | "opaqueUtf8Bytes" + | "canonicalRelations" + | "applicableEdges" + | "logicalAssessments" + | "packedResultBytes"; + +export type Wcag22FeasibilityCoreErrorV1 = + | { + readonly code: "invalidRequest"; + readonly details: Wcag22FeasibilityInvalidRequestV1; + } + | { + readonly code: "resourceLimitExceeded"; + readonly details: { + readonly profileId: "compile-v1"; + readonly dimension: Wcag22FeasibilityResourceDimensionV1; + readonly requested: DecimalU64V1; + readonly limit: DecimalU64V1; + }; + } + | { + readonly code: "allocationFailed"; + readonly details: { + readonly profileId: "compile-v1"; + readonly requestedBytes: DecimalU64V1; + }; + } + | { + readonly code: "evaluatorInvariantViolation"; + readonly details: { + readonly candidate: Srgb8BytesV1; + readonly relationId: string; + readonly adjacent: Srgb8BytesV1; + readonly violation: Wcag22FeasibilityEvaluatorInvariantV1; + }; + } + | { + readonly code: "compilerInvariantViolation"; + readonly details: Wcag22FeasibilityCompilerInvariantV1; + }; + +export type Wcag22FeasibilityProtocolErrorV1 = + | { readonly source: "transport"; readonly error: Wcag22FeasibilityTransportErrorV1 } + | { readonly source: "core"; readonly error: Wcag22FeasibilityCoreErrorV1 } + | { readonly source: "incompatibleCoreContract" }; + +export type Wcag22FeasibilityOutcomeV1 = + | { + readonly schemaVersion: 1; + readonly outcome: "success"; + readonly feasibility: Wcag22FeasibilityV1; + } + | { + readonly schemaVersion: 1; + readonly outcome: "failure"; + readonly error: Wcag22FeasibilityProtocolErrorV1; + }; + /** The full result of resolving one background under one theme. */ export interface ResolvedTheme { readonly theme: ThemeName; @@ -530,6 +746,9 @@ extern "C" { #[wasm_bindgen(typescript_type = "Wcag22AssessmentV1")] pub type JsWcag22Assessment; + + #[wasm_bindgen(typescript_type = "Wcag22FeasibilityOutcomeV1")] + pub type JsWcag22FeasibilityOutcomeV1; } /// Единственный public numerical capability manifest: proof-capable V2. @@ -586,6 +805,61 @@ pub fn evaluate_wcag22( Ok(parsed.unchecked_into()) } +const WASM_MAX_ENVELOPE_BYTES_V1: u32 = { + assert!(labcolors_protocol::MAX_ENVELOPE_BYTES_V1 <= u32::MAX as u64); + labcolors_protocol::MAX_ENVELOPE_BYTES_V1 as u32 +}; + +/// Exact protocol-owned ceiling exposed as a JavaScript `number`. +#[wasm_bindgen(js_name = wcag22FeasibilityMaxRequestBytesV1)] +pub fn wcag22_feasibility_max_request_bytes_v1() -> u32 { + WASM_MAX_ENVELOPE_BYTES_V1 +} + +/// Evaluate one exact V1 UTF-8 JSON byte envelope. +/// +/// Transport and Core failures are returned inside the typed outcome. Only an +/// impossible canonical-encoding/JSON.parse failure rejects with `JsError`. +#[wasm_bindgen(js_name = evaluateWcag22FeasibilityV1)] +pub fn evaluate_wcag22_feasibility_v1( + request: &[u8], +) -> Result { + protocol_outcome_to_js(labcolors_protocol::evaluate_wcag22_feasibility_v1(request)) +} + +/// Build the same canonical typed oversize failure without copying the raw +/// request into WebAssembly. The package-root host wrapper calls this scalar +/// helper after its avoidable-copy preflight; Rust still rechecks raw calls. +#[wasm_bindgen(js_name = wcag22FeasibilityEnvelopeTooLargeV1)] +pub fn wcag22_feasibility_envelope_too_large_v1( + requested_bytes: u64, +) -> Result { + protocol_outcome_to_js(labcolors_protocol::envelope_too_large_outcome_v1( + requested_bytes, + )) +} + +fn protocol_outcome_to_js( + outcome: labcolors_protocol::ProtocolOutcomeV1, +) -> Result { + let encoded = labcolors_protocol::encode_outcome_v1(&outcome).map_err(|error| { + to_js_error(BindingError::Internal { + reason: format!("WCAG22 feasibility protocol encoding failed: {error}"), + }) + })?; + let json = std::str::from_utf8(&encoded).map_err(|_| { + to_js_error(BindingError::Internal { + reason: "WCAG22 feasibility protocol emitted non-UTF-8 JSON".to_string(), + }) + })?; + let parsed = js_sys::JSON::parse(json).map_err(|_| { + to_js_error(BindingError::Internal { + reason: "WCAG22 feasibility protocol JSON did not parse".to_string(), + }) + })?; + Ok(parsed.unchecked_into()) +} + /// A contrast engine over a consumer-supplied design system. Construct with /// [`LabColors::new`], load a config with [`loadConfig`](LabColors::load_config), /// then call [`resolve_theme`](LabColors::resolve_theme) many times; identical diff --git a/crates/labcolors-wasm/tests/wasm_parity.rs b/crates/labcolors-wasm/tests/wasm_parity.rs index 245b04bf..27903fc1 100644 --- a/crates/labcolors-wasm/tests/wasm_parity.rs +++ b/crates/labcolors-wasm/tests/wasm_parity.rs @@ -12,13 +12,16 @@ use labcolors_conformance::{ AlphaVector, ContrastVector, DRIFT_TOL, LadderVector, Manifest, MuddinessVector, Pack, - SolveOutcome, SolveVector, Wcag22Vector, + SolveOutcome, SolveVector, Wcag22FeasibilityVector, Wcag22Vector, }; use labcolors_core::config::ThemeConfig; use labcolors_core::semantic::NamedRoleTable; use labcolors_core::{BgInput, Resolved, ViewingConditions, resolve_named_set}; -use labcolors_wasm::LabColors; use labcolors_wasm::config_dto::ConfigDto; +use labcolors_wasm::{ + LabColors, evaluate_wcag22_feasibility_v1, wcag22_feasibility_envelope_too_large_v1, + wcag22_feasibility_max_request_bytes_v1, +}; use wasm_bindgen::JsValue; use wasm_bindgen_test::*; @@ -94,6 +97,75 @@ fn get_bool(obj: &JsValue, key: &str) -> bool { .unwrap_or_else(|| panic!("{key} must be a boolean")) } +fn get_array(obj: &JsValue, key: &str) -> js_sys::Array { + js_sys::Array::from(&get_obj(obj, key)) +} + +fn json_text(value: &JsValue) -> String { + js_sys::JSON::stringify(value) + .expect("value is JSON-serializable") + .as_string() + .expect("JSON.stringify returns text") +} + +fn protocol_request(relations: Vec) -> Vec { + let request = labcolors_protocol::RequestV1::try_new( + labcolors_protocol::DomainIdV1::Srgb8NeutralAxis, + relations, + labcolors_protocol::ResourceProfileIdV1::Compile, + ) + .expect("protocol request is locally valid"); + labcolors_protocol::encode_request_v1(&request).expect("canonical request encoding") +} + +fn applicable_relation( + relation_id: &str, + occurrence_id: &str, + criterion: labcolors_protocol::Wcag22CriterionV1, + adjacent: Vec<[u8; 3]>, +) -> labcolors_protocol::RelationV1 { + labcolors_protocol::RelationV1::applicable(relation_id, occurrence_id, criterion, adjacent) + .expect("applicable relation is locally valid") +} + +fn not_applicable_relation( + relation_id: &str, + occurrence_id: &str, + reason_id: &str, +) -> labcolors_protocol::RelationV1 { + labcolors_protocol::RelationV1::not_applicable(relation_id, occurrence_id, reason_id) + .expect("NotApplicable relation is locally valid") +} + +fn evaluate_protocol_bytes(raw: &[u8]) -> JsValue { + evaluate_wcag22_feasibility_v1(raw) + .expect("canonical protocol projection cannot fail") + .into() +} + +fn feasibility_result(outcome: &JsValue, expected_status: &str) -> JsValue { + assert_eq!(get_str(outcome, "outcome").as_deref(), Some("success")); + let feasibility = get_obj(outcome, "feasibility"); + assert_eq!( + get_str(&feasibility, "status").as_deref(), + Some(expected_status) + ); + get_obj(&feasibility, "result") +} + +fn partition_count(outcome: &JsValue, expected_status: &str) -> u32 { + let result = feasibility_result(outcome, expected_status); + let proof = get_obj(&result, "proof"); + get_array(&proof, "partition") + .iter() + .map(|value| { + let byte = value.as_f64().expect("packed byte is numeric"); + assert!(byte.fract() == 0.0 && (0.0..=255.0).contains(&byte)); + (byte as u8).count_ones() + }) + .sum() +} + /// Read the `message` of a rejected `JsError`. A `JsError` crosses as a JS /// `Error` object, so the human text (carrying our stable code) is its /// `.message` property, not the value's own string form. @@ -214,6 +286,22 @@ fn committed_conformance_pack_replays_in_wasm32() { assert_eq!(actual, committed, "WCAG22 finite assessment drift"); } + let wcag22_feasibility: Vec = serde_json::from_str(include_str!( + "../../../conformance/vectors/wcag22-feasibility.json" + )) + .unwrap(); + assert_eq!(wcag22_feasibility.len(), fresh.wcag22_feasibility.len()); + for (committed, actual) in wcag22_feasibility.iter().zip(&fresh.wcag22_feasibility) { + assert_eq!(actual, committed, "feasibility protocol fixture drift"); + let projected = evaluate_protocol_bytes(committed.request_json.as_bytes()); + assert_eq!( + json_text(&projected), + committed.outcome_json, + "{}: public WASM protocol projection drift", + committed.case_id + ); + } + let committed_manifest: Manifest = serde_json::from_str(include_str!("../../../conformance/vectors/manifest.json")).unwrap(); let fresh_manifest = fresh.manifest(); @@ -229,7 +317,8 @@ fn committed_conformance_pack_replays_in_wasm32() { + alpha.len() + solve.len() + muddiness.len() - + wcag22.len(); + + wcag22.len() + + wcag22_feasibility.len(); assert_eq!( committed_manifest.counts.total, replayed_total, "manifest total must equal every replayed committed family" @@ -762,3 +851,232 @@ fn config_boundary_two_configs_diverge() { .unwrap_or_default(); assert!(msg.contains("invalid_config"), "код в сообщении: {msg}"); } + +#[wasm_bindgen_test] +fn feasibility_exact_oracle_counts_cross_the_wasm_boundary() { + use labcolors_protocol::Wcag22CriterionV1::{Sc143TextDefault, Sc1411UiComponentOrState}; + let cases = [ + (Sc143TextDefault, vec![[0x76; 3]], "feasible", 7), + (Sc143TextDefault, vec![[0; 3], [255; 3]], "feasible", 2), + ( + Sc143TextDefault, + vec![[0; 3], [255; 3], [0x76; 3]], + "infeasible", + 0, + ), + (Sc1411UiComponentOrState, vec![[0x76; 3]], "feasible", 92), + ( + Sc1411UiComponentOrState, + vec![[0; 3], [255; 3]], + "feasible", + 59, + ), + ]; + for (index, (criterion, adjacent, status, expected)) in cases.into_iter().enumerate() { + let raw = protocol_request(vec![applicable_relation( + &format!("relation-{index}"), + "occurrence", + criterion, + adjacent, + )]); + let outcome = evaluate_protocol_bytes(&raw); + assert_eq!(partition_count(&outcome, status), expected, "case {index}"); + + let result = feasibility_result(&outcome, status); + let domain = get_array(&result, "domain"); + assert_eq!(domain.length(), 256, "Core-owned domain crosses once"); + assert_eq!(json_text(&domain.get(0)), "[0,0,0]"); + assert_eq!(json_text(&domain.get(255)), "[255,255,255]"); + let proof = get_obj(&result, "proof"); + let edges = get_str(&proof, "applicableEdges") + .expect("exact decimal edge count") + .parse::() + .expect("edge count parses"); + assert_eq!(get_array(&result, "failureMatrix").length(), 32 * edges); + assert_eq!(get_array(&proof, "partition").length(), 32); + let wire = json_text(&outcome); + for forbidden in [ + "feasibleCandidates", + "infeasibleCandidates", + "cells", + "assessments", + ] { + assert!( + !wire.contains(forbidden), + "forbidden proportional field {forbidden}" + ); + } + } +} + +#[wasm_bindgen_test] +fn feasibility_preserves_mixed_and_declaration_only_terminals() { + let mixed = protocol_request(vec![ + applicable_relation( + "applicable", + "shared-occurrence", + labcolors_protocol::Wcag22CriterionV1::Sc143TextDefault, + vec![[0x76; 3]], + ), + not_applicable_relation("declared-na", "shared-occurrence", "client-reason"), + ]); + let mixed = evaluate_protocol_bytes(&mixed); + let mixed_result = feasibility_result(&mixed, "feasible"); + assert_eq!(get_array(&mixed_result, "relations").length(), 2); + let proof = get_obj(&mixed_result, "proof"); + assert_eq!(get_str(&proof, "applicableRelations").as_deref(), Some("1")); + assert_eq!( + get_str(&proof, "notApplicableRelations").as_deref(), + Some("1") + ); + + let declarations = protocol_request(vec![not_applicable_relation( + "declared-na", + "occurrence", + "client-reason", + )]); + let declarations = evaluate_protocol_bytes(&declarations); + let result = feasibility_result(&declarations, "notEvaluated"); + assert_eq!(get_array(&result, "relations").length(), 1); + assert!( + js_sys::Reflect::get(&result, &JsValue::from_str("proof")) + .expect("property lookup") + .is_undefined() + ); + assert!( + js_sys::Reflect::get(&result, &JsValue::from_str("failureMatrix")) + .expect("property lookup") + .is_undefined() + ); + assert!( + js_sys::Reflect::get(&result, &JsValue::from_str("domain")) + .expect("property lookup") + .is_undefined() + ); +} + +fn assert_failure_code(outcome: &JsValue, source: &str, code: &str) -> JsValue { + assert_eq!(get_str(outcome, "outcome").as_deref(), Some("failure")); + assert!( + js_sys::Reflect::get(outcome, &JsValue::from_str("feasibility")) + .expect("property lookup") + .is_undefined() + ); + let error = get_obj(outcome, "error"); + assert_eq!(get_str(&error, "source").as_deref(), Some(source)); + let detail = get_obj(&error, "error"); + assert_eq!(get_str(&detail, "code").as_deref(), Some(code)); + detail +} + +#[wasm_bindgen_test] +fn feasibility_transport_and_core_failures_are_typed_data() { + let invalid_utf8 = evaluate_protocol_bytes(&[0xff]); + assert_failure_code(&invalid_utf8, "transport", "invalidUtf8"); + + for malformed in [ + br#"{"schemaVersion":1,"domainId":"srgb8-neutral-axis-v1","resourceProfileId":"compile-v1","relations":[],"unknown":true}"#.as_slice(), + br#"{"schemaVersion":1,"schemaVersion":1,"domainId":"srgb8-neutral-axis-v1","resourceProfileId":"compile-v1","relations":[]}"#.as_slice(), + ] { + let outcome = evaluate_protocol_bytes(malformed); + assert_failure_code(&outcome, "transport", "malformedEnvelope"); + } + + let conflict = protocol_request(vec![ + applicable_relation( + "same-id", + "first", + labcolors_protocol::Wcag22CriterionV1::Sc143TextDefault, + vec![[0; 3]], + ), + applicable_relation( + "same-id", + "second", + labcolors_protocol::Wcag22CriterionV1::Sc143TextDefault, + vec![[0; 3]], + ), + ]); + let conflict = evaluate_protocol_bytes(&conflict); + let error = assert_failure_code(&conflict, "core", "invalidRequest"); + let details = get_obj(&error, "details"); + assert_eq!( + get_str(&details, "code").as_deref(), + Some("conflictingRelationId") + ); + assert_eq!(get_str(&details, "relationId").as_deref(), Some("same-id")); + + let repeated = applicable_relation( + "duplicate", + "occurrence", + labcolors_protocol::Wcag22CriterionV1::Sc143TextDefault, + vec![[0; 3]], + ); + let resource = protocol_request(vec![repeated; 2_048]); + let resource = evaluate_protocol_bytes(&resource); + let error = assert_failure_code(&resource, "core", "resourceLimitExceeded"); + let details = get_obj(&error, "details"); + assert_eq!( + get_str(&details, "dimension").as_deref(), + Some("rawRelations") + ); + assert_eq!(get_str(&details, "requested").as_deref(), Some("2048")); + assert_eq!(get_str(&details, "limit").as_deref(), Some("2047")); +} + +#[wasm_bindgen_test] +fn feasibility_opaque_identity_does_not_change_physical_result() { + let outcome = |relation_id: &str| { + let raw = protocol_request(vec![applicable_relation( + relation_id, + "occurrence", + labcolors_protocol::Wcag22CriterionV1::Sc143TextDefault, + vec![[0x76; 3]], + )]); + evaluate_protocol_bytes(&raw) + }; + let first = outcome("first-id"); + let second = outcome("second-id"); + let first_result = feasibility_result(&first, "feasible"); + let second_result = feasibility_result(&second, "feasible"); + assert_eq!( + json_text(&get_obj(&first_result, "failureMatrix")), + json_text(&get_obj(&second_result, "failureMatrix")) + ); + let first_proof = get_obj(&first_result, "proof"); + let second_proof = get_obj(&second_result, "proof"); + assert_eq!( + json_text(&get_obj(&first_proof, "partition")), + json_text(&get_obj(&second_proof, "partition")) + ); + assert_ne!( + json_text(&get_obj(&first_proof, "relationSetDigest")), + json_text(&get_obj(&second_proof, "relationSetDigest")) + ); + assert_ne!( + json_text(&get_obj(&first_proof, "evaluationId")), + json_text(&get_obj(&second_proof, "evaluationId")) + ); +} + +#[wasm_bindgen_test] +fn feasibility_raw_boundary_rechecks_the_exact_protocol_ceiling() { + let limit = wcag22_feasibility_max_request_bytes_v1(); + assert_eq!(u64::from(limit), labcolors_protocol::MAX_ENVELOPE_BYTES_V1); + let oversized = vec![b' '; limit as usize + 1]; + let raw = evaluate_protocol_bytes(&oversized); + let scalar: JsValue = wcag22_feasibility_envelope_too_large_v1(u64::from(limit) + 1) + .expect("scalar protocol projection") + .into(); + assert_eq!(json_text(&raw), json_text(&scalar)); + let error = assert_failure_code(&raw, "transport", "envelopeTooLarge"); + let requested_text = (u64::from(limit) + 1).to_string(); + let limit_text = u64::from(limit).to_string(); + assert_eq!( + get_str(&error, "requestedBytes").as_deref(), + Some(requested_text.as_str()) + ); + assert_eq!( + get_str(&error, "limitBytes").as_deref(), + Some(limit_text.as_str()) + ); +} diff --git a/docs/NAMING.md b/docs/NAMING.md index ab4ed1d9..01a62b08 100644 --- a/docs/NAMING.md +++ b/docs/NAMING.md @@ -13,12 +13,12 @@ | Метрика | N | | --- | --- | -| членов workspace (Cargo.toml `members`, глоб развёрнут по ФС) | 5 | -| крейтов семейства в crates/ | 4 | +| членов workspace (Cargo.toml `members`, глоб развёрнут по ФС) | 6 | +| крейтов семейства в crates/ | 5 | | экспорт-субпутей package.json @labpics/colors | 8 | -| python-скриптов scripts/*.py | 8 | +| python-скриптов scripts/*.py | 9 | | маркдаун-доков docs/**/*.md (включая этот канон) | 12 | -| векторов conformance/vectors/*.json (включая manifest) | 7 | +| векторов conformance/vectors/*.json (включая manifest) | 8 | | файлов вне закона имён | 4 | ## Общие принципы (эталон lab-icons) @@ -39,8 +39,8 @@ ### Крейты workspace - Имя крейта = `labcolors-<роль>`, kebab-case; роль — одно слово: - `labcolors-core`, `labcolors-conformance`, `labcolors-ffi`, - `labcolors-wasm`. Директория `crates/<имя крейта>`. + `labcolors-core`, `labcolors-protocol`, `labcolors-conformance`, + `labcolors-ffi`, `labcolors-wasm`. Директория `crates/<имя крейта>`. - Члены workspace объявлены в корневом Cargo.toml (`members`); глоб `crates/*` разворачивается по ФС, harness-члены вне crates/ перечислены поимённо (experiments/psychophysics — имя пакета без префикса: не публикуется, @@ -73,8 +73,11 @@ - `verify_wcag22_neutral_axis.py` независимо выводит точные фикстуры нейтральной оси; `verify_wcag22_feasibility_identity.py` воспроизводит канонические прообразы и упаковку битов; `check_wcag22_feasibility_benchmark.py` проверяет - с отказом при любом расхождении первичный допуск и тождество измеренного графа - зависимостей после объединения коммитов. + с отказом при любом расхождении неизменяемый первичный допуск; + `check_wcag22_feasibility_applicability.py` отдельно доказывает его текущую + применимость, сохраняя полное Core source tree и точные внешние dependency- + записи; изменяться могут только source-less не-Core workspace-записи без + удаления исторического пакета. - Скрипты живут только в scripts/; каждый упомянут в этом каноне — появление нового скрипта требует строчки здесь (иначе гейт красный). @@ -87,7 +90,8 @@ - Миграции в docs/migrations/ — kebab-case по предмету breaking-контракта (`exact-alpha-glow.md`); одна дока обязана покрывать upgrade и rollback. - Векторы конформанса — conformance/vectors/`<домен>.json`, домен — одно - kebab-слово (alpha, contrasts, ladders, muddiness, solve, wcag22) + manifest.json. + kebab-слово (alpha, contrasts, ladders, muddiness, solve, wcag22, + wcag22-feasibility) + manifest.json. ### Swift-биндинг diff --git a/docs/migrations/exact-alpha-glow.md b/docs/migrations/exact-alpha-glow.md index 4729d0ec..d5c15878 100644 --- a/docs/migrations/exact-alpha-glow.md +++ b/docs/migrations/exact-alpha-glow.md @@ -342,7 +342,7 @@ binary64 identity alpha, каноническую CSS-строку и composite цвета. 5. Сравнивайте alpha через `alphaCss` или побитный parse round-trip; не округляйте её до фиксированного числа знаков. -6. Используйте актуальный conformance pack (4.0.0; half-tie введён в 2.0.0 +6. Используйте актуальный conformance pack (5.0.0; half-tie введён в 2.0.0 и обязателен с тех пор). Half-tie `#C0B2FA @ 0.122` над `#000000` обязан дать `#17161F`. Обрабатывайте `generate_solve()` / `Pack::generate()` как `Result`: internal core failure @@ -405,7 +405,7 @@ Rollback выполняется парой runtime + config: ## Историческая migration-note: атомарный `NumericalDecisionV1` и pack 3.0.0 (#292) > Этот подраздел фиксирует переход #292 до добавления WCAG-семейства. Для -> текущего unreleased-контракта используйте pack 4.0.0 и дополнение ниже. +> текущего unreleased-контракта используйте pack 5.0.0 и дополнения ниже. Последующий rework численной границы (см. дополнение ADR-0004 от 2026-07-12) намеренно НЕ меняет wire: прежние ключи сохранены byte-for-byte как @@ -432,7 +432,7 @@ boundary-адаптер, поэтому для JS/TS-потребителей и FNV-1a-32 drift-checksum). Векторные семейства и `packDigest` не изменились; потребители манифеста должны читать новую секцию. -## Текущий unreleased-контракт: WCAG 2.2 и pack 4.0.0 (#284) +## Исторический контракт: WCAG 2.2 и pack 4.0.0 (#284) - Единственный public capability contract — V2; он добавляет proof-capable `wcag22-srgb8-contrast-v1` с artifact/bound/proof IDs. Временный V1 не @@ -453,3 +453,16 @@ boundary-адаптер, поэтому для JS/TS-потребителей и исходном `srgb8.rs` связан exact SHA без роста optimized WASM size или code-body lengths, а добавление несвязанного API больше не выглядит сменой математики. + +## Текущий transport-контракт: complete feasibility и pack 5.0.0 (#295) + +- Pack 5.0.0 добавляет ровно `wcag22-feasibility.json`; байты шести family из + pack 4 сохранены. Новый corpus фиксирует versioned request/outcome bytes, + packed LSB0 evidence, все три feasibility-терминала и типизированные + conflict/resource error paths. +- npm добавляет `evaluateWcag22Feasibility(Uint8Array)` и два root-типа — + request/outcome. Вложенные wire-типы остаются деталями исчерпывающего outcome, + а raw wasm-bindgen ABI не экспортируется из package root. +- Feasibility полностью перечисляет зарегистрированный домен и не выбирает + цвет. Selection policy, брендовая близость, polarity и appearance-scoring не + являются скрытой частью этого migration-контракта. diff --git a/docs/verification-map.md b/docs/verification-map.md index 467710e3..47c1b2f4 100644 --- a/docs/verification-map.md +++ b/docs/verification-map.md @@ -88,7 +88,7 @@ | производственный вердикт использует только наружу округлённые Q55 и целочисленные сравнения; ядро, парсер, фасад и терминальное доказательство нельзя подменить независимо | точные SHA узких production-листьев (`kernel.rs`, terminal evidence), production-only капсулы парсера в `srgb8.rs` и нормализованного фасада (исключены только три self-digest literal) + семантические проверки; `anti_epsilon_witnesses_are_definite_fail`, тесты парсера на panic и свойства | независимый verifier + внутренние граничные примеры | | доказательство связано с фактическим публичным путём, но не с посторонним текстом всего `lib.rs` или родительского `srgb8.rs` | схема привязки источников V1: Cargo metadata фиксирует каноническую цель библиотеки `src/lib.rs`; length-prefixed SHA-256 связывает этот факт, четыре маршрута из корня crate и точное тело парсера; независимый тест отвергает перенаправления `[lib] path`, `cfg`, `path` и parser, доказывая невлияние будущих `Srgb8`/root re-export; скомпилированная проба registry вызывает оба публичных входа, границу 4.5, различие критериев 4.5/3.0, ID доказательств и невалидный транспорт | Cargo metadata + точный capsule-оракул + внешний Rust-потребитель внутри verifier-а | | право выпускать терминальное доказательство связано с фактической типизированной строкой WCAG registry | скомпилированная Rust-проба читает действующую строку; Python канонизирует 10 значимых для выпуска полей через length-prefix/SHA-256; 10 мутаций полей + 2 мутации hex/count транспорта обязаны отказать | независимая локальная привязка допуска (в proof всего 15 негативных контролей) | -| один вердикт и его доказательство сохраняются через Core → FFI/WASM → JS/Swift/контракт соответствия | `wcag22_transport_*`, `wasm_parity`, `wcag22.test.mjs`, Swift conformance, зафиксированный набор из четырёх `wcag22.json`; release verifier повторно проверяет байты доказательств | дифференциальный межграничный оракул | +| один вердикт и его доказательство сохраняются через Core → FFI/WASM → JS/Swift/контракт соответствия | `wcag22_transport_*`, `wasm_parity`, `wcag22.test.mjs`, Swift conformance, зафиксированный набор из шести `wcag22.json`; release verifier повторно проверяет байты доказательств | дифференциальный межграничный оракул | ## Конечная компиляция выполнимости WCAG 2.2 — `wcag22_feasibility.rs` (#295) @@ -101,6 +101,12 @@ | все 256 нейтралей проверяются против каждого канонического применимого соседа; граничные множества для 4.5:1 и 3:1 совпадают с независимо пересчитанным эталоном | `verify_wcag22_neutral_axis.py`; `production_vectors_are_bound_to_the_exact_independent_oracle_fixture`; тесты полной матрицы и границ | независимая `Fraction`-арифметика с адаптивными точными границами корня пятой степени; производственный Q55 и Rust-вычислитель не импортируются | | перестановки и точные дубликаты не меняют канонические ID содержимого; изменение непрозрачного ID меняет идентичность, но не физическое разбиение | `verify_wcag22_feasibility_identity.py`; `exact_identity_preimages_match_the_independent_cross_language_fixture`; тесты свойств и фиксации текущего поведения канонизации | независимая Python-транскрипция точной байтовой грамматики и SHA-256 + внутренние метаморфные тесты | | терминал появляется только после полного `W=256E`; упакованное хранилище равно `B=0` при `A=0`, иначе `B=32(E+1)`; частичный терминал и неявная подстановка результата отсутствуют | тесты с внедрением отказов вычислителя, хранилища, выделения памяти и полноты; `check_wcag22_feasibility_benchmark.py` проверяет полный набор граничных форм, точные счётчики и SHA-256 графа зависимостей | типизированные негативные контроли + прямые измерения скомпилированного процесса текущей платформы; прошедшее время не является порогом приёмки | +| transport V1 принимает только strict UTF-8 JSON bytes, сначала применяет выведенный предел envelope, затем сохраняет полную Core-алгебру как `Success(feasibility) \| Failure(error)` | `labcolors-protocol`: exact-limit witness, limit+1 decoder-spy, strict-schema/error-algebra tests и compile-fail запрет forged outcome | литеральная грамматика + Core resource-profile SSOT + тип-уровневый негативный контроль | +| WASM/npm не повторяет Core-математику, preflight-ит `Uint8Array.byteLength` до избегаемой ABI-копии и переносит domain/relations один раз с candidate-major LSB0 matrix | `wasm_parity::committed_conformance_pack_replays_in_wasm32`; feasibility boundary tests; `wcag22-feasibility.test.mjs` с независимым LSB0 reduction и mutation subject | дифференциальный public-boundary replay pack 5 + независимый packed consumer | +| UniFFI/Swift вызывает тот же protocol byte path, preflight-ит `Data`/`[UInt8]` до сырой FFI-копии и исчерпывающе декодирует terminal/error algebra | FFI mechanical-shell tests; Swift pack-5 replay, limit+1 bridge spy, structural mutation tests и extreme-shape whole-call observations | побайтный FFI/protocol differential + независимый Swift packed consumer | +| conformance pack 5 добавляет только feasibility-family; прежние шесть family остаются byte-identical, а 13 новых outcomes воспроизводятся canonical protocol encoder побайтно | `pack_v5_contract`; `reference_runner::protocol_reproduces_committed_wcag22_feasibility_exactly`; release verifier/clean-install replay | SHA-256 immutable-family guards + дифференциальный protocol/public-package replay | +| исторический native-допуск остаётся неизменным, а текущая применимость не зависит от добавленных transport-крейтов | старый checker исполняется в чистом snapshot `6001cf4`; отдельный applicability verifier фиксирует полный Core source tree, Core package record и все sourced lock records, разрешая только изменения source-less non-Core workspace-записей | exact Git objects + fail-closed Cargo.lock V4 parser и мутационные негативные контроли | +| канонический WASM transport воспроизводим побайтно и завершает все граничные whole-call формы | append-only size V2: `521240 B` + точный SHA без headroom; committed whole-call artifact: 10 форм × 5 fresh-process samples, exact request/outcome hashes и source/pack/toolchain bindings | независимые Linux `wc`/`sha256sum` + повторный package-root verifier; latency/maxRSS/pages не превращаются в пороги | Здесь `E` — число канонических применимых рёбер «связь × сосед» (`E∈{0,1,…,2047}`), `A` — число применимых связей (`A∈{0,1,…,E}`; diff --git a/packages/colors/README.md b/packages/colors/README.md index 9b277c5c..59fffc6a 100644 --- a/packages/colors/README.md +++ b/packages/colors/README.md @@ -14,6 +14,13 @@ npm install @labpics/colors ``` +Минимально поддерживается TypeScript `>= 5.2.2`: ветка 5.2 впервые добавила +стандартный `esnext.disposable` ([официальное описание TypeScript +5.2](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html)). +Публичный `.d.ts` подключает эту библиотеку сам, поэтому потребителю не требуется +расширять свой `lib`. Release-gate компилирует чисто установленный пакет с +`skipLibCheck: false` и точным floor `5.2.2`, и текущим compiler из lockfile. + При сборке из монорепо: ```sh @@ -269,6 +276,56 @@ diagnostics и legacy `wcagRatio` не могут изменить этот ве --- +### `evaluateWcag22Feasibility(request): Wcag22FeasibilityOutcomeV1` + +Полностью перебирает зарегистрированную конечную ось sRGB8 против всех явно +объявленных клиентом связей. Операция отвечает только на вопрос «какие +кандидаты проходят все эти ограничения?». Она не выбирает лучший цвет, не +угадывает применимость и не понимает семантику ID. + +```ts +import init, { + evaluateWcag22Feasibility, + type Wcag22FeasibilityRequestV1, +} from "@labpics/colors"; + +await init(); + +const request: Wcag22FeasibilityRequestV1 = { + schemaVersion: 1, + domainId: "srgb8-neutral-axis-v1", + resourceProfileId: "compile-v1", + relations: [{ + relationId: "opaque-relation-7f3a", + occurrenceId: "opaque-occurrence-17", + kind: "applicable", + criterion: "sc-1.4.3-text-default", + adjacent: [[0, 0, 0], [255, 255, 255]], + }], +}; + +const bytes = new TextEncoder().encode(JSON.stringify(request)); +const outcome = evaluateWcag22Feasibility(bytes); + +if (outcome.outcome === "success") { + // feasible | infeasible | notEvaluated + console.log(outcome.feasibility.status); +} else { + // strict transport/core failure as typed data; no fallback colour + console.error(outcome.error); +} +``` + +Вход — только настоящий `Uint8Array` со strict JSON V1; иной JavaScript-тип +детерминированно отклоняется `TypeError` до чтения WASM-owned ceiling и копии. +Граница размера выведена из грамматики и resource profile; после `init()` её +возвращает `wcag22FeasibilityMaxBytes()`. Package wrapper проверяет `byteLength` +до избежимой копии в WASM, а Rust повторяет авторитетную проверку. Выход хранит +домен и канонические связи по одному разу, а решения — в candidate-major LSB0 +bitset; объектного графа `256 × E` в public result нет. + +--- + ### `numericalCapabilityManifest(): NumericalCapabilityManifestV2` Возвращает статический манифест численных возможностей установленной сборки: @@ -401,19 +458,23 @@ replacement принадлежит #283. ## Размер бандла -Raw-размер WASM — hard gate. Его versioned SSOT — -`bench/wasm-size-budget-v1.json`: точное принятое измерение Issue #284 вместе -с toolchain provenance, SHA-256 измеренного baseline и ceiling без -произвольного запаса. SHA текущего воспроизводимого артефакта хранится отдельно -в `currentArtifact`, поэтому его recertification не переписывает происхождение -лимита. Канонический артефакт строит release-equivalent Linux x64 CI: там -checker требует одновременно точный текущий SHA-256 и неизменный ceiling. На -других host-платформах тот же checker только сообщает raw/gzip/SHA-диагностику: -host-native toolchain bytes не выдаются за канонический release artifact и не -сравниваются с чужим ceiling. Каноническая сборка remap-ит mutable workspace и -Cargo registry roots в стабильные виртуальные пути, поэтому имя конкретного -self-hosted Linux runner не меняет бинарь. `gzip -9` также остаётся живой -диагностикой и не хранится как ложная константа SSOT. +Raw-размер WASM — hard gate с append-only историей. Неизменяемый +`bench/wasm-size-budget-v1.json` сохраняет допуск #284 (`454385 B`), а +`bench/wasm-size-budget-v2.json` отдельно допускает полный transport #295: +ровно `521240 B`, SHA-256 +`d37841bfb2615d05c8366b08dcc7e5aed1bbd3cf27c3db67896108c5ec9c9ca0`. +V2 ссылается на точные байты V1 и его build recipe; новый ceiling равен +измерению, поэтому произвольного запаса нет и история #284 не переписана. + +Release-equivalent Linux x64 CI требует одновременно точный размер и SHA. +Отдельный `bench/wcag22-feasibility-wasm-boundary-v1.json` фиксирует 10 крайних +whole-call форм × 5 свежих процессов, request/outcome bytes, packed shape и +привязки Core/pack/toolchain. Время, process maxRSS и страницы WASM остаются +наблюдениями без выдуманного production-порога. На других host-платформах +checker сообщает только raw/gzip/SHA-диагностику: host-native bytes не выдаются +за канонический release artifact. Сборка remap-ит mutable workspace и Cargo +registry roots в стабильные виртуальные пути; `gzip -9` остаётся диагностикой, +а не второй константой допуска. Это весь движок: CAM16, солверы контраста, лестницы и граница конфига. `.wasm` поставляется отдельным ассетом. Будет ли его загрузка критическим путём первого diff --git a/packages/colors/adapt-theme.d.ts b/packages/colors/adapt-theme.d.ts index 35db47b0..dc617c0b 100644 --- a/packages/colors/adapt-theme.d.ts +++ b/packages/colors/adapt-theme.d.ts @@ -1,6 +1,6 @@ // Public types for the adaptive hysteresis controller. -import type { LabColors, ThemeName } from "./pkg/labcolors.js"; +import type { LabColors, ThemeName } from "./index.js"; export interface AdaptThemeOptions { /** diff --git a/packages/colors/apply-theme.d.ts b/packages/colors/apply-theme.d.ts index 532b7a09..07def9b8 100644 --- a/packages/colors/apply-theme.d.ts +++ b/packages/colors/apply-theme.d.ts @@ -1,4 +1,4 @@ -import type { ResolvedTheme } from "./pkg/labcolors.js"; +import type { ResolvedTheme } from "./index.js"; /** * Apply a resolved theme's CSS variables to an element. diff --git a/packages/colors/bench/wasm-size-budget-v2.json b/packages/colors/bench/wasm-size-budget-v2.json new file mode 100644 index 00000000..d56bd487 --- /dev/null +++ b/packages/colors/bench/wasm-size-budget-v2.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 3, + "budgetId": "labcolors-wasm-raw-issue-295-v2", + "artifact": "packages/colors/pkg/labcolors_bg.wasm", + "buildRecipe": { + "path": "packages/colors/bench/wasm-size-budget-v1.json", + "fileSha256": "4f7340fc8cfd0ccb97377c385f2f8d8e7a9ef2c5ba96177f518c5d07de2825e1", + "recipeSha256": "0ea74cb070e0a5facb7280f6124930a0bb673ee4dcee9c99fff110db6c9389d4" + }, + "measurement": { + "issue": 295, + "measurementPlatform": "linux-x64", + "rawBytes": 521240, + "sha256": "d37841bfb2615d05c8366b08dcc7e5aed1bbd3cf27c3db67896108c5ec9c9ca0" + }, + "policy": { + "maxRawBytes": 521240, + "derivation": "exact-accepted-issue-295-slice-b-measurement", + "gzip": "diagnostic-only" + } +} diff --git a/packages/colors/bench/wcag22-feasibility-boundary.bench.mjs b/packages/colors/bench/wcag22-feasibility-boundary.bench.mjs new file mode 100644 index 00000000..e1e99e06 --- /dev/null +++ b/packages/colors/bench/wcag22-feasibility-boundary.bench.mjs @@ -0,0 +1,1260 @@ +#!/usr/bin/env node + +// Whole-call #295 boundary evidence through the built package-root API. +// `--emit` prints diagnostic JSON with explicit canonical eligibility; `--record PATH` +// admits only the pinned Linux x64/Node/toolchain context and never overwrites; +// `--verify [PATH]` binds the exact WASM and reruns every structural law. +// Wall time, process maxRSS and post-call WASM pages are observations only. + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { isDeepStrictEqual } from "node:util"; + +const here = dirname(fileURLToPath(import.meta.url)); +const harnessPath = fileURLToPath(import.meta.url); +const canonicalPackageRoot = resolve(here, ".."); +const packageRoot = process.env.LABCOLORS_BOUNDARY_PACKAGE_ROOT + ? resolve(process.env.LABCOLORS_BOUNDARY_PACKAGE_ROOT) + : canonicalPackageRoot; +const canonicalPackageInput = packageRoot === canonicalPackageRoot; +const repoRoot = resolve(canonicalPackageRoot, "../.."); +const packageEntry = resolve(packageRoot, "index.js"); +const packageManifestPath = resolve(packageRoot, "package.json"); +const wasmGluePath = resolve(packageRoot, "pkg/labcolors.js"); +const wasmPath = resolve(packageRoot, "pkg/labcolors_bg.wasm"); +const eagerRuntimeModules = { + adaptTheme: [resolve(packageRoot, "adapt-theme.js"), "packages/colors/adapt-theme.js"], + applyTheme: [resolve(packageRoot, "apply-theme.js"), "packages/colors/apply-theme.js"], + effectiveBackground: [ + resolve(packageRoot, "effective-bg.js"), + "packages/colors/effective-bg.js", + ], + watchTheme: [resolve(packageRoot, "watch-theme.js"), "packages/colors/watch-theme.js"], +}; +const runtimeSourceIds = [ + ...Object.keys(eagerRuntimeModules), + "harness", + "packageManifest", + "packageRoot", + "wasmGlue", +]; +const coreAdmissionPath = resolve( + repoRoot, + "crates/labcolors-core/contracts/wcag22-feasibility-benchmark-v1.json", +); +const packOraclePath = resolve(repoRoot, "conformance/vectors/wcag22-feasibility.json"); +const conformanceManifestPath = resolve(repoRoot, "conformance/vectors/manifest.json"); +const wasmToolchainPath = resolve(here, "wasm-size-budget-v1.json"); +const ciWorkflowPath = resolve(repoRoot, ".github/workflows/ci.yml"); +const defaultMeasurementPath = resolve(here, "wcag22-feasibility-wasm-boundary-v1.json"); +const pageBytes = 65_536; +const candidateCount = 256; + +export const MEASUREMENT_ARTIFACT_ID = "wcag22-feasibility-wasm-whole-call-v1"; +export const SCENARIO_IDS = Object.freeze([ + "minimum-evaluated", + "maximum-canonical-applicable-relations", + "maximum-applicable-edges", + "maximum-opaque-utf8-bytes", + "maximum-canonical-not-applicable-relations", + "maximum-combined-not-applicable-envelope", + "maximum-combined-applicable-envelope", + "maximum-raw-duplicate-relations", + "maximum-raw-adjacent-duplicates", + "transport-limit-plus-one", +]); + +const artifactTopKeys = [ + "artifactId", + "bindings", + "claimBoundary", + "claims", + "environment", + "limits", + "scenarios", + "schemaVersion", +]; +const hardGates = [ + "completion", + "request-and-outcome-bytes", + "sha256-binding", + "terminal-algebra", + "packed-shape", + "candidate-major-lsb0-pack-oracle", + "no-proportional-dto", +]; +const memoryClaim = + "process maxRSS is total-process high-water including V8; post-call WASM pages are linear-memory high-water observations; neither is total operation memory"; +const proportionalKeys = new Set([ + "assessments", + "cells", + "feasibleCandidates", + "infeasibleCandidates", +]); + +function fail(message) { + throw new Error(`WCAG22 feasibility WASM boundary: ${message}`); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exactKeys(value, expected, label) { + if (!isRecord(value)) fail(`${label} must be an object`); + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (!isDeepStrictEqual(actual, wanted)) { + fail(`${label} fields ${JSON.stringify(actual)} differ from ${JSON.stringify(wanted)}`); + } +} + +function positiveSafeInteger(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) fail(`${label} must be a positive safe integer`); +} + +function nonNegativeSafeInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 0) { + fail(`${label} must be a non-negative safe integer`); + } +} + +function decimal(value, label, { positive = false } = {}) { + if (typeof value !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(value)) { + fail(`${label} must be canonical unsigned decimal text`); + } + const parsed = BigInt(value); + if (positive && parsed === 0n) fail(`${label} must be positive`); + return parsed; +} + +function digest(value, label) { + if (!/^[0-9a-f]{64}$/u.test(value ?? "")) fail(`${label} must be lowercase SHA-256`); +} + +function readJsonWithBytes(path, label) { + let bytes; + try { + bytes = readFileSync(path); + } catch (error) { + fail(`cannot read ${label}: ${error.message}`); + } + try { + return { bytes, value: JSON.parse(bytes.toString("utf8")) }; + } catch (error) { + fail(`${label} is not JSON: ${error.message}`); + } +} + +function sourceContracts() { + const core = readJsonWithBytes(coreAdmissionPath, "Core admission artifact"); + const toolchain = readJsonWithBytes(wasmToolchainPath, "WASM toolchain artifact"); + if ( + core.value?.schemaVersion !== 1 || + core.value?.artifactId !== "wcag22-feasibility-admission-raw-v1" || + core.value?.profileLimits?.profileId !== "compile-v1" + ) { + fail("unsupported Core admission artifact identity"); + } + if ( + toolchain.value?.schemaVersion !== 2 || + toolchain.value?.budgetId !== "labcolors-wasm-raw-issue-284-v1" + ) { + fail("unsupported canonical WASM toolchain artifact identity"); + } + return { core, toolchain }; +} + +function toolchainRecipe(toolchain) { + const measurement = toolchain.measurement; + const recipe = { + rustToolchain: measurement?.rustToolchain, + rustcCommit: measurement?.rustcCommit, + wasmPack: measurement?.wasmPack, + wasmBindgen: measurement?.wasmBindgen, + target: measurement?.target, + cargoProfile: measurement?.cargoProfile, + wasmOpt: measurement?.wasmOpt, + wasmOptVersion: measurement?.wasmOptVersion, + measurementPlatform: measurement?.measurementPlatform, + rustPathRemap: measurement?.rustPathRemap, + command: measurement?.command, + }; + if ( + Object.values(recipe).some((value) => value === undefined) || + !Array.isArray(recipe.rustPathRemap) + ) { + fail("canonical WASM toolchain recipe is incomplete"); + } + return recipe; +} + +function runtimeSourceBindings() { + const binding = (path, repositoryPath, label) => { + let bytes; + try { + bytes = readFileSync(path); + } catch (error) { + fail(`cannot read ${label}: ${error.message}`); + } + return { path: repositoryPath, sha256: sha256(bytes) }; + }; + const eagerModules = Object.fromEntries( + Object.entries(eagerRuntimeModules).map(([sourceId, [path, repositoryPath]]) => [ + sourceId, + binding(path, repositoryPath, `${sourceId} eager runtime module`), + ]), + ); + return { + harness: binding( + harnessPath, + "packages/colors/bench/wcag22-feasibility-boundary.bench.mjs", + "measurement harness", + ), + packageManifest: binding( + packageManifestPath, + "packages/colors/package.json", + "package manifest", + ), + packageRoot: binding(packageEntry, "packages/colors/index.js", "package-root module"), + wasmGlue: binding( + wasmGluePath, + "packages/colors/pkg/labcolors.js", + "wasm-bindgen JS glue", + ), + ...eagerModules, + }; +} + +function packOracle() { + const pack = readJsonWithBytes(packOraclePath, "pack-5 WCAG22 feasibility oracle"); + const manifest = readJsonWithBytes(conformanceManifestPath, "pack-5 manifest"); + if (!Array.isArray(pack.value)) fail("pack-5 WCAG22 feasibility oracle must be an array"); + if ( + manifest.value?.packVersion !== "5.0.0" || + typeof manifest.value?.packDigest !== "string" || + manifest.value?.counts?.wcag22Feasibility !== pack.value.length + ) { + fail("pack-5 manifest does not bind the WCAG22 feasibility family"); + } + const vector = pack.value.find((entry) => entry.caseId === "text-default-seven"); + if (!vector || typeof vector.requestJson !== "string" || typeof vector.outcomeJson !== "string") { + fail("pack-5 WCAG22 feasibility oracle lacks text-default-seven"); + } + let requestValue; + let outcomeValue; + try { + requestValue = JSON.parse(vector.requestJson); + outcomeValue = JSON.parse(vector.outcomeJson); + } catch (error) { + fail(`pack-5 text-default-seven is not nested JSON: ${error.message}`); + } + const relation = requestValue?.relations?.[0]; + const result = outcomeValue?.feasibility?.result; + const matrix = result?.failureMatrix; + const partition = result?.proof?.partition; + if ( + requestValue?.relations?.length !== 1 || + relation?.kind !== "applicable" || + relation?.criterion !== "sc-1.4.3-text-default" || + !isDeepStrictEqual(relation?.adjacent, [[118, 118, 118]]) || + outcomeValue?.outcome !== "success" || + outcomeValue?.feasibility?.status !== "feasible" || + !Array.isArray(matrix) || + matrix.length !== 32 || + !Array.isArray(partition) || + partition.length !== 32 + ) { + fail("pack-5 text-default-seven no longer defines the exact minimum LSB0 oracle"); + } + return { + path: "conformance/vectors/wcag22-feasibility.json", + sha256: sha256(pack.bytes), + caseId: vector.caseId, + requestSha256: sha256(Buffer.from(vector.requestJson, "utf8")), + outcomeSha256: sha256(Buffer.from(vector.outcomeJson, "utf8")), + manifestPath: "conformance/vectors/manifest.json", + manifestSha256: sha256(manifest.bytes), + packVersion: manifest.value.packVersion, + packDigest: manifest.value.packDigest, + matrix, + partition, + }; +} + +function coreLimits(core) { + const profile = core.profileLimits; + for (const field of [ + "rawRelations", + "rawAdjacentEntries", + "opaqueUtf8Bytes", + "canonicalRelations", + "applicableEdges", + "logicalAssessments", + "packedResultBytes", + ]) { + positiveSafeInteger(profile?.[field], `Core profileLimits.${field}`); + } + return structuredClone(profile); +} + +function requiredSampleCount(core) { + positiveSafeInteger(core.sampleCount, "Core admission sampleCount"); + return core.sampleCount; +} + +function canonicalNodeVersion() { + const source = readFileSync(ciWorkflowPath, "utf8"); + const version = source.match(/^\s{2}NODE_TOOLCHAIN: (\d+\.\d+\.\d+)$/mu)?.[1]; + if (!version) fail("CI does not expose one exact NODE_TOOLCHAIN version"); + return `v${version}`; +} + +function scenarioSourceName(scenarioId) { + return scenarioId === "transport-limit-plus-one" + ? "maximum-combined-applicable-envelope" + : scenarioId; +} + +function expectedShape(core, scenarioId) { + const source = core.scenarios.find( + (scenario) => scenario.name === scenarioSourceName(scenarioId), + ); + if (!source) fail(`Core admission artifact lacks ${scenarioSourceName(scenarioId)}`); + return structuredClone(source.shape); +} + +function validateSummary(summary, shape, scenarioId, limits, label) { + if (scenarioId === "transport-limit-plus-one") { + exactKeys( + summary, + [ + "code", + "limitBytes", + "outcome", + "proportionalFieldsPresent", + "requestedBytes", + "source", + ], + label, + ); + if ( + summary.outcome !== "failure" || + summary.source !== "transport" || + summary.code !== "envelopeTooLarge" || + decimal(summary.requestedBytes, `${label}.requestedBytes`) !== + BigInt(limits.maxRequestBytes + 1) || + decimal(summary.limitBytes, `${label}.limitBytes`) !== BigInt(limits.maxRequestBytes) || + summary.proportionalFieldsPresent !== false + ) { + fail(`${label} does not preserve the exact host-preflight failure`); + } + return; + } + + const declarationOnly = + scenarioId === "maximum-opaque-utf8-bytes" || + scenarioId === "maximum-canonical-not-applicable-relations" || + scenarioId === "maximum-combined-not-applicable-envelope"; + if (declarationOnly) { + exactKeys( + summary, + [ + "applicableRelations", + "canonicalRelations", + "notApplicableRelations", + "numericalEvidencePresent", + "outcome", + "proportionalFieldsPresent", + "terminal", + ], + label, + ); + if ( + summary.outcome !== "success" || + summary.terminal !== "notEvaluated" || + decimal(summary.canonicalRelations, `${label}.canonicalRelations`) !== + BigInt(shape.canonicalRelations) || + decimal(summary.applicableRelations, `${label}.applicableRelations`) !== 0n || + decimal(summary.notApplicableRelations, `${label}.notApplicableRelations`) !== + BigInt(shape.canonicalRelations) || + summary.numericalEvidencePresent !== false || + summary.proportionalFieldsPresent !== false + ) { + fail(`${label} fabricates or loses declaration-only evidence`); + } + return; + } + + const evaluatedKeys = [ + "applicableEdges", + "applicableRelations", + "canonicalRelations", + "domainCount", + "failureMatrixBytes", + "feasibleCandidates", + "logicalAssessments", + "lsb0PartitionMatchesMatrix", + "notApplicableRelations", + "outcome", + "partitionBytes", + "proportionalFieldsPresent", + "terminal", + ]; + if (scenarioId === "minimum-evaluated") evaluatedKeys.push("lsb0PackOracleMatches"); + exactKeys(summary, evaluatedKeys, label); + const feasible = summary.feasibleCandidates; + nonNegativeSafeInteger(feasible, `${label}.feasibleCandidates`); + if ( + summary.outcome !== "success" || + !["feasible", "infeasible"].includes(summary.terminal) || + decimal(summary.domainCount, `${label}.domainCount`) !== BigInt(candidateCount) || + decimal(summary.canonicalRelations, `${label}.canonicalRelations`) !== + BigInt(shape.canonicalRelations) || + decimal(summary.applicableRelations, `${label}.applicableRelations`) !== + BigInt(shape.applicableRelations) || + decimal(summary.notApplicableRelations, `${label}.notApplicableRelations`) !== + BigInt(shape.canonicalRelations - shape.applicableRelations) || + decimal(summary.applicableEdges, `${label}.applicableEdges`) !== + BigInt(shape.applicableEdges) || + decimal(summary.logicalAssessments, `${label}.logicalAssessments`) !== + BigInt(candidateCount * shape.applicableEdges) || + summary.failureMatrixBytes !== 32 * shape.applicableEdges || + summary.partitionBytes !== 32 || + summary.lsb0PartitionMatchesMatrix !== true || + (scenarioId === "minimum-evaluated" && summary.lsb0PackOracleMatches !== true) || + summary.proportionalFieldsPresent !== false || + (summary.terminal === "feasible") !== (feasible > 0) + ) { + fail(`${label} violates terminal, count, packed-shape or LSB0 algebra`); + } +} + +function validateSample(sample, shape, scenarioId, limits, sampleIndex, label) { + exactKeys( + sample, + [ + "elapsedNs", + "outcomeBytes", + "outcomeSha256", + "processMaxRssKiBAfter", + "processMaxRssKiBBefore", + "requestBytes", + "requestSha256", + "sampleIndex", + "summary", + "wasmMemoryBytesAfter", + "wasmMemoryBytesBefore", + "wasmMemoryPagesAfter", + "wasmMemoryPagesBefore", + ], + label, + ); + if (sample.sampleIndex !== sampleIndex) fail(`${label}.sampleIndex is not contiguous`); + decimal(sample.elapsedNs, `${label}.elapsedNs`, { positive: true }); + positiveSafeInteger(sample.requestBytes, `${label}.requestBytes`); + positiveSafeInteger(sample.outcomeBytes, `${label}.outcomeBytes`); + digest(sample.requestSha256, `${label}.requestSha256`); + digest(sample.outcomeSha256, `${label}.outcomeSha256`); + for (const field of [ + "processMaxRssKiBBefore", + "processMaxRssKiBAfter", + "wasmMemoryBytesBefore", + "wasmMemoryBytesAfter", + "wasmMemoryPagesBefore", + "wasmMemoryPagesAfter", + ]) { + nonNegativeSafeInteger(sample[field], `${label}.${field}`); + } + if ( + sample.processMaxRssKiBAfter < sample.processMaxRssKiBBefore || + sample.wasmMemoryBytesAfter < sample.wasmMemoryBytesBefore || + sample.wasmMemoryPagesAfter < sample.wasmMemoryPagesBefore || + sample.wasmMemoryBytesBefore !== sample.wasmMemoryPagesBefore * pageBytes || + sample.wasmMemoryBytesAfter !== sample.wasmMemoryPagesAfter * pageBytes + ) { + fail(`${label} has contradictory maxRSS or WASM linear-memory observations`); + } + validateSummary(sample.summary, shape, scenarioId, limits, `${label}.summary`); +} + +/** Validate immutable structure and claims without applying timing/memory thresholds. */ +export function validateMeasurementArtifact( + artifact, + { requireCanonicalArtifact = true } = {}, +) { + const { core, toolchain } = sourceContracts(); + const profileLimits = coreLimits(core.value); + const expectedSampleCount = requiredSampleCount(core.value); + exactKeys(artifact, artifactTopKeys, "artifact"); + if ( + artifact.schemaVersion !== 1 || + artifact.artifactId !== MEASUREMENT_ARTIFACT_ID || + artifact.claimBoundary !== "canonical-wasm-package-root-whole-call-observations-only" + ) { + fail("artifact identity or claim boundary drifted"); + } + + exactKeys( + artifact.claims, + ["admission", "hardGates", "latency", "memory", "timingThresholdNs"], + "claims", + ); + if ( + artifact.claims.admission !== "canonical-linux-x64-exact-wasm-only" || + !isDeepStrictEqual(artifact.claims.hardGates, hardGates) || + artifact.claims.timingThresholdNs !== null || + artifact.claims.latency !== "observation-only-no-production-threshold" || + artifact.claims.memory !== memoryClaim + ) { + fail("claims add a guessed threshold, inflate memory meaning or weaken hard gates"); + } + + exactKeys( + artifact.environment, + [ + "canonicalCandidate", + "cargoProfile", + "execution", + "nodeVersion", + "packageRootApi", + "platform", + "requestConstructionMeasured", + "rustToolchain", + "sampleCount", + "target", + "timer", + "wasmBindgen", + "wasmOpt", + "wasmPack", + ], + "environment", + ); + const measuredToolchain = toolchain.value.measurement; + if ( + artifact.environment.execution !== "fresh-node-child-process-per-sample" || + artifact.environment.sampleCount !== expectedSampleCount || + artifact.environment.requestConstructionMeasured !== false || + artifact.environment.timer !== "process.hrtime.bigint" || + artifact.environment.packageRootApi !== "packages/colors/index.js" || + !/^v\d+\.\d+\.\d+$/u.test(artifact.environment.nodeVersion ?? "") || + (requireCanonicalArtifact && + artifact.environment.nodeVersion !== canonicalNodeVersion()) || + artifact.environment.rustToolchain !== measuredToolchain.rustToolchain || + artifact.environment.wasmPack !== measuredToolchain.wasmPack || + artifact.environment.wasmBindgen !== measuredToolchain.wasmBindgen || + artifact.environment.target !== measuredToolchain.target || + artifact.environment.cargoProfile !== measuredToolchain.cargoProfile || + artifact.environment.wasmOpt !== measuredToolchain.wasmOpt + ) { + fail("environment is not the pinned whole-call/toolchain contract"); + } + if ( + requireCanonicalArtifact && + (artifact.environment.platform !== "linux-x64" || + artifact.environment.canonicalCandidate !== true) + ) { + fail("only canonical linux-x64 observations are admissible"); + } + + exactKeys( + artifact.bindings, + ["coreAdmission", "packOracle", "runtimeSources", "wasm", "wasmToolchain"], + "bindings", + ); + exactKeys( + artifact.bindings.coreAdmission, + ["artifactId", "path", "profileId", "schemaVersion", "sha256"], + "bindings.coreAdmission", + ); + if ( + artifact.bindings.coreAdmission.path !== + "crates/labcolors-core/contracts/wcag22-feasibility-benchmark-v1.json" || + artifact.bindings.coreAdmission.schemaVersion !== core.value.schemaVersion || + artifact.bindings.coreAdmission.artifactId !== core.value.artifactId || + artifact.bindings.coreAdmission.profileId !== core.value.profileLimits.profileId || + artifact.bindings.coreAdmission.sha256 !== sha256(core.bytes) + ) { + fail("Core admission binding drifted"); + } + const oracle = packOracle(); + exactKeys( + artifact.bindings.packOracle, + [ + "caseId", + "manifestPath", + "manifestSha256", + "outcomeSha256", + "packDigest", + "packVersion", + "path", + "requestSha256", + "sha256", + ], + "bindings.packOracle", + ); + if ( + artifact.bindings.packOracle.path !== oracle.path || + artifact.bindings.packOracle.sha256 !== oracle.sha256 || + artifact.bindings.packOracle.caseId !== oracle.caseId || + artifact.bindings.packOracle.requestSha256 !== oracle.requestSha256 || + artifact.bindings.packOracle.outcomeSha256 !== oracle.outcomeSha256 || + artifact.bindings.packOracle.manifestPath !== oracle.manifestPath || + artifact.bindings.packOracle.manifestSha256 !== oracle.manifestSha256 || + artifact.bindings.packOracle.packVersion !== oracle.packVersion || + artifact.bindings.packOracle.packDigest !== oracle.packDigest + ) { + fail("pack-5 LSB0 oracle binding drifted"); + } + const sources = runtimeSourceBindings(); + exactKeys( + artifact.bindings.runtimeSources, + runtimeSourceIds, + "bindings.runtimeSources", + ); + for (const sourceId of runtimeSourceIds) { + exactKeys( + artifact.bindings.runtimeSources[sourceId], + ["path", "sha256"], + `bindings.runtimeSources.${sourceId}`, + ); + if (!isDeepStrictEqual(artifact.bindings.runtimeSources[sourceId], sources[sourceId])) { + fail(`runtime source binding ${sourceId} drifted`); + } + } + exactKeys( + artifact.bindings.wasmToolchain, + ["budgetId", "path", "recipeSha256", "schemaVersion"], + "bindings.wasmToolchain", + ); + if ( + artifact.bindings.wasmToolchain.path !== + "packages/colors/bench/wasm-size-budget-v1.json" || + artifact.bindings.wasmToolchain.schemaVersion !== toolchain.value.schemaVersion || + artifact.bindings.wasmToolchain.budgetId !== toolchain.value.budgetId || + artifact.bindings.wasmToolchain.recipeSha256 !== + sha256(Buffer.from(JSON.stringify(toolchainRecipe(toolchain.value)), "utf8")) + ) { + fail("canonical WASM toolchain binding drifted"); + } + exactKeys(artifact.bindings.wasm, ["bytes", "path", "sha256"], "bindings.wasm"); + if (artifact.bindings.wasm.path !== "packages/colors/pkg/labcolors_bg.wasm") { + fail("WASM binding path drifted"); + } + positiveSafeInteger(artifact.bindings.wasm.bytes, "bindings.wasm.bytes"); + digest(artifact.bindings.wasm.sha256, "bindings.wasm.sha256"); + + exactKeys( + artifact.limits, + ["maxRequestBytes", ...Object.keys(profileLimits)], + "limits", + ); + positiveSafeInteger(artifact.limits.maxRequestBytes, "limits.maxRequestBytes"); + const recordedProfileLimits = { ...artifact.limits }; + delete recordedProfileLimits.maxRequestBytes; + if (!isDeepStrictEqual(recordedProfileLimits, profileLimits)) { + fail("artifact Core limits differ from the bound admission artifact"); + } + if (!Array.isArray(artifact.scenarios) || artifact.scenarios.length !== SCENARIO_IDS.length) { + fail(`artifact must contain exactly ${SCENARIO_IDS.length} scenarios`); + } + const requestDigests = new Set(); + artifact.scenarios.forEach((scenario, scenarioIndex) => { + const scenarioId = SCENARIO_IDS[scenarioIndex]; + exactKeys(scenario, ["samples", "scenarioId", "shape"], `scenarios[${scenarioIndex}]`); + if (scenario.scenarioId !== scenarioId) { + fail(`scenario order/id drift at ${scenarioIndex}: ${scenario.scenarioId}`); + } + const shape = expectedShape(core.value, scenarioId); + if (!isDeepStrictEqual(scenario.shape, shape)) fail(`${scenarioId} shape drifted`); + if (!Array.isArray(scenario.samples) || scenario.samples.length !== expectedSampleCount) { + fail(`${scenarioId} must contain exactly ${expectedSampleCount} fresh-process samples`); + } + scenario.samples.forEach((sample, index) => + validateSample( + sample, + shape, + scenarioId, + artifact.limits, + index, + `${scenarioId}.samples[${index}]`, + )); + const first = scenario.samples[0]; + const stable = { + requestBytes: first.requestBytes, + requestSha256: first.requestSha256, + outcomeBytes: first.outcomeBytes, + outcomeSha256: first.outcomeSha256, + summary: first.summary, + }; + for (const sample of scenario.samples.slice(1)) { + if ( + !isDeepStrictEqual( + { + requestBytes: sample.requestBytes, + requestSha256: sample.requestSha256, + outcomeBytes: sample.outcomeBytes, + outcomeSha256: sample.outcomeSha256, + summary: sample.summary, + }, + stable, + ) + ) { + fail(`${scenarioId} request/outcome evidence is not byte-stable across samples`); + } + } + if ( + scenarioId === "maximum-combined-applicable-envelope" && + first.requestBytes !== artifact.limits.maxRequestBytes + ) { + fail("maximum-combined-applicable-envelope does not attain the public byte ceiling"); + } + if ( + scenarioId === "transport-limit-plus-one" && + first.requestBytes !== artifact.limits.maxRequestBytes + 1 + ) { + fail("transport-limit-plus-one is not the exact host-preflight boundary"); + } + requestDigests.add(first.requestSha256); + }); + if (requestDigests.size !== SCENARIO_IDS.length) { + fail("distinct boundary scenarios collapsed to the same request evidence"); + } + return artifact; +} + +function utf8Bytes(value) { + return new TextEncoder().encode(value).byteLength; +} + +function relation(relationId, occurrenceId, criterion, adjacent) { + return { relationId, occurrenceId, kind: "applicable", criterion, adjacent }; +} + +function notApplicable(relationId, occurrenceId, reasonId) { + return { relationId, occurrenceId, kind: "notApplicable", reasonId }; +} + +function request(relations) { + return { + schemaVersion: 1, + domainId: "srgb8-neutral-axis-v1", + resourceProfileId: "compile-v1", + relations, + }; +} + +function maximallyEscapedIdentity(index) { + const codePoints = [ + 0, 1, 2, 3, 4, 5, 6, 7, 11, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 29, 30, 31, + ]; + const radix = codePoints.length; + return String.fromCharCode( + codePoints[index % radix], + codePoints[Math.floor(index / radix) % radix], + codePoints[Math.floor(index / (radix * radix)) % radix], + ); +} + +function buildScenario(scenarioId, limits) { + const max = limits.rawRelations; + const criterion = "sc-1.4.3-text-default"; + const neutralAdjacent = [118, 118, 118]; + let value; + switch (scenarioId) { + case "minimum-evaluated": + value = request([relation("r", "o", criterion, [neutralAdjacent])]); + break; + case "maximum-canonical-applicable-relations": + value = request(Array.from({ length: max }, (_, index) => { + const suffix = String(index).padStart(4, "0"); + return relation(`r${suffix}`, `o${suffix}`, criterion, [neutralAdjacent]); + })); + break; + case "maximum-applicable-edges": + value = request([relation( + "r", + "o".repeat(18), + criterion, + Array.from({ length: limits.rawAdjacentEntries }, (_, index) => [ + index & 0xff, + (index >>> 8) & 0xff, + 255, + ]), + )]); + break; + case "maximum-opaque-utf8-bytes": + value = request([ + notApplicable("r", "o", "é".repeat((limits.opaqueUtf8Bytes - 2) / 2)), + ]); + break; + case "maximum-canonical-not-applicable-relations": + value = request(Array.from({ length: max }, (_, index) => { + const suffix = String(index).padStart(4, "0"); + return notApplicable(`r${suffix}`, `o${suffix}`, "declared"); + })); + break; + case "maximum-combined-not-applicable-envelope": { + const baseOpaqueBytes = max * 5; + const remaining = limits.opaqueUtf8Bytes - baseOpaqueBytes; + value = request(Array.from({ length: max }, (_, index) => notApplicable( + maximallyEscapedIdentity(index), + "\0", + "\0".repeat(index === 0 ? remaining + 1 : 1), + ))); + break; + } + case "maximum-combined-applicable-envelope": + case "transport-limit-plus-one": { + const baseOpaqueBytes = max * 4; + const remaining = limits.opaqueUtf8Bytes - baseOpaqueBytes; + value = request(Array.from({ length: max }, (_, index) => relation( + maximallyEscapedIdentity(index), + "\0".repeat(index === 0 ? remaining + 1 : 1), + "sc-1.4.11-ui-component-or-state", + [[255, 255, 255]], + ))); + break; + } + case "maximum-raw-duplicate-relations": { + const duplicate = relation("duplicate", "same", criterion, [neutralAdjacent]); + value = request(Array.from({ length: max }, () => duplicate)); + break; + } + case "maximum-raw-adjacent-duplicates": + value = request([relation( + "r", + "o", + criterion, + Array.from({ length: limits.rawAdjacentEntries }, () => neutralAdjacent), + )]); + break; + default: + fail(`unknown scenario ${scenarioId}`); + } + + const source = JSON.stringify(value); + const raw = new TextEncoder().encode(source); + const expected = expectedShape(sourceContracts().core.value, scenarioId); + const rawRelations = value.relations.length; + const rawAdjacentEntries = value.relations.reduce( + (sum, item) => sum + (item.kind === "applicable" ? item.adjacent.length : 0), + 0, + ); + const opaqueUtf8Bytes = value.relations.reduce( + (sum, item) => + sum + + utf8Bytes(item.relationId) + + utf8Bytes(item.occurrenceId) + + (item.kind === "notApplicable" ? utf8Bytes(item.reasonId) : 0), + 0, + ); + if ( + rawRelations !== expected.rawRelations || + rawAdjacentEntries !== expected.rawAdjacentEntries || + opaqueUtf8Bytes !== expected.opaqueUtf8Bytes + ) { + fail(`${scenarioId} generator does not attain its Core-owned raw shape`); + } + if ( + (scenarioId === "maximum-combined-applicable-envelope" || + scenarioId === "transport-limit-plus-one") && + raw.byteLength !== limits.maxRequestBytes + ) { + fail(`${scenarioId} generator does not attain the derived compact byte ceiling`); + } + return { + shape: expected, + bytes: + scenarioId === "transport-limit-plus-one" + ? new Uint8Array([...raw, 0x20]) + : raw, + }; +} + +function hasProportionalField(value) { + if (Array.isArray(value)) return value.some(hasProportionalField); + if (!isRecord(value)) return false; + return Object.entries(value).some( + ([key, child]) => proportionalKeys.has(key) || hasProportionalField(child), + ); +} + +function packedBit(bytes, logicalIndex) { + return (bytes[Math.floor(logicalIndex / 8)] & (1 << (logicalIndex % 8))) !== 0; +} + +function summarizeOutcome(outcome, scenarioId) { + const proportionalFieldsPresent = hasProportionalField(outcome); + if (outcome?.outcome === "failure") { + const protocol = outcome.error; + if (protocol?.source !== "transport" || protocol.error?.code !== "envelopeTooLarge") { + fail("measurement encountered an unexpected failure branch"); + } + return { + outcome: "failure", + source: protocol.source, + code: protocol.error.code, + requestedBytes: protocol.error.requestedBytes, + limitBytes: protocol.error.limitBytes, + proportionalFieldsPresent, + }; + } + if (outcome?.outcome !== "success") fail("measurement returned an unknown outcome algebra"); + const feasibility = outcome.feasibility; + if (feasibility?.status === "notEvaluated") { + const relations = feasibility.result?.relations; + if (!Array.isArray(relations) || relations.some((item) => item.kind !== "notApplicable")) { + fail("NotEvaluated did not retain declaration-only relations"); + } + return { + outcome: "success", + terminal: feasibility.status, + canonicalRelations: String(relations.length), + applicableRelations: "0", + notApplicableRelations: String(relations.length), + numericalEvidencePresent: ["domain", "failureMatrix", "partition", "proof"].some( + (field) => field in feasibility.result, + ), + proportionalFieldsPresent, + }; + } + if (!["feasible", "infeasible"].includes(feasibility?.status)) { + fail("measurement returned an unknown feasibility terminal"); + } + const result = feasibility.result; + const proof = result?.proof; + const domain = result?.domain; + const matrix = result?.failureMatrix; + const partition = proof?.partition; + if (!Array.isArray(domain) || domain.length !== candidateCount) { + fail("evaluated result does not transport the complete 256-state domain"); + } + domain.forEach((candidate, index) => { + if (!isDeepStrictEqual(candidate, [index, index, index])) { + fail(`domain candidate ${index} is not the registered neutral-axis state`); + } + }); + if (!Array.isArray(matrix) || !Array.isArray(partition)) { + fail("evaluated result lacks packed matrix or partition bytes"); + } + if ( + matrix.some((byte) => !Number.isInteger(byte) || byte < 0 || byte > 255) || + partition.some((byte) => !Number.isInteger(byte) || byte < 0 || byte > 255) + ) { + fail("evaluated result contains a non-byte packed value"); + } + const edges = Number(decimal(proof.applicableEdges, "proof.applicableEdges")); + if (matrix.length !== 32 * edges || partition.length !== 32) { + fail("evaluated result violates the exact packed-storage law"); + } + let feasibleCandidates = 0; + let lsb0PartitionMatchesMatrix = true; + for (let candidate = 0; candidate < candidateCount; candidate += 1) { + let rowFailed = false; + for (let edge = 0; edge < edges; edge += 1) { + rowFailed ||= packedBit(matrix, candidate * edges + edge); + } + const rowFeasible = !rowFailed; + lsb0PartitionMatchesMatrix &&= packedBit(partition, candidate) === rowFeasible; + feasibleCandidates += Number(rowFeasible); + } + let lsb0PackOracleMatches; + if (scenarioId === "minimum-evaluated") { + const oracle = packOracle(); + lsb0PackOracleMatches = + isDeepStrictEqual(matrix, oracle.matrix) && + isDeepStrictEqual(partition, oracle.partition); + } + return { + outcome: "success", + terminal: feasibility.status, + domainCount: proof.domainCount, + canonicalRelations: proof.canonicalRelations, + applicableRelations: proof.applicableRelations, + notApplicableRelations: proof.notApplicableRelations, + applicableEdges: proof.applicableEdges, + logicalAssessments: proof.logicalAssessments, + failureMatrixBytes: matrix.length, + partitionBytes: partition.length, + feasibleCandidates, + lsb0PartitionMatchesMatrix, + ...(scenarioId === "minimum-evaluated" ? { lsb0PackOracleMatches } : {}), + proportionalFieldsPresent, + }; +} + +function wasmMemory(initOutput) { + const memory = initOutput?.memory; + if (!(memory instanceof WebAssembly.Memory)) { + fail("real wasm-bindgen InitOutput does not expose WebAssembly.Memory"); + } + const bytes = memory.buffer.byteLength; + if (bytes % pageBytes !== 0) fail("WASM memory byte length is not page-aligned"); + return { bytes, pages: bytes / pageBytes }; +} + +async function measureOneSample(scenarioId) { + const { core } = sourceContracts(); + const wasmBytes = readFileSync(wasmPath); + const rootApi = await import(pathToFileURL(packageEntry).href); + const initOutput = rootApi.initSync({ module: wasmBytes }); + const publicMax = rootApi.wcag22FeasibilityMaxBytes(); + positiveSafeInteger(publicMax, "public max getter"); + const limits = { maxRequestBytes: publicMax, ...coreLimits(core.value) }; + const built = buildScenario(scenarioId, limits); + const requestSha256 = sha256(built.bytes); + + const beforeRss = process.resourceUsage().maxRSS; + const beforeMemory = wasmMemory(initOutput); + const started = process.hrtime.bigint(); + const outcome = rootApi.evaluateWcag22Feasibility(built.bytes); + const elapsedNs = process.hrtime.bigint() - started; + const afterMemory = wasmMemory(initOutput); + const afterRss = process.resourceUsage().maxRSS; + + const outcomeBytes = new TextEncoder().encode(JSON.stringify(outcome)); + return { + scenarioId, + maxRequestBytes: publicMax, + shape: built.shape, + sample: { + sampleIndex: 0, + elapsedNs: elapsedNs.toString(), + requestBytes: built.bytes.byteLength, + requestSha256, + outcomeBytes: outcomeBytes.byteLength, + outcomeSha256: sha256(outcomeBytes), + summary: summarizeOutcome(outcome, scenarioId), + processMaxRssKiBBefore: beforeRss, + processMaxRssKiBAfter: afterRss, + wasmMemoryBytesBefore: beforeMemory.bytes, + wasmMemoryBytesAfter: afterMemory.bytes, + wasmMemoryPagesBefore: beforeMemory.pages, + wasmMemoryPagesAfter: afterMemory.pages, + }, + }; +} + +function childSample(scenarioId) { + const child = spawnSync(process.execPath, [fileURLToPath(import.meta.url), "--sample", scenarioId], { + cwd: packageRoot, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + timeout: 5 * 60 * 1_000, + }); + if (child.status !== 0) { + fail( + `fresh child failed for ${scenarioId}: ${child.stderr.trim() || child.stdout.trim()}`, + ); + } + try { + return JSON.parse(child.stdout.trim()); + } catch (error) { + fail(`fresh child emitted non-JSON for ${scenarioId}: ${error.message}`); + } +} + +function measurementArtifact() { + const { core, toolchain } = sourceContracts(); + const profileLimits = coreLimits(core.value); + const measuredSampleCount = requiredSampleCount(core.value); + const oracle = packOracle(); + const sources = runtimeSourceBindings(); + const wasm = readFileSync(wasmPath); + if (wasm.length < 8 || !wasm.subarray(0, 4).equals(Buffer.from([0, 97, 115, 109]))) { + fail("built package WASM is absent or malformed"); + } + let publicMax; + const scenarios = SCENARIO_IDS.map((scenarioId) => { + const samples = Array.from({ length: measuredSampleCount }, (_, index) => { + const measured = childSample(scenarioId); + publicMax ??= measured.maxRequestBytes; + if ( + measured.scenarioId !== scenarioId || + measured.maxRequestBytes !== publicMax || + !isDeepStrictEqual(measured.shape, expectedShape(core.value, scenarioId)) + ) { + fail(`${scenarioId} child did not preserve derived identity/shape`); + } + return { ...measured.sample, sampleIndex: index }; + }); + return { + scenarioId, + shape: expectedShape(core.value, scenarioId), + samples, + }; + }); + positiveSafeInteger(publicMax, "fresh-child public max getter"); + const limits = { maxRequestBytes: publicMax, ...profileLimits }; + const platform = `${process.platform}-${process.arch}`; + const measuredToolchain = toolchain.value.measurement; + const artifact = { + schemaVersion: 1, + artifactId: MEASUREMENT_ARTIFACT_ID, + claimBoundary: "canonical-wasm-package-root-whole-call-observations-only", + claims: { + admission: "canonical-linux-x64-exact-wasm-only", + hardGates, + timingThresholdNs: null, + latency: "observation-only-no-production-threshold", + memory: memoryClaim, + }, + environment: { + execution: "fresh-node-child-process-per-sample", + platform, + nodeVersion: process.version, + sampleCount: measuredSampleCount, + requestConstructionMeasured: false, + timer: "process.hrtime.bigint", + packageRootApi: "packages/colors/index.js", + canonicalCandidate: + platform === "linux-x64" && + canonicalPackageInput && + process.version === canonicalNodeVersion(), + rustToolchain: measuredToolchain.rustToolchain, + wasmPack: measuredToolchain.wasmPack, + wasmBindgen: measuredToolchain.wasmBindgen, + target: measuredToolchain.target, + cargoProfile: measuredToolchain.cargoProfile, + wasmOpt: measuredToolchain.wasmOpt, + }, + bindings: { + coreAdmission: { + path: "crates/labcolors-core/contracts/wcag22-feasibility-benchmark-v1.json", + schemaVersion: core.value.schemaVersion, + artifactId: core.value.artifactId, + profileId: core.value.profileLimits.profileId, + sha256: sha256(core.bytes), + }, + packOracle: { + path: oracle.path, + sha256: oracle.sha256, + caseId: oracle.caseId, + requestSha256: oracle.requestSha256, + outcomeSha256: oracle.outcomeSha256, + manifestPath: oracle.manifestPath, + manifestSha256: oracle.manifestSha256, + packVersion: oracle.packVersion, + packDigest: oracle.packDigest, + }, + runtimeSources: sources, + wasmToolchain: { + path: "packages/colors/bench/wasm-size-budget-v1.json", + schemaVersion: toolchain.value.schemaVersion, + budgetId: toolchain.value.budgetId, + recipeSha256: sha256( + Buffer.from(JSON.stringify(toolchainRecipe(toolchain.value)), "utf8"), + ), + }, + wasm: { + path: "packages/colors/pkg/labcolors_bg.wasm", + bytes: wasm.length, + sha256: sha256(wasm), + }, + }, + limits, + scenarios, + }; + validateMeasurementArtifact(artifact, { requireCanonicalArtifact: false }); + return artifact; +} + +function deterministicProjection(artifact) { + return { + bindings: artifact.bindings, + limits: artifact.limits, + scenarios: artifact.scenarios.map((scenario) => ({ + scenarioId: scenario.scenarioId, + shape: scenario.shape, + samples: scenario.samples.map((sample) => ({ + sampleIndex: sample.sampleIndex, + requestBytes: sample.requestBytes, + requestSha256: sample.requestSha256, + outcomeBytes: sample.outcomeBytes, + outcomeSha256: sample.outcomeSha256, + summary: sample.summary, + })), + })), + }; +} + +function verifyMeasurement(path) { + const loaded = readJsonWithBytes(path, "committed WASM boundary artifact"); + const expected = loaded.value; + if (loaded.bytes.toString("utf8") !== `${JSON.stringify(expected)}\n`) { + fail("committed WASM boundary artifact must be canonical compact JSON plus newline"); + } + validateMeasurementArtifact(expected); + const actualPlatform = `${process.platform}-${process.arch}`; + if ( + actualPlatform !== expected.environment.platform || + process.version !== expected.environment.nodeVersion + ) { + fail( + `verify requires ${expected.environment.platform}/${expected.environment.nodeVersion}, ` + + `got ${actualPlatform}/${process.version}`, + ); + } + const wasm = readFileSync(wasmPath); + if ( + wasm.length !== expected.bindings.wasm.bytes || + sha256(wasm) !== expected.bindings.wasm.sha256 + ) { + fail("built WASM bytes differ from the exact committed canonical artifact"); + } + const rerun = measurementArtifact(); + if (!isDeepStrictEqual(deterministicProjection(rerun), deterministicProjection(expected))) { + fail("whole-call rerun differs in immutable request/outcome/scenario evidence"); + } + process.stdout.write( + `PASS ${MEASUREMENT_ARTIFACT_ID} ${expected.bindings.wasm.bytes}B ` + + `${expected.bindings.wasm.sha256} scenarios=${SCENARIO_IDS.length}\n`, + ); +} + +function usage() { + fail( + "usage: --emit | --record | --verify [path] | --sample ", + ); +} + +async function main(args) { + const [mode, value, extra] = args; + if (extra !== undefined) usage(); + if (mode === "--sample" && value) { + process.stdout.write(JSON.stringify(await measureOneSample(value))); + return; + } + if (mode === "--emit" && value === undefined) { + process.stdout.write(`${JSON.stringify(measurementArtifact())}\n`); + return; + } + if (mode === "--record" && value) { + const artifact = measurementArtifact(); + validateMeasurementArtifact(artifact); + writeFileSync(resolve(value), `${JSON.stringify(artifact)}\n`, { flag: "wx" }); + process.stdout.write(`${JSON.stringify(artifact)}\n`); + return; + } + if (mode === "--verify" && extra === undefined) { + verifyMeasurement(value ? resolve(value) : defaultMeasurementPath); + return; + } + usage(); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)).catch((error) => { + process.stderr.write(`${error.stack ?? error}\n`); + process.exitCode = 1; + }); +} diff --git a/packages/colors/bench/wcag22-feasibility-wasm-boundary-v1.json b/packages/colors/bench/wcag22-feasibility-wasm-boundary-v1.json new file mode 100644 index 00000000..bda8e8e1 --- /dev/null +++ b/packages/colors/bench/wcag22-feasibility-wasm-boundary-v1.json @@ -0,0 +1 @@ +{"schemaVersion":1,"artifactId":"wcag22-feasibility-wasm-whole-call-v1","claimBoundary":"canonical-wasm-package-root-whole-call-observations-only","claims":{"admission":"canonical-linux-x64-exact-wasm-only","hardGates":["completion","request-and-outcome-bytes","sha256-binding","terminal-algebra","packed-shape","candidate-major-lsb0-pack-oracle","no-proportional-dto"],"timingThresholdNs":null,"latency":"observation-only-no-production-threshold","memory":"process maxRSS is total-process high-water including V8; post-call WASM pages are linear-memory high-water observations; neither is total operation memory"},"environment":{"execution":"fresh-node-child-process-per-sample","platform":"linux-x64","nodeVersion":"v24.14.0","sampleCount":5,"requestConstructionMeasured":false,"timer":"process.hrtime.bigint","packageRootApi":"packages/colors/index.js","canonicalCandidate":true,"rustToolchain":"1.96.0","wasmPack":"0.13.1","wasmBindgen":"0.2.126","target":"wasm32-unknown-unknown","cargoProfile":"release","wasmOpt":"-Oz"},"bindings":{"coreAdmission":{"path":"crates/labcolors-core/contracts/wcag22-feasibility-benchmark-v1.json","schemaVersion":1,"artifactId":"wcag22-feasibility-admission-raw-v1","profileId":"compile-v1","sha256":"7e9ffcbdd9d5d50fe681f511c34fc5c5dd270e9c475ce23ae56e9776922a3c5e"},"packOracle":{"path":"conformance/vectors/wcag22-feasibility.json","sha256":"ae2caec47a7b650e73b8d4029a69b4e401dfb7cc199db579c0f95106eebe8dc3","caseId":"text-default-seven","requestSha256":"8da46d4cb835c4e3c89b8f56ec4025457e0e4308ed28adcfa7beb134858e3d6f","outcomeSha256":"9dbe5499abfd07cd915da6da0ca1578278aebc33b0f24c41c6318858026ff442","manifestPath":"conformance/vectors/manifest.json","manifestSha256":"6b873967ba648b4ec160fd3e909a3359d6ffcb0f129bf38711aa74f3dff5a63d","packVersion":"5.0.0","packDigest":"48d00cd5"},"runtimeSources":{"harness":{"path":"packages/colors/bench/wcag22-feasibility-boundary.bench.mjs","sha256":"edad6ca3e5f5361c176831cbdb5b6caec0a8c552d21ac7937c8aa86480452f49"},"packageManifest":{"path":"packages/colors/package.json","sha256":"a3fa15d4ee232abb8ff392a47bd1b2768198255106e9435da2ab2ef0777c9fdf"},"packageRoot":{"path":"packages/colors/index.js","sha256":"5b414bfc3abca90996003d30a87d868b652dc298dd0deec3c1110a746fe3f833"},"wasmGlue":{"path":"packages/colors/pkg/labcolors.js","sha256":"b9933eff570f8120726341933dc765efa0c2cba7d37a83cef83f3bd1f13db9d7"},"adaptTheme":{"path":"packages/colors/adapt-theme.js","sha256":"5629566a0dce766c7101852a2b19b58066916427db11b7844b29b3b45133b06a"},"applyTheme":{"path":"packages/colors/apply-theme.js","sha256":"36a4c598042cd2be8579ef7a315ee598a25954c4e990aeec6382177bab53a76a"},"effectiveBackground":{"path":"packages/colors/effective-bg.js","sha256":"03cf758df0cdb34c840bedfab8a996eb44a8ad9b603bf241c3a8a9f88b359460"},"watchTheme":{"path":"packages/colors/watch-theme.js","sha256":"f0287092f1ed4d413886b2650e930afcb527ecb3ad6536dbe548347d12159ebd"}},"wasmToolchain":{"path":"packages/colors/bench/wasm-size-budget-v1.json","schemaVersion":2,"budgetId":"labcolors-wasm-raw-issue-284-v1","recipeSha256":"0ea74cb070e0a5facb7280f6124930a0bb673ee4dcee9c99fff110db6c9389d4"},"wasm":{"path":"packages/colors/pkg/labcolors_bg.wasm","bytes":521240,"sha256":"d37841bfb2615d05c8366b08dcc7e5aed1bbd3cf27c3db67896108c5ec9c9ca0"}},"limits":{"maxRequestBytes":657380,"profileId":"compile-v1","rawRelations":2047,"rawAdjacentEntries":2047,"opaqueUtf8Bytes":65536,"canonicalRelations":2047,"applicableEdges":2047,"logicalAssessments":524032,"packedResultBytes":65536},"scenarios":[{"scenarioId":"minimum-evaluated","shape":{"rawRelations":1,"rawAdjacentEntries":1,"opaqueUtf8Bytes":2,"canonicalRelations":1,"applicableRelations":1,"applicableEdges":1},"samples":[{"sampleIndex":0,"elapsedNs":"6797003","requestBytes":222,"requestSha256":"21ff2cc1a9b3da86e633f999a5c8417f0be059c499fc9114490e274b319d2ee7","outcomeBytes":4823,"outcomeSha256":"a6196a5aaec38b74029c0d6f5016b9249008d7cd9544c3d5cf5de9932770e090","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"1","logicalAssessments":"256","failureMatrixBytes":32,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"lsb0PackOracleMatches":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":55968,"processMaxRssKiBAfter":57260,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1245184,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":19},{"sampleIndex":1,"elapsedNs":"4074156","requestBytes":222,"requestSha256":"21ff2cc1a9b3da86e633f999a5c8417f0be059c499fc9114490e274b319d2ee7","outcomeBytes":4823,"outcomeSha256":"a6196a5aaec38b74029c0d6f5016b9249008d7cd9544c3d5cf5de9932770e090","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"1","logicalAssessments":"256","failureMatrixBytes":32,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"lsb0PackOracleMatches":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":56060,"processMaxRssKiBAfter":57212,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1245184,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":19},{"sampleIndex":2,"elapsedNs":"9780233","requestBytes":222,"requestSha256":"21ff2cc1a9b3da86e633f999a5c8417f0be059c499fc9114490e274b319d2ee7","outcomeBytes":4823,"outcomeSha256":"a6196a5aaec38b74029c0d6f5016b9249008d7cd9544c3d5cf5de9932770e090","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"1","logicalAssessments":"256","failureMatrixBytes":32,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"lsb0PackOracleMatches":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":56108,"processMaxRssKiBAfter":57440,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1245184,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":19},{"sampleIndex":3,"elapsedNs":"22370955","requestBytes":222,"requestSha256":"21ff2cc1a9b3da86e633f999a5c8417f0be059c499fc9114490e274b319d2ee7","outcomeBytes":4823,"outcomeSha256":"a6196a5aaec38b74029c0d6f5016b9249008d7cd9544c3d5cf5de9932770e090","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"1","logicalAssessments":"256","failureMatrixBytes":32,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"lsb0PackOracleMatches":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":54260,"processMaxRssKiBAfter":55464,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1245184,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":19},{"sampleIndex":4,"elapsedNs":"9011452","requestBytes":222,"requestSha256":"21ff2cc1a9b3da86e633f999a5c8417f0be059c499fc9114490e274b319d2ee7","outcomeBytes":4823,"outcomeSha256":"a6196a5aaec38b74029c0d6f5016b9249008d7cd9544c3d5cf5de9932770e090","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"1","logicalAssessments":"256","failureMatrixBytes":32,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"lsb0PackOracleMatches":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":54220,"processMaxRssKiBAfter":55372,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1245184,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":19}]},{"scenarioId":"maximum-canonical-applicable-relations","shape":{"rawRelations":2047,"rawAdjacentEntries":2047,"opaqueUtf8Bytes":20470,"canonicalRelations":2047,"applicableRelations":2047,"applicableEdges":2047},"samples":[{"sampleIndex":0,"elapsedNs":"284403596","requestBytes":264164,"requestSha256":"83955d22987a29e4df0c91647cecadcb3a7e7f5bc06010e0b05519fac4b938d2","outcomeBytes":527090,"outcomeSha256":"dedc563b2a3f7bee7a0f37c5976af853814ac4e9ce12f27efaa3ec8661a79b1c","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"2047","applicableRelations":"2047","notApplicableRelations":"0","applicableEdges":"2047","logicalAssessments":"524032","failureMatrixBytes":65504,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":56624,"processMaxRssKiBAfter":68348,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":4259840,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":65},{"sampleIndex":1,"elapsedNs":"211332656","requestBytes":264164,"requestSha256":"83955d22987a29e4df0c91647cecadcb3a7e7f5bc06010e0b05519fac4b938d2","outcomeBytes":527090,"outcomeSha256":"dedc563b2a3f7bee7a0f37c5976af853814ac4e9ce12f27efaa3ec8661a79b1c","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"2047","applicableRelations":"2047","notApplicableRelations":"0","applicableEdges":"2047","logicalAssessments":"524032","failureMatrixBytes":65504,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":58256,"processMaxRssKiBAfter":72704,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":4259840,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":65},{"sampleIndex":2,"elapsedNs":"328038886","requestBytes":264164,"requestSha256":"83955d22987a29e4df0c91647cecadcb3a7e7f5bc06010e0b05519fac4b938d2","outcomeBytes":527090,"outcomeSha256":"dedc563b2a3f7bee7a0f37c5976af853814ac4e9ce12f27efaa3ec8661a79b1c","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"2047","applicableRelations":"2047","notApplicableRelations":"0","applicableEdges":"2047","logicalAssessments":"524032","failureMatrixBytes":65504,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":58300,"processMaxRssKiBAfter":72040,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":4259840,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":65},{"sampleIndex":3,"elapsedNs":"377642199","requestBytes":264164,"requestSha256":"83955d22987a29e4df0c91647cecadcb3a7e7f5bc06010e0b05519fac4b938d2","outcomeBytes":527090,"outcomeSha256":"dedc563b2a3f7bee7a0f37c5976af853814ac4e9ce12f27efaa3ec8661a79b1c","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"2047","applicableRelations":"2047","notApplicableRelations":"0","applicableEdges":"2047","logicalAssessments":"524032","failureMatrixBytes":65504,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":56868,"processMaxRssKiBAfter":67596,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":4259840,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":65},{"sampleIndex":4,"elapsedNs":"150545765","requestBytes":264164,"requestSha256":"83955d22987a29e4df0c91647cecadcb3a7e7f5bc06010e0b05519fac4b938d2","outcomeBytes":527090,"outcomeSha256":"dedc563b2a3f7bee7a0f37c5976af853814ac4e9ce12f27efaa3ec8661a79b1c","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"2047","applicableRelations":"2047","notApplicableRelations":"0","applicableEdges":"2047","logicalAssessments":"524032","failureMatrixBytes":65504,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":58376,"processMaxRssKiBAfter":71784,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":4259840,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":65}]},{"scenarioId":"maximum-applicable-edges","shape":{"rawRelations":1,"rawAdjacentEntries":2047,"opaqueUtf8Bytes":19,"canonicalRelations":1,"applicableRelations":1,"applicableEdges":2047},"samples":[{"sampleIndex":0,"elapsedNs":"231849342","requestBytes":23909,"requestSha256":"9a60a035743121bc4b79660f3a76f75ff1d3da2c5b3a243449442f972e8af9ae","outcomeBytes":269795,"outcomeSha256":"7f08a3d65184a40adc8de04edc352511130fe6fc8e223729996a11cd18bc622c","summary":{"outcome":"success","terminal":"infeasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"2047","logicalAssessments":"524032","failureMatrixBytes":65504,"partitionBytes":32,"feasibleCandidates":0,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":56256,"processMaxRssKiBAfter":66104,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":2228224,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":34},{"sampleIndex":1,"elapsedNs":"285102118","requestBytes":23909,"requestSha256":"9a60a035743121bc4b79660f3a76f75ff1d3da2c5b3a243449442f972e8af9ae","outcomeBytes":269795,"outcomeSha256":"7f08a3d65184a40adc8de04edc352511130fe6fc8e223729996a11cd18bc622c","summary":{"outcome":"success","terminal":"infeasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"2047","logicalAssessments":"524032","failureMatrixBytes":65504,"partitionBytes":32,"feasibleCandidates":0,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":54440,"processMaxRssKiBAfter":65656,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":2228224,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":34},{"sampleIndex":2,"elapsedNs":"101696689","requestBytes":23909,"requestSha256":"9a60a035743121bc4b79660f3a76f75ff1d3da2c5b3a243449442f972e8af9ae","outcomeBytes":269795,"outcomeSha256":"7f08a3d65184a40adc8de04edc352511130fe6fc8e223729996a11cd18bc622c","summary":{"outcome":"success","terminal":"infeasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"2047","logicalAssessments":"524032","failureMatrixBytes":65504,"partitionBytes":32,"feasibleCandidates":0,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":54232,"processMaxRssKiBAfter":64752,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":2228224,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":34},{"sampleIndex":3,"elapsedNs":"157785234","requestBytes":23909,"requestSha256":"9a60a035743121bc4b79660f3a76f75ff1d3da2c5b3a243449442f972e8af9ae","outcomeBytes":269795,"outcomeSha256":"7f08a3d65184a40adc8de04edc352511130fe6fc8e223729996a11cd18bc622c","summary":{"outcome":"success","terminal":"infeasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"2047","logicalAssessments":"524032","failureMatrixBytes":65504,"partitionBytes":32,"feasibleCandidates":0,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":54224,"processMaxRssKiBAfter":63764,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":2228224,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":34},{"sampleIndex":4,"elapsedNs":"162747300","requestBytes":23909,"requestSha256":"9a60a035743121bc4b79660f3a76f75ff1d3da2c5b3a243449442f972e8af9ae","outcomeBytes":269795,"outcomeSha256":"7f08a3d65184a40adc8de04edc352511130fe6fc8e223729996a11cd18bc622c","summary":{"outcome":"success","terminal":"infeasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"2047","logicalAssessments":"524032","failureMatrixBytes":65504,"partitionBytes":32,"feasibleCandidates":0,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":54332,"processMaxRssKiBAfter":64136,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":2228224,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":34}]},{"scenarioId":"maximum-opaque-utf8-bytes","shape":{"rawRelations":1,"rawAdjacentEntries":0,"opaqueUtf8Bytes":65536,"canonicalRelations":1,"applicableRelations":0,"applicableEdges":0},"samples":[{"sampleIndex":0,"elapsedNs":"33663889","requestBytes":65710,"requestSha256":"4c2106974c30967456ac4d92bced32418cb0cb9b8f9ba97e53f19e0f629cf1f2","outcomeBytes":66050,"outcomeSha256":"4651c1ed3d6d43ef8d0eeaf78bb745d4b58ba7d160877df4a23433b233869f18","summary":{"outcome":"success","terminal":"notEvaluated","canonicalRelations":"1","applicableRelations":"0","notApplicableRelations":"1","numericalEvidencePresent":false,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":56232,"processMaxRssKiBAfter":61504,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1572864,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":24},{"sampleIndex":1,"elapsedNs":"15353309","requestBytes":65710,"requestSha256":"4c2106974c30967456ac4d92bced32418cb0cb9b8f9ba97e53f19e0f629cf1f2","outcomeBytes":66050,"outcomeSha256":"4651c1ed3d6d43ef8d0eeaf78bb745d4b58ba7d160877df4a23433b233869f18","summary":{"outcome":"success","terminal":"notEvaluated","canonicalRelations":"1","applicableRelations":"0","notApplicableRelations":"1","numericalEvidencePresent":false,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":56236,"processMaxRssKiBAfter":61640,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1572864,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":24},{"sampleIndex":2,"elapsedNs":"7861688","requestBytes":65710,"requestSha256":"4c2106974c30967456ac4d92bced32418cb0cb9b8f9ba97e53f19e0f629cf1f2","outcomeBytes":66050,"outcomeSha256":"4651c1ed3d6d43ef8d0eeaf78bb745d4b58ba7d160877df4a23433b233869f18","summary":{"outcome":"success","terminal":"notEvaluated","canonicalRelations":"1","applicableRelations":"0","notApplicableRelations":"1","numericalEvidencePresent":false,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":54404,"processMaxRssKiBAfter":59456,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1572864,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":24},{"sampleIndex":3,"elapsedNs":"10565477","requestBytes":65710,"requestSha256":"4c2106974c30967456ac4d92bced32418cb0cb9b8f9ba97e53f19e0f629cf1f2","outcomeBytes":66050,"outcomeSha256":"4651c1ed3d6d43ef8d0eeaf78bb745d4b58ba7d160877df4a23433b233869f18","summary":{"outcome":"success","terminal":"notEvaluated","canonicalRelations":"1","applicableRelations":"0","notApplicableRelations":"1","numericalEvidencePresent":false,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":54196,"processMaxRssKiBAfter":59564,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1572864,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":24},{"sampleIndex":4,"elapsedNs":"7964923","requestBytes":65710,"requestSha256":"4c2106974c30967456ac4d92bced32418cb0cb9b8f9ba97e53f19e0f629cf1f2","outcomeBytes":66050,"outcomeSha256":"4651c1ed3d6d43ef8d0eeaf78bb745d4b58ba7d160877df4a23433b233869f18","summary":{"outcome":"success","terminal":"notEvaluated","canonicalRelations":"1","applicableRelations":"0","notApplicableRelations":"1","numericalEvidencePresent":false,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":54288,"processMaxRssKiBAfter":59788,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1572864,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":24}]},{"scenarioId":"maximum-canonical-not-applicable-relations","shape":{"rawRelations":2047,"rawAdjacentEntries":0,"opaqueUtf8Bytes":36846,"canonicalRelations":2047,"applicableRelations":0,"applicableEdges":0},"samples":[{"sampleIndex":0,"elapsedNs":"19423868","requestBytes":186378,"requestSha256":"b92c8700a4f6f6979e9476d56e57166618971aaecdbf4445ccd6aac124f821b7","outcomeBytes":186719,"outcomeSha256":"bf21a25ad92cfefad9c7116c16f918d97dab5e18e610a21482b26fdf5a948d69","summary":{"outcome":"success","terminal":"notEvaluated","canonicalRelations":"2047","applicableRelations":"0","notApplicableRelations":"2047","numericalEvidencePresent":false,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":59116,"processMaxRssKiBAfter":70660,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":2162688,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":33},{"sampleIndex":1,"elapsedNs":"26496648","requestBytes":186378,"requestSha256":"b92c8700a4f6f6979e9476d56e57166618971aaecdbf4445ccd6aac124f821b7","outcomeBytes":186719,"outcomeSha256":"bf21a25ad92cfefad9c7116c16f918d97dab5e18e610a21482b26fdf5a948d69","summary":{"outcome":"success","terminal":"notEvaluated","canonicalRelations":"2047","applicableRelations":"0","notApplicableRelations":"2047","numericalEvidencePresent":false,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":59272,"processMaxRssKiBAfter":68364,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":2162688,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":33},{"sampleIndex":2,"elapsedNs":"27000578","requestBytes":186378,"requestSha256":"b92c8700a4f6f6979e9476d56e57166618971aaecdbf4445ccd6aac124f821b7","outcomeBytes":186719,"outcomeSha256":"bf21a25ad92cfefad9c7116c16f918d97dab5e18e610a21482b26fdf5a948d69","summary":{"outcome":"success","terminal":"notEvaluated","canonicalRelations":"2047","applicableRelations":"0","notApplicableRelations":"2047","numericalEvidencePresent":false,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":59240,"processMaxRssKiBAfter":70760,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":2162688,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":33},{"sampleIndex":3,"elapsedNs":"48798368","requestBytes":186378,"requestSha256":"b92c8700a4f6f6979e9476d56e57166618971aaecdbf4445ccd6aac124f821b7","outcomeBytes":186719,"outcomeSha256":"bf21a25ad92cfefad9c7116c16f918d97dab5e18e610a21482b26fdf5a948d69","summary":{"outcome":"success","terminal":"notEvaluated","canonicalRelations":"2047","applicableRelations":"0","notApplicableRelations":"2047","numericalEvidencePresent":false,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":57076,"processMaxRssKiBAfter":69924,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":2162688,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":33},{"sampleIndex":4,"elapsedNs":"37050528","requestBytes":186378,"requestSha256":"b92c8700a4f6f6979e9476d56e57166618971aaecdbf4445ccd6aac124f821b7","outcomeBytes":186719,"outcomeSha256":"bf21a25ad92cfefad9c7116c16f918d97dab5e18e610a21482b26fdf5a948d69","summary":{"outcome":"success","terminal":"notEvaluated","canonicalRelations":"2047","applicableRelations":"0","notApplicableRelations":"2047","numericalEvidencePresent":false,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":57544,"processMaxRssKiBAfter":68648,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":2162688,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":33}]},{"scenarioId":"maximum-combined-not-applicable-envelope","shape":{"rawRelations":2047,"rawAdjacentEntries":0,"opaqueUtf8Bytes":65536,"canonicalRelations":2047,"applicableRelations":0,"applicableEdges":0},"samples":[{"sampleIndex":0,"elapsedNs":"27843880","requestBytes":542748,"requestSha256":"25ecc80095ed218f461bfe5283fa19d3e440eb603d49e8b65b65f5ec57a92ece","outcomeBytes":543093,"outcomeSha256":"dab7eda461b67a099559b2419b077c541991f62263f5e393322f278164835c33","summary":{"outcome":"success","terminal":"notEvaluated","canonicalRelations":"2047","applicableRelations":"0","notApplicableRelations":"2047","numericalEvidencePresent":false,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":60836,"processMaxRssKiBAfter":73804,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":4194304,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":64},{"sampleIndex":1,"elapsedNs":"59693443","requestBytes":542748,"requestSha256":"25ecc80095ed218f461bfe5283fa19d3e440eb603d49e8b65b65f5ec57a92ece","outcomeBytes":543093,"outcomeSha256":"dab7eda461b67a099559b2419b077c541991f62263f5e393322f278164835c33","summary":{"outcome":"success","terminal":"notEvaluated","canonicalRelations":"2047","applicableRelations":"0","notApplicableRelations":"2047","numericalEvidencePresent":false,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":59700,"processMaxRssKiBAfter":74976,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":4194304,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":64},{"sampleIndex":2,"elapsedNs":"27117204","requestBytes":542748,"requestSha256":"25ecc80095ed218f461bfe5283fa19d3e440eb603d49e8b65b65f5ec57a92ece","outcomeBytes":543093,"outcomeSha256":"dab7eda461b67a099559b2419b077c541991f62263f5e393322f278164835c33","summary":{"outcome":"success","terminal":"notEvaluated","canonicalRelations":"2047","applicableRelations":"0","notApplicableRelations":"2047","numericalEvidencePresent":false,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":58028,"processMaxRssKiBAfter":69676,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":4194304,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":64},{"sampleIndex":3,"elapsedNs":"98384906","requestBytes":542748,"requestSha256":"25ecc80095ed218f461bfe5283fa19d3e440eb603d49e8b65b65f5ec57a92ece","outcomeBytes":543093,"outcomeSha256":"dab7eda461b67a099559b2419b077c541991f62263f5e393322f278164835c33","summary":{"outcome":"success","terminal":"notEvaluated","canonicalRelations":"2047","applicableRelations":"0","notApplicableRelations":"2047","numericalEvidencePresent":false,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":57900,"processMaxRssKiBAfter":70220,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":4194304,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":64},{"sampleIndex":4,"elapsedNs":"37439595","requestBytes":542748,"requestSha256":"25ecc80095ed218f461bfe5283fa19d3e440eb603d49e8b65b65f5ec57a92ece","outcomeBytes":543093,"outcomeSha256":"dab7eda461b67a099559b2419b077c541991f62263f5e393322f278164835c33","summary":{"outcome":"success","terminal":"notEvaluated","canonicalRelations":"2047","applicableRelations":"0","notApplicableRelations":"2047","numericalEvidencePresent":false,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":60040,"processMaxRssKiBAfter":74420,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":4194304,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":64}]},{"scenarioId":"maximum-combined-applicable-envelope","shape":{"rawRelations":2047,"rawAdjacentEntries":2047,"opaqueUtf8Bytes":65536,"canonicalRelations":2047,"applicableRelations":2047,"applicableEdges":2047},"samples":[{"sampleIndex":0,"elapsedNs":"142330702","requestBytes":657380,"requestSha256":"53bbc4fa41ce062a2056151b8e5f7ae68a8d81b122e6178bc919aa749b9898c3","outcomeBytes":847668,"outcomeSha256":"885a51a2136c2a09189bd5c88c35b886d343f99917a3c55f7231b2b37203e270","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"2047","applicableRelations":"2047","notApplicableRelations":"0","applicableEdges":"2047","logicalAssessments":"524032","failureMatrixBytes":65504,"partitionBytes":32,"feasibleCandidates":149,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":59196,"processMaxRssKiBAfter":72624,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":4718592,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":72},{"sampleIndex":1,"elapsedNs":"154085935","requestBytes":657380,"requestSha256":"53bbc4fa41ce062a2056151b8e5f7ae68a8d81b122e6178bc919aa749b9898c3","outcomeBytes":847668,"outcomeSha256":"885a51a2136c2a09189bd5c88c35b886d343f99917a3c55f7231b2b37203e270","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"2047","applicableRelations":"2047","notApplicableRelations":"0","applicableEdges":"2047","logicalAssessments":"524032","failureMatrixBytes":65504,"partitionBytes":32,"feasibleCandidates":149,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":58892,"processMaxRssKiBAfter":73784,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":4718592,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":72},{"sampleIndex":2,"elapsedNs":"121344687","requestBytes":657380,"requestSha256":"53bbc4fa41ce062a2056151b8e5f7ae68a8d81b122e6178bc919aa749b9898c3","outcomeBytes":847668,"outcomeSha256":"885a51a2136c2a09189bd5c88c35b886d343f99917a3c55f7231b2b37203e270","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"2047","applicableRelations":"2047","notApplicableRelations":"0","applicableEdges":"2047","logicalAssessments":"524032","failureMatrixBytes":65504,"partitionBytes":32,"feasibleCandidates":149,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":59200,"processMaxRssKiBAfter":72356,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":4718592,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":72},{"sampleIndex":3,"elapsedNs":"122747833","requestBytes":657380,"requestSha256":"53bbc4fa41ce062a2056151b8e5f7ae68a8d81b122e6178bc919aa749b9898c3","outcomeBytes":847668,"outcomeSha256":"885a51a2136c2a09189bd5c88c35b886d343f99917a3c55f7231b2b37203e270","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"2047","applicableRelations":"2047","notApplicableRelations":"0","applicableEdges":"2047","logicalAssessments":"524032","failureMatrixBytes":65504,"partitionBytes":32,"feasibleCandidates":149,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":57372,"processMaxRssKiBAfter":72616,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":4718592,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":72},{"sampleIndex":4,"elapsedNs":"125001524","requestBytes":657380,"requestSha256":"53bbc4fa41ce062a2056151b8e5f7ae68a8d81b122e6178bc919aa749b9898c3","outcomeBytes":847668,"outcomeSha256":"885a51a2136c2a09189bd5c88c35b886d343f99917a3c55f7231b2b37203e270","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"2047","applicableRelations":"2047","notApplicableRelations":"0","applicableEdges":"2047","logicalAssessments":"524032","failureMatrixBytes":65504,"partitionBytes":32,"feasibleCandidates":149,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":57484,"processMaxRssKiBAfter":70344,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":4718592,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":72}]},{"scenarioId":"maximum-raw-duplicate-relations","shape":{"rawRelations":2047,"rawAdjacentEntries":2047,"opaqueUtf8Bytes":26611,"canonicalRelations":1,"applicableRelations":1,"applicableEdges":1},"samples":[{"sampleIndex":0,"elapsedNs":"10587180","requestBytes":270305,"requestSha256":"fc5f72ce71f8f9d81164dbe7632ac3345fca31c2eb677db9709c45bd7a04077a","outcomeBytes":4837,"outcomeSha256":"ef8e4cf8ac7580773571fa354b6b2eed38e4ca60d32780b0ba86e2be8cf6bcf2","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"1","logicalAssessments":"256","failureMatrixBytes":32,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":57572,"processMaxRssKiBAfter":67528,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1769472,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":27},{"sampleIndex":1,"elapsedNs":"10756735","requestBytes":270305,"requestSha256":"fc5f72ce71f8f9d81164dbe7632ac3345fca31c2eb677db9709c45bd7a04077a","outcomeBytes":4837,"outcomeSha256":"ef8e4cf8ac7580773571fa354b6b2eed38e4ca60d32780b0ba86e2be8cf6bcf2","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"1","logicalAssessments":"256","failureMatrixBytes":32,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":58236,"processMaxRssKiBAfter":65408,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1769472,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":27},{"sampleIndex":2,"elapsedNs":"14821536","requestBytes":270305,"requestSha256":"fc5f72ce71f8f9d81164dbe7632ac3345fca31c2eb677db9709c45bd7a04077a","outcomeBytes":4837,"outcomeSha256":"ef8e4cf8ac7580773571fa354b6b2eed38e4ca60d32780b0ba86e2be8cf6bcf2","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"1","logicalAssessments":"256","failureMatrixBytes":32,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":56228,"processMaxRssKiBAfter":65624,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1769472,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":27},{"sampleIndex":3,"elapsedNs":"13961971","requestBytes":270305,"requestSha256":"fc5f72ce71f8f9d81164dbe7632ac3345fca31c2eb677db9709c45bd7a04077a","outcomeBytes":4837,"outcomeSha256":"ef8e4cf8ac7580773571fa354b6b2eed38e4ca60d32780b0ba86e2be8cf6bcf2","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"1","logicalAssessments":"256","failureMatrixBytes":32,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":56072,"processMaxRssKiBAfter":67248,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1769472,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":27},{"sampleIndex":4,"elapsedNs":"27038746","requestBytes":270305,"requestSha256":"fc5f72ce71f8f9d81164dbe7632ac3345fca31c2eb677db9709c45bd7a04077a","outcomeBytes":4837,"outcomeSha256":"ef8e4cf8ac7580773571fa354b6b2eed38e4ca60d32780b0ba86e2be8cf6bcf2","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"1","logicalAssessments":"256","failureMatrixBytes":32,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":56276,"processMaxRssKiBAfter":68532,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1769472,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":27}]},{"scenarioId":"maximum-raw-adjacent-duplicates","shape":{"rawRelations":1,"rawAdjacentEntries":2047,"opaqueUtf8Bytes":2,"canonicalRelations":1,"applicableRelations":1,"applicableEdges":1},"samples":[{"sampleIndex":0,"elapsedNs":"8530819","requestBytes":28866,"requestSha256":"495db8cf72c85e1811f87180cefb4a90c8d64be1277ad4ed41562a2e97034979","outcomeBytes":4823,"outcomeSha256":"a6196a5aaec38b74029c0d6f5016b9249008d7cd9544c3d5cf5de9932770e090","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"1","logicalAssessments":"256","failureMatrixBytes":32,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":56116,"processMaxRssKiBAfter":62108,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1441792,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":22},{"sampleIndex":1,"elapsedNs":"5619217","requestBytes":28866,"requestSha256":"495db8cf72c85e1811f87180cefb4a90c8d64be1277ad4ed41562a2e97034979","outcomeBytes":4823,"outcomeSha256":"a6196a5aaec38b74029c0d6f5016b9249008d7cd9544c3d5cf5de9932770e090","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"1","logicalAssessments":"256","failureMatrixBytes":32,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":54096,"processMaxRssKiBAfter":58404,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1441792,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":22},{"sampleIndex":2,"elapsedNs":"5719256","requestBytes":28866,"requestSha256":"495db8cf72c85e1811f87180cefb4a90c8d64be1277ad4ed41562a2e97034979","outcomeBytes":4823,"outcomeSha256":"a6196a5aaec38b74029c0d6f5016b9249008d7cd9544c3d5cf5de9932770e090","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"1","logicalAssessments":"256","failureMatrixBytes":32,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":54348,"processMaxRssKiBAfter":60628,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1441792,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":22},{"sampleIndex":3,"elapsedNs":"9152595","requestBytes":28866,"requestSha256":"495db8cf72c85e1811f87180cefb4a90c8d64be1277ad4ed41562a2e97034979","outcomeBytes":4823,"outcomeSha256":"a6196a5aaec38b74029c0d6f5016b9249008d7cd9544c3d5cf5de9932770e090","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"1","logicalAssessments":"256","failureMatrixBytes":32,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":54624,"processMaxRssKiBAfter":60128,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1441792,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":22},{"sampleIndex":4,"elapsedNs":"24554589","requestBytes":28866,"requestSha256":"495db8cf72c85e1811f87180cefb4a90c8d64be1277ad4ed41562a2e97034979","outcomeBytes":4823,"outcomeSha256":"a6196a5aaec38b74029c0d6f5016b9249008d7cd9544c3d5cf5de9932770e090","summary":{"outcome":"success","terminal":"feasible","domainCount":"256","canonicalRelations":"1","applicableRelations":"1","notApplicableRelations":"0","applicableEdges":"1","logicalAssessments":"256","failureMatrixBytes":32,"partitionBytes":32,"feasibleCandidates":7,"lsb0PartitionMatchesMatrix":true,"proportionalFieldsPresent":false},"processMaxRssKiBBefore":55940,"processMaxRssKiBAfter":61612,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1441792,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":22}]},{"scenarioId":"transport-limit-plus-one","shape":{"rawRelations":2047,"rawAdjacentEntries":2047,"opaqueUtf8Bytes":65536,"canonicalRelations":2047,"applicableRelations":2047,"applicableEdges":2047},"samples":[{"sampleIndex":0,"elapsedNs":"1115563","requestBytes":657381,"requestSha256":"8569362aca144e7f9b4f83af9f9b91ef6287768b91afef3a2feb4d9d2e27606d","outcomeBytes":154,"outcomeSha256":"1a074b69790ebf713dcd65a76ddc630a75ce996ae01404a552583a2211b5e125","summary":{"outcome":"failure","source":"transport","code":"envelopeTooLarge","requestedBytes":"657381","limitBytes":"657380","proportionalFieldsPresent":false},"processMaxRssKiBBefore":91932,"processMaxRssKiBAfter":92240,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1245184,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":19},{"sampleIndex":1,"elapsedNs":"1125767","requestBytes":657381,"requestSha256":"8569362aca144e7f9b4f83af9f9b91ef6287768b91afef3a2feb4d9d2e27606d","outcomeBytes":154,"outcomeSha256":"1a074b69790ebf713dcd65a76ddc630a75ce996ae01404a552583a2211b5e125","summary":{"outcome":"failure","source":"transport","code":"envelopeTooLarge","requestedBytes":"657381","limitBytes":"657380","proportionalFieldsPresent":false},"processMaxRssKiBBefore":93336,"processMaxRssKiBAfter":93644,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1245184,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":19},{"sampleIndex":2,"elapsedNs":"1025406","requestBytes":657381,"requestSha256":"8569362aca144e7f9b4f83af9f9b91ef6287768b91afef3a2feb4d9d2e27606d","outcomeBytes":154,"outcomeSha256":"1a074b69790ebf713dcd65a76ddc630a75ce996ae01404a552583a2211b5e125","summary":{"outcome":"failure","source":"transport","code":"envelopeTooLarge","requestedBytes":"657381","limitBytes":"657380","proportionalFieldsPresent":false},"processMaxRssKiBBefore":92264,"processMaxRssKiBAfter":92444,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1245184,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":19},{"sampleIndex":3,"elapsedNs":"1062113","requestBytes":657381,"requestSha256":"8569362aca144e7f9b4f83af9f9b91ef6287768b91afef3a2feb4d9d2e27606d","outcomeBytes":154,"outcomeSha256":"1a074b69790ebf713dcd65a76ddc630a75ce996ae01404a552583a2211b5e125","summary":{"outcome":"failure","source":"transport","code":"envelopeTooLarge","requestedBytes":"657381","limitBytes":"657380","proportionalFieldsPresent":false},"processMaxRssKiBBefore":91584,"processMaxRssKiBAfter":91892,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1245184,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":19},{"sampleIndex":4,"elapsedNs":"1122203","requestBytes":657381,"requestSha256":"8569362aca144e7f9b4f83af9f9b91ef6287768b91afef3a2feb4d9d2e27606d","outcomeBytes":154,"outcomeSha256":"1a074b69790ebf713dcd65a76ddc630a75ce996ae01404a552583a2211b5e125","summary":{"outcome":"failure","source":"transport","code":"envelopeTooLarge","requestedBytes":"657381","limitBytes":"657380","proportionalFieldsPresent":false},"processMaxRssKiBBefore":93108,"processMaxRssKiBAfter":93416,"wasmMemoryBytesBefore":1179648,"wasmMemoryBytesAfter":1245184,"wasmMemoryPagesBefore":18,"wasmMemoryPagesAfter":19}]}]} diff --git a/packages/colors/index.d.ts b/packages/colors/index.d.ts index fb017516..ca88d945 100644 --- a/packages/colors/index.d.ts +++ b/packages/colors/index.d.ts @@ -1,3 +1,5 @@ +/// + // Public types for @labpics/colors. // // Re-exports the wasm-bindgen-generated types (the rich `ResolvedTheme` / @@ -7,6 +9,7 @@ import type { Wcag22AssessmentV1, Wcag22CriterionV1, + Wcag22FeasibilityOutcomeV1, } from "./pkg/labcolors.js"; export { @@ -24,6 +27,14 @@ export declare function evaluateWcag22( criterion: Wcag22CriterionV1, ): Wcag22AssessmentV1; +/** Exact derived V1 request ceiling, available after WASM initialization. */ +export declare function wcag22FeasibilityMaxBytes(): number; + +/** Evaluate one strict V1 UTF-8 JSON byte envelope; protocol failures are data. */ +export declare function evaluateWcag22Feasibility( + request: Uint8Array, +): Wcag22FeasibilityOutcomeV1; + // Curated public schema/result surface. wasm-bindgen's InitOutput and raw // __wbg_* ABI helpers remain implementation details. export type { @@ -71,6 +82,8 @@ export type { Wcag22DecisionV1, Wcag22Q55BoundsV1, Wcag22AssessmentV1, + Wcag22FeasibilityRequestV1, + Wcag22FeasibilityOutcomeV1, } from "./pkg/labcolors.js"; export { applyTheme } from "./apply-theme.js"; diff --git a/packages/colors/index.js b/packages/colors/index.js index 8403f22c..6f936ae1 100644 --- a/packages/colors/index.js +++ b/packages/colors/index.js @@ -6,6 +6,21 @@ // effective-background resolver. The wasm glue is the generated `pkg/` artifact // (built by `npm run build`). +import { + evaluateWcag22FeasibilityV1 as evaluateWcag22FeasibilityRawV1, + wcag22FeasibilityEnvelopeTooLargeV1, + wcag22FeasibilityMaxRequestBytesV1, +} from "./pkg/labcolors.js"; + +const typedArrayTag = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(Uint8Array.prototype), + Symbol.toStringTag, +).get; + +function hasUint8ArrayBrand(value) { + return ArrayBuffer.isView(value) && typedArrayTag.call(value) === "Uint8Array"; +} + export { default, default as init, @@ -15,6 +30,33 @@ export { numericalCapabilityManifest, } from "./pkg/labcolors.js"; +/** Exact derived V1 request ceiling, available after WASM initialization. */ +export function wcag22FeasibilityMaxBytes() { + return wcag22FeasibilityMaxRequestBytesV1(); +} + +/** + * Evaluate one strict V1 UTF-8 JSON envelope. + * + * The host checks the typed array's byte length before wasm-bindgen performs + * its avoidable input copy. Rust repeats the authoritative check. For the + * declared Uint8Array input, envelope, resource and Core failures are returned + * as typed outcome data. Any other JavaScript value throws a deterministic + * TypeError before the host reads the WASM-owned ceiling or copies input. + * + * @param {Uint8Array} request + */ +export function evaluateWcag22Feasibility(request) { + if (!hasUint8ArrayBrand(request)) { + throw new TypeError("evaluateWcag22Feasibility request must be a Uint8Array"); + } + const requestedBytes = request.byteLength; + if (requestedBytes > wcag22FeasibilityMaxBytes()) { + return wcag22FeasibilityEnvelopeTooLargeV1(BigInt(requestedBytes)); + } + return evaluateWcag22FeasibilityRawV1(request); +} + export { applyTheme } from "./apply-theme.js"; export { watchTheme } from "./watch-theme.js"; export { adaptTheme } from "./adapt-theme.js"; diff --git a/packages/colors/package-lock.json b/packages/colors/package-lock.json index 07491794..7170dfdb 100644 --- a/packages/colors/package-lock.json +++ b/packages/colors/package-lock.json @@ -8,11 +8,12 @@ "name": "@labpics/colors", "version": "0.10.0", "license": "MIT", + "devDependencies": { + "typescript": "^5.5.0", + "typescript-floor": "npm:typescript@5.2.2" + }, "engines": { "node": ">=22.11.0" - }, - "devDependencies": { - "typescript": "^5.5.0" } }, "node_modules/typescript": { @@ -28,6 +29,21 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/typescript-floor": { + "name": "typescript", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } } } } diff --git a/packages/colors/package.json b/packages/colors/package.json index e63a50dd..4937a01c 100644 --- a/packages/colors/package.json +++ b/packages/colors/package.json @@ -69,7 +69,8 @@ "release:verify": "node ../../scripts/verify-package-release.mjs" }, "devDependencies": { - "typescript": "^5.5.0" + "typescript": "^5.5.0", + "typescript-floor": "npm:typescript@5.2.2" }, "types": "./index.d.ts", "sideEffects": false, diff --git a/packages/colors/test/release-contract.test.mjs b/packages/colors/test/release-contract.test.mjs index c1e9c435..2c5e07a1 100644 --- a/packages/colors/test/release-contract.test.mjs +++ b/packages/colors/test/release-contract.test.mjs @@ -18,7 +18,10 @@ import { dirname, join, resolve } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { validateWcag22EvidenceArtifacts } from "../../../scripts/verify-package-release.mjs"; +import { + validateWcag22EvidenceArtifacts, + validateWcag22FeasibilityFamily, +} from "../../../scripts/verify-package-release.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, "../../.."); @@ -118,6 +121,53 @@ test("every workspace package inherits the declared MSRV", () => { } }); +test("WCAG22 feasibility has one protocol feature owner shared by every boundary consumer", () => { + const isolatedCoreEdge = + /labcolors-core = \{ path = "\.\.\/labcolors-core", default-features = false \}/u; + const protocolEdge = /labcolors-protocol = \{ path = "\.\.\/labcolors-protocol" \}/u; + const protocolManifest = read("crates", "labcolors-protocol", "Cargo.toml"); + const wasmManifest = read("crates", "labcolors-wasm", "Cargo.toml"); + const ffiManifest = read("crates", "labcolors-ffi", "Cargo.toml"); + const conformanceManifest = read("crates", "labcolors-conformance", "Cargo.toml"); + + assert.match( + protocolManifest, + /labcolors-core = \{ path = "\.\.\/labcolors-core", default-features = false, features = \["wcag22-feasibility"\] \}/u, + ); + for (const manifest of [wasmManifest, ffiManifest, conformanceManifest]) { + assert.match(manifest, isolatedCoreEdge); + assert.match(manifest, protocolEdge); + assert.doesNotMatch(manifest, /features = \["wcag22-feasibility"\]/u); + } + + const ci = read(".github", "workflows", "ci.yml"); + const projection = workflowRunScript( + ci, + "name: prove core capability projection boundary", + ); + const declaredConsumers = projection.match( + /consumers = \(\n(?(?: "[^"]+",\n)+)\)/u, + )?.groups?.items; + assert.ok(declaredConsumers, "CI must declare one consumer SSOT"); + assert.deepEqual( + [...declaredConsumers.matchAll(/"([^"]+)"/gu)].map((match) => match[1]), + ["labcolors-wasm", "labcolors-ffi", "labcolors-conformance"], + ); + assert.equal( + projection.match(/for consumer in consumers:/gu)?.length, + 1, + "the dependency and feature-tree checks must share one consumer loop", + ); + assert.match(projection, /protocol_core\["features"\] != \["wcag22-feasibility"\]/u); + assert.match(projection, /core_dependency\["features"\]/u); + assert.match(projection, /dependency\["name"\] == "labcolors-protocol"/u); + assert.match( + projection, + /\["cargo", "tree", "-p", consumer, "--edges", "normal", "-e", "features"\]/u, + ); + assert.doesNotMatch(projection, /for consumer in labcolors-/u); +}); + test("MSRV and packaged Rust crate gates are executable CI contracts", () => { assert.ok(existsSync(join(root, "LICENSE")), "root LICENSE отсутствует"); const coreLicense = join(root, "crates", "labcolors-core", "LICENSE"); @@ -748,41 +798,294 @@ test("release verifier performs an independent byte-for-byte reproduction pass", ); }); -test("WCAG22 WASM budget is measured and rejects a one-byte regression", () => { - const budgetPath = join(root, "packages", "colors", "bench", "wasm-size-budget-v1.json"); +test("conformance pack 5 adds only the feasibility family", () => { + const immutableFamilies = new Map([ + ["contrasts.json", "57d99bb3138edba769a185af5589651ab1cd3140f92e5cf493be2f998b2f1145"], + ["ladders.json", "496f562e55ad8110aeb8a07042b1964ec9ff4d0f1e8c09e362d1b2d14c513036"], + ["alpha.json", "b9c71e26c96c977c51cb2ffc98ff8f24a24705105c1962479e72e687b1b05bb1"], + ["solve.json", "64acfc4a8c613a4b11e4e83c52a33ecf308320abc6ab18fde20853a7f2399f06"], + ["muddiness.json", "3c5497b251f04c089d33452b9bf0bfba7f4ef9a72dc496180ff42aad08377aa3"], + ["wcag22.json", "6e234fa3a0d4e2b21f515b8f4e6be76f223768821e0308e774c31a5ce7a1d826"], + ]); + assert.equal(immutableFamilies.size, 6, "anti-vacuum: prior family set changed"); + for (const [name, expected] of immutableFamilies) { + const bytes = readFileSync(join(root, "conformance", "vectors", name)); + assert.equal(createHash("sha256").update(bytes).digest("hex"), expected, name); + } + + const manifest = JSON.parse(read("conformance", "vectors", "manifest.json")); + assert.equal(manifest.packVersion, "5.0.0"); + assert.ok( + existsSync(join(root, "conformance", "vectors", "wcag22-feasibility.json")), + "pack 5 must add the single feasibility family", + ); + assert.equal(manifest.counts.wcag22Feasibility > 0, true); +}); + +test("release checker independently validates and mutation-proves feasibility pack semantics", () => { + const canonical = JSON.parse( + read("conformance", "vectors", "wcag22-feasibility.json"), + ); + const atomicProofSha256 = createHash("sha256") + .update(readFileSync(join( + root, + "crates", + "labcolors-core", + "contracts", + "wcag22-srgb8-q55-proof-v1.json", + ))) + .digest("hex"); + assert.doesNotThrow(() => validateWcag22FeasibilityFamily(canonical, atomicProofSha256)); + + const mutateOutcome = (family, caseId, mutate) => { + const vector = family.find((entry) => entry.caseId === caseId); + assert.ok(vector, `mutation fixture missing ${caseId}`); + const outcome = JSON.parse(vector.outcomeJson); + mutate(outcome); + vector.outcomeJson = JSON.stringify(outcome); + }; + const mutations = [ + ["vector schema expansion", (family) => { family[0].extra = true; }], + ["non-compact request", (family) => { family[0].requestJson += " "; }], + ["proportional cells", (family) => mutateOutcome( + family, + "text-default-seven", + (outcome) => { outcome.feasibility.result.cells = []; }, + )], + ["domain truncation", (family) => mutateOutcome( + family, + "text-default-seven", + (outcome) => { outcome.feasibility.result.domain.pop(); }, + )], + ["matrix truncation", (family) => mutateOutcome( + family, + "text-default-two", + (outcome) => { outcome.feasibility.result.failureMatrix.pop(); }, + )], + ["partition LSB0 flip", (family) => mutateOutcome( + family, + "ui-component-fifty-nine", + (outcome) => { outcome.feasibility.result.proof.partition[0] ^= 1; }, + )], + ["domain digest flip", (family) => mutateOutcome( + family, + "text-default-seven", + (outcome) => { outcome.feasibility.result.proof.domainDigest[0] ^= 1; }, + )], + ["relation-set digest flip", (family) => mutateOutcome( + family, + "text-default-seven", + (outcome) => { outcome.feasibility.result.proof.relationSetDigest[0] ^= 1; }, + )], + ["evaluation ID flip", (family) => mutateOutcome( + family, + "text-default-seven", + (outcome) => { outcome.feasibility.result.proof.evaluationId[0] ^= 1; }, + )], + ["atomic proof digest flip", (family) => mutateOutcome( + family, + "text-default-seven", + (outcome) => { outcome.feasibility.result.proof.proofSha256[0] ^= 1; }, + )], + ["not-evaluated domain digest flip", (family) => mutateOutcome( + family, + "all-not-applicable", + (outcome) => { outcome.feasibility.result.domainDigest[0] ^= 1; }, + )], + ["not-evaluated relation-set digest flip", (family) => mutateOutcome( + family, + "all-not-applicable", + (outcome) => { outcome.feasibility.result.relationSetDigest[0] ^= 1; }, + )], + ["numeric u64", (family) => mutateOutcome( + family, + "text-default-seven", + (outcome) => { outcome.feasibility.result.proof.domainCount = 256; }, + )], + ["conflict collapsed", (family) => { + const source = family.find((entry) => entry.caseId === "all-not-applicable"); + const target = family.find((entry) => entry.caseId === "conflicting-relation-id"); + target.outcomeJson = source.outcomeJson; + }], + ["resource failure collapsed", (family) => { + const source = family.find((entry) => entry.caseId === "all-not-applicable"); + const target = family.find((entry) => entry.caseId === "raw-adjacent-resource-rejection"); + target.outcomeJson = source.outcomeJson; + }], + ["opaque identity collapsed", (family) => { + const first = family.find((entry) => entry.caseId === "opaque-identity-a"); + const second = family.find((entry) => entry.caseId === "opaque-identity-b"); + const firstOutcome = JSON.parse(first.outcomeJson); + const secondOutcome = JSON.parse(second.outcomeJson); + secondOutcome.feasibility.result.proof.relationSetDigest = + firstOutcome.feasibility.result.proof.relationSetDigest; + second.outcomeJson = JSON.stringify(secondOutcome); + }], + ]; + assert.equal(mutations.length, 16, "anti-vacuum mutation corpus changed"); + for (const [name, mutate] of mutations) { + const family = structuredClone(canonical); + mutate(family); + assert.throws( + () => validateWcag22FeasibilityFamily(family, atomicProofSha256), + undefined, + `${name} must fail the release checker`, + ); + } +}); + +test("release evidence carries the versioned WCAG22 feasibility operation", () => { + const prepare = read("scripts", "prepare-npm-package.mjs"); + const verifier = read("scripts", "verify-package-release.mjs"); + + assert.match(prepare, /"wcag22-feasibility\.json"/u); + assert.match(verifier, /"wcag22-feasibility\.json"/u); + assert.match(verifier, /conformance\.packVersion !== "5\.0\.0"/u); + assert.match( + verifier, + /"wcag22Feasibility"/u, + "release count projection must include the new family", + ); + assert.match( + verifier, + /"wcag22-feasibility-v1"/u, + "release supported list must advertise the compiler operation", + ); + assert.match( + verifier, + /validateWcag22FeasibilityFamily\(families\[6\], sha256\(proofBytes\)\)/u, + "pack validation must bind feasibility proof fields to the canonical atomic proof bytes", + ); + assert.match(verifier, /evaluateWcag22Feasibility/u); + assert.match(verifier, /wcag22FeasibilityMaxBytes/u); + assert.match(verifier, /type Wcag22FeasibilityRequestV1/u); + assert.match(verifier, /type Wcag22FeasibilityOutcomeV1/u); + assert.match(verifier, /get\("text-default-seven"\)\?\.vector/u); + assert.match( + verifier, + /JSON\.stringify\(evaluateWcag22Feasibility\(feasibilityRequest\)\)[\s\S]*?feasibilityFixture\.outcomeJson/u, + ); + assert.equal( + verifier.match(/writeFile\(runtimePath, runtimeSmokeSource\(feasibilityFixture\)\)/gu)?.length, + 2, + "clean-install and Node-floor smokes must execute the same canonical fixture", + ); + assert.ok( + verifier.indexOf("await init({ module_or_path:") < + verifier.indexOf("wcag22FeasibilityMaxBytes()"), + "clean smoke must import safely and call the getter only after WASM init", + ); + assert.match(verifier, /@ts-expect-error byte API rejects strings/u); + assert.match(verifier, /case "notEvaluated"/u); + assert.match(verifier, /case "incompatibleCoreContract"/u); +}); + +test("WCAG22 WASM budget v2 is exact, immutable, and acyclic", async () => { + const v1Path = join(root, "packages", "colors", "bench", "wasm-size-budget-v1.json"); + const v2Path = join(root, "packages", "colors", "bench", "wasm-size-budget-v2.json"); const checkerPath = join(root, "scripts", "check-wasm-size-budget.mjs"); - const budget = JSON.parse(readFileSync(budgetPath, "utf8")); - - assert.equal(budget.schemaVersion, 2); - assert.equal(budget.budgetId, "labcolors-wasm-raw-issue-284-v1"); - assert.equal(budget.measurement.issue, 284); - assert.equal(budget.measurement.rustToolchain, "1.96.0"); - assert.equal(budget.measurement.wasmPack, "0.13.1"); - assert.equal(budget.measurement.target, "wasm32-unknown-unknown"); - assert.equal(budget.measurement.cargoProfile, "release"); - assert.equal(budget.measurement.wasmOpt, "-Oz"); - assert.equal(budget.measurement.rawBytes, 454385); - assert.equal(budget.policy.maxRawBytes, 454385); + const sha256 = (value) => createHash("sha256").update(value).digest("hex"); + const canonicalJson = (value) => `${JSON.stringify(value, null, 2)}\n`; + + const v1Bytes = readFileSync(v1Path); + const v1 = JSON.parse(v1Bytes); assert.equal( - budget.measurement.sha256, - "94c61c1689fa2e1c10d79817864471f41c623463bd9b5b4e0dac2a850a58f09f", + sha256(v1Bytes), + "4f7340fc8cfd0ccb97377c385f2f8d8e7a9ef2c5ba96177f518c5d07de2825e1", + "the immutable #284 evidence and build recipe must remain byte-identical", ); + const recipe = { + rustToolchain: v1.measurement.rustToolchain, + rustcCommit: v1.measurement.rustcCommit, + wasmPack: v1.measurement.wasmPack, + wasmBindgen: v1.measurement.wasmBindgen, + target: v1.measurement.target, + cargoProfile: v1.measurement.cargoProfile, + wasmOpt: v1.measurement.wasmOpt, + wasmOptVersion: v1.measurement.wasmOptVersion, + measurementPlatform: v1.measurement.measurementPlatform, + rustPathRemap: v1.measurement.rustPathRemap, + command: v1.measurement.command, + }; assert.equal( - budget.currentArtifact.sha256, - "58015ea92c539eec9a715dac79d80148f103b83c628be9d95ade63b87fc76533", + sha256(JSON.stringify(recipe)), + "0ea74cb070e0a5facb7280f6124930a0bb673ee4dcee9c99fff110db6c9389d4", ); - assert.equal(budget.currentArtifact.recertificationIssue, 295); - assert.equal(budget.measurement.measurementPlatform, "linux-x64"); - assert.deepEqual(budget.measurement.rustPathRemap, [ + assert.deepEqual(v1.measurement.rustPathRemap, [ "GITHUB_WORKSPACE=/workspace/lab-colors", "CARGO_HOME=/cargo-home", ]); - assert.equal(budget.policy.derivation, "exact-accepted-issue-284-measurement"); - assert.equal(budget.policy.gzip, "diagnostic-only"); + + const v2Bytes = readFileSync(v2Path); + const v2 = JSON.parse(v2Bytes); + assert.equal(v2Bytes.toString("utf8"), canonicalJson(v2)); + assert.equal( + sha256(v2Bytes), + "713ccc314b3e6f638d87a54716d665d52f77c86f34a2b6edefe0a354a499d8b1", + "the admitted v2 document must be byte-immutable", + ); + assert.deepEqual(Object.keys(v2), [ + "schemaVersion", + "budgetId", + "artifact", + "buildRecipe", + "measurement", + "policy", + ]); + assert.deepEqual(Object.keys(v2.buildRecipe), ["path", "fileSha256", "recipeSha256"]); + assert.deepEqual(Object.keys(v2.measurement), [ + "issue", + "measurementPlatform", + "rawBytes", + "sha256", + ]); + assert.deepEqual(Object.keys(v2.policy), ["maxRawBytes", "derivation", "gzip"]); + assert.equal(v2.schemaVersion, 3); + assert.equal(v2.budgetId, "labcolors-wasm-raw-issue-295-v2"); + assert.equal(v2.artifact, "packages/colors/pkg/labcolors_bg.wasm"); + assert.deepEqual(v2.buildRecipe, { + path: "packages/colors/bench/wasm-size-budget-v1.json", + fileSha256: "4f7340fc8cfd0ccb97377c385f2f8d8e7a9ef2c5ba96177f518c5d07de2825e1", + recipeSha256: "0ea74cb070e0a5facb7280f6124930a0bb673ee4dcee9c99fff110db6c9389d4", + }); + assert.deepEqual(v2.measurement, { + issue: 295, + measurementPlatform: "linux-x64", + rawBytes: 521240, + sha256: "d37841bfb2615d05c8366b08dcc7e5aed1bbd3cf27c3db67896108c5ec9c9ca0", + }); + assert.deepEqual(v2.policy, { + maxRawBytes: 521240, + derivation: "exact-accepted-issue-295-slice-b-measurement", + gzip: "diagnostic-only", + }); + + const checker = await import( + new URL("../../../scripts/check-wasm-size-budget.mjs", import.meta.url) + ); + assert.equal(checker.DEFAULT_BUDGET, v2Path); + assert.equal(checker.V1_FILE_SHA256, v2.buildRecipe.fileSha256); + assert.equal(checker.V1_RECIPE_SHA256, v2.buildRecipe.recipeSha256); + assert.equal(checker.V2_FILE_SHA256, sha256(v2Bytes)); + + const wholeCallSource = read( + "packages", + "colors", + "bench", + "wcag22-feasibility-boundary.bench.mjs", + ); + assert.match(wholeCallSource, /wasmToolchainPath = resolve\(here, "wasm-size-budget-v1\.json"\)/u); + assert.doesNotMatch(wholeCallSource, /wasm-size-budget-v2\.json/u); + assert.doesNotMatch( + read("scripts", "check-wasm-size-budget.mjs"), + /wcag22-feasibility-wasm-boundary-v1\.json/u, + "the sibling whole-call artifact must not become a size-budget dependency", + ); + const ci = read(".github", "workflows", "ci.yml"); - assert.match(ci, /name: enforce measured WASM raw-byte budget/); - assert.match(ci, /run: node scripts\/check-wasm-size-budget\.mjs/); - assert.doesNotMatch(ci, /Not a hard gate yet/); + assert.match(ci, /name: enforce measured WASM raw-byte budget/u); + assert.match(ci, /run: node scripts\/check-wasm-size-budget\.mjs/u); + assert.doesNotMatch(ci, /Not a hard gate yet/u); const wasmJob = ci.match(/\n wasm:\n(?[\s\S]*?)(?=\n [a-z][a-z0-9_-]*:\n)/u)?.groups?.body; assert.ok(wasmJob, "CI must contain a bounded wasm job"); assert.match(wasmJob, /runs-on: \[self-hosted, Linux, X64\]/u); @@ -795,134 +1098,191 @@ test("WCAG22 WASM budget is measured and rejects a one-byte regression", () => { ).toString("latin1"); assert.match(builtWasm, /\/cargo-home\/registry\/src\//u); assert.doesNotMatch(builtWasm, /\/(?:Users|home)\/[^\0]*?\/\.cargo\/registry\/src\//u); - assert.doesNotMatch(builtWasm, /\/opt\/actions-runner\/[^\0]*?\/cargo-wasm\/registry\/src\//u); + assert.doesNotMatch( + builtWasm, + /\/opt\/actions-runner\/[^\0]*?\/cargo-wasm\/registry\/src\//u, + ); - const temporary = mkdtempSync(join(tmpdir(), "labcolors-wasm-budget-")); + const temporary = mkdtempSync(join(tmpdir(), "labcolors-wasm-budget-v2-")); try { - const wasm = join(temporary, "fixture.wasm"); - const fixtureBudget = join(temporary, "budget.json"); - const bytes = Buffer.alloc(8); + const wasmPath = join(temporary, "fixture.wasm"); + const fixtureBudgetPath = join(temporary, "budget.json"); + const bytes = Buffer.alloc(16); bytes.set([0x00, 0x61, 0x73, 0x6d]); - writeFileSync(wasm, bytes); - writeFileSync( - fixtureBudget, - `${JSON.stringify({ - ...budget, - measurement: { - ...budget.measurement, - rawBytes: bytes.length, - sha256: createHash("sha256").update(bytes).digest("hex"), - measurementPlatform: `${process.platform}-${process.arch}`, - }, - currentArtifact: { - ...budget.currentArtifact, - sha256: createHash("sha256").update(bytes).digest("hex"), - }, - policy: { ...budget.policy, maxRawBytes: bytes.length }, - })}\n`, - ); + const fixture = structuredClone(v2); + fixture.measurement.rawBytes = bytes.length; + fixture.measurement.sha256 = sha256(bytes); + fixture.policy.maxRawBytes = bytes.length; + writeFileSync(wasmPath, bytes); + writeFileSync(fixtureBudgetPath, canonicalJson(fixture)); const run = () => execFileSync( process.execPath, - [checkerPath, "--wasm", wasm, "--budget", fixtureBudget], + [checkerPath, "--wasm", wasmPath, "--budget", fixtureBudgetPath], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, ); assert.match( run(), - /PASS raw=8B ceiling=8B remaining=0B gzip=\d+B .*current-sha=match/u, + /WASM size budget (?:PASS|DIAGNOSTIC) raw=16B .*artifact-sha=match/u, ); - const canonicalFixtureBudget = readFileSync(fixtureBudget, "utf8"); - for (const [currentArtifact, expectedError] of [ - [undefined, /currentArtifact\.sha256 must identify the exact current artifact/u], - [{ ...budget.currentArtifact, sha256: "0" }, /currentArtifact\.sha256/u], - [ - { ...budget.currentArtifact, recertificationIssue: 0 }, - /currentArtifact\.recertificationIssue must be a positive issue number/u, - ], - ]) { - const invalid = JSON.parse(canonicalFixtureBudget); - invalid.currentArtifact = currentArtifact; - writeFileSync(fixtureBudget, `${JSON.stringify(invalid)}\n`); - assert.throws(run, (error) => { - assert.match(error.stderr.toString(), expectedError); - return true; - }); + const schemaMutations = [ + ["schema rollback", (value) => { value.schemaVersion = 2; }], + ["identity drift", (value) => { value.budgetId = "other"; }], + ["artifact path drift", (value) => { value.artifact = "other.wasm"; }], + ["missing field", (value) => { delete value.artifact; }], + ["unknown field", (value) => { value.unknown = true; }], + ["recipe path drift", (value) => { value.buildRecipe.path = "other.json"; }], + ["v1 file drift", (value) => { value.buildRecipe.fileSha256 = "0".repeat(64); }], + ["recipe drift", (value) => { value.buildRecipe.recipeSha256 = "0".repeat(64); }], + ["missing recipe field", (value) => { delete value.buildRecipe.recipeSha256; }], + ["measurement issue drift", (value) => { value.measurement.issue = 294; }], + ["measurement platform drift", (value) => { + value.measurement.measurementPlatform = "darwin-arm64"; + }], + ["unknown measurement field", (value) => { value.measurement.unknown = true; }], + ["zero bytes", (value) => { value.measurement.rawBytes = 0; }], + ["fractional bytes", (value) => { value.measurement.rawBytes = 1.5; }], + ["unsafe bytes", (value) => { + value.measurement.rawBytes = Number.MAX_SAFE_INTEGER + 1; + }], + ["invalid SHA", (value) => { value.measurement.sha256 = "0"; }], + ["uppercase SHA", (value) => { value.measurement.sha256 = "A".repeat(64); }], + ["ceiling plus one", (value) => { value.policy.maxRawBytes += 1; }], + ["ceiling minus one", (value) => { value.policy.maxRawBytes -= 1; }], + ["derivation drift", (value) => { value.policy.derivation = "guessed"; }], + ["gzip gate", (value) => { value.policy.gzip = 123; }], + ["missing policy field", (value) => { delete value.policy.gzip; }], + ["whole-call cycle", (value) => { value.wholeCallArtifact = "forbidden"; }], + ["key reorder", (value) => ({ + budgetId: value.budgetId, + schemaVersion: value.schemaVersion, + artifact: value.artifact, + buildRecipe: value.buildRecipe, + measurement: value.measurement, + policy: value.policy, + })], + ]; + assert.equal(schemaMutations.length, 24, "v2 schema mutation set changed"); + for (const [name, mutate] of schemaMutations) { + const invalid = structuredClone(fixture); + const result = mutate(invalid) ?? invalid; + writeFileSync(fixtureBudgetPath, canonicalJson(result)); + assert.throws(run, undefined, `${name} must fail the checker`); } - const independentlyRecertifiedBudget = JSON.parse(canonicalFixtureBudget); - independentlyRecertifiedBudget.measurement.sha256 = "0".repeat(64); - writeFileSync(fixtureBudget, `${JSON.stringify(independentlyRecertifiedBudget)}\n`); - assert.match( - run(), - /PASS raw=8B ceiling=8B remaining=0B gzip=\d+B .*baseline-sha=different current-sha=match/u, - "current reproducibility attestation must not rewrite the immutable size baseline", + writeFileSync(fixtureBudgetPath, `${JSON.stringify(fixture)}\n`); + assert.throws(run, /canonical JSON/u, "non-canonical JSON must fail"); + writeFileSync( + fixtureBudgetPath, + canonicalJson(fixture).replace( + ' "schemaVersion": 3,\n', + ' "schemaVersion": 3,\n "schemaVersion": 3,\n', + ), ); + assert.throws(run, /canonical JSON/u, "duplicate JSON fields must fail"); - writeFileSync(fixtureBudget, canonicalFixtureBudget); - const largerHistoricalBaseline = JSON.parse(canonicalFixtureBudget); - largerHistoricalBaseline.measurement.rawBytes = bytes.length + 1; - largerHistoricalBaseline.policy.maxRawBytes = bytes.length + 1; - writeFileSync(fixtureBudget, `${JSON.stringify(largerHistoricalBaseline)}\n`); - assert.match( - run(), - /PASS raw=8B ceiling=9B remaining=1B gzip=\d+B .*current-sha=match/u, - "a smaller recertified artifact must not rewrite the historical size measurement", - ); - writeFileSync(fixtureBudget, canonicalFixtureBudget); + const canonical = checker.evaluateWasmBudget(fixture, bytes, "linux-x64"); + assert.equal(canonical.status, "PASS"); + assert.equal(canonical.artifactSha, "match"); - const sameSizeDifferentArtifact = Buffer.from(bytes); - sameSizeDifferentArtifact[7] = 1; - writeFileSync(wasm, sameSizeDifferentArtifact); + const sameSizeMutation = Buffer.from(bytes); + sameSizeMutation[15] = 1; assert.throws( - run, - (error) => { - assert.match(error.stderr.toString(), /current artifact SHA-256 mismatch/u); - return true; - }, - "canonical host must reject a same-size artifact with different bytes", + () => checker.evaluateWasmBudget(fixture, sameSizeMutation, "linux-x64"), + /SHA-256 mismatch/u, + "same-size byte drift must fail", ); - - const oversizedArtifact = Buffer.concat([bytes, Buffer.from([0])]); - const oversizedBudget = JSON.parse(canonicalFixtureBudget); - oversizedBudget.currentArtifact.sha256 = createHash("sha256") - .update(oversizedArtifact) - .digest("hex"); - writeFileSync(fixtureBudget, `${JSON.stringify(oversizedBudget)}\n`); - writeFileSync(wasm, oversizedArtifact); assert.throws( - run, - (error) => { - assert.match(error.stderr.toString(), /raw=9B exceeds ceiling=8B by=1B/u); - return true; - }, - "canonical ceiling + 1 byte must hard-fail", + () => checker.evaluateWasmBudget( + fixture, + Buffer.concat([bytes, Buffer.from([0])]), + "linux-x64", + ), + /length mismatch/u, + "append must fail", ); - - const nonCanonicalBudget = JSON.parse(canonicalFixtureBudget); - nonCanonicalBudget.measurement.measurementPlatform = - `${process.platform}-${process.arch}` === "linux-x64" - ? "darwin-arm64" - : "linux-x64"; - writeFileSync(fixtureBudget, `${JSON.stringify(nonCanonicalBudget)}\n`); - writeFileSync(wasm, sameSizeDifferentArtifact); - assert.match( - run(), - /DIAGNOSTIC raw=8B canonical-ceiling=8B delta=\+0B gzip=\d+B .*baseline-sha=different current-sha=different/u, - "non-canonical host reports diagnostics without claiming byte identity", + assert.throws( + () => checker.evaluateWasmBudget(fixture, bytes.subarray(0, 15), "linux-x64"), + /length mismatch/u, + "truncate must fail", + ); + assert.equal( + checker.evaluateWasmBudget(fixture, sameSizeMutation, "darwin-arm64").status, + "DIAGNOSTIC", + "non-canonical hosts report evidence without admitting it", + ); + assert.equal( + checker.evaluateWasmBudget( + fixture, + Buffer.concat([bytes, Buffer.from([0])]), + "darwin-arm64", + ).status, + "DIAGNOSTIC", + "non-canonical growth remains diagnostic", + ); + assert.equal( + checker.evaluateWasmBudget(fixture, bytes.subarray(0, 15), "darwin-arm64").status, + "DIAGNOSTIC", + "non-canonical shrink remains diagnostic", ); - writeFileSync(wasm, Buffer.concat([bytes, Buffer.from([0])])); - assert.match( - run(), - /DIAGNOSTIC raw=9B canonical-ceiling=8B delta=\+1B gzip=\d+B .*baseline-sha=different current-sha=different/u, - "non-canonical bytes remain diagnostic even above the canonical host ceiling", + const coordinatedMutation = structuredClone(fixture); + coordinatedMutation.measurement.sha256 = sha256(sameSizeMutation); + const coordinatedBytes = Buffer.from(canonicalJson(coordinatedMutation)); + assert.throws( + () => checker.parseBudgetDocument(coordinatedBytes, v2Path), + /immutable v2 file SHA-256 mismatch/u, + "coordinated artifact and document drift must still fail the default identity", ); } finally { rmSync(temporary, { recursive: true, force: true }); } }); +test("feasibility benchmark applicability permits only source-less non-Core lock drift", () => { + const checker = join( + root, + "scripts", + "check_wcag22_feasibility_applicability.py", + ); + assert.doesNotThrow(() => { + execFileSync("python3", [ + checker, + join( + root, + "crates", + "labcolors-core", + "contracts", + "wcag22-feasibility-benchmark-v1.json", + ), + "--artifact-sha256", + "7e9ffcbdd9d5d50fe681f511c34fc5c5dd270e9c475ce23ae56e9776922a3c5e", + "--self-test", + ], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + }); + + const ci = read(".github", "workflows", "ci.yml"); + assert.match( + ci, + /historical_snapshot=6001cf41e0a8364f25543e7955ceaf64d50129b4[\s\S]*?git worktree add --detach "\$historical_root" "\$historical_snapshot"[\s\S]*?\(\n\s+cd "\$historical_root"\n\s+python3 scripts\/check_wcag22_feasibility_benchmark\.py[\s\S]*?--verify-current-subjects[\s\S]*?--artifact-sha256 7e9ffcbdd9d5d50fe681f511c34fc5c5dd270e9c475ce23ae56e9776922a3c5e[\s\S]*?--self-test/u, + "the unchanged checker must replay in its exact clean Slice-A snapshot", + ); + assert.equal( + ci.match(/python3 scripts\/check_wcag22_feasibility_benchmark\.py/gu)?.length, + 1, + "the historical checker must never be applied to the additive current workspace", + ); + assert.match( + ci, + /python3 scripts\/check_wcag22_feasibility_applicability\.py[\s\S]*?--artifact-sha256 7e9ffcbdd9d5d50fe681f511c34fc5c5dd270e9c475ce23ae56e9776922a3c5e[\s\S]*?--self-test/u, + ); +}); + test("runtime WASM does not duplicate separately shipped WCAG22 evidence documents", () => { const wasm = readFileSync( join(root, "packages", "colors", "pkg", "labcolors_bg.wasm"), @@ -982,14 +1342,16 @@ test("npm release carries and re-verifies the exact WCAG22 finite evidence", () "a replacement without interpolation must not use an f-string", ); const conformanceReadme = read("conformance", "README.md"); - assert.match(conformanceReadme, /manifest\.packVersion`, сейчас `4\.0\.0`/u); + assert.match(conformanceReadme, /manifest\.packVersion`, сейчас `5\.0\.0`/u); + assert.match(conformanceReadme, /4\.0\.0 → 5\.0\.0/u); assert.match(conformanceReadme, /3\.0\.0 → 4\.0\.0/u); assert.match(conformanceReadme, /`wcag22\.json`/u); + assert.match(conformanceReadme, /`wcag22-feasibility\.json`/u); assert.match( conformanceReadme, - /contrasts, ladders, alpha, solve, muddiness, wcag22/u, + /contrasts, ladders, alpha, solve, muddiness, wcag22,\s*wcag22-feasibility/u, ); - assert.doesNotMatch(conformanceReadme, /сейчас `3\.0\.0`/u); + assert.doesNotMatch(conformanceReadme, /сейчас `[34]\.0\.0`/u); const workflow = read(".github", "workflows", "ci.yml"); assert.match(workflow, /python3 scripts\/verify_wcag22_q55\.py/); }); @@ -1102,12 +1464,17 @@ test("published build metadata binds source, conformance, and WASM inputs", () = assert.match(verifier, /installedBuildMetadata/); assert.match(verifier, /isDeepStrictEqual\(installedBuildMetadata, expectedBuildMetadata\)/); assert.match(verifier, /"--offline"/); - assert.match(verifier, /node_modules\/typescript\/package\.json/); + assert.match(verifier, /packageDirectory: "typescript"/u); + assert.match(verifier, /packageDirectory: "typescript-floor"/u); + assert.match(verifier, /compiler\.packageDirectory,[\s\S]*?"package\.json"/u); assert.doesNotMatch(verifier, /`typescript@\$\{typescriptVersion\}`/); + assert.match(verifier, /"--lib",\s+"ES2022,DOM"/u); + assert.doesNotMatch(verifier, /ES2022,DOM,ESNext\.Disposable/u); + assert.match(verifier, /libraries: \["ES2022", "DOM"\]/u); assert.match(verifier, /artifacts: \{ tarball, wasm, buildMetadata \}/); }); -test("package root curates every custom schema/result type without exporting wasm ABI", () => { +test("package root curates public types while keeping feasibility internals private", () => { const wasmSource = read("crates", "labcolors-wasm", "src", "lib.rs"); const customSection = wasmSource.match( /const TS_RESULT_TYPES: &'static str = r##"([\s\S]*?)"##;/u, @@ -1119,17 +1486,144 @@ test("package root curates every custom schema/result type without exporting was assert.ok(generatedNames.length > 10, "anti-vacuum: custom type surface is non-trivial"); assert.equal(new Set(generatedNames).size, generatedNames.length, "duplicate custom type name"); - const rootTypes = read("packages", "colors", "index.d.ts").match( + const rootDeclarations = read("packages", "colors", "index.d.ts"); + assert.match( + rootDeclarations, + /^\/\/\/ /u, + "package root must make wasm-bindgen disposal types self-contained for consumers", + ); + const typecheck = JSON.parse(read("packages", "colors", "tsconfig.json")); + assert.deepEqual(typecheck.compilerOptions.lib, ["ES2022", "DOM"]); + assert.equal(typecheck.compilerOptions.skipLibCheck, false); + + for (const subpath of ["apply-theme", "watch-theme", "adapt-theme"]) { + const declarations = read("packages", "colors", `${subpath}.d.ts`); + assert.match( + declarations, + /from "\.\/index\.js";/u, + `${subpath} declarations must reuse the curated root type owner`, + ); + assert.doesNotMatch( + declarations, + /\.\/pkg\/labcolors\.js/u, + `${subpath} declarations must not bypass the curated root type owner`, + ); + } + + const verifier = read("scripts", "verify-package-release.mjs"); + for (const subpath of ["apply-theme", "watch-theme", "adapt-theme", "effective-bg"]) { + assert.match( + verifier, + new RegExp(`@labpics/colors/${subpath}`, "u"), + `clean-consumer type smoke must compile the ${subpath} public subpath`, + ); + } + + const rootTypes = rootDeclarations.match( /export type \{([\s\S]*?)\} from "\.\/pkg\/labcolors\.js";/u, )?.[1]; assert.ok(rootTypes, "curated root type export block not found"); const exportedNames = [...rootTypes.matchAll(/^\s{2}([A-Za-z][A-Za-z0-9_]*),$/gmu)].map( (match) => match[1], ); + const feasibilityInternals = new Set([ + "Bytes32V1", + "DecimalU64V1", + "Srgb8BytesV1", + "Wcag22FeasibilityApplicableRelationV1", + "Wcag22FeasibilityAtomicErrorV1", + "Wcag22FeasibilityCompilerInvariantV1", + "Wcag22FeasibilityCoreErrorV1", + "Wcag22FeasibilityEvaluatedV1", + "Wcag22FeasibilityEvaluatorInvariantV1", + "Wcag22FeasibilityInvalidRequestV1", + "Wcag22FeasibilityNotApplicableRelationV1", + "Wcag22FeasibilityNotEvaluatedResultV1", + "Wcag22FeasibilityProofV1", + "Wcag22FeasibilityProtocolErrorV1", + "Wcag22FeasibilityRelationV1", + "Wcag22FeasibilityResourceDimensionV1", + "Wcag22FeasibilityTransportErrorV1", + "Wcag22FeasibilityV1", + ]); + for (const name of feasibilityInternals) { + assert.ok(generatedNames.includes(name), `generated declarations omit ${name}`); + assert.ok(!exportedNames.includes(name), `package root leaks internal ${name}`); + } + for (const publicName of [ + "Wcag22FeasibilityRequestV1", + "Wcag22FeasibilityOutcomeV1", + ]) { + assert.ok(exportedNames.includes(publicName), `package root omits ${publicName}`); + } assert.deepEqual( [...exportedNames].sort(), - [...generatedNames].sort(), - "root types must equal the custom public section exactly", + generatedNames.filter((name) => !feasibilityInternals.has(name)).sort(), + "root types must equal the reviewed public subset exactly", ); assert.doesNotMatch(rootTypes, /InitOutput|__wbg_/u, "raw wasm ABI must stay private"); }); + +test("public declarations compile at the documented minimum TypeScript version", () => { + const packageJson = JSON.parse(read("packages", "colors", "package.json")); + const packageLock = JSON.parse(read("packages", "colors", "package-lock.json")); + assert.equal(packageJson.devDependencies["typescript-floor"], "npm:typescript@5.2.2"); + assert.equal( + packageLock.packages["node_modules/typescript-floor"]?.version, + "5.2.2", + "the consumer floor must be an exact offline lock, not a floating install", + ); + + const readme = read("packages", "colors", "README.md"); + assert.match(readme, /TypeScript `>= 5\.2\.2`/u); + assert.match( + readme, + /typescriptlang\.org\/docs\/handbook\/release-notes\/typescript-5-2\.html/u, + ); + + execFileSync(process.execPath, [ + join(root, "packages", "colors", "node_modules", "typescript-floor", "lib", "tsc.js"), + "--noEmit", + "--strict", + "--skipLibCheck", + "false", + "--target", + "ES2022", + "--lib", + "ES2022,DOM", + "--module", + "NodeNext", + "--moduleResolution", + "NodeNext", + "index.d.ts", + "apply-theme.d.ts", + "watch-theme.d.ts", + "adapt-theme.d.ts", + "effective-bg.d.ts", + ], { + cwd: join(root, "packages", "colors"), + stdio: ["ignore", "pipe", "pipe"], + }); + + const verifier = read("scripts", "verify-package-release.mjs"); + assert.match(verifier, /minimumConsumerCompiler/u); + assert.match(verifier, /node_modules\/typescript-floor/u); +}); + +test("conformance docs define every feasibility count as an oracle output", () => { + const readme = read("conformance", "README.md"); + for (const range of [ + "#000000…#040404", + "#FEFEFE…#FFFFFF", + "#757575…#767676", + "#000000…#2D2D2D", + "#D2D2D2…#FFFFFF", + "#5A5A5A…#949494", + ]) { + assert.ok(readme.includes(range), `feasibility count docs omit exact range ${range}`); + } + assert.match(readme, /256/u); + assert.match(readme, /scripts\/verify_wcag22_neutral_axis\.py/u); + assert.match(readme, /wcag22-neutral-axis-oracle-v1\.json/u); + assert.match(readme, /не параметры/u); +}); diff --git a/packages/colors/test/wcag22-feasibility-boundary.test.mjs b/packages/colors/test/wcag22-feasibility-boundary.test.mjs new file mode 100644 index 00000000..0d00a55c --- /dev/null +++ b/packages/colors/test/wcag22-feasibility-boundary.test.mjs @@ -0,0 +1,361 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + MEASUREMENT_ARTIFACT_ID, + SCENARIO_IDS, + validateMeasurementArtifact, +} from "../bench/wcag22-feasibility-boundary.bench.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, "../../.."); +const corePath = resolve( + root, + "crates/labcolors-core/contracts/wcag22-feasibility-benchmark-v1.json", +); +const toolchainPath = resolve(root, "packages/colors/bench/wasm-size-budget-v1.json"); +const harnessPath = resolve( + root, + "packages/colors/bench/wcag22-feasibility-boundary.bench.mjs", +); +const packPath = resolve(root, "conformance/vectors/wcag22-feasibility.json"); +const conformanceManifestPath = resolve(root, "conformance/vectors/manifest.json"); +const packageManifestPath = resolve(root, "packages/colors/package.json"); +const packageRootPath = resolve(root, "packages/colors/index.js"); +const wasmGluePath = resolve(root, "packages/colors/pkg/labcolors.js"); +const eagerRuntimePaths = { + adaptTheme: "adapt-theme.js", + applyTheme: "apply-theme.js", + effectiveBackground: "effective-bg.js", + watchTheme: "watch-theme.js", +}; +const coreBytes = readFileSync(corePath); +const core = JSON.parse(coreBytes); +const toolchain = JSON.parse(readFileSync(toolchainPath)); +const packBytes = readFileSync(packPath); +const pack = JSON.parse(packBytes); +const conformanceManifestBytes = readFileSync(conformanceManifestPath); +const conformanceManifest = JSON.parse(conformanceManifestBytes); +const ciSource = readFileSync(resolve(root, ".github/workflows/ci.yml"), "utf8"); +const canonicalNodeVersion = `v${ciSource.match(/^\s{2}NODE_TOOLCHAIN: (\d+\.\d+\.\d+)$/mu)[1]}`; +const nativeByName = new Map(core.scenarios.map((scenario) => [scenario.name, scenario])); +const packOracleCase = pack.find((entry) => entry.caseId === "text-default-seven"); +const sha256 = (value) => createHash("sha256").update(value).digest("hex"); + +function toolchainRecipe() { + const measurement = toolchain.measurement; + return { + rustToolchain: measurement.rustToolchain, + rustcCommit: measurement.rustcCommit, + wasmPack: measurement.wasmPack, + wasmBindgen: measurement.wasmBindgen, + target: measurement.target, + cargoProfile: measurement.cargoProfile, + wasmOpt: measurement.wasmOpt, + wasmOptVersion: measurement.wasmOptVersion, + measurementPlatform: measurement.measurementPlatform, + rustPathRemap: measurement.rustPathRemap, + command: measurement.command, + }; +} + +function maxRequestBytes() { + // Independent test oracle only. Production measurement takes this value + // exclusively from the initialized package-root getter, then binds the exact + // WASM plus limit/limit+1 witnesses; this mirror mutation-kills a fabricated + // artifact limit without making JS a second production authority. + const limits = core.profileLimits; + return ( + 101 + + 115 * limits.rawRelations + + 14 * limits.rawAdjacentEntries + + 6 * limits.opaqueUtf8Bytes + ); +} + +function summaryFor(scenarioId, shape) { + if (scenarioId === "transport-limit-plus-one") { + return { + outcome: "failure", + source: "transport", + code: "envelopeTooLarge", + requestedBytes: String(maxRequestBytes() + 1), + limitBytes: String(maxRequestBytes()), + proportionalFieldsPresent: false, + }; + } + if ( + scenarioId === "maximum-opaque-utf8-bytes" || + scenarioId === "maximum-canonical-not-applicable-relations" || + scenarioId === "maximum-combined-not-applicable-envelope" + ) { + return { + outcome: "success", + terminal: "notEvaluated", + canonicalRelations: String(shape.canonicalRelations), + applicableRelations: "0", + notApplicableRelations: String(shape.canonicalRelations), + numericalEvidencePresent: false, + proportionalFieldsPresent: false, + }; + } + return { + outcome: "success", + terminal: "feasible", + domainCount: "256", + canonicalRelations: String(shape.canonicalRelations), + applicableRelations: String(shape.applicableRelations), + notApplicableRelations: String(shape.canonicalRelations - shape.applicableRelations), + applicableEdges: String(shape.applicableEdges), + logicalAssessments: String(256 * shape.applicableEdges), + failureMatrixBytes: 32 * shape.applicableEdges, + partitionBytes: 32, + feasibleCandidates: 1, + lsb0PartitionMatchesMatrix: true, + ...(scenarioId === "minimum-evaluated" ? { lsb0PackOracleMatches: true } : {}), + proportionalFieldsPresent: false, + }; +} + +function fixture() { + const limits = { + maxRequestBytes: maxRequestBytes(), + ...core.profileLimits, + }; + const scenarios = SCENARIO_IDS.map((scenarioId, scenarioIndex) => { + const sourceName = + scenarioId === "transport-limit-plus-one" + ? "maximum-combined-applicable-envelope" + : scenarioId; + const shape = structuredClone(nativeByName.get(sourceName).shape); + const requestBytes = + scenarioId === "maximum-combined-applicable-envelope" + ? limits.maxRequestBytes + : scenarioId === "transport-limit-plus-one" + ? limits.maxRequestBytes + 1 + : 100 + scenarioIndex; + const requestSha256 = sha256(`request:${scenarioId}`); + const outcomeSha256 = sha256(`outcome:${scenarioId}`); + const summary = summaryFor(scenarioId, shape); + return { + scenarioId, + shape, + samples: Array.from({ length: core.sampleCount }, (_, sampleIndex) => ({ + sampleIndex, + elapsedNs: String(sampleIndex + 1), + requestBytes, + requestSha256, + outcomeBytes: 200 + scenarioIndex, + outcomeSha256, + summary: structuredClone(summary), + processMaxRssKiBBefore: 1_000, + processMaxRssKiBAfter: 1_001 + sampleIndex, + wasmMemoryBytesBefore: 65_536, + wasmMemoryBytesAfter: 65_536 * 2, + wasmMemoryPagesBefore: 1, + wasmMemoryPagesAfter: 2, + })), + }; + }); + return { + schemaVersion: 1, + artifactId: MEASUREMENT_ARTIFACT_ID, + claimBoundary: "canonical-wasm-package-root-whole-call-observations-only", + claims: { + admission: "canonical-linux-x64-exact-wasm-only", + hardGates: [ + "completion", + "request-and-outcome-bytes", + "sha256-binding", + "terminal-algebra", + "packed-shape", + "candidate-major-lsb0-pack-oracle", + "no-proportional-dto", + ], + timingThresholdNs: null, + latency: "observation-only-no-production-threshold", + memory: + "process maxRSS is total-process high-water including V8; post-call WASM pages are linear-memory high-water observations; neither is total operation memory", + }, + environment: { + execution: "fresh-node-child-process-per-sample", + platform: "linux-x64", + nodeVersion: canonicalNodeVersion, + sampleCount: core.sampleCount, + requestConstructionMeasured: false, + timer: "process.hrtime.bigint", + packageRootApi: "packages/colors/index.js", + canonicalCandidate: true, + rustToolchain: toolchain.measurement.rustToolchain, + wasmPack: toolchain.measurement.wasmPack, + wasmBindgen: toolchain.measurement.wasmBindgen, + target: toolchain.measurement.target, + cargoProfile: toolchain.measurement.cargoProfile, + wasmOpt: toolchain.measurement.wasmOpt, + }, + bindings: { + coreAdmission: { + path: "crates/labcolors-core/contracts/wcag22-feasibility-benchmark-v1.json", + schemaVersion: core.schemaVersion, + artifactId: core.artifactId, + profileId: core.profileLimits.profileId, + sha256: sha256(coreBytes), + }, + packOracle: { + path: "conformance/vectors/wcag22-feasibility.json", + sha256: sha256(packBytes), + caseId: packOracleCase.caseId, + requestSha256: sha256(Buffer.from(packOracleCase.requestJson, "utf8")), + outcomeSha256: sha256(Buffer.from(packOracleCase.outcomeJson, "utf8")), + manifestPath: "conformance/vectors/manifest.json", + manifestSha256: sha256(conformanceManifestBytes), + packVersion: conformanceManifest.packVersion, + packDigest: conformanceManifest.packDigest, + }, + runtimeSources: { + harness: { + path: "packages/colors/bench/wcag22-feasibility-boundary.bench.mjs", + sha256: sha256(readFileSync(harnessPath)), + }, + packageManifest: { + path: "packages/colors/package.json", + sha256: sha256(readFileSync(packageManifestPath)), + }, + packageRoot: { + path: "packages/colors/index.js", + sha256: sha256(readFileSync(packageRootPath)), + }, + wasmGlue: { + path: "packages/colors/pkg/labcolors.js", + sha256: sha256(readFileSync(wasmGluePath)), + }, + ...Object.fromEntries( + Object.entries(eagerRuntimePaths).map(([sourceId, filename]) => [ + sourceId, + { + path: `packages/colors/${filename}`, + sha256: sha256(readFileSync(resolve(root, "packages/colors", filename))), + }, + ]), + ), + }, + wasmToolchain: { + path: "packages/colors/bench/wasm-size-budget-v1.json", + schemaVersion: toolchain.schemaVersion, + budgetId: toolchain.budgetId, + recipeSha256: sha256(Buffer.from(JSON.stringify(toolchainRecipe()), "utf8")), + }, + wasm: { + path: "packages/colors/pkg/labcolors_bg.wasm", + bytes: 500_000, + sha256: "a".repeat(64), + }, + }, + limits, + scenarios, + }; +} + +test("canonical whole-call artifact schema accepts all immutable scenarios", () => { + const artifact = fixture(); + assert.doesNotThrow(() => validateMeasurementArtifact(artifact)); + assert.equal(artifact.claims.timingThresholdNs, null); + assert.equal(artifact.scenarios.length, 10); +}); + +test("whole-call checker mutation-kills missing evidence and inflated claims", () => { + const mutations = [ + ["missing scenario", (artifact) => { artifact.scenarios.pop(); }], + ["guessed timing gate", (artifact) => { artifact.claims.timingThresholdNs = 1; }], + ["total-memory overclaim", (artifact) => { + artifact.claims.memory = "total operation memory"; + }], + ["unstable outcome digest", (artifact) => { + artifact.scenarios[0].samples[1].outcomeSha256 = "b".repeat(64); + }], + ["LSB0 unchecked", (artifact) => { + artifact.scenarios[0].samples[0].summary.lsb0PartitionMatchesMatrix = false; + }], + ["pack-5 LSB0 oracle unchecked", (artifact) => { + artifact.scenarios[0].samples[0].summary.lsb0PackOracleMatches = false; + }], + ["matrix law drift", (artifact) => { + artifact.scenarios[1].samples[0].summary.failureMatrixBytes -= 1; + }], + ["non-canonical host", (artifact) => { + artifact.environment.platform = "darwin-arm64"; + artifact.environment.canonicalCandidate = false; + }], + ["fabricated public ceiling", (artifact) => { + artifact.limits.maxRequestBytes += 1; + }], + ["missing fresh-process sample", (artifact) => { + artifact.scenarios[0].samples.pop(); + }], + ["stale measurement harness", (artifact) => { + artifact.bindings.runtimeSources.harness.sha256 = "b".repeat(64); + }], + ["stale package root", (artifact) => { + artifact.bindings.runtimeSources.packageRoot.sha256 = "b".repeat(64); + }], + ["stale generated glue", (artifact) => { + artifact.bindings.runtimeSources.wasmGlue.sha256 = "b".repeat(64); + }], + ]; + assert.equal(mutations.length, 13, "anti-vacuum mutation set changed"); + for (const [name, mutate] of mutations) { + const artifact = fixture(); + mutate(artifact); + assert.throws( + () => validateMeasurementArtifact(artifact), + undefined, + `${name} must fail the checker`, + ); + } +}); + +test("final CI verifies and uploads committed Linux evidence before the size gate", () => { + const ci = ciSource; + const harness = "node bench/wcag22-feasibility-boundary.bench.mjs"; + const verify = "\n --verify"; + const fingerprint = "sha256sum packages/colors/pkg/labcolors_bg.wasm"; + const upload = + "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02"; + const oldSizeGate = "node scripts/check-wasm-size-budget.mjs"; + const harnessIndex = ci.indexOf(harness); + const verifyIndex = ci.indexOf(verify, harnessIndex); + const fingerprintIndex = ci.indexOf(fingerprint, verifyIndex); + const uploadIndex = ci.indexOf(upload, verifyIndex); + const oldSizeGateIndex = ci.indexOf(oldSizeGate); + assert.match(ci, /verify committed #295 canonical whole-call WASM boundary evidence/u); + assert.match( + ci, + /name: "upload exact #295 verified whole-call evidence"/u, + ); + assert.ok(harnessIndex >= 0, "the package-root harness must run in CI"); + assert.ok(verifyIndex > harnessIndex, "CI must rerun the committed evidence verifier"); + assert.ok( + fingerprintIndex > verifyIndex, + "an independent system tool must fingerprint the verified WASM", + ); + assert.ok(uploadIndex > fingerprintIndex, "upload must follow the independent fingerprint"); + assert.ok( + oldSizeGateIndex > uploadIndex, + "the retrievable candidate must precede the immutable #284 size gate", + ); + assert.match( + ci, + /path: \|[\s\S]*?packages\/colors\/bench\/wcag22-feasibility-wasm-boundary-v1\.json[\s\S]*?packages\/colors\/pkg\/labcolors_bg\.wasm/u, + ); + assert.doesNotMatch(ci, /--record/u, "candidate-recording mode must not survive admission"); + assert.match(ci, /if-no-files-found: error/u); + assert.match( + readFileSync(resolve(root, "packages/colors/bench/wcag22-feasibility-boundary.bench.mjs"), "utf8"), + /--verify/u, + "the same harness must expose the post-record hard-verify path", + ); +}); diff --git a/packages/colors/test/wcag22-feasibility.test.mjs b/packages/colors/test/wcag22-feasibility.test.mjs new file mode 100644 index 00000000..9a060b7b --- /dev/null +++ b/packages/colors/test/wcag22-feasibility.test.mjs @@ -0,0 +1,355 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { pathToFileURL } from "node:url"; +import { runInNewContext } from "node:vm"; + +const packageRoot = new URL("../", import.meta.url); +const require = createRequire(import.meta.url); + +async function importRootWithInstrumentedWasm(t) { + const fixture = await mkdtemp(join(tmpdir(), "labcolors-feasibility-host-")); + t.after(() => rm(fixture, { recursive: true, force: true })); + await mkdir(join(fixture, "pkg")); + await writeFile(join(fixture, "package.json"), '{"type":"module"}\n'); + await writeFile(join(fixture, "index.js"), await readFile(new URL("../index.js", import.meta.url))); + await writeFile( + join(fixture, "pkg/labcolors.js"), + ` +globalThis.__labcolorsFeasibilityCalls = { evaluate: [], max: [], oversize: [] }; +let initialized = false; +export default async function init() { initialized = true; } +export function initSync() { initialized = true; } +export class LabColors {} +export function evaluateWcag22() {} +export function numericalCapabilityManifest() {} +export function wcag22FeasibilityMaxRequestBytesV1() { + if (!initialized) throw new Error("WASM not initialized"); + globalThis.__labcolorsFeasibilityCalls.max.push(true); + return 657380; +} +export function evaluateWcag22FeasibilityV1(request) { + if (!initialized) throw new Error("WASM not initialized"); + globalThis.__labcolorsFeasibilityCalls.evaluate.push(request); + return { schemaVersion: 1, outcome: "success", feasibility: { status: "notEvaluated", result: {} } }; +} +export function wcag22FeasibilityEnvelopeTooLargeV1(requestedBytes) { + if (!initialized) throw new Error("WASM not initialized"); + globalThis.__labcolorsFeasibilityCalls.oversize.push(requestedBytes); + return { + schemaVersion: 1, + outcome: "failure", + error: { + source: "transport", + error: { + code: "envelopeTooLarge", + requestedBytes: requestedBytes.toString(), + limitBytes: "657380", + }, + }, + }; +} +`, + ); + for (const [file, exports] of [ + ["apply-theme.js", "export function applyTheme() {}\n"], + ["watch-theme.js", "export function watchTheme() {}\n"], + ["adapt-theme.js", "export function adaptTheme() {}\n"], + [ + "effective-bg.js", + "export function effectiveBackground() {}\nexport function parseCssColor() {}\nexport function compositeOver() {}\nexport function compositeStackToHex() {}\nexport function toHex() {}\nexport function oklabLerp() {}\n", + ], + ]) { + await writeFile(join(fixture, file), exports); + } + return import(`${pathToFileURL(join(fixture, "index.js")).href}?case=${Date.now()}`); +} + +test("package root rejects an oversized envelope before the avoidable WASM copy", async (t) => { + const root = await importRootWithInstrumentedWasm(t); + assert.deepEqual( + globalThis.__labcolorsFeasibilityCalls.max, + [], + "import must not touch uninitialized WASM", + ); + root.initSync(); + assert.equal(root.wcag22FeasibilityMaxBytes(), 657380); + + const oversized = new Uint8Array(root.wcag22FeasibilityMaxBytes() + 1); + const outcome = root.evaluateWcag22Feasibility(oversized); + assert.equal(outcome.outcome, "failure"); + assert.equal(outcome.error.error.code, "envelopeTooLarge"); + assert.deepEqual(globalThis.__labcolorsFeasibilityCalls.evaluate, []); + assert.deepEqual(globalThis.__labcolorsFeasibilityCalls.oversize, [657381n]); + + const exactLimit = new Uint8Array(root.wcag22FeasibilityMaxBytes()); + assert.equal(root.evaluateWcag22Feasibility(exactLimit).outcome, "success"); + assert.equal(globalThis.__labcolorsFeasibilityCalls.evaluate.length, 1); + assert.strictEqual(globalThis.__labcolorsFeasibilityCalls.evaluate[0], exactLimit); +}); + +test("package root rejects non-Uint8Array inputs before touching WASM", async (t) => { + const root = await importRootWithInstrumentedWasm(t); + const invalidInputs = [ + ["Array", []], + ["Int8Array", new Int8Array()], + ["DataView", new DataView(new ArrayBuffer(0))], + ["array-like object", { byteLength: 0, length: 0 }], + [ + "tag-spoofed array-like object", + { byteLength: 0, length: 0, [Symbol.toStringTag]: "Uint8Array" }, + ], + ]; + + for (const [label, input] of invalidInputs) { + assert.throws( + () => root.evaluateWcag22Feasibility(input), + { + name: "TypeError", + message: "evaluateWcag22Feasibility request must be a Uint8Array", + }, + label, + ); + } + assert.deepEqual(globalThis.__labcolorsFeasibilityCalls, { + evaluate: [], + max: [], + oversize: [], + }); + + root.initSync(); + const crossRealm = runInNewContext("new Uint8Array([123])"); + assert.equal(root.evaluateWcag22Feasibility(crossRealm).outcome, "success"); + assert.strictEqual(globalThis.__labcolorsFeasibilityCalls.evaluate[0], crossRealm); +}); + +test("package root derives the envelope ceiling from WASM instead of copying a literal", async () => { + const source = await readFile(new URL("../index.js", import.meta.url), "utf8"); + const declarations = await readFile(new URL("../index.d.ts", import.meta.url), "utf8"); + assert.doesNotMatch(source, /657380/u); + assert.match(source, /wcag22FeasibilityMaxBytes/u); + assert.match(source, /wcag22FeasibilityMaxRequestBytesV1/u); + assert.match(source, /wcag22FeasibilityEnvelopeTooLargeV1/u); + assert.match(source, /evaluateWcag22FeasibilityV1/u); + assert.match(declarations, /wcag22FeasibilityMaxBytes\(\): number/u); + assert.match(declarations, /request: Uint8Array/u); + assert.match(declarations, /Wcag22FeasibilityRequestV1/u); + assert.match(declarations, /Wcag22FeasibilityOutcomeV1/u); + for (const internal of [ + "Wcag22FeasibilityProofV1", + "Wcag22FeasibilityCoreErrorV1", + "Wcag22FeasibilityTransportErrorV1", + ]) { + assert.doesNotMatch( + declarations, + new RegExp(`\\b${internal},`, "u"), + `${internal} must not widen the curated package-root type menu`, + ); + } +}); + +test("feasibility TypeScript is exhaustive and excludes forged/proportional states", async (t) => { + const fixture = await mkdtemp(join(tmpdir(), "labcolors-feasibility-types-")); + t.after(() => rm(fixture, { recursive: true, force: true })); + let declarations; + try { + declarations = await readFile(new URL("../pkg/labcolors.d.ts", import.meta.url), "utf8"); + } catch (error) { + if (error?.code === "ENOENT") { + throw new Error("pkg/labcolors.d.ts is required; run `npm run build` before tests", { + cause: error, + }); + } + throw error; + } + assert.match( + declarations, + /export function evaluateWcag22FeasibilityV1\(request: Uint8Array\): Wcag22FeasibilityOutcomeV1;/u, + "wasm-bindgen must publish the reviewed byte API declaration", + ); + await writeFile(join(fixture, "labcolors.d.ts"), declarations); + await writeFile( + join(fixture, "consumer.ts"), + ` +import { + evaluateWcag22FeasibilityV1, + type DecimalU64V1, + type Wcag22FeasibilityEvaluatedV1, + type Wcag22FeasibilityOutcomeV1, + type Wcag22FeasibilityRequestV1, +} from "./labcolors.js"; + +const request: Wcag22FeasibilityRequestV1 = { + schemaVersion: 1, + domainId: "srgb8-neutral-axis-v1", + resourceProfileId: "compile-v1", + relations: [{ + relationId: "opaque-client-id", + occurrenceId: "opaque-occurrence-id", + kind: "applicable", + criterion: "sc-1.4.3-text-default", + adjacent: [[0, 0, 0]], + }], +}; +const bytes = new TextEncoder().encode(JSON.stringify(request)); +const outcome = evaluateWcag22FeasibilityV1(bytes); + +function consume(value: Wcag22FeasibilityOutcomeV1): string { + if (value.outcome === "success") { + switch (value.feasibility.status) { + case "feasible": return value.feasibility.result.proof.logicalAssessments; + case "infeasible": return value.feasibility.result.proof.logicalAssessments; + case "notEvaluated": return value.feasibility.result.relationSetDigest.join(","); + default: { + const exhaustive: never = value.feasibility; + return exhaustive; + } + } + } + switch (value.error.source) { + case "transport": return value.error.error.code; + case "core": return value.error.error.code; + case "incompatibleCoreContract": return value.error.source; + default: { + const exhaustive: never = value.error; + return exhaustive; + } + } +} +consume(outcome); + +declare const exact: DecimalU64V1; +const exactText: string = exact; +void exactText; +// @ts-expect-error decimal u64 values are sealed Rust output, not forgeable text +const forgedNegative: DecimalU64V1 = "-1"; +// @ts-expect-error even in-range-looking text must come from the protocol output +const forgedPositive: DecimalU64V1 = "524032"; +// @ts-expect-error values beyond u64 are not accepted by a loose bigint template +const forgedOverflow: DecimalU64V1 = "18446744073709551616"; +type HasCells = "cells" extends keyof Wcag22FeasibilityEvaluatedV1 ? true : false; +type HasAssessments = "assessments" extends keyof Wcag22FeasibilityEvaluatedV1 ? true : false; +type HasCandidates = "feasibleCandidates" extends keyof Wcag22FeasibilityEvaluatedV1 ? true : false; +const noCells: false = false as HasCells; +const noAssessments: false = false as HasAssessments; +const noCandidates: false = false as HasCandidates; +void [noCells, noAssessments, noCandidates, forgedNegative, forgedPositive, forgedOverflow]; + +// @ts-expect-error byte API has no string overload +evaluateWcag22FeasibilityV1("{}"); +// @ts-expect-error a successful outcome cannot omit its feasibility terminal +const forgedSuccess: Wcag22FeasibilityOutcomeV1 = { schemaVersion: 1, outcome: "success" }; +const forgedFailure: Wcag22FeasibilityOutcomeV1 = { + schemaVersion: 1, + outcome: "failure", + error: { source: "incompatibleCoreContract" }, + // @ts-expect-error a failure cannot carry a successful terminal + feasibility: { status: "notEvaluated", result: {} }, +}; +void [forgedSuccess, forgedFailure]; +`, + ); + const tsc = require.resolve("typescript/bin/tsc"); + execFileSync( + process.execPath, + [ + tsc, + "--noEmit", + "--strict", + "--skipLibCheck", + "false", + "--target", + "ES2022", + "--lib", + "ES2022,DOM,ESNext.Disposable", + "--module", + "ESNext", + "--moduleResolution", + "Bundler", + join(fixture, "consumer.ts"), + ], + { stdio: "inherit" }, + ); +}); + +function packedBit(bytes, index) { + return (bytes[Math.floor(index / 8)] & (1 << (index % 8))) !== 0; +} + +function assertPackedPartition(result) { + const edges = Number(result.proof.applicableEdges); + assert.equal(result.domain.length, 256); + assert.deepEqual(result.domain[0], [0, 0, 0]); + assert.deepEqual(result.domain[255], [255, 255, 255]); + assert.equal(result.failureMatrix.length, 32 * edges); + assert.equal(result.proof.partition.length, 32); + for (let candidate = 0; candidate < result.domain.length; candidate += 1) { + let hasFailure = false; + for (let edge = 0; edge < edges; edge += 1) { + hasFailure ||= packedBit(result.failureMatrix, candidate * edges + edge); + } + assert.equal( + packedBit(result.proof.partition, candidate), + !hasFailure, + `candidate ${candidate}: partition must be the LSB0 all-edge reduction`, + ); + } +} + +test("built package root replays pack 5 through an independent packed consumer", async () => { + const glueUrl = new URL("../pkg/labcolors.js", import.meta.url); + const wasmBytes = await readFile(new URL("../pkg/labcolors_bg.wasm", import.meta.url)); + const raw = await import(glueUrl.href); + raw.initSync({ module: new WebAssembly.Module(wasmBytes) }); + const root = await import(`../index.js?feasibility=${Date.now()}`); + assert.equal( + root.wcag22FeasibilityMaxBytes(), + raw.wcag22FeasibilityMaxRequestBytesV1(), + ); + + const vectors = JSON.parse( + await readFile( + new URL("../../../conformance/vectors/wcag22-feasibility.json", import.meta.url), + "utf8", + ), + ); + const expectedCounts = new Map([ + ["text-default-seven", 7], + ["text-default-two", 2], + ["text-default-zero", 0], + ["text-large-scale-ninety-two", 92], + ["ui-component-ninety-two", 92], + ["graphical-object-ninety-two", 92], + ["ui-component-fifty-nine", 59], + ]); + const encoder = new TextEncoder(); + let mutationSubject; + for (const vector of vectors) { + const outcome = root.evaluateWcag22Feasibility(encoder.encode(vector.requestJson)); + assert.equal(JSON.stringify(outcome), vector.outcomeJson, `${vector.caseId}: wire drift`); + if (outcome.outcome !== "success" || outcome.feasibility.status === "notEvaluated") { + continue; + } + const result = outcome.feasibility.result; + assertPackedPartition(result); + const expected = expectedCounts.get(vector.caseId); + if (expected !== undefined) { + const actual = result.proof.partition.reduce( + (count, byte) => count + byte.toString(2).replaceAll("0", "").length, + 0, + ); + assert.equal(actual, expected, vector.caseId); + } + mutationSubject ??= structuredClone(result); + } + assert.ok(mutationSubject, "corpus contains an evaluated packed result"); + mutationSubject.proof.partition[0] ^= 1; + assert.throws( + () => assertPackedPartition(mutationSubject), + /partition must be the LSB0 all-edge reduction/u, + ); +}); diff --git a/packages/colors/tsconfig.json b/packages/colors/tsconfig.json index 84a7f9ea..733b4f56 100644 --- a/packages/colors/tsconfig.json +++ b/packages/colors/tsconfig.json @@ -6,7 +6,7 @@ "lib": ["ES2022", "DOM"], "strict": true, "noEmit": true, - "skipLibCheck": true, + "skipLibCheck": false, "types": [] }, "include": [ diff --git a/packages/colors/watch-theme.d.ts b/packages/colors/watch-theme.d.ts index 120c61ab..3f24e541 100644 --- a/packages/colors/watch-theme.d.ts +++ b/packages/colors/watch-theme.d.ts @@ -1,6 +1,6 @@ // Public types for the reactive theme runtime. -import type { LabColors, ResolvedTheme, ThemeName } from "./pkg/labcolors.js"; +import type { LabColors, ResolvedTheme, ThemeName } from "./index.js"; export interface WatchThemeOptions { /** An initialised `LabColors` engine (after `await init()`). */ diff --git a/scripts/check-wasm-size-budget.mjs b/scripts/check-wasm-size-budget.mjs index 0df41fa1..2a8910a8 100644 --- a/scripts/check-wasm-size-budget.mjs +++ b/scripts/check-wasm-size-budget.mjs @@ -6,149 +6,281 @@ import { dirname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { gzipSync } from "node:zlib"; -const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const SCRIPT_PATH = fileURLToPath(import.meta.url); +const SCRIPT_DIR = dirname(SCRIPT_PATH); const REPO_ROOT = resolve(SCRIPT_DIR, ".."); const DEFAULT_WASM = resolve(REPO_ROOT, "packages/colors/pkg/labcolors_bg.wasm"); -const DEFAULT_BUDGET = resolve( +const V1_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v1.json"); + +export const DEFAULT_BUDGET = resolve( REPO_ROOT, - "packages/colors/bench/wasm-size-budget-v1.json", + "packages/colors/bench/wasm-size-budget-v2.json", ); +export const V1_FILE_SHA256 = + "4f7340fc8cfd0ccb97377c385f2f8d8e7a9ef2c5ba96177f518c5d07de2825e1"; +export const V1_RECIPE_SHA256 = + "0ea74cb070e0a5facb7280f6124930a0bb673ee4dcee9c99fff110db6c9389d4"; +export const V2_FILE_SHA256 = + "713ccc314b3e6f638d87a54716d665d52f77c86f34a2b6edefe0a354a499d8b1"; + +const V1_REPOSITORY_PATH = "packages/colors/bench/wasm-size-budget-v1.json"; +const V2_BUDGET_ID = "labcolors-wasm-raw-issue-295-v2"; +const WASM_REPOSITORY_PATH = "packages/colors/pkg/labcolors_bg.wasm"; function fail(message) { throw new Error(`WASM size budget: ${message}`); } -function pathsFromArgs(args) { - let wasm = DEFAULT_WASM; - let budget = DEFAULT_BUDGET; - for (let index = 0; index < args.length; index += 2) { - const flag = args[index]; - const value = args[index + 1]; - if (value === undefined) fail(`${flag ?? "argument"} requires a path`); - if (flag === "--wasm") wasm = resolve(value); - else if (flag === "--budget") budget = resolve(value); - else fail(`unknown argument ${flag}`); +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function exactKeys(value, expected, label) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + fail(`${label} must be an object with exactly ${expected.join(", ")}`); + } + const actual = Object.keys(value); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + fail(`${label} must contain exactly ${expected.join(", ")} in canonical order`); } - return { wasm, budget }; } -function readBudget(path) { - let budget; - try { - budget = JSON.parse(readFileSync(path, "utf8")); - } catch (error) { - fail(`cannot read ${path}: ${error.message}`); +function lowercaseDigest(value, label) { + if (typeof value !== "string" || !/^[0-9a-f]{64}$/u.test(value)) { + fail(`${label} must be a lowercase SHA-256 digest`); } - const measurement = budget?.measurement; - const currentArtifact = budget?.currentArtifact; - const policy = budget?.policy; - if (budget?.schemaVersion !== 2) fail("supported schemaVersion is exactly 2"); - if (budget?.budgetId !== "labcolors-wasm-raw-issue-284-v1") { - fail("unexpected budgetId"); +} + +function positiveSafeInteger(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) { + fail(`${label} must be a positive safe integer`); } - if (measurement?.issue !== 284) fail("measurement must cite Issue #284"); - if (!Number.isSafeInteger(measurement?.rawBytes) || measurement.rawBytes <= 0) { - fail("measurement.rawBytes must be a positive safe integer"); +} + +function toolchainRecipe(v1) { + const measurement = v1.measurement; + return { + rustToolchain: measurement?.rustToolchain, + rustcCommit: measurement?.rustcCommit, + wasmPack: measurement?.wasmPack, + wasmBindgen: measurement?.wasmBindgen, + target: measurement?.target, + cargoProfile: measurement?.cargoProfile, + wasmOpt: measurement?.wasmOpt, + wasmOptVersion: measurement?.wasmOptVersion, + measurementPlatform: measurement?.measurementPlatform, + rustPathRemap: measurement?.rustPathRemap, + command: measurement?.command, + }; +} + +function verifyImmutableBuildRecipe() { + let bytes; + try { + bytes = readFileSync(V1_PATH); + } catch (error) { + fail(`cannot read immutable build recipe ${V1_PATH}: ${error.message}`); } - if (!/^[0-9a-f]{64}$/u.test(measurement?.sha256 ?? "")) { - fail("measurement.sha256 must identify the immutable measured baseline"); + if (sha256(bytes) !== V1_FILE_SHA256) { + fail(`immutable v1 file SHA-256 mismatch: expected=${V1_FILE_SHA256} actual=${sha256(bytes)}`); } - if (!/^[0-9a-f]{64}$/u.test(currentArtifact?.sha256 ?? "")) { - fail("currentArtifact.sha256 must identify the exact current artifact"); + + let v1; + try { + v1 = JSON.parse(bytes.toString("utf8")); + } catch (error) { + fail(`immutable v1 build recipe is not JSON: ${error.message}`); } if ( - !Number.isSafeInteger(currentArtifact?.recertificationIssue) || - currentArtifact.recertificationIssue <= 0 + v1?.schemaVersion !== 2 || + v1?.budgetId !== "labcolors-wasm-raw-issue-284-v1" ) { - fail("currentArtifact.recertificationIssue must be a positive issue number"); - } - for (const field of [ - "rustToolchain", - "rustcCommit", - "wasmPack", - "wasmBindgen", - "target", - "cargoProfile", - "wasmOpt", - "wasmOptVersion", - "measurementPlatform", - "command", - ]) { - if (typeof measurement[field] !== "string" || measurement[field].length === 0) { - fail(`measurement.${field} must be non-empty provenance`); - } + fail("immutable v1 build recipe identity drifted"); } - const expectedPathRemap = [ - "GITHUB_WORKSPACE=/workspace/lab-colors", - "CARGO_HOME=/cargo-home", - ]; - if ( - !Array.isArray(measurement.rustPathRemap) || - measurement.rustPathRemap.length !== expectedPathRemap.length || - measurement.rustPathRemap.some( - (entry, index) => entry !== expectedPathRemap[index], - ) - ) { - fail("measurement.rustPathRemap must pin the canonical workspace and CARGO_HOME roots"); + const actualRecipeSha256 = sha256(JSON.stringify(toolchainRecipe(v1))); + if (actualRecipeSha256 !== V1_RECIPE_SHA256) { + fail( + `immutable v1 toolchain recipe SHA-256 mismatch: ` + + `expected=${V1_RECIPE_SHA256} actual=${actualRecipeSha256}`, + ); } - if (!Number.isSafeInteger(policy?.maxRawBytes) || policy.maxRawBytes <= 0) { - fail("policy.maxRawBytes must be a positive safe integer"); +} + +function validateBudgetValue(budget) { + exactKeys( + budget, + ["schemaVersion", "budgetId", "artifact", "buildRecipe", "measurement", "policy"], + "budget", + ); + if (budget.schemaVersion !== 3) fail("supported schemaVersion is exactly 3"); + if (budget.budgetId !== V2_BUDGET_ID) fail(`budgetId must be ${V2_BUDGET_ID}`); + if (budget.artifact !== WASM_REPOSITORY_PATH) { + fail(`artifact must be ${WASM_REPOSITORY_PATH}`); } - if (policy.maxRawBytes !== measurement.rawBytes) { - fail("V1 ceiling must equal the exact accepted measurement (no arbitrary headroom)"); + + exactKeys( + budget.buildRecipe, + ["path", "fileSha256", "recipeSha256"], + "buildRecipe", + ); + if (budget.buildRecipe.path !== V1_REPOSITORY_PATH) { + fail(`buildRecipe.path must be ${V1_REPOSITORY_PATH}`); } - if (policy.derivation !== "exact-accepted-issue-284-measurement") { - fail("unexpected raw-byte ceiling derivation"); + if (budget.buildRecipe.fileSha256 !== V1_FILE_SHA256) { + fail("buildRecipe.fileSha256 must bind the immutable v1 file"); } - if (policy.gzip !== "diagnostic-only") { + if (budget.buildRecipe.recipeSha256 !== V1_RECIPE_SHA256) { + fail("buildRecipe.recipeSha256 must bind the canonical v1 toolchain projection"); + } + + exactKeys( + budget.measurement, + ["issue", "measurementPlatform", "rawBytes", "sha256"], + "measurement", + ); + if (budget.measurement.issue !== 295) fail("measurement must cite Issue #295"); + if (budget.measurement.measurementPlatform !== "linux-x64") { + fail("measurement.measurementPlatform must be canonical linux-x64"); + } + positiveSafeInteger(budget.measurement.rawBytes, "measurement.rawBytes"); + lowercaseDigest(budget.measurement.sha256, "measurement.sha256"); + + exactKeys(budget.policy, ["maxRawBytes", "derivation", "gzip"], "policy"); + positiveSafeInteger(budget.policy.maxRawBytes, "policy.maxRawBytes"); + if (budget.policy.maxRawBytes !== budget.measurement.rawBytes) { + fail("v2 ceiling must equal the exact accepted measurement (zero arbitrary headroom)"); + } + if (budget.policy.derivation !== "exact-accepted-issue-295-slice-b-measurement") { + fail("policy.derivation must cite the exact accepted Issue #295 Slice B measurement"); + } + if (budget.policy.gzip !== "diagnostic-only") { fail("gzip must remain diagnostic-only across implementations"); } + + verifyImmutableBuildRecipe(); +} + +export function parseBudgetDocument(bytes, budgetPath) { + const document = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes); + let budget; + try { + budget = JSON.parse(document.toString("utf8")); + } catch (error) { + fail(`cannot parse ${budgetPath}: ${error.message}`); + } + const canonical = `${JSON.stringify(budget, null, 2)}\n`; + if (!document.equals(Buffer.from(canonical, "utf8"))) { + fail(`${budgetPath} must be canonical JSON with two-space indentation and one final newline`); + } + validateBudgetValue(budget); + if (resolve(budgetPath) === DEFAULT_BUDGET) { + const actualFileSha256 = sha256(document); + if (actualFileSha256 !== V2_FILE_SHA256) { + fail( + `immutable v2 file SHA-256 mismatch: ` + + `expected=${V2_FILE_SHA256} actual=${actualFileSha256}`, + ); + } + } return budget; } -const { wasm: wasmPath, budget: budgetPath } = pathsFromArgs(process.argv.slice(2)); -const budget = readBudget(budgetPath); -let wasm; -try { - wasm = readFileSync(wasmPath); -} catch (error) { - fail(`cannot read ${wasmPath}: ${error.message}`); +function readBudget(path) { + let bytes; + try { + bytes = readFileSync(path); + } catch (error) { + fail(`cannot read ${path}: ${error.message}`); + } + return parseBudgetDocument(bytes, path); +} + +function pathsFromArgs(args) { + let wasm = DEFAULT_WASM; + let budget = DEFAULT_BUDGET; + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const value = args[index + 1]; + if (value === undefined) fail(`${flag ?? "argument"} requires a path`); + if (flag === "--wasm") wasm = resolve(value); + else if (flag === "--budget") budget = resolve(value); + else fail(`unknown argument ${flag}`); + } + return { wasm, budget }; } -if (wasm.length < 8 || !wasm.subarray(0, 4).equals(Buffer.from([0, 97, 115, 109]))) { - fail(`${wasmPath} is not a WebAssembly binary`); + +export function evaluateWasmBudget(budget, wasm, currentPlatform) { + const bytes = Buffer.isBuffer(wasm) ? wasm : Buffer.from(wasm); + if ( + bytes.length < 8 || + !bytes.subarray(0, 4).equals(Buffer.from([0, 97, 115, 109])) + ) { + fail("artifact is not a WebAssembly binary"); + } + + const rawBytes = bytes.length; + const gzipBytes = gzipSync(bytes, { level: 9 }).length; + const artifactSha256 = sha256(bytes); + const artifactSha = artifactSha256 === budget.measurement.sha256 ? "match" : "different"; + const isCanonicalPlatform = currentPlatform === budget.measurement.measurementPlatform; + if (isCanonicalPlatform && rawBytes !== budget.measurement.rawBytes) { + fail( + `exact artifact length mismatch on ${currentPlatform}: ` + + `expected=${budget.measurement.rawBytes}B actual=${rawBytes}B; ` + + `gzip=${gzipBytes}B diagnostic-only sha256=${artifactSha256}`, + ); + } + if (isCanonicalPlatform && artifactSha !== "match") { + fail( + `exact artifact SHA-256 mismatch on ${currentPlatform}: ` + + `expected=${budget.measurement.sha256} actual=${artifactSha256}; ` + + `raw=${rawBytes}B gzip=${gzipBytes}B diagnostic-only`, + ); + } + + return { + status: isCanonicalPlatform ? "PASS" : "DIAGNOSTIC", + rawBytes, + maxRawBytes: budget.policy.maxRawBytes, + deltaBytes: rawBytes - budget.policy.maxRawBytes, + gzipBytes, + currentPlatform, + artifactSha, + artifactSha256, + }; } -const rawBytes = wasm.length; -const maxRawBytes = budget.policy.maxRawBytes; -const gzipBytes = gzipSync(wasm, { level: 9 }).length; -const sha256 = createHash("sha256").update(wasm).digest("hex"); -const baselineSha = sha256 === budget.measurement.sha256 ? "match" : "different"; -const currentSha = sha256 === budget.currentArtifact.sha256 ? "match" : "different"; -const currentPlatform = `${process.platform}-${process.arch}`; -const isCanonicalPlatform = currentPlatform === budget.measurement.measurementPlatform; -const artifact = relative(REPO_ROOT, wasmPath).replaceAll("\\", "/"); - -if (isCanonicalPlatform && rawBytes > maxRawBytes) { - fail( - `FAIL ${artifact} raw=${rawBytes}B exceeds ceiling=${maxRawBytes}B ` + - `by=${rawBytes - maxRawBytes}B; gzip=${gzipBytes}B diagnostic-only; sha256=${sha256}`, +function formatResult(result, artifact) { + const delta = `${result.deltaBytes >= 0 ? "+" : ""}${result.deltaBytes}`; + return ( + `WASM size budget ${result.status} raw=${result.rawBytes}B ` + + `ceiling=${result.maxRawBytes}B delta=${delta}B gzip=${result.gzipBytes}B ` + + `diagnostic-only platform=${result.currentPlatform} artifact=${artifact} ` + + `artifact-sha=${result.artifactSha} recipe-sha=match` ); } -if (isCanonicalPlatform && currentSha !== "match") { - fail( - `current artifact SHA-256 mismatch on ${currentPlatform}: ` + - `expected=${budget.currentArtifact.sha256} actual=${sha256}; ` + - `raw=${rawBytes}B gzip=${gzipBytes}B diagnostic-only`, + +function main(args) { + const { wasm: wasmPath, budget: budgetPath } = pathsFromArgs(args); + const budget = readBudget(budgetPath); + let wasm; + try { + wasm = readFileSync(wasmPath); + } catch (error) { + fail(`cannot read ${wasmPath}: ${error.message}`); + } + const artifact = relative(REPO_ROOT, wasmPath).replaceAll("\\", "/"); + const result = evaluateWasmBudget( + budget, + wasm, + `${process.platform}-${process.arch}`, ); + console.log(formatResult(result, artifact)); +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === SCRIPT_PATH) { + main(process.argv.slice(2)); } -const sizeDelta = rawBytes - maxRawBytes; -const sizeStatus = isCanonicalPlatform - ? `PASS raw=${rawBytes}B ceiling=${maxRawBytes}B remaining=${-sizeDelta}B` - : `DIAGNOSTIC raw=${rawBytes}B canonical-ceiling=${maxRawBytes}B ` + - `delta=${sizeDelta >= 0 ? "+" : ""}${sizeDelta}B`; - -console.log( - `WASM size budget ${sizeStatus} gzip=${gzipBytes}B ` + - `diagnostic-only platform=${currentPlatform} ` + - `baseline-sha=${baselineSha} current-sha=${currentSha}`, -); diff --git a/scripts/check_wcag22_feasibility_applicability.py b/scripts/check_wcag22_feasibility_applicability.py new file mode 100644 index 00000000..6e56705f --- /dev/null +++ b/scripts/check_wcag22_feasibility_applicability.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +"""Verify current applicability of the immutable feasibility benchmark. + +The admitted Slice-A artifact keeps the exact measured ``Cargo.lock`` blob as +historical provenance. This verifier deliberately does not reinterpret that +blob as a byte-for-byte constraint on unrelated workspace packages. Instead +it proves that every current Core/benchmark subject is unchanged and that the +current lock preserves the exact ``labcolors-core`` record plus every sourced +registry/git package from the measured lock. Only source-less non-Core +workspace records may differ. +""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import re +import subprocess +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_ARTIFACT = ROOT / ( + "crates/labcolors-core/contracts/" + "wcag22-feasibility-benchmark-v1.json" +) +HISTORICAL_ONLY_PATH = "Cargo.lock" +CORE_SOURCE_TREE_PATH = "crates/labcolors-core/src" +EXPECTED_SUBJECT_PATHS = ( + "Cargo.toml", + "crates/labcolors-core/src/lib.rs", + "crates/labcolors-core/src/wcag22_feasibility.rs", + "crates/labcolors-core/src/srgb8.rs", + "crates/labcolors-core/src/sha256.rs", + "crates/labcolors-core/src/wcag22.rs", + "crates/labcolors-core/src/wcag22/kernel.rs", + "crates/labcolors-core/src/wcag22/q55_data.rs", + "crates/labcolors-core/src/wcag22_evidence.rs", + "crates/labcolors-core/src/numerics.rs", + "crates/labcolors-core/contracts/wcag22-srgb8-v1.json", + "crates/labcolors-core/contracts/wcag22-srgb8-q55-proof-v1.json", + "crates/labcolors-core/Cargo.toml", + HISTORICAL_ONLY_PATH, + "crates/labcolors-core/benches/wcag22_feasibility_admission.rs", + "scripts/check_wcag22_feasibility_benchmark.py", +) +HEX_256 = re.compile(r"[0-9a-f]{64}") +GIT_OBJECT = re.compile(r"[0-9a-f]{40}") +LOCK_HEADER = ( + "# This file is automatically @generated by Cargo.\n" + "# It is not intended for manual editing.\n" + "version = 4\n" +) +LOCK_STRING = re.compile(r'[^"\\\x00-\x1f\x7f]+') +LOCK_DEPENDENCY = re.compile(r' "[^"\\\x00-\x1f\x7f]+",\n') + + +class ApplicabilityError(ValueError): + """One exact applicability law failed.""" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ApplicabilityError(message) + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def decode_artifact(raw: bytes) -> dict[str, Any]: + try: + payload = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ApplicabilityError("benchmark artifact is not JSON") from error + require(isinstance(payload, dict), + "benchmark artifact root must be an object") + return payload + + +def git_blob(object_id: str) -> bytes: + require(GIT_OBJECT.fullmatch(object_id) is not None, + "historical Cargo.lock Git object is invalid") + result = subprocess.run( + ["git", "cat-file", "blob", object_id], + cwd=ROOT, + check=False, + capture_output=True, + ) + require(result.returncode == 0, + "historical Cargo.lock Git object is unavailable") + return result.stdout + + +def lock_string(line: str, field: str, label: str, index: int) -> str: + prefix = f'{field} = "' + require( + line.startswith(prefix) and line.endswith('"\n'), + f"{label} package record {index} has non-canonical {field}", + ) + value = line[len(prefix):-2] + require( + LOCK_STRING.fullmatch(value) is not None, + f"{label} package record {index} has invalid {field}", + ) + return value + + +def package_record(raw: str, label: str, index: int) -> tuple[str, str, str]: + require(raw.endswith("\n"), + f"{label} package record {index} lacks its canonical newline") + lines = raw.splitlines(keepends=True) + require(len(lines) >= 2, + f"{label} package record {index} is incomplete") + name = lock_string(lines[0], "name", label, index) + version = lock_string(lines[1], "version", label, index) + cursor = 2 + source = "" + if cursor < len(lines) and lines[cursor].startswith("source"): + source = lock_string(lines[cursor], "source", label, index) + cursor += 1 + if cursor < len(lines) and lines[cursor].startswith("checksum"): + checksum = lock_string(lines[cursor], "checksum", label, index) + require(source != "", + f"{label} package record {index} has checksum without source") + require(HEX_256.fullmatch(checksum) is not None, + f"{label} package record {index} has invalid checksum") + cursor += 1 + if cursor < len(lines): + require(lines[cursor] == "dependencies = [\n", + f"{label} package record {index} has unexpected syntax") + cursor += 1 + first_dependency = cursor + while cursor < len(lines) and lines[cursor] != "]\n": + require(LOCK_DEPENDENCY.fullmatch(lines[cursor]) is not None, + f"{label} package record {index} has non-canonical dependency") + cursor += 1 + require(cursor > first_dependency, + f"{label} package record {index} has an empty dependency list") + require(cursor < len(lines) and lines[cursor] == "]\n", + f"{label} package record {index} has an unterminated dependency list") + cursor += 1 + require(cursor == len(lines), + f"{label} package record {index} has trailing syntax") + return name, version, source + + +def package_blocks(lock: bytes, label: str) -> dict[tuple[str, str, str], bytes]: + try: + text = lock.decode("utf-8") + except UnicodeDecodeError as error: + raise ApplicabilityError(f"{label} is not UTF-8") from error + require(text.startswith(LOCK_HEADER), + f"{label} is not canonical Cargo lockfile v4") + parts = text.split("\n[[package]]\n") + require(parts[0] == LOCK_HEADER, + f"{label} contains material outside canonical package records") + require(len(parts) > 1, f"{label} contains no package records") + records: dict[tuple[str, str, str], bytes] = {} + previous_key: tuple[str, str, str] | None = None + for index, raw in enumerate(parts[1:]): + block = f"[[package]]\n{raw}".encode() + name, version, source = package_record(raw, label, index) + key = (name, version, source) + require(previous_key is None or previous_key < key, + f"{label} package records are not in canonical order") + require(key not in records, f"{label} contains duplicate package identity {key}") + records[key] = block + previous_key = key + return records + + +def check_lock_compatibility(historical: bytes, current: bytes) -> int: + historical_records = package_blocks(historical, "historical Cargo.lock") + current_records = package_blocks(current, "current Cargo.lock") + core_keys = [key for key in historical_records if key[0] == "labcolors-core"] + require(len(core_keys) == 1, "historical lock must contain one labcolors-core record") + current_core_keys = [key for key in current_records if key[0] == "labcolors-core"] + require(len(current_core_keys) == 1, + "current lock must contain one labcolors-core record") + core_key = core_keys[0] + require(current_records.get(core_key) == historical_records[core_key], + "current Cargo.lock changed the exact labcolors-core dependency record") + + historical_sourced = { + key: block for key, block in historical_records.items() if key[2] + } + current_sourced = { + key: block for key, block in current_records.items() if key[2] + } + require(set(current_sourced) == set(historical_sourced), + "current Cargo.lock changed the sourced package identity set") + for key, block in historical_sourced.items(): + require(current_sourced[key] == block, + f"current Cargo.lock changed sourced package record {key}") + + historical_workspace_names = { + key[0] + for key in historical_records + if not key[2] and key[0] != "labcolors-core" + } + current_workspace_names = { + key[0] + for key in current_records + if not key[2] and key[0] != "labcolors-core" + } + missing_workspace_names = historical_workspace_names - current_workspace_names + require( + not missing_workspace_names, + "current Cargo.lock removed historical source-less non-Core package(s): " + + ", ".join(sorted(missing_workspace_names)), + ) + + return sum( + 1 + for key in set(historical_records) | set(current_records) + if not key[2] + and key[0] != "labcolors-core" + and historical_records.get(key) != current_records.get(key) + ) + + +def subject_manifest(payload: dict[str, Any]) -> dict[str, str]: + raw = payload.get("subjectManifest") + require(isinstance(raw, list) and len(raw) == len(EXPECTED_SUBJECT_PATHS), + "subjectManifest must retain the exact measured path set") + manifest: dict[str, str] = {} + for index, expected_path in enumerate(EXPECTED_SUBJECT_PATHS): + entry = raw[index] + require(isinstance(entry, dict) and set(entry) == {"path", "sha256"}, + f"subjectManifest[{index}] has unexpected fields") + require(entry.get("path") == expected_path, + f"subjectManifest[{index}] path/order drifted") + digest = entry.get("sha256") + require(isinstance(digest, str) and HEX_256.fullmatch(digest) is not None, + f"subjectManifest[{index}] SHA-256 is invalid") + require(expected_path not in manifest, + f"subjectManifest duplicates {expected_path}") + manifest[expected_path] = digest + return manifest + + +def historical_source_object( + payload: dict[str, Any], name: str, expected_path: str +) -> str: + environment = payload.get("environment") + require(isinstance(environment, dict), "environment must be an object") + source_objects = environment.get("sourceObjects") + require(isinstance(source_objects, dict), + "environment.sourceObjects must be an object") + entry = source_objects.get(name) + require(isinstance(entry, dict) and set(entry) == {"path", "gitObject"}, + f"{name} source object has unexpected fields") + require(entry.get("path") == expected_path, + f"{name} source object path drifted") + object_id = entry.get("gitObject") + require(isinstance(object_id, str) and GIT_OBJECT.fullmatch(object_id) is not None, + f"{name} source object ID is invalid") + return object_id + + +def require_current_tree_binding(expected: str, current: str, status: str) -> None: + require(status == "", f"current {CORE_SOURCE_TREE_PATH} worktree is dirty") + require(current == expected, + f"current {CORE_SOURCE_TREE_PATH} tree differs from admitted measurement") + + +def check_current_core_source_tree(expected: str) -> None: + status = subprocess.run( + [ + "git", + "status", + "--porcelain=v1", + "--untracked-files=all", + "--", + CORE_SOURCE_TREE_PATH, + ], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + require(status.returncode == 0, "cannot inspect current Core source tree") + tree = subprocess.run( + ["git", "rev-parse", f"HEAD:{CORE_SOURCE_TREE_PATH}"], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + require(tree.returncode == 0, "current Core source tree is unavailable") + current = tree.stdout.strip() + require(GIT_OBJECT.fullmatch(current) is not None, + "current Core source tree object is invalid") + require_current_tree_binding(expected, current, status.stdout) + + +def check_current_applicability( + artifact_path: Path, + expected_artifact_sha256: str, +) -> int: + raw = artifact_path.read_bytes() + require(HEX_256.fullmatch(expected_artifact_sha256) is not None, + "expected artifact SHA-256 is invalid") + require(sha256(raw) == expected_artifact_sha256, + "benchmark artifact SHA-256 mismatch") + payload = decode_artifact(raw) + require(payload.get("schemaVersion") == 1, + "unsupported benchmark artifact schema") + require(payload.get("artifactId") == "wcag22-feasibility-admission-raw-v1", + "unexpected benchmark artifact identity") + + manifest = subject_manifest(payload) + lock_object = historical_source_object( + payload, "workspaceLock", HISTORICAL_ONLY_PATH + ) + core_source_tree = historical_source_object( + payload, "coreSourceTree", CORE_SOURCE_TREE_PATH + ) + historical_lock = git_blob(lock_object) + require(sha256(historical_lock) == manifest[HISTORICAL_ONLY_PATH], + "historical Cargo.lock blob disagrees with measured subject manifest") + + for path, expected in manifest.items(): + if path == HISTORICAL_ONLY_PATH: + continue + current_path = ROOT / path + require(current_path.is_file(), f"current subject is missing: {path}") + require(sha256(current_path.read_bytes()) == expected, + f"current benchmark subject drift: {path}") + + check_current_core_source_tree(core_source_tree) + + return check_lock_compatibility(historical_lock, (ROOT / "Cargo.lock").read_bytes()) + + +def canonical_lock(records: list[str]) -> bytes: + bodies = (record.rstrip("\n") for record in records) + return ( + LOCK_HEADER + + "".join(f"\n[[package]]\n{record}\n" for record in bodies) + ).encode() + + +def self_test() -> None: + require(decode_artifact(b"{}") == {}, + "object artifact root must remain admissible") + for label, mutation in ( + ("invalid UTF-8", b"\xff"), + ("array root", b"[]"), + ("null root", b"null"), + ("string scalar root", b'"scalar"'), + ("number scalar root", b"0"), + ("boolean scalar root", b"true"), + ): + try: + decode_artifact(mutation) + except ApplicabilityError: + continue + raise ApplicabilityError(f"self-test mutation survived: {label}") + + core = 'name = "labcolors-core"\nversion = "0.2.0"\ndependencies = [\n "dep",\n]' + dep = ( + 'name = "dep"\nversion = "1.0.0"\n' + 'source = "registry+https://example.invalid/index"\n' + f'checksum = "{"1" * 64}"' + ) + other = 'name = "other-workspace"\nversion = "0.2.0"' + historical = canonical_lock([dep, core, other]) + + changed_workspace = canonical_lock([ + dep, + core, + 'name = "new-workspace"\nversion = "0.2.0"', + 'name = "other-workspace"\nversion = "0.2.0"\n' + 'dependencies = [\n "labcolors-core",\n]', + ]) + require(check_lock_compatibility(historical, changed_workspace) == 2, + "source-less non-Core workspace drift must remain admissible") + + mutations = { + "core dependency": canonical_lock([ + dep, + core.replace(' "dep",', ' "different",'), + other, + ]), + "sourced checksum": canonical_lock([ + dep.replace("1" * 64, "2" * 64), core, other, + ]), + "missing sourced package": canonical_lock([core, other]), + "missing historical workspace package": canonical_lock([dep, core]), + "new sourced package": canonical_lock([ + dep, + core, + 'name = "new-dep"\nversion = "1.0.0"\n' + 'source = "registry+https://example.invalid/index"\n' + f'checksum = "{"3" * 64}"', + other, + ]), + "duplicate Core package": canonical_lock([ + dep, + core, + 'name = "labcolors-core"\nversion = "999.0.0"', + other, + ]), + "non-canonical source grammar": canonical_lock([ + dep, + core, + 'name = "new-dep"\nversion = "1.0.0"\n' + "source = 'registry+https://example.invalid/index'\n" + f'checksum = "{"3" * 64}"', + other, + ]), + "sourced record trailing spaces": historical.replace( + (f'checksum = "{"1" * 64}"\n\n[[package]]').encode(), + (f'checksum = "{"1" * 64}" \n\n[[package]]').encode(), + 1, + ), + "pre-package material": historical.replace( + b"version = 4\n", + b'version = 4\nforged = "outside-package-records"\n', + 1, + ), + "package record reorder": canonical_lock([core, dep, other]), + } + for label, mutation in mutations.items(): + try: + check_lock_compatibility(historical, mutation) + except ApplicabilityError: + continue + raise ApplicabilityError(f"self-test mutation survived: {label}") + + tree_object = "0" * 40 + require_current_tree_binding(tree_object, tree_object, "") + for label, current, status in ( + ("Core source tree object", "1" * 40, ""), + ("dirty Core source tree", tree_object, " M crates/labcolors-core/src/lib.rs\n"), + ): + try: + require_current_tree_binding(tree_object, current, status) + except ApplicabilityError: + continue + raise ApplicabilityError(f"self-test mutation survived: {label}") + + source_payload = { + "environment": { + "sourceObjects": { + "coreSourceTree": { + "path": CORE_SOURCE_TREE_PATH, + "gitObject": tree_object, + } + } + } + } + require( + historical_source_object( + source_payload, "coreSourceTree", CORE_SOURCE_TREE_PATH + ) == tree_object, + "Core source tree object must remain readable from historical provenance", + ) + + payload = { + "subjectManifest": [ + {"path": path, "sha256": "0" * 64} + for path in EXPECTED_SUBJECT_PATHS + ] + } + subject_manifest(payload) + for label, mutate in { + "reordered subject": lambda value: value["subjectManifest"].reverse(), + "missing subject": lambda value: value["subjectManifest"].pop(), + "extra field": lambda value: value["subjectManifest"][0].update(extra=True), + }.items(): + candidate = copy.deepcopy(payload) + mutate(candidate) + try: + subject_manifest(candidate) + except ApplicabilityError: + continue + raise ApplicabilityError(f"self-test mutation survived: {label}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("artifact", nargs="?", type=Path) + parser.add_argument("--artifact-sha256") + parser.add_argument("--self-test", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.self_test: + self_test() + if args.artifact is None: + require(args.self_test, "artifact path is required unless --self-test is used") + print("WCAG22 feasibility applicability self-test: PASS") + return 0 + require(args.artifact_sha256 is not None, + "--artifact-sha256 is required for current applicability") + allowed = check_current_applicability( + args.artifact.resolve(), args.artifact_sha256 + ) + print( + "WCAG22 feasibility current applicability: PASS " + f"historical-lock={HISTORICAL_ONLY_PATH} " + f"allowed-workspace-record-drift={allowed}" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (ApplicabilityError, OSError) as error: + raise SystemExit(f"WCAG22 feasibility applicability: {error}") from error diff --git a/scripts/prepare-npm-package.mjs b/scripts/prepare-npm-package.mjs index e78cd221..cd3fca61 100644 --- a/scripts/prepare-npm-package.mjs +++ b/scripts/prepare-npm-package.mjs @@ -28,6 +28,7 @@ const CONFORMANCE_FILES = [ "solve.json", "muddiness.json", "wcag22.json", + "wcag22-feasibility.json", ]; const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); diff --git a/scripts/verify-package-release.mjs b/scripts/verify-package-release.mjs index 807ccebb..ebe2de3d 100644 --- a/scripts/verify-package-release.mjs +++ b/scripts/verify-package-release.mjs @@ -37,6 +37,7 @@ const CONFORMANCE_FAMILY_FILES = [ "solve.json", "muddiness.json", "wcag22.json", + "wcag22-feasibility.json", ]; const WASM_PATH = resolve(PACKAGE_DIR, "pkg/labcolors_bg.wasm"); @@ -478,9 +479,609 @@ function validateCapabilityManifest(capabilities) { } } +const FEASIBILITY_VECTOR_KEYS_V1 = ["caseId", "outcomeJson", "requestJson"]; +const FEASIBILITY_CASES_V1 = new Map([ + ["text-default-seven", { terminal: "feasible", count: 7 }], + ["text-default-two", { terminal: "feasible", count: 2 }], + ["text-default-zero", { terminal: "infeasible", count: 0 }], + ["text-large-scale-ninety-two", { terminal: "feasible", count: 92 }], + ["ui-component-ninety-two", { terminal: "feasible", count: 92 }], + ["graphical-object-ninety-two", { terminal: "feasible", count: 92 }], + ["ui-component-fifty-nine", { terminal: "feasible", count: 59 }], + ["mixed-not-applicable", { terminal: "feasible" }], + ["all-not-applicable", { terminal: "notEvaluated" }], + ["conflicting-relation-id", { failure: "conflict" }], + ["raw-adjacent-resource-rejection", { failure: "resource" }], + ["opaque-identity-a", { terminal: "feasible" }], + ["opaque-identity-b", { terminal: "feasible" }], +]); +const FEASIBILITY_CRITERIA_V1 = new Set([ + "sc-1.4.3-text-default", + "sc-1.4.3-text-large-scale", + "sc-1.4.11-ui-component-or-state", + "sc-1.4.11-graphical-object", +]); +const FEASIBILITY_PROPORTIONAL_KEYS_V1 = new Set([ + "assessments", + "cells", + "feasibleCandidates", + "infeasibleCandidates", +]); +const FEASIBILITY_PROOF_KEYS_V1 = [ + "applicableEdges", + "applicableRelations", + "artifactId", + "boundId", + "canonicalRelations", + "domainCount", + "domainDigest", + "domainFirst", + "domainId", + "domainLast", + "evaluationId", + "logicalAssessments", + "matrixDigest", + "notApplicableRelations", + "partition", + "proofId", + "proofSha256", + "relationSetDigest", + "resourceProfileId", + "wcag22ProfileId", +]; +const FEASIBILITY_DOMAIN_SEPARATOR_V1 = Buffer.from( + "labcolors/wcag22-feasibility/domain/v1\0", + "utf8", +); +const FEASIBILITY_RELATION_SEPARATOR_V1 = Buffer.from( + "labcolors/wcag22-feasibility/relations/v1\0", + "utf8", +); +const FEASIBILITY_EVALUATION_SEPARATOR_V1 = Buffer.from( + "labcolors/wcag22-feasibility/evaluation/v1\0", + "utf8", +); + +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exactKeys(value, expected, label) { + if (!isRecord(value)) fail(`${label} must be an object`); + const actual = Object.keys(value).sort(); + const canonical = [...expected].sort(); + if (!isDeepStrictEqual(actual, canonical)) { + fail(`${label} fields ${JSON.stringify(actual)} differ from ${JSON.stringify(canonical)}`); + } +} + +function canonicalJson(source, label) { + if (typeof source !== "string" || source.length === 0) fail(`${label} must be non-empty JSON text`); + let value; + try { + value = JSON.parse(source); + } catch (error) { + fail(`${label} is not valid JSON: ${error.message}`); + } + if (JSON.stringify(value) !== source) fail(`${label} is not canonical compact JSON`); + return value; +} + +function decimalU64(value, label) { + if (typeof value !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(value)) { + fail(`${label} must be a canonical decimal u64 string`); + } + const parsed = BigInt(value); + if (parsed > 18_446_744_073_709_551_615n) fail(`${label} exceeds u64`); + return parsed; +} + +function byteArray(value, expectedLength, label) { + if ( + !Array.isArray(value) || + value.length !== expectedLength || + !value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255) + ) { + fail(`${label} must contain exactly ${expectedLength} byte values`); + } +} + +function u64be(value, label) { + const parsed = BigInt(value); + if (parsed < 0n || parsed > 18_446_744_073_709_551_615n) { + fail(`${label} is outside u64`); + } + const bytes = Buffer.alloc(8); + bytes.writeBigUInt64BE(parsed); + return bytes; +} + +function updateLengthPrefixed(hasher, value, label) { + const bytes = Buffer.from(value, "utf8"); + hasher.update(u64be(bytes.length, `${label}.length`)); + hasher.update(bytes); +} + +function requireDigest(actual, expected, label) { + byteArray(actual, 32, label); + if (!Buffer.from(actual).equals(expected)) { + fail(`${label} does not bind its canonical preimage`); + } +} + +function neutralAxisDomainV1() { + return Array.from({ length: 256 }, (_, value) => [value, value, value]); +} + +function feasibilityDomainDigestV1(domainId, domain) { + const hasher = createHash("sha256"); + hasher.update(FEASIBILITY_DOMAIN_SEPARATOR_V1); + updateLengthPrefixed(hasher, domainId, "domainId"); + hasher.update(u64be(domain.length, "domainCount")); + for (const candidate of domain) hasher.update(Buffer.from(candidate)); + return hasher.digest(); +} + +function feasibilityRelationSetDigestV1(relations) { + const hasher = createHash("sha256"); + hasher.update(FEASIBILITY_RELATION_SEPARATOR_V1); + hasher.update(u64be(relations.length, "canonicalRelations")); + for (const relation of relations) { + if (relation.kind === "applicable") { + hasher.update(Buffer.from([1])); + updateLengthPrefixed(hasher, relation.relationId, "relationId"); + updateLengthPrefixed(hasher, relation.occurrenceId, "occurrenceId"); + updateLengthPrefixed(hasher, relation.criterion, "criterion"); + hasher.update(u64be(relation.adjacent.length, "adjacentCount")); + for (const adjacent of relation.adjacent) hasher.update(Buffer.from(adjacent)); + } else { + hasher.update(Buffer.from([2])); + updateLengthPrefixed(hasher, relation.relationId, "relationId"); + updateLengthPrefixed(hasher, relation.occurrenceId, "occurrenceId"); + updateLengthPrefixed(hasher, relation.reasonId, "reasonId"); + } + } + return hasher.digest(); +} + +function feasibilityEvaluationIdV1({ proof, counts, matrixDigest, atomicProofSha256 }) { + const hasher = createHash("sha256"); + hasher.update(FEASIBILITY_EVALUATION_SEPARATOR_V1); + hasher.update(Buffer.from(proof.domainDigest)); + hasher.update(Buffer.from(proof.relationSetDigest)); + for (const [field, value] of [ + ["wcag22ProfileId", proof.wcag22ProfileId], + ["artifactId", proof.artifactId], + ["boundId", proof.boundId], + ["proofId", proof.proofId], + ]) { + updateLengthPrefixed(hasher, value, field); + } + hasher.update(atomicProofSha256); + for (const [field, value] of [ + ["canonicalRelations", counts.canonicalRelations], + ["applicableRelations", counts.applicableRelations], + ["notApplicableRelations", counts.notApplicableRelations], + ["applicableEdges", counts.applicableEdges], + ["logicalAssessments", counts.logicalAssessments], + ["packedResultBytes", counts.packedResultBytes], + ]) { + hasher.update(u64be(value, field)); + } + hasher.update(matrixDigest); + hasher.update(Buffer.from(proof.partition)); + return hasher.digest(); +} + +function rgb(value, label) { + byteArray(value, 3, label); +} + +function walkNoProportionalDto(value, label) { + if (Array.isArray(value)) { + for (const child of value) walkNoProportionalDto(child, label); + return; + } + if (!isRecord(value)) return; + for (const [key, child] of Object.entries(value)) { + if (FEASIBILITY_PROPORTIONAL_KEYS_V1.has(key)) { + fail(`${label} contains forbidden proportional field ${key}`); + } + walkNoProportionalDto(child, label); + } +} + +function validateFeasibilityRelation(relation, label) { + if (relation?.kind === "applicable") { + exactKeys( + relation, + ["adjacent", "criterion", "kind", "occurrenceId", "relationId"], + label, + ); + if (!FEASIBILITY_CRITERIA_V1.has(relation.criterion)) { + fail(`${label} has unsupported criterion ${relation.criterion}`); + } + if (!Array.isArray(relation.adjacent) || relation.adjacent.length === 0) { + fail(`${label} must declare non-empty adjacency`); + } + relation.adjacent.forEach((value, index) => rgb(value, `${label}.adjacent[${index}]`)); + } else if (relation?.kind === "notApplicable") { + exactKeys(relation, ["kind", "occurrenceId", "reasonId", "relationId"], label); + if (typeof relation.reasonId !== "string" || relation.reasonId.length === 0) { + fail(`${label} must declare a non-empty reasonId`); + } + } else { + fail(`${label} has unsupported kind ${relation?.kind}`); + } + for (const field of ["relationId", "occurrenceId"]) { + if (typeof relation[field] !== "string" || relation[field].length === 0) { + fail(`${label}.${field} must be a non-empty opaque string`); + } + } +} + +function validateFeasibilityRequest(source, caseId) { + const request = canonicalJson(source, `${caseId}.requestJson`); + exactKeys( + request, + ["domainId", "relations", "resourceProfileId", "schemaVersion"], + `${caseId}.request`, + ); + if ( + request.schemaVersion !== 1 || + request.domainId !== "srgb8-neutral-axis-v1" || + request.resourceProfileId !== "compile-v1" + ) { + fail(`${caseId}.request has unsupported version/domain/profile`); + } + if (!Array.isArray(request.relations) || request.relations.length === 0) { + fail(`${caseId}.request must contain relations`); + } + request.relations.forEach((relation, index) => + validateFeasibilityRelation(relation, `${caseId}.request.relations[${index}]`)); + return request; +} + +function compareRgb(left, right) { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +function canonicalRelations(relations) { + const values = relations.map((relation) => { + if (relation.kind !== "applicable") return structuredClone(relation); + const adjacent = [...relation.adjacent].sort(compareRgb); + const unique = adjacent.filter( + (value, index) => index === 0 || compareRgb(value, adjacent[index - 1]) !== 0, + ); + return { ...structuredClone(relation), adjacent: unique }; + }); + values.sort((left, right) => + Buffer.compare(Buffer.from(left.relationId, "utf8"), Buffer.from(right.relationId, "utf8"))); + return values.filter( + (value, index) => index === 0 || !isDeepStrictEqual(value, values[index - 1]), + ); +} + +function packedBit(bytes, logicalIndex) { + return (bytes[Math.floor(logicalIndex / 8)] & (1 << (logicalIndex % 8))) !== 0; +} + +function validateEvaluatedFeasibility( + result, + request, + status, + caseId, + atomicProofSha256, +) { + exactKeys(result, ["domain", "failureMatrix", "proof", "relations"], `${caseId}.result`); + if (!Array.isArray(result.domain) || result.domain.length !== 256) { + fail(`${caseId}.domain must contain exactly 256 candidates`); + } + result.domain.forEach((candidate, index) => { + rgb(candidate, `${caseId}.domain[${index}]`); + if (!isDeepStrictEqual(candidate, [index, index, index])) { + fail(`${caseId}.domain candidate ${index} is not the registered neutral-axis value`); + } + }); + if (!Array.isArray(result.relations) || result.relations.length === 0) { + fail(`${caseId}.result must retain canonical relations once`); + } + result.relations.forEach((relation, index) => + validateFeasibilityRelation(relation, `${caseId}.result.relations[${index}]`)); + if (!isDeepStrictEqual(result.relations, canonicalRelations(request.relations))) { + fail(`${caseId}.result relations differ from independent canonical request projection`); + } + + const proof = result.proof; + exactKeys(proof, FEASIBILITY_PROOF_KEYS_V1, `${caseId}.proof`); + for (const [field, value] of [ + ["evaluationId", proof.evaluationId], + ["domainDigest", proof.domainDigest], + ["relationSetDigest", proof.relationSetDigest], + ["matrixDigest", proof.matrixDigest], + ["partition", proof.partition], + ["proofSha256", proof.proofSha256], + ]) { + byteArray(value, 32, `${caseId}.proof.${field}`); + } + rgb(proof.domainFirst, `${caseId}.proof.domainFirst`); + rgb(proof.domainLast, `${caseId}.proof.domainLast`); + if ( + proof.resourceProfileId !== "compile-v1" || + proof.domainId !== "srgb8-neutral-axis-v1" || + proof.wcag22ProfileId !== "wcag22-srgb8-contrast-v1" || + proof.artifactId !== "wcag22-srgb8-luminance-q55-v1" || + proof.boundId !== "wcag22-srgb8-outward-q55-v1" || + proof.proofId !== "wcag22-srgb8-full-domain-q55-v1" + ) { + fail(`${caseId}.proof typed identities drifted`); + } + if ( + !isDeepStrictEqual(proof.domainFirst, [0, 0, 0]) || + !isDeepStrictEqual(proof.domainLast, [255, 255, 255]) + ) { + fail(`${caseId}.proof domain endpoints drifted`); + } + requireDigest( + proof.domainDigest, + feasibilityDomainDigestV1(proof.domainId, result.domain), + `${caseId}.proof.domainDigest`, + ); + requireDigest( + proof.relationSetDigest, + feasibilityRelationSetDigestV1(result.relations), + `${caseId}.proof.relationSetDigest`, + ); + requireDigest(proof.proofSha256, atomicProofSha256, `${caseId}.proof.proofSha256`); + + const counts = Object.fromEntries( + [ + "domainCount", + "canonicalRelations", + "applicableRelations", + "notApplicableRelations", + "applicableEdges", + "logicalAssessments", + ].map((field) => [field, decimalU64(proof[field], `${caseId}.proof.${field}`)]), + ); + const applicable = result.relations.filter((relation) => relation.kind === "applicable"); + const notApplicable = result.relations.length - applicable.length; + const edges = applicable.reduce((sum, relation) => sum + relation.adjacent.length, 0); + if ( + counts.domainCount !== 256n || + counts.canonicalRelations !== BigInt(result.relations.length) || + counts.applicableRelations !== BigInt(applicable.length) || + counts.notApplicableRelations !== BigInt(notApplicable) || + counts.applicableEdges !== BigInt(edges) || + counts.logicalAssessments !== 256n * BigInt(edges) + ) { + fail(`${caseId}.proof decimal counts disagree with transported content`); + } + byteArray(result.failureMatrix, 32 * edges, `${caseId}.failureMatrix`); + const matrixDigest = createHash("sha256").update(Buffer.from(result.failureMatrix)).digest(); + requireDigest(proof.matrixDigest, matrixDigest, `${caseId}.proof.matrixDigest`); + + let feasibleCount = 0; + for (let candidate = 0; candidate < 256; candidate += 1) { + let hasFailure = false; + for (let edge = 0; edge < edges; edge += 1) { + hasFailure ||= packedBit(result.failureMatrix, candidate * edges + edge); + } + const feasible = !hasFailure; + if (packedBit(proof.partition, candidate) !== feasible) { + fail(`${caseId}.partition is not the candidate-major LSB0 all-edge reduction`); + } + feasibleCount += Number(feasible); + } + if ((status === "feasible") !== (feasibleCount > 0)) { + fail(`${caseId}.${status} contradicts its complete partition`); + } + requireDigest( + proof.evaluationId, + feasibilityEvaluationIdV1({ + proof, + matrixDigest, + atomicProofSha256, + counts: { + ...counts, + packedResultBytes: BigInt(result.failureMatrix.length + proof.partition.length), + }, + }), + `${caseId}.proof.evaluationId`, + ); + return feasibleCount; +} + +function validateNotEvaluatedFeasibility(result, request, caseId) { + exactKeys( + result, + ["domainDigest", "domainId", "relationSetDigest", "relations", "resourceProfileId"], + `${caseId}.result`, + ); + if ( + result.domainId !== "srgb8-neutral-axis-v1" || + result.resourceProfileId !== "compile-v1" + ) { + fail(`${caseId}.NotEvaluated typed identities drifted`); + } + byteArray(result.domainDigest, 32, `${caseId}.domainDigest`); + byteArray(result.relationSetDigest, 32, `${caseId}.relationSetDigest`); + if ( + !Array.isArray(result.relations) || + result.relations.some((relation) => relation?.kind !== "notApplicable") + ) { + fail(`${caseId}.NotEvaluated must retain only declared NotApplicable relations`); + } + result.relations.forEach((relation, index) => + validateFeasibilityRelation(relation, `${caseId}.result.relations[${index}]`)); + if (!isDeepStrictEqual(result.relations, canonicalRelations(request.relations))) { + fail(`${caseId}.NotEvaluated relations differ from its canonical request`); + } + requireDigest( + result.domainDigest, + feasibilityDomainDigestV1(result.domainId, neutralAxisDomainV1()), + `${caseId}.domainDigest`, + ); + requireDigest( + result.relationSetDigest, + feasibilityRelationSetDigestV1(result.relations), + `${caseId}.relationSetDigest`, + ); +} + +function validateFeasibilityFailure(outcome, expectation, request, caseId) { + exactKeys(outcome, ["error", "outcome", "schemaVersion"], `${caseId}.outcome`); + exactKeys(outcome.error, ["error", "source"], `${caseId}.error`); + if (outcome.error.source !== "core") fail(`${caseId} must preserve a Core failure`); + const error = outcome.error.error; + if (expectation.failure === "conflict") { + exactKeys(error, ["code", "details"], `${caseId}.coreError`); + exactKeys(error.details, ["code", "relationId"], `${caseId}.coreError.details`); + const conflictingIds = new Set(); + for (const relation of request.relations) { + const peers = request.relations.filter( + (candidate) => candidate.relationId === relation.relationId, + ); + if (peers.some((candidate) => !isDeepStrictEqual(candidate, relation))) { + conflictingIds.add(relation.relationId); + } + } + if ( + error.code !== "invalidRequest" || + error.details.code !== "conflictingRelationId" || + !conflictingIds.has(error.details.relationId) + ) { + fail(`${caseId} does not preserve the conflicting relation failure`); + } + return; + } + exactKeys(error, ["code", "details"], `${caseId}.coreError`); + exactKeys( + error.details, + ["dimension", "limit", "profileId", "requested"], + `${caseId}.coreError.details`, + ); + const rawAdjacent = request.relations.reduce( + (sum, relation) => sum + (relation.kind === "applicable" ? relation.adjacent.length : 0), + 0, + ); + if ( + error.code !== "resourceLimitExceeded" || + error.details.profileId !== "compile-v1" || + error.details.dimension !== "rawAdjacentEntries" || + decimalU64(error.details.requested, `${caseId}.requested`) !== BigInt(rawAdjacent) || + decimalU64(error.details.limit, `${caseId}.limit`) !== 2_047n || + rawAdjacent !== 2_048 + ) { + fail(`${caseId} does not preserve the exact raw-adjacent resource failure`); + } +} + +/** Independently validate the complete pack-5 feasibility family. */ +export function validateWcag22FeasibilityFamily(family, atomicProofSha256Hex) { + if (!/^[0-9a-f]{64}$/u.test(atomicProofSha256Hex ?? "")) { + fail("WCAG22 feasibility validation requires the canonical atomic proof SHA-256"); + } + const atomicProofSha256 = Buffer.from(atomicProofSha256Hex, "hex"); + if (!Array.isArray(family) || family.length !== FEASIBILITY_CASES_V1.size) { + fail(`wcag22-feasibility family must contain exactly ${FEASIBILITY_CASES_V1.size} vectors`); + } + const byCase = new Map(); + for (const [index, vector] of family.entries()) { + exactKeys(vector, FEASIBILITY_VECTOR_KEYS_V1, `wcag22-feasibility[${index}]`); + if (typeof vector.caseId !== "string" || !FEASIBILITY_CASES_V1.has(vector.caseId)) { + fail(`wcag22-feasibility[${index}] has unknown caseId ${vector.caseId}`); + } + if (byCase.has(vector.caseId)) fail(`duplicate wcag22-feasibility caseId ${vector.caseId}`); + const expectation = FEASIBILITY_CASES_V1.get(vector.caseId); + const request = validateFeasibilityRequest(vector.requestJson, vector.caseId); + const outcome = canonicalJson(vector.outcomeJson, `${vector.caseId}.outcomeJson`); + walkNoProportionalDto(outcome, `${vector.caseId}.outcome`); + if (outcome.schemaVersion !== 1) fail(`${vector.caseId}.outcome schemaVersion must be 1`); + + let evaluated; + let feasibleCount; + if (expectation.failure) { + if (outcome.outcome !== "failure") fail(`${vector.caseId} must be a failure outcome`); + validateFeasibilityFailure(outcome, expectation, request, vector.caseId); + } else { + exactKeys( + outcome, + ["feasibility", "outcome", "schemaVersion"], + `${vector.caseId}.outcome`, + ); + if (outcome.outcome !== "success") fail(`${vector.caseId} must be a success outcome`); + exactKeys(outcome.feasibility, ["result", "status"], `${vector.caseId}.feasibility`); + if (outcome.feasibility.status !== expectation.terminal) { + fail(`${vector.caseId} terminal ${outcome.feasibility.status} != ${expectation.terminal}`); + } + if (expectation.terminal === "notEvaluated") { + validateNotEvaluatedFeasibility(outcome.feasibility.result, request, vector.caseId); + } else { + evaluated = outcome.feasibility.result; + feasibleCount = validateEvaluatedFeasibility( + evaluated, + request, + expectation.terminal, + vector.caseId, + atomicProofSha256, + ); + if (expectation.count !== undefined && feasibleCount !== expectation.count) { + fail(`${vector.caseId} feasible count ${feasibleCount} != ${expectation.count}`); + } + } + } + byCase.set(vector.caseId, { vector, request, outcome, evaluated, feasibleCount }); + } + + const opaqueARecord = byCase.get("opaque-identity-a"); + const opaqueBRecord = byCase.get("opaque-identity-b"); + const opaqueA = opaqueARecord?.evaluated; + const opaqueB = opaqueBRecord?.evaluated; + if (!opaqueA || !opaqueB || !opaqueARecord || !opaqueBRecord) { + fail("opaque identity fixtures must both be evaluated"); + } + const physicalRequest = (request) => ({ + ...request, + relations: request.relations.map(({ relationId, occurrenceId, ...physical }) => physical), + }); + if ( + !isDeepStrictEqual( + physicalRequest(opaqueARecord.request), + physicalRequest(opaqueBRecord.request), + ) || + opaqueARecord.request.relations.some( + (relation, index) => + relation.relationId === opaqueBRecord.request.relations[index]?.relationId || + relation.occurrenceId === opaqueBRecord.request.relations[index]?.occurrenceId, + ) + ) { + fail("opaque identity fixtures must differ only in client-owned identities"); + } + for (const field of ["domain", "failureMatrix"]) { + if (!isDeepStrictEqual(opaqueA[field], opaqueB[field])) { + fail(`opaque identities changed physical ${field}`); + } + } + for (const field of ["partition", "matrixDigest", "domainDigest", "proofSha256"]) { + if (!isDeepStrictEqual(opaqueA.proof[field], opaqueB.proof[field])) { + fail(`opaque identities changed physical proof ${field}`); + } + } + for (const field of ["relationSetDigest", "evaluationId"]) { + if (isDeepStrictEqual(opaqueA.proof[field], opaqueB.proof[field])) { + fail(`opaque identities did not change declared ${field}`); + } + } + return byCase; +} + async function validateConformance(conformance) { - if (conformance.packVersion !== "4.0.0") { - fail(`release requires conformance pack 4.0.0, got ${conformance.packVersion}`); + if (conformance.packVersion !== "5.0.0") { + fail(`release requires conformance pack 5.0.0, got ${conformance.packVersion}`); } if (!/^[0-9a-f]{8}$/u.test(conformance.packDigest ?? "")) { fail(`invalid conformance packDigest: ${conformance.packDigest}`); @@ -489,6 +1090,9 @@ async function validateConformance(conformance) { const familyBuffers = await Promise.all( CONFORMANCE_FAMILY_FILES.map((name) => readFile(resolve(CONFORMANCE_DIR, name))), ); + const proofPath = resolve(WCAG22_CONTRACT_DIR, "wcag22-srgb8-q55-proof-v1.json"); + const proofBytes = await readFile(proofPath); + const proof = await readJson(proofPath); const actualDigest = fnv1a32(familyBuffers); if (actualDigest !== conformance.packDigest) { fail( @@ -507,7 +1111,15 @@ async function validateConformance(conformance) { fail(`${CONFORMANCE_FAMILY_FILES[index]} is not valid JSON: ${error.message}`); } }); - const countKeys = ["contrasts", "ladders", "alpha", "solve", "muddiness", "wcag22"]; + const countKeys = [ + "contrasts", + "ladders", + "alpha", + "solve", + "muddiness", + "wcag22", + "wcag22Feasibility", + ]; let total = 0; for (const [index, key] of countKeys.entries()) { const actual = families[index].length; @@ -519,6 +1131,7 @@ async function validateConformance(conformance) { if (conformance.counts?.total !== total) { fail(`conformance total=${conformance.counts?.total} differs from ${total}`); } + validateWcag22FeasibilityFamily(families[6], sha256(proofBytes)); const halfTie = families[2].find( (entry) => entry.tint === "#C0B2FA" && entry.bg === "#000000" && entry.alpha === 0.122, ); @@ -531,9 +1144,6 @@ async function validateConformance(conformance) { entry.background === "#8212DB" && entry.criterion === "sc-1.4.11-ui-component-or-state", ); - const proofPath = resolve(WCAG22_CONTRACT_DIR, "wcag22-srgb8-q55-proof-v1.json"); - const proofBytes = await readFile(proofPath); - const proof = await readJson(proofPath); if ( antiEpsilon?.decision !== "fail" || antiEpsilon?.evidenceKind !== "canonical-finite-bounded" || @@ -567,14 +1177,50 @@ async function validateConformance(conformance) { }; } -function lockedTypescriptVersion(packageLock) { - const version = packageLock.packages?.["node_modules/typescript"]?.version; +function lockedTypescriptVersion(packageLock, packagePath, label) { + const version = packageLock.packages?.[packagePath]?.version; if (!/^\d+\.\d+\.\d+(?:[-+].+)?$/u.test(version ?? "")) { - fail("package-lock.json does not pin an exact TypeScript compiler version"); + fail(`package-lock.json does not pin an exact ${label} version`); } return version; } +function lockedTypescriptCompilers(packageJson, packageLock) { + const minimumSpec = packageJson.devDependencies?.["typescript-floor"]; + const minimumDeclared = minimumSpec?.match( + /^npm:typescript@(\d+\.\d+\.\d+)$/u, + )?.[1]; + if (!minimumDeclared) { + fail("package.json must declare an exact npm:typescript@X.Y.Z consumer floor"); + } + const compilers = [ + { + role: "minimum-consumer", + packageDirectory: "typescript-floor", + version: lockedTypescriptVersion( + packageLock, + "node_modules/typescript-floor", + "minimum consumer TypeScript", + ), + }, + { + role: "workspace-locked", + packageDirectory: "typescript", + version: lockedTypescriptVersion( + packageLock, + "node_modules/typescript", + "workspace TypeScript compiler", + ), + }, + ]; + if (compilers[0].version !== minimumDeclared) { + fail( + `minimum TypeScript lock ${compilers[0].version} differs from declared ${minimumDeclared}`, + ); + } + return compilers; +} + function lockedNpmVersion(packageJson) { const declared = packageJson.packageManager?.match(/^npm@(\d+\.\d+\.\d+)$/u)?.[1]; if (!declared) { @@ -587,7 +1233,21 @@ function lockedNpmVersion(packageJson) { return declared; } -function runtimeSmokeSource() { +async function wcag22FeasibilitySmokeFixture() { + const family = await readJson(resolve(CONFORMANCE_DIR, "wcag22-feasibility.json")); + const proofBytes = await readFile( + resolve(WCAG22_CONTRACT_DIR, "wcag22-srgb8-q55-proof-v1.json"), + ); + const canonical = validateWcag22FeasibilityFamily(family, sha256(proofBytes)) + .get("text-default-seven")?.vector; + if (!canonical) fail("wcag22-feasibility smoke fixture is missing text-default-seven"); + return { + requestJson: canonical.requestJson, + outcomeJson: canonical.outcomeJson, + }; +} + +function runtimeSmokeSource(feasibilityFixture) { return String.raw` import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; @@ -596,7 +1256,9 @@ import { createRequire } from "node:module"; import init, { LabColors, evaluateWcag22, + evaluateWcag22Feasibility, numericalCapabilityManifest, + wcag22FeasibilityMaxBytes, } from "@labpics/colors"; const require = createRequire(import.meta.url); @@ -614,6 +1276,14 @@ assert.match(metadata.coreVersion, /^\d+\.\d+\.\d+$/u); assert.equal(metadata.wasm.bytes, (await readFile(wasmPath)).length); await init({ module_or_path: await readFile(wasmPath) }); +const feasibilityFixture = ${JSON.stringify(feasibilityFixture)}; +const feasibilityRequest = new TextEncoder().encode(feasibilityFixture.requestJson); +assert.ok(feasibilityRequest.byteLength <= wcag22FeasibilityMaxBytes()); +assert.equal( + JSON.stringify(evaluateWcag22Feasibility(feasibilityRequest)), + feasibilityFixture.outcomeJson, +); + const capability = numericalCapabilityManifest(); assert.equal(capability.schemaVersion, 2); assert.ok(capability.sites.some((site) => @@ -800,7 +1470,9 @@ function typeSmokeSource() { import init, { LabColors, evaluateWcag22, + evaluateWcag22Feasibility, numericalCapabilityManifest, + wcag22FeasibilityMaxBytes, type GlowDecisionGuaranteeV1, type GlowDeterminateRole, type GlowDeterminateRoleBase, @@ -816,9 +1488,40 @@ import init, { type TranslucentRole, type Wcag22AssessmentV1, type Wcag22CriterionV1, + type Wcag22FeasibilityOutcomeV1, + type Wcag22FeasibilityRequestV1, } from "@labpics/colors"; +import { applyTheme } from "@labpics/colors/apply-theme"; +import { + watchTheme, + type WatchController, + type WatchThemeOptions, +} from "@labpics/colors/watch-theme"; +import { + adaptTheme, + type AdaptController, + type AdaptThemeOptions, +} from "@labpics/colors/adapt-theme"; +import { + effectiveBackground, + type EffectiveBackgroundOptions, + type Rgba, +} from "@labpics/colors/effective-bg"; const initialise: typeof init = init; +const apply: typeof applyTheme = applyTheme; +const watch: typeof watchTheme = watchTheme; +const adapt: typeof adaptTheme = adaptTheme; +const effective: typeof effectiveBackground = effectiveBackground; +type PublicSubpathTypes = + | WatchController + | WatchThemeOptions + | AdaptController + | AdaptThemeOptions + | EffectiveBackgroundOptions + | Rgba; +declare const publicSubpathType: PublicSubpathTypes; +void [apply, watch, adapt, effective, publicSubpathType]; const engine = new LabColors(); const fingerprint: string = engine.loadConfig("{}"); const resolved: ResolvedTheme = engine.resolveTheme("#000000", "light"); @@ -831,6 +1534,103 @@ const wcagAssessment: Wcag22AssessmentV1 = evaluateWcag22( ); // @ts-expect-error criterion is an explicit closed menu, not an opaque string. evaluateWcag22("#000000", "#FFFFFF", "danger"); + +const feasibilityRequest: Wcag22FeasibilityRequestV1 = { + schemaVersion: 1, + domainId: "srgb8-neutral-axis-v1", + resourceProfileId: "compile-v1", + relations: [{ + relationId: "opaque-client-relation", + occurrenceId: "opaque-client-occurrence", + kind: "applicable", + criterion: "sc-1.4.3-text-default", + adjacent: [[118, 118, 118]], + }], +}; +const feasibilityBytes = new TextEncoder().encode(JSON.stringify(feasibilityRequest)); +const feasibilityCeiling: number = wcag22FeasibilityMaxBytes(); +const feasibilityOutcome: Wcag22FeasibilityOutcomeV1 = + evaluateWcag22Feasibility(feasibilityBytes); +// @ts-expect-error byte API rejects strings. +evaluateWcag22Feasibility(JSON.stringify(feasibilityRequest)); + +function assertNever(value: never): never { + throw new Error("unreachable: " + String(value)); +} + +type FeasibilityFailure = Extract< + Wcag22FeasibilityOutcomeV1, + { readonly outcome: "failure" } +>["error"]; +type FeasibilityTransportFailure = Extract< + FeasibilityFailure, + { readonly source: "transport" } +>["error"]; +type FeasibilityCoreFailure = Extract< + FeasibilityFailure, + { readonly source: "core" } +>["error"]; + +function describeTransportFailure(error: FeasibilityTransportFailure): string { + switch (error.code) { + case "envelopeTooLarge": + case "invalidUtf8": + case "malformedEnvelope": + case "unsupportedSchemaVersion": + case "unsupportedDomainId": + case "unsupportedResourceProfileId": + case "unsupportedCriterion": + case "emptyNotApplicableReason": + return error.code; + default: + return assertNever(error); + } +} + +function describeCoreFailure(error: FeasibilityCoreFailure): string { + switch (error.code) { + case "invalidRequest": + case "resourceLimitExceeded": + case "allocationFailed": + case "evaluatorInvariantViolation": + case "compilerInvariantViolation": + return error.code; + default: + return assertNever(error); + } +} + +function describeFeasibilityOutcome(outcome: Wcag22FeasibilityOutcomeV1): string { + switch (outcome.outcome) { + case "success": { + const feasibility = outcome.feasibility; + switch (feasibility.status) { + case "feasible": + case "infeasible": + case "notEvaluated": + return feasibility.status; + default: + return assertNever(feasibility); + } + } + case "failure": { + const failure = outcome.error; + switch (failure.source) { + case "transport": + return describeTransportFailure(failure.error); + case "core": + return describeCoreFailure(failure.error); + case "incompatibleCoreContract": + return failure.source; + default: + return assertNever(failure); + } + } + default: + return assertNever(outcome); + } +} + const borderPosition: LadderPositionV1 = "border-strong"; const config: ThemeConfig = { brand: { @@ -984,6 +1784,11 @@ void [ fingerprint, resolved, wcagAssessment, + feasibilityRequest, + feasibilityBytes, + feasibilityCeiling, + feasibilityOutcome, + describeFeasibilityOutcome, capability, config, alphaContract, @@ -1001,7 +1806,7 @@ void [ async function verifyCleanConsumer( tarballPath, packageJson, - packageLock, + typescriptCompilers, expectedBuildMetadata, expectedWcag22Artifacts, ) { @@ -1012,16 +1817,22 @@ async function verifyCleanConsumer( `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`, ); - const typescriptVersion = lockedTypescriptVersion(packageLock); - const localTypescript = await readJson( - resolve(PACKAGE_DIR, "node_modules/typescript/package.json"), - ); - if (localTypescript.version !== typescriptVersion) { - fail( - `installed TypeScript ${localTypescript.version} differs from lockfile ${typescriptVersion}`, + for (const compiler of typescriptCompilers) { + const localTypescript = await readJson( + resolve( + PACKAGE_DIR, + "node_modules", + compiler.packageDirectory, + "package.json", + ), ); + if (localTypescript.version !== compiler.version) { + fail( + `installed ${compiler.role} TypeScript ${localTypescript.version} ` + + `differs from lockfile ${compiler.version}`, + ); + } } - const typescriptCompiler = resolve(PACKAGE_DIR, "node_modules/typescript/bin/tsc"); npm( [ @@ -1061,32 +1872,41 @@ async function verifyCleanConsumer( fail("clean-installed build metadata differs from the verified release inputs"); } + const feasibilityFixture = await wcag22FeasibilitySmokeFixture(); const runtimePath = resolve(consumer, "smoke.mjs"); const typesPath = resolve(consumer, "smoke.ts"); - await writeFile(runtimePath, runtimeSmokeSource()); + await writeFile(runtimePath, runtimeSmokeSource(feasibilityFixture)); await writeFile(typesPath, typeSmokeSource()); command(process.execPath, [runtimePath], consumer); - command( - process.execPath, - [ - typescriptCompiler, - "--noEmit", - "--strict", - "--skipLibCheck", - "false", - "--target", - "ES2022", - "--lib", - "ES2022,DOM,ESNext.Disposable", - "--module", - "NodeNext", - "--moduleResolution", - "NodeNext", - typesPath, - ], - consumer, - ); + for (const compiler of typescriptCompilers) { + command( + process.execPath, + [ + resolve( + PACKAGE_DIR, + "node_modules", + compiler.packageDirectory, + "lib", + "tsc.js", + ), + "--noEmit", + "--strict", + "--skipLibCheck", + "false", + "--target", + "ES2022", + "--lib", + "ES2022,DOM", + "--module", + "NodeNext", + "--moduleResolution", + "NodeNext", + typesPath, + ], + consumer, + ); + } } finally { await rm(consumer, { recursive: true, force: true }); } @@ -1116,8 +1936,9 @@ export async function smokePackedRuntime(tarballPath) { ], consumer, ); + const feasibilityFixture = await wcag22FeasibilitySmokeFixture(); const runtimePath = resolve(consumer, "smoke.mjs"); - await writeFile(runtimePath, runtimeSmokeSource()); + await writeFile(runtimePath, runtimeSmokeSource(feasibilityFixture)); command(process.execPath, [runtimePath], consumer); } finally { await rm(consumer, { recursive: true, force: true }); @@ -1138,6 +1959,7 @@ export async function verifyPackageRelease() { const coreVersion = workspaceVersion(cargoSource); const npmVersion = lockedNpmVersion(packageJson); + const typescriptCompilers = lockedTypescriptCompilers(packageJson, packageLock); const lockRoot = packageLock.packages?.[""]; if (packageJson.name !== "@labpics/colors") fail(`unexpected npm package name: ${packageJson.name}`); const consumerNodeFloor = packageJson.engines?.node?.match(/^>=(\d+\.\d+\.\d+)$/u)?.[1]; @@ -1205,7 +2027,7 @@ export async function verifyPackageRelease() { await verifyCleanConsumer( canonicalPack.path, packageJson, - packageLock, + typescriptCompilers, buildMetadataValue, wcag22Evidence.artifacts, ); @@ -1242,9 +2064,10 @@ export async function verifyPackageRelease() { npm: npmVersion, }, typescript: { - compiler: lockedTypescriptVersion(packageLock), + compiler: typescriptCompilers[1].version, + minimumConsumerCompiler: typescriptCompilers[0].version, target: "ES2022", - libraries: ["ES2022", "DOM", "ESNext.Disposable"], + libraries: ["ES2022", "DOM"], skipLibCheck: false, }, }, @@ -1253,6 +2076,7 @@ export async function verifyPackageRelease() { "exact-screen-composite-srgb8-v1", "typed-glow-indeterminate-v1", "wcag22-srgb8-contrast-v1", + "wcag22-feasibility-v1", ], numericalCapabilities: conformance.numericalCapabilities, unsupported: [