diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 730b7938..8c3f0bd1 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -20,6 +20,10 @@ examine_globs = [ "crates/labcolors-core/src/numerical_plan.rs", "crates/labcolors-core/src/numerics.rs", "crates/labcolors-core/src/pair.rs", + "crates/labcolors-core/src/srgb8.rs", + "crates/labcolors-core/src/wcag22.rs", + "crates/labcolors-core/src/wcag22_evidence.rs", + "crates/labcolors-core/src/wcag22/kernel.rs", ] # Запас времени на мутант: legacy proxy coordinate считается быстро, но набор в целом не diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c577e1c9..c04cbb07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -220,6 +220,8 @@ jobs: - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: cargo test run: cargo test --workspace --locked + - name: prove WCAG22 sRGB8 Q55 artifact over the full finite domain + run: python3 scripts/verify_wcag22_q55.py audit: name: cargo audit (rustsec) @@ -315,12 +317,21 @@ jobs: - name: wasm-pack build (release) # The release bundle: panic=abort, no panic hook, wasm-opt -Oz. This is # the artifact @labpics/colors ships, so the size measured below is real. - run: wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked + # Rust error locations otherwise embed the self-hosted runner's mutable + # workspace/CARGO_HOME roots and make identical source hash differently. + run: | + export CARGO_ENCODED_RUSTFLAGS="--remap-path-prefix=$GITHUB_WORKSPACE=/workspace/lab-colors"$'\x1f'"--remap-path-prefix=$CARGO_HOME=/cargo-home" + wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: ${{ env.NODE_TOOLCHAIN }} cache: npm cache-dependency-path: packages/colors/package-lock.json + - name: enforce measured WASM raw-byte budget + # This Linux x64 release-equivalent build is the canonical Issue #284 + # artifact: the checker binds its exact SHA-256 and raw-byte ceiling, + # without invented headroom. 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 # against the *real* wasm-bindgen declarations — so the type-level smoke @@ -357,8 +368,11 @@ jobs: # 1. Download exact CfT chrome + chromedriver archives and verify SHA-256. # wasm-pack reads CHROME_PATH for the browser but ignores a CHROMEDRIVER # variable, so the matching driver is passed through --chromedriver. - # 2. Extract required shared libs (.so) from Ubuntu .deb packages without sudo: - # `apt-get download` fetches .deb; `dpkg --extract` unpacks without root. + # 2. Extract required shared libs (.so) from the runner's configured + # Debian/Ubuntu repository without sudo. Refresh package metadata in + # runner.temp first: shared self-hosted images can retain stale lists + # whose package URLs already return 404. `apt-get download` fetches + # .deb and `dpkg --extract` unpacks it without root. # Chrome/chromedriver need libnspr4, libnss3, libasound2t64, libgbm1 # which are absent on the slim WSL runner image. # 3. Diagnose chromedriver session creation (Chrome's stderr) before wasm-pack. @@ -385,14 +399,27 @@ jobs: echo "CHROMEDRIVER_PATH=$CHROMEDRIVER_BIN" >> "$GITHUB_ENV" echo "Chrome for Testing $CHROME_FOR_TESTING_VERSION installed" - # -- Extract Chrome shared-lib dependencies from Ubuntu .deb (no sudo needed) -- + # -- Extract Chrome shared-lib dependencies from distro .deb (no sudo needed) -- DEPS_DIR="$RUNNER_TEMP/chrome-deps-$GITHUB_JOB" DEBS_DIR="$DEPS_DIR/debs" - mkdir -p "$DEBS_DIR" + APT_LISTS="$DEPS_DIR/apt-lists" + APT_CACHE="$DEPS_DIR/apt-cache" + mkdir -p "$DEBS_DIR" "$APT_LISTS/partial" "$APT_CACHE/archives/partial" + # Keep every mutable APT path job-local and disable global locks. The + # system sources/keyrings and dpkg status remain read-only inputs. + APT_OPTIONS=( + -o "Dir::State::lists=$APT_LISTS" + -o "Dir::State::status=/var/lib/dpkg/status" + -o "Dir::Cache=$APT_CACHE" + -o "Dir::Cache::archives=$APT_CACHE/archives" + -o "Debug::NoLocking=1" + -o "Acquire::Retries=3" + ) + apt-get "${APT_OPTIONS[@]}" update # libnspr4/libnss3: NSS/NSPR crypto deps for Chrome and chromedriver. - # libasound2t64: ALSA (renamed from libasound2 in Ubuntu 24.04 noble). + # libasound2t64: ALSA after the current Debian/Ubuntu time64 transition. # libgbm1: GPU buffer manager (needed by Chrome headless for DRM/render node). - (cd "$DEBS_DIR" && apt-get download libnspr4 libnss3 libasound2t64 libgbm1 2>&1) + (cd "$DEBS_DIR" && apt-get "${APT_OPTIONS[@]}" download libnspr4 libnss3 libasound2t64 libgbm1 2>&1) for deb in "$DEBS_DIR"/*.deb; do dpkg --extract "$deb" "$DEPS_DIR" done @@ -459,14 +486,6 @@ jobs: # Chrome is provisioned above; the parity smoke runs against native # resolve_set inside the same wasm runtime. D1 default from the chapter. run: wasm-pack test --headless --chrome --chromedriver "$CHROMEDRIVER_PATH" crates/labcolors-wasm --locked - - name: report bundle size (gzip) - # Size is part of the perf story (perf-bench). Not a hard gate yet — a - # budget gate lands with perf-bench; here we surface the number in logs. - run: | - wasm="packages/colors/pkg/labcolors_bg.wasm" - glue="packages/colors/pkg/labcolors.js" - echo "wasm: raw=$(wc -c <"$wasm")B gzip=$(gzip -9 -c "$wasm" | wc -c)B" - echo "glue: raw=$(wc -c <"$glue")B gzip=$(gzip -9 -c "$glue" | wc -c)B" docs-drift: name: docs-drift (нейминг-канон) runs-on: [self-hosted, Linux, X64] diff --git a/.gitignore b/.gitignore index 9ee12379..08ffc41a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ target/ packages/colors/.release/ packages/colors/LICENSE packages/colors/build-metadata.json +packages/colors/evidence/ # Психофизический харнесс: сгенерированные манифесты/раннеры/экспорты сессий — # это артефакты запуска (детерминированы из seed), в гит не коммитятся. diff --git a/CHANGELOG.md b/CHANGELOG.md index 116d969a..7ca908b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,23 @@ Rust различаются, потому что это разные delivery su ## [Unreleased] -Атомарная numerical-decision граница (#292). Wire/npm JSON, эмитируемые цвета, -config fingerprint и `packDigest` conformance-векторов НЕ изменились; breaking -только Rust API. Migration-note: [exact alpha / typed -Glow](docs/migrations/exact-alpha-glow.md), дополнение ADR-0004 от 2026-07-12. +Атомарная 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. +Migration-note: [exact alpha / typed Glow](docs/migrations/exact-alpha-glow.md), +дополнение ADR-0004 от 2026-07-12. + +### Added + +- Versioned `evaluate_wcag22_srgb8` / `evaluate_wcag22_hex` и эквивалентные + WASM/TypeScript/UniFFI границы. Criterion всегда объявляет клиент; verdict + возвращает точное terminal evidence без epsilon или округлённого ratio. +- Canonical Q55 artifact и независимый verifier: Decimal directed-rounding + + integer tightness для 768 строк, полный scan всех `16 777 216` sRGB8-цветов, + source bindings и SHA-256 live typed registry admission-row. +- npm package несёт byte-exact profile/table/proof в `evidence/`; release + verifier и clean-install gate повторно проверяют их хэши и содержимое. ### Breaking (Rust API) @@ -23,18 +36,29 @@ Glow](docs/migrations/exact-alpha-glow.md), дополнение ADR-0004 от 2 - `NumericalDecisionEvidenceV1::BitExact` запечатан (приватное поле-печать): внешний код матчит только с `..`, минт выполняет registry-owned конструктор (закреплено compile-fail тестом). +- Все terminal-варианты `NumericalDecisionV1` и + `GlowDecisionOutcomeV1` запечатаны variant-level `#[non_exhaustive]`: + подлинное evidence одного site нельзя переупаковать как результат другого; + внешний match обязан использовать `..`. +- Временный capability V1 заменён единственным public + `NumericalCapabilityManifestV2`; WCAG site несёт artifact/bound/proof IDs. + Published `@labpics/colors` 0.10.0 остаётся неизменным, а следующий npm + release должен мигрировать на V2 явно. - `RoleSpec::Glow` несёт typed execution mode (`NumericalExecutionModeV1::StableOnly` | `ExplicitCompatibility { release_id }`); строковый `GlowDecisionProfileV1` остался boundary-адаптером, прежние wire keys (`stable-v1`, `legacy-platform-dependent-v1`, `bit-exact`) сохранены byte-for-byte. +- Raw WCAG profile/proof JSON больше не поля runtime-профиля: используйте + `Wcag22ProfileV1::source_json()` / `proof_json()`. Это позволяет linker-у не + включать отдельно поставляемые evidence-документы в WASM. ### Changed -- Conformance pack 3.0.0: `manifest.numericalSites` заменён typed - `numericalCapabilities` — capability manifest ядра (schema v1, coverage - `migrated-sites-only-v1`, FNV-1a-32 drift-checksum над canonical - length-prefixed preimage). Векторные семейства и `packDigest` не изменились. +- 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). - Release manifest schema v2: секция `numericalSites` заменена на `numericalCapabilities`; release verifier и Swift conformance-тесты пересчитывают capability checksum независимо от Rust-кода. diff --git a/README.md b/README.md index f93a6f2d..10c55c2d 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,9 @@ CSS-строка, CSSOM и обход DOM сами по себе не повыш registry с независимо пересчитываемым drift-checksum — входит в conformance manifest. Он описывает возможности сборки и не повышает незарегистрированный или explicit `Compatibility`-результат до determinate только потому, что тесты -на одной платформе зелёные. +на одной платформе зелёные. До появления внешних клиентов capability-контракт +исправлен атомарно: единственный `numericalCapabilityManifest()` возвращает +proof-capable schema V2; промежуточный public V1 и второй V2-entrypoint удалены. ## Источники и производные значения @@ -216,7 +218,8 @@ anchors - Нормативный floor применяется только там, где его требует контракт клиента или компонента. - Core не определяет размер текста, essentialness, disabled/decorative status по имени роли. - Экспериментальный LPC/APCA-shaped или appearance-результат не меняет WCAG pass/fail. -- До миграции на единый versioned WCAG 2.2 profile старое поле `wcagRatio` нельзя автоматически рекламировать как `Wcag22`. +- Для финальной пары sRGB8 новый `wcag22-srgb8-contrast-v1` принимает явно объявленный критерий и возвращает строгий `Pass | Fail`; профиль, Q55-артефакт и full-domain proof входят в релиз. +- Старое поле `wcagRatio` остаётся compatibility-диагностикой текущего resolver/runtime и не может автоматически рекламироваться как результат нового evaluator-а. - Цвет не должен быть единственным носителем смысла; текст, иконка и форма принадлежат компоненту. ## Runtime и фон diff --git a/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift b/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift index db307dcf..5247bb8e 100644 --- a/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift +++ b/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift @@ -53,6 +53,16 @@ final class ConformanceTests: XCTestCase { } } + func wcag22Criterion(_ key: String) -> Wcag22Criterion { + switch key { + case "sc-1.4.3-text-default": return .sc143TextDefault + case "sc-1.4.3-text-large-scale": return .sc143TextLargeScale + case "sc-1.4.11-ui-component-or-state": return .sc1411UiComponentOrState + case "sc-1.4.11-graphical-object": return .sc1411GraphicalObject + default: fatalError("unknown WCAG22 criterion in pack: \(key)") + } + } + func channels(_ hex: String) -> [Int] { let s = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex precondition(s.count == 6, "ожидался #RRGGBB, получено \(hex)") @@ -118,16 +128,16 @@ final class ConformanceTests: XCTestCase { /// preimage ядра (labcolors-core/src/numerics.rs): length-prefixed (u32 LE /// длина + байты) домен-сепаратор, u32 LE schema version, coverage key, /// u32 LE счётчик sites (сортировка по сырым UTF-8 байтам siteId), на site — - /// siteId и шесть списков ключей; каждый список: u32 LE count (явный и для + /// siteId и семь списков ключей; каждый список: u32 LE count (явный и для /// пустого) + отсортированные length-prefixed ключи. Кодирование повторено /// здесь НАМЕРЕННО: тест — оракул, он не должен переиспользовать encoder, /// который проверяет. func testCapabilityManifestChecksumRecomputes() throws { let manifest = try load("manifest.json", as: Manifest.self) let caps = manifest.numericalCapabilities - // Оракул реализует canonical preimage V1: другая версия схемы обязана + // Оракул реализует canonical preimage V2: другая версия схемы обязана // падать здесь, а не молча проходить с пересчитанным checksum. - XCTAssertEqual(caps.schemaVersion, 1, "неподдерживаемая версия capability-схемы") + XCTAssertEqual(caps.schemaVersion, 2, "неподдерживаемая версия capability-схемы") XCTAssertEqual(caps.coverage, "migrated-sites-only-v1", "coverage capability manifest") XCTAssertFalse(caps.sites.isEmpty, "capability manifest без единого migrated site пуст") for site in caps.sites { @@ -161,7 +171,7 @@ final class ConformanceTests: XCTestCase { for key in sorted { pushLenPrefixed(key) } } - pushLenPrefixed("labcolors.numerical-capability.v1") + pushLenPrefixed("labcolors.numerical-capability.v2") pushU32LE(caps.schemaVersion) pushLenPrefixed(caps.coverage) let sites = caps.sites.sorted { @@ -175,6 +185,7 @@ final class ConformanceTests: XCTestCase { pushSortedKeyList(site.evidenceClasses) pushSortedKeyList(site.artifactIds) pushSortedKeyList(site.boundIds) + pushSortedKeyList(site.proofIds) pushSortedKeyList(site.runtimeAttestations) } @@ -422,6 +433,41 @@ final class ConformanceTests: XCTestCase { XCTAssertEqual(got, v.score, accuracy: Self.driftTol, "muddiness \(v.hex)") } } + + + // MARK: - Exact WCAG 2.2 final-sRGB8 assessment + + func testWcag22() throws { + let vectors = try load("wcag22.json", as: [Wcag22Vec].self) + XCTAssertFalse(vectors.isEmpty) + for vector in vectors { + let got = try evaluateWcag22( + foreground: vector.foreground, + background: vector.background, + criterion: wcag22Criterion(vector.criterion)) + XCTAssertEqual(got.profileId, vector.profileId) + XCTAssertEqual(got.criterion, wcag22Criterion(vector.criterion)) + XCTAssertEqual(got.foreground, vector.foreground) + XCTAssertEqual(got.background, vector.background) + XCTAssertEqual(got.decision == .pass ? "pass" : "fail", vector.decision) + XCTAssertEqual(got.foregroundLuminance.lower, UInt64(vector.foregroundLowerQ55)) + XCTAssertEqual(got.foregroundLuminance.upper, UInt64(vector.foregroundUpperQ55)) + XCTAssertEqual(got.backgroundLuminance.lower, UInt64(vector.backgroundLowerQ55)) + XCTAssertEqual(got.backgroundLuminance.upper, UInt64(vector.backgroundUpperQ55)) + XCTAssertEqual(got.q55Scale, UInt64(vector.q55Scale)) + XCTAssertEqual(got.evidence.kind, vector.evidenceKind) + XCTAssertEqual(got.evidence.artifactId, vector.artifactId) + XCTAssertEqual(got.evidence.artifactSha256, vector.artifactSha256) + XCTAssertEqual(got.evidence.boundId, vector.boundId) + XCTAssertEqual(got.evidence.proofId, vector.proofId) + XCTAssertEqual(got.evidence.proofSha256, vector.proofSha256) + XCTAssertEqual(got.evidence.proofPayloadSha256, vector.proofPayloadSha256) + XCTAssertEqual(got.evidence.generatorSha256, vector.generatorSha256) + XCTAssertEqual(got.evidence.verifierSha256, vector.verifierSha256) + XCTAssertEqual(got.evidence.profileChecksum, vector.profileChecksum) + XCTAssertEqual(got.evidence.profileSha256, vector.profileSha256) + } + } } // MARK: - Codable-зеркала схемы векторов @@ -433,7 +479,7 @@ struct Manifest: Codable { let numericalCapabilities: CapabilityManifest } -/// Зеркало capability manifest (pack 3.0.0): typed-проекция core registry +/// Зеркало proof-capable capability manifest (pack 4.0.0): typed-проекция core registry /// численных решений. Заменяет прозаический `numericalSites` из pack 2.x — /// биндинг сверяет typed rows и drift-checksum, а не research-тексты. struct CapabilityManifest: Codable { @@ -452,9 +498,34 @@ struct CapabilitySite: Codable { let evidenceClasses: [String] let artifactIds: [String] let boundIds: [String] + let proofIds: [String] let runtimeAttestations: [String] } +struct Wcag22Vec: Codable { + let foreground: String + let background: String + let criterion: String + let profileId: String + let decision: String + let foregroundLowerQ55: String + let foregroundUpperQ55: String + let backgroundLowerQ55: String + let backgroundUpperQ55: String + let q55Scale: String + let evidenceKind: String + let artifactId: String + let artifactSha256: String + let boundId: String + let proofId: String + let proofSha256: String + let proofPayloadSha256: String + let generatorSha256: String + let verifierSha256: String + let profileChecksum: String + let profileSha256: String +} + struct ContrastVec: Codable { let fg: String let bg: String diff --git a/conformance/README.md b/conformance/README.md index ba750c6c..783dd0a4 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -13,20 +13,23 @@ ## Версионирование -- **Версия пака** (`manifest.packVersion`, сейчас `3.0.0`) — семантическая - версия СХЕМЫ и состава векторов. Bump 2.0.0 → 3.0.0 менял только схему - манифеста (`numericalSites` → `numericalCapabilities`); векторные семейства - и `packDigest` не изменились. +- **Версия пака** (`manifest.packVersion`, сейчас `4.0.0`) — семантическая + версия СХЕМЫ и состава векторов. Bump 3.0.0 → 4.0.0 перевёл + `numericalCapabilities` на proof-capable schema V2 и добавил семейство + `wcag22`; поэтому состав семейств и `packDigest` изменились. Предыдущий bump + 2.0.0 → 3.0.0 менял только схему манифеста + (`numericalSites` → `numericalCapabilities`), без изменения векторных + семейств. - **Версия ядра** (`manifest.coreVersion`, для этого пака `0.2.0`) — версия `labcolors-core`, из канона которой сгенерированы значения. Пак действителен ровно для этой версии ядра; при легитимной смене канона (значения якорей/ручек, формулы) генератор перегенерирует векторы и `coreVersion` сдвигается. -- **Дайджест** (`manifest.packDigest`) — FNV-1a-32 над сырыми байтами семейств - (в порядке `contrasts, ladders, alpha, solve, muddiness`). Отпечаток - КОНКРЕТНОГО закоммиченного артефакта. Зависит от платформы генерации (последний - ULP f64 в сериализации) — не кросс-платформенный инвариант, а якорь - целостности файлов. +- **Дайджест** (`manifest.packDigest`) — FNV-1a-32 над сырыми байтами шести + семейств (в порядке `contrasts, ladders, alpha, solve, muddiness, wcag22`). + Отпечаток КОНКРЕТНОГО закоммиченного артефакта. Зависит от платформы + генерации (последний ULP f64 в сериализации) — не кросс-платформенный + инвариант, а якорь целостности файлов. ## Семейства векторов (`vectors/*.json`) @@ -37,6 +40,7 @@ | `alpha.json` | подложка→α | `{tint, alpha, bg, composite, minAlpha}` | | `solve.json` | резолв контракта | `{bg, contract, theme, outcome}` | | `muddiness.json` | замороженная legacy-координата `muddiness` | `{hex, score}` | +| `wcag22.json` | финальная sRGB8-пара и явно выбранный критерий WCAG 2.2 | `{foreground, background, criterion, decision, *Q55, evidence*}` | | `manifest.json` | метаданные и capability manifest численных решений | `{packVersion, coreVersion, packDigest, counts, numericalCapabilities}` | `muddiness.json` — это `experimental compatibility proxy`: corpus доказывает @@ -55,22 +59,29 @@ 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` (схема пака 3.0.0) генерируется из - core-owned `numerical_capability_manifest_v1()` и заменяет прозаический - `numericalSites` пака 2.x. Форма: +- `manifest.numericalCapabilities` в pack `4.0.0` генерируется из + proof-capable core-owned `numerical_capability_manifest_v2()`. До появления + внешних клиентов промежуточная Glow-only capability-схема V1 удалена из + public API: один `numericalCapabilityManifest()` сразу возвращает V2, без + второго version-suffixed entrypoint. Это намеренная pre-client breaking + коррекция ложной схемы, а не поддержка двух конкурирующих контрактов. Форма V2: `{schemaVersion, coverage, sites[], checksum}`, где `schemaVersion` — - независимый version domain capability-схемы (сейчас `1`); `coverage` — + независимый version domain capability-схемы (сейчас `2`); `coverage` — `migrated-sites-only-v1` (перечислены только **уже мигрированные** branch-sensitive sites, не утверждение полного аудита исторических `f64`-ветвлений — он остаётся в scope #291); каждая строка `sites[]` несёт - `siteId` и шесть списков стабильных ключей (`stableOutcomes`, + `siteId` и семь списков стабильных ключей (`stableOutcomes`, `compatibilityReleases`, `evidenceClasses`, `artifactIds`, `boundIds`, - `runtimeAttestations`; пустой список — явное «evidence отсутствует», не - пропуск); `checksum` — FNV-1a-32 (8 lowercase hex) над canonical + `proofIds`, `runtimeAttestations`; пустой список — явное «evidence отсутствует», + не пропуск); `checksum` — FNV-1a-32 (8 lowercase hex) над canonical length-prefixed preimage с домен-сепаратором - `labcolors.numerical-capability.v1`. Release verifier и Swift-тесты - пересчитывают checksum НЕЗАВИСИМО от Rust-кода. Сейчас в manifest только - `glow-target-or-maximum-v1`. + `labcolors.numerical-capability.v2`. Release verifier и Swift-тесты + пересчитывают checksum НЕЗАВИСИМО от Rust-кода. Manifest содержит + `glow-target-or-maximum-v1` и proof-bound `wcag22-srgb8-contrast-v1`. + Отдельно full-domain WCAG proof несёт SHA-256 private admission-row: ровно + десять live typed полей, которые разрешают mint terminal evidence, включая + `boundStatus` и `fallbackStatus`. Это site-local proof binding, а не новые + public capability-поля и не FNV checksum всего manifest. Словарь **позиций лестницы** (не ролей): `label-*`, `fill-*`, `border-*`, `focus-ring`, `glow`, `skeleton-*`, `neutral-fill-*`, `neutral-border-*`, @@ -112,14 +123,16 @@ solve-векторами: `Pack::generate()` возвращает `PackGeneratio вектор в пределах толерантности, дайджест сходится с сырыми байтами, а опубликованные WCAG-якоря (21:1, граница `#767676`) держатся. Раннер входит в `cargo test --workspace` на Linux x86_64. Активный Swift/UniFFI gate также -прогоняет все пять семейств пака в pinned Linux x86_64 container. - -Активный browser-gate теперь воспроизводит все 82 закоммиченных вектора внутри -фактического wasm32 core runtime и отдельно держит targeted parity-тесты -публичной JS-границы. Это доказывает wasm32-исполнение ядра против независимых -байтов пака, но ещё не прогоняет каждый вектор всех пяти семейств непосредственно -через публичный JS API. Поэтому полная conformance именно JS-поверхности текущего -пака пока не заявляется. В +прогоняет все перечисленные manifest-ом семейства в pinned Linux x86_64 +container. + +Активный browser-gate теперь воспроизводит каждый вектор всех перечисленных +manifest-ом семейств внутри фактического wasm32 core runtime; anti-vacuum total +вычисляется из длин самих replayed family files, а не поддерживается отдельным +числом. Targeted parity-тесты отдельно проверяют публичную JS-границу. Это +доказывает wasm32-исполнение ядра против независимых байтов пака, но ещё не +прогоняет каждый вектор непосредственно через публичный JS API. Поэтому полная +conformance именно JS-поверхности текущего пака пока не заявляется. В `native-conformance.yml` сохранён ручной macOS/arm64 reference path, но он не запускается на PR/push и не считается достигнутой аттестацией текущего пака. Полная runtime-матрица остаётся scope #258; допуск `DRIFT_TOL` задаёт правило diff --git a/conformance/vectors/manifest.json b/conformance/vectors/manifest.json index 99a784f2..fc7a76ab 100644 --- a/conformance/vectors/manifest.json +++ b/conformance/vectors/manifest.json @@ -1,17 +1,18 @@ { - "packVersion": "3.0.0", + "packVersion": "4.0.0", "coreVersion": "0.2.0", - "packDigest": "64a68cbd", + "packDigest": "63d9a93f", "counts": { "contrasts": 40, "ladders": 25, "alpha": 7, "solve": 6, "muddiness": 4, - "total": 82 + "wcag22": 6, + "total": 88 }, "numericalCapabilities": { - "schemaVersion": 1, + "schemaVersion": 2, "coverage": "migrated-sites-only-v1", "sites": [ { @@ -28,9 +29,30 @@ ], "artifactIds": [], "boundIds": [], + "proofIds": [], + "runtimeAttestations": [] + }, + { + "siteId": "wcag22-srgb8-contrast-v1", + "stableOutcomes": [ + "canonical-finite-bounded" + ], + "compatibilityReleases": [], + "evidenceClasses": [ + "canonical-finite-bounded" + ], + "artifactIds": [ + "wcag22-srgb8-luminance-q55-v1" + ], + "boundIds": [ + "wcag22-srgb8-outward-q55-v1" + ], + "proofIds": [ + "wcag22-srgb8-full-domain-q55-v1" + ], "runtimeAttestations": [] } ], - "checksum": "5a3f6cbc" + "checksum": "f8d0c63d" } } diff --git a/conformance/vectors/wcag22.json b/conformance/vectors/wcag22.json new file mode 100644 index 00000000..9e2f7dd2 --- /dev/null +++ b/conformance/vectors/wcag22.json @@ -0,0 +1,140 @@ +[ + { + "foreground": "#000000", + "background": "#FFFFFF", + "criterion": "sc-1.4.3-text-default", + "profileId": "wcag22-srgb8-contrast-v1", + "decision": "pass", + "foregroundLowerQ55": "0", + "foregroundUpperQ55": "0", + "backgroundLowerQ55": "36028797018963966", + "backgroundUpperQ55": "36028797018963969", + "q55Scale": "36028797018963968", + "evidenceKind": "canonical-finite-bounded", + "artifactId": "wcag22-srgb8-luminance-q55-v1", + "artifactSha256": "7ff239d9052b346f3c50da01ca65ca2330892ed1a3ff30e190797fcef6f03604", + "boundId": "wcag22-srgb8-outward-q55-v1", + "proofId": "wcag22-srgb8-full-domain-q55-v1", + "proofSha256": "c4a35a902ea49729704d05c2a9a07530a1731ebd4ff7325b5e5baf261fbe7b9e", + "proofPayloadSha256": "fa10908a1960e51b122a11ca0413083ab67749c79161c8b5d22a6c0c69ce71fb", + "generatorSha256": "7ad72f53e26ebb74ebfc2d8945f833613fa1317bb441d821f518d012c8c09687", + "verifierSha256": "8757b348b99926700c6d9854ed4ae515d4c676b31c7924b0812a3745f2181221", + "profileChecksum": "152813fe", + "profileSha256": "b4bb7e5f17a99f2c911fdbe3da23a48b049277b796291094950f14680cc3cc7b" + }, + { + "foreground": "#FFFFFF", + "background": "#000000", + "criterion": "sc-1.4.11-graphical-object", + "profileId": "wcag22-srgb8-contrast-v1", + "decision": "pass", + "foregroundLowerQ55": "36028797018963966", + "foregroundUpperQ55": "36028797018963969", + "backgroundLowerQ55": "0", + "backgroundUpperQ55": "0", + "q55Scale": "36028797018963968", + "evidenceKind": "canonical-finite-bounded", + "artifactId": "wcag22-srgb8-luminance-q55-v1", + "artifactSha256": "7ff239d9052b346f3c50da01ca65ca2330892ed1a3ff30e190797fcef6f03604", + "boundId": "wcag22-srgb8-outward-q55-v1", + "proofId": "wcag22-srgb8-full-domain-q55-v1", + "proofSha256": "c4a35a902ea49729704d05c2a9a07530a1731ebd4ff7325b5e5baf261fbe7b9e", + "proofPayloadSha256": "fa10908a1960e51b122a11ca0413083ab67749c79161c8b5d22a6c0c69ce71fb", + "generatorSha256": "7ad72f53e26ebb74ebfc2d8945f833613fa1317bb441d821f518d012c8c09687", + "verifierSha256": "8757b348b99926700c6d9854ed4ae515d4c676b31c7924b0812a3745f2181221", + "profileChecksum": "152813fe", + "profileSha256": "b4bb7e5f17a99f2c911fdbe3da23a48b049277b796291094950f14680cc3cc7b" + }, + { + "foreground": "#89BB09", + "background": "#8212DB", + "criterion": "sc-1.4.11-ui-component-or-state", + "profileId": "wcag22-srgb8-contrast-v1", + "decision": "fail", + "foregroundLowerQ55": "14728116861854887", + "foregroundUpperQ55": "14728116861854890", + "backgroundLowerQ55": "3708412386652940", + "backgroundUpperQ55": "3708412386652943", + "q55Scale": "36028797018963968", + "evidenceKind": "canonical-finite-bounded", + "artifactId": "wcag22-srgb8-luminance-q55-v1", + "artifactSha256": "7ff239d9052b346f3c50da01ca65ca2330892ed1a3ff30e190797fcef6f03604", + "boundId": "wcag22-srgb8-outward-q55-v1", + "proofId": "wcag22-srgb8-full-domain-q55-v1", + "proofSha256": "c4a35a902ea49729704d05c2a9a07530a1731ebd4ff7325b5e5baf261fbe7b9e", + "proofPayloadSha256": "fa10908a1960e51b122a11ca0413083ab67749c79161c8b5d22a6c0c69ce71fb", + "generatorSha256": "7ad72f53e26ebb74ebfc2d8945f833613fa1317bb441d821f518d012c8c09687", + "verifierSha256": "8757b348b99926700c6d9854ed4ae515d4c676b31c7924b0812a3745f2181221", + "profileChecksum": "152813fe", + "profileSha256": "b4bb7e5f17a99f2c911fdbe3da23a48b049277b796291094950f14680cc3cc7b" + }, + { + "foreground": "#898CB8", + "background": "#3E2217", + "criterion": "sc-1.4.3-text-default", + "profileId": "wcag22-srgb8-contrast-v1", + "decision": "fail", + "foregroundLowerQ55": "9920609921116460", + "foregroundUpperQ55": "9920609921116463", + "backgroundLowerQ55": "803460098399708", + "backgroundUpperQ55": "803460098399711", + "q55Scale": "36028797018963968", + "evidenceKind": "canonical-finite-bounded", + "artifactId": "wcag22-srgb8-luminance-q55-v1", + "artifactSha256": "7ff239d9052b346f3c50da01ca65ca2330892ed1a3ff30e190797fcef6f03604", + "boundId": "wcag22-srgb8-outward-q55-v1", + "proofId": "wcag22-srgb8-full-domain-q55-v1", + "proofSha256": "c4a35a902ea49729704d05c2a9a07530a1731ebd4ff7325b5e5baf261fbe7b9e", + "proofPayloadSha256": "fa10908a1960e51b122a11ca0413083ab67749c79161c8b5d22a6c0c69ce71fb", + "generatorSha256": "7ad72f53e26ebb74ebfc2d8945f833613fa1317bb441d821f518d012c8c09687", + "verifierSha256": "8757b348b99926700c6d9854ed4ae515d4c676b31c7924b0812a3745f2181221", + "profileChecksum": "152813fe", + "profileSha256": "b4bb7e5f17a99f2c911fdbe3da23a48b049277b796291094950f14680cc3cc7b" + }, + { + "foreground": "#8A8A8A", + "background": "#FFFFFF", + "criterion": "sc-1.4.3-text-default", + "profileId": "wcag22-srgb8-contrast-v1", + "decision": "fail", + "foregroundLowerQ55": "9156794218589936", + "foregroundUpperQ55": "9156794218589939", + "backgroundLowerQ55": "36028797018963966", + "backgroundUpperQ55": "36028797018963969", + "q55Scale": "36028797018963968", + "evidenceKind": "canonical-finite-bounded", + "artifactId": "wcag22-srgb8-luminance-q55-v1", + "artifactSha256": "7ff239d9052b346f3c50da01ca65ca2330892ed1a3ff30e190797fcef6f03604", + "boundId": "wcag22-srgb8-outward-q55-v1", + "proofId": "wcag22-srgb8-full-domain-q55-v1", + "proofSha256": "c4a35a902ea49729704d05c2a9a07530a1731ebd4ff7325b5e5baf261fbe7b9e", + "proofPayloadSha256": "fa10908a1960e51b122a11ca0413083ab67749c79161c8b5d22a6c0c69ce71fb", + "generatorSha256": "7ad72f53e26ebb74ebfc2d8945f833613fa1317bb441d821f518d012c8c09687", + "verifierSha256": "8757b348b99926700c6d9854ed4ae515d4c676b31c7924b0812a3745f2181221", + "profileChecksum": "152813fe", + "profileSha256": "b4bb7e5f17a99f2c911fdbe3da23a48b049277b796291094950f14680cc3cc7b" + }, + { + "foreground": "#8A8A8A", + "background": "#FFFFFF", + "criterion": "sc-1.4.3-text-large-scale", + "profileId": "wcag22-srgb8-contrast-v1", + "decision": "pass", + "foregroundLowerQ55": "9156794218589936", + "foregroundUpperQ55": "9156794218589939", + "backgroundLowerQ55": "36028797018963966", + "backgroundUpperQ55": "36028797018963969", + "q55Scale": "36028797018963968", + "evidenceKind": "canonical-finite-bounded", + "artifactId": "wcag22-srgb8-luminance-q55-v1", + "artifactSha256": "7ff239d9052b346f3c50da01ca65ca2330892ed1a3ff30e190797fcef6f03604", + "boundId": "wcag22-srgb8-outward-q55-v1", + "proofId": "wcag22-srgb8-full-domain-q55-v1", + "proofSha256": "c4a35a902ea49729704d05c2a9a07530a1731ebd4ff7325b5e5baf261fbe7b9e", + "proofPayloadSha256": "fa10908a1960e51b122a11ca0413083ab67749c79161c8b5d22a6c0c69ce71fb", + "generatorSha256": "7ad72f53e26ebb74ebfc2d8945f833613fa1317bb441d821f518d012c8c09687", + "verifierSha256": "8757b348b99926700c6d9854ed4ae515d4c676b31c7924b0812a3745f2181221", + "profileChecksum": "152813fe", + "profileSha256": "b4bb7e5f17a99f2c911fdbe3da23a48b049277b796291094950f14680cc3cc7b" + } +] diff --git a/crates/labcolors-conformance/src/bin/gen.rs b/crates/labcolors-conformance/src/bin/gen.rs index b86b50b7..4c8cdf2d 100644 --- a/crates/labcolors-conformance/src/bin/gen.rs +++ b/crates/labcolors-conformance/src/bin/gen.rs @@ -45,13 +45,14 @@ fn main() -> Result<(), Box> { let c = pack.counts(); println!( - "готово: {} векторов (contrasts={}, ladders={}, alpha={}, solve={}, muddiness={}), дайджест={}", + "готово: {} векторов (contrasts={}, ladders={}, alpha={}, solve={}, muddiness={}, wcag22={}), дайджест={}", c.total, c.contrasts, c.ladders, c.alpha, c.solve, c.muddiness, + c.wcag22, pack.digest() ); Ok(()) diff --git a/crates/labcolors-conformance/src/lib.rs b/crates/labcolors-conformance/src/lib.rs index c7db5fae..fd8a9bac 100644 --- a/crates/labcolors-conformance/src/lib.rs +++ b/crates/labcolors-conformance/src/lib.rs @@ -27,7 +27,8 @@ //! | `alpha.json` | подложка→α: композит и α_min | `alpha::composite_hex` / `alpha::min_alpha_hex` | //! | `solve.json` | (bg, контракт, тема) → резолв или честный отказ | `solve` | //! | `muddiness.json` | hex → замороженная legacy-координата | `cleanliness::muddiness_from_hex` | -//! | `manifest.json` | версии, дайджест, счётчики, capability manifest | `numerical_capability_manifest_v1` | +//! | `wcag22.json` | final sRGB8 pair + criterion → exact assessment | `wcag22::evaluate_wcag22_hex` | +//! | `manifest.json` | версии, дайджест, счётчики, capability manifest | `numerical_capability_manifest_v2` | //! //! `muddiness.json` фиксирует только воспроизводимость исторического числового //! API. Это `experimental compatibility proxy`, а не валидированный на @@ -53,7 +54,7 @@ use labcolors_core::{ /// Семантическая версия conformance-пака. Меняется при изменении СХЕМЫ или /// состава векторов; значения векторов при этом диктует канон ядра. -pub const PACK_VERSION: &str = "3.0.0"; +pub const PACK_VERSION: &str = "4.0.0"; /// Версия ядра, к которой привязан пак. Все крейты воркспейса делят одну версию /// (`version.workspace = true`), поэтому собственная `CARGO_PKG_VERSION` этого @@ -456,6 +457,152 @@ pub fn generate_muddiness() -> Vec { .collect() } +// ───────────────────────────────────────────────────────────────────────────── +// Семейство: exact WCAG 2.2 final-sRGB8 assessment +// ───────────────────────────────────────────────────────────────────────────── + +/// One cross-runtime exact WCAG 2.2 assessment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Wcag22Vector { + /// Final foreground bytes. + pub foreground: String, + /// Final background bytes. + pub background: String, + /// Explicit occurrence-level criterion key. + pub criterion: String, + /// Immutable evaluator profile. + pub profile_id: String, + /// Exact terminal decision. + pub decision: String, + /// Q55 bounds are decimal strings to remain exact in JavaScript. + pub foreground_lower_q55: String, + pub foreground_upper_q55: String, + pub background_lower_q55: String, + pub background_upper_q55: String, + pub q55_scale: String, + /// Sealed evidence identities and digests. + pub evidence_kind: String, + pub artifact_id: String, + pub artifact_sha256: String, + pub bound_id: String, + pub proof_id: String, + pub proof_sha256: String, + pub proof_payload_sha256: String, + pub generator_sha256: String, + pub verifier_sha256: String, + pub profile_checksum: String, + pub profile_sha256: String, +} + +const WCAG22_CASES: [(&str, &str, labcolors_core::wcag22::Wcag22CriterionV1); 6] = [ + ( + "#000000", + "#FFFFFF", + labcolors_core::wcag22::Wcag22CriterionV1::Sc143TextDefault, + ), + ( + "#FFFFFF", + "#000000", + labcolors_core::wcag22::Wcag22CriterionV1::Sc1411GraphicalObject, + ), + ( + "#89BB09", + "#8212DB", + labcolors_core::wcag22::Wcag22CriterionV1::Sc1411UiComponentOrState, + ), + ( + "#898CB8", + "#3E2217", + labcolors_core::wcag22::Wcag22CriterionV1::Sc143TextDefault, + ), + ( + "#8A8A8A", + "#FFFFFF", + labcolors_core::wcag22::Wcag22CriterionV1::Sc143TextDefault, + ), + ( + "#8A8A8A", + "#FFFFFF", + labcolors_core::wcag22::Wcag22CriterionV1::Sc143TextLargeScale, + ), +]; + +/// Generate exact vectors by transporting the core assessment, never by +/// reimplementing the formula in the conformance crate. +pub fn generate_wcag22() -> Result, PackGenerationError> { + use labcolors_core::NumericalDecisionEvidenceV1; + use labcolors_core::wcag22::{Wcag22ApplicableDecisionV1, Wcag22AssessmentV1}; + + WCAG22_CASES + .iter() + .map(|&(foreground, background, criterion)| { + let assessment = + labcolors_core::wcag22::evaluate_wcag22_hex(foreground, background, criterion) + .map_err(|error| PackGenerationError::InternalCoreInvariant { + reason: error.to_string(), + })?; + let Wcag22AssessmentV1::Evaluated { + profile_id, + criterion: assessed_criterion, + measurement, + decision, + evidence, + .. + } = assessment + else { + return Err(PackGenerationError::InternalCoreInvariant { + reason: "pair evaluator returned NotEvaluated".to_string(), + }); + }; + let NumericalDecisionEvidenceV1::CanonicalFiniteBounded(evidence_payload) = evidence + else { + return Err(PackGenerationError::IncompatibleCoreContract { + reason: "WCAG22 evidence is not canonical-finite-bounded".to_string(), + }); + }; + let artifact_id = evidence_payload.artifact_id(); + let bound_id = evidence_payload.bound_id(); + let proof_id = evidence_payload.proof_id(); + let profile = labcolors_core::wcag22::wcag22_profile_v1(); + let criterion = assessed_criterion.key(); + let decision = match decision { + Wcag22ApplicableDecisionV1::Pass => "pass", + Wcag22ApplicableDecisionV1::Fail => "fail", + _ => { + return Err(PackGenerationError::IncompatibleCoreContract { + reason: "unknown WCAG22 decision".to_string(), + }); + } + }; + let hex = |bytes: [u8; 3]| format!("#{:02X}{:02X}{:02X}", bytes[0], bytes[1], bytes[2]); + Ok(Wcag22Vector { + foreground: hex(measurement.foreground), + background: hex(measurement.background), + criterion: criterion.to_string(), + profile_id: profile_id.key().to_string(), + decision: decision.to_string(), + foreground_lower_q55: measurement.foreground_luminance.lower().to_string(), + foreground_upper_q55: measurement.foreground_luminance.upper().to_string(), + background_lower_q55: measurement.background_luminance.lower().to_string(), + background_upper_q55: measurement.background_luminance.upper().to_string(), + q55_scale: labcolors_core::wcag22::Wcag22LuminanceBoundsQ55V1::scale().to_string(), + evidence_kind: "canonical-finite-bounded".to_string(), + artifact_id: artifact_id.key().to_string(), + artifact_sha256: profile.artifact_sha256.to_string(), + bound_id: bound_id.key().to_string(), + proof_id: proof_id.key().to_string(), + proof_sha256: profile.proof_sha256.to_string(), + proof_payload_sha256: profile.proof_payload_sha256.to_string(), + generator_sha256: profile.generator_sha256.to_string(), + verifier_sha256: profile.verifier_sha256.to_string(), + profile_checksum: profile.profile_checksum.to_string(), + profile_sha256: profile.source_sha256.to_string(), + }) + }) + .collect() +} + // ───────────────────────────────────────────────────────────────────────────── // Агрегат пака + сериализация + дайджест // ───────────────────────────────────────────────────────────────────────────── @@ -465,12 +612,13 @@ pub const MANIFEST_FILE: &str = "manifest.json"; /// Имена файлов семейств в КАНОНИЧЕСКОМ порядке — единый источник порядка для /// генератора, дайджеста и раннера-референса (дайджест зависит от порядка). -pub const FAMILY_FILES: [&str; 5] = [ +pub const FAMILY_FILES: [&str; 6] = [ "contrasts.json", "ladders.json", "alpha.json", "solve.json", "muddiness.json", + "wcag22.json", ]; /// Каноническая толерантность сравнения f64 для conformance- @@ -498,6 +646,8 @@ pub struct Counts { pub solve: usize, /// Compatibility-векторы legacy-координаты `muddiness`. pub muddiness: usize, + /// Exact WCAG 2.2 vectors. + pub wcag22: usize, /// Итого. pub total: usize, } @@ -508,13 +658,13 @@ pub struct Counts { /// consumes this projection instead of a hand-written semantic registry. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct CapabilityManifestProjection { +pub struct CapabilityManifestProjectionV2 { /// Capability schema version (independent version domain). pub schema_version: u32, /// Registry coverage key (`migrated-sites-only-v1`). pub coverage: String, /// Capability rows sorted by UTF-8 `siteId` bytes. - pub sites: Vec, + pub sites: Vec, /// FNV-1a-32 drift-checksum canonical preimage, 8 lowercase hex. pub checksum: String, } @@ -522,7 +672,7 @@ pub struct CapabilityManifestProjection { /// One site capability row (no selected mode; manifest describes the build). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct CapabilitySiteProjection { +pub struct CapabilitySiteProjectionV2 { /// Stable site identity key. pub site_id: String, /// Lawful stable outcome keys. @@ -533,23 +683,25 @@ pub struct CapabilitySiteProjection { pub evidence_classes: Vec, /// Canonical finite artifact IDs (empty = no evidence, not implicit support). pub artifact_ids: Vec, - /// Registered error bound IDs (empty in V1). + /// Registered error bound IDs (empty when none are admitted). pub bound_ids: Vec, + /// Replayable proof artifact IDs. + pub proof_ids: Vec, /// Runtime attestation IDs (empty until #258). pub runtime_attestations: Vec, } /// Generate the release-facing capability manifest directly from the core SSOT. #[must_use] -pub fn generate_capability_manifest() -> CapabilityManifestProjection { - let manifest = labcolors_core::numerical_capability_manifest_v1(); - CapabilityManifestProjection { +pub fn generate_capability_manifest_v2() -> CapabilityManifestProjectionV2 { + let manifest = labcolors_core::numerical_capability_manifest_v2(); + CapabilityManifestProjectionV2 { schema_version: manifest.schema_version, coverage: manifest.coverage.key().to_string(), sites: manifest .sites .iter() - .map(|site| CapabilitySiteProjection { + .map(|site| CapabilitySiteProjectionV2 { site_id: site.site_id.key().to_string(), stable_outcomes: site .stable_outcomes @@ -572,6 +724,7 @@ pub fn generate_capability_manifest() -> CapabilityManifestProjection { .map(|v| v.key().to_string()) .collect(), bound_ids: site.bound_ids.iter().map(|v| v.key().to_string()).collect(), + proof_ids: site.proof_ids.iter().map(|v| v.key().to_string()).collect(), runtime_attestations: site .runtime_attestations .iter() @@ -598,7 +751,7 @@ pub struct Manifest { /// Счётчики по семействам. pub counts: Counts, /// Canonical numerical capability manifest (core registry projection). - pub numerical_capabilities: CapabilityManifestProjection, + pub numerical_capabilities: CapabilityManifestProjectionV2, } /// Весь пак в памяти. `serialize_family` даёт КАНОНИЧЕСКИЕ байты каждого файла @@ -615,6 +768,8 @@ pub struct Pack { pub solve: Vec, /// Compatibility-векторы legacy-координаты `muddiness`. pub muddiness: Vec, + /// Exact final-sRGB8 WCAG 2.2 vectors. + pub wcag22: Vec, } impl Pack { @@ -626,6 +781,7 @@ impl Pack { alpha: generate_alpha(), solve: generate_solve()?, muddiness: generate_muddiness(), + wcag22: generate_wcag22()?, }) } @@ -637,13 +793,15 @@ impl Pack { let alpha = self.alpha.len(); let solve = self.solve.len(); let muddiness = self.muddiness.len(); + let wcag22 = self.wcag22.len(); Counts { contrasts, ladders, alpha, solve, muddiness, - total: contrasts + ladders + alpha + solve + muddiness, + wcag22, + total: contrasts + ladders + alpha + solve + muddiness + wcag22, } } @@ -668,7 +826,7 @@ impl Pack { core_version: core_version().to_string(), pack_digest: self.digest(), counts: self.counts(), - numerical_capabilities: generate_capability_manifest(), + numerical_capabilities: generate_capability_manifest_v2(), } } @@ -682,6 +840,7 @@ impl Pack { (FAMILY_FILES[2], to_canonical_json(&self.alpha)), (FAMILY_FILES[3], to_canonical_json(&self.solve)), (FAMILY_FILES[4], to_canonical_json(&self.muddiness)), + (FAMILY_FILES[5], to_canonical_json(&self.wcag22)), ] } } @@ -699,7 +858,18 @@ pub fn to_canonical_json(value: &T) -> String { #[cfg(test)] mod tests { use super::*; - use labcolors_core::numerical_registry_v1; + use labcolors_core::numerical_registry_v2; + + #[test] + fn wcag22_vector_generator_has_no_private_wire_key_vocabulary() { + let private_prefix: String = ['"', 's', 'c', '-', '1', '.', '4', '.'] + .into_iter() + .collect(); + assert!( + !include_str!("lib.rs").contains(&private_prefix), + "WCAG22 vector keys must come from Wcag22CriterionV1::key()" + ); + } #[test] fn unreachable_code_mapping_is_fallible_without_generic_fallback() { @@ -739,7 +909,7 @@ mod tests { assert!(c.total > 0, "пустой пак бессмыслен"); assert_eq!( c.total, - c.contrasts + c.ladders + c.alpha + c.solve + c.muddiness, + c.contrasts + c.ladders + c.alpha + c.solve + c.muddiness + c.wcag22, "итог не сходится с семействами" ); // Лестниц ровно столько, сколько канонических позиций. @@ -753,11 +923,11 @@ mod tests { .manifest(); assert_eq!( manifest.numerical_capabilities, - generate_capability_manifest() + generate_capability_manifest_v2() ); assert_eq!( manifest.numerical_capabilities.sites.len(), - numerical_registry_v1().len() + numerical_registry_v2().len() ); assert!(manifest.numerical_capabilities.sites.iter().any(|site| { site.site_id == "glow-target-or-maximum-v1" @@ -766,27 +936,35 @@ mod tests { && site.evidence_classes == ["bit-exact"] && site.artifact_ids.is_empty() && site.bound_ids.is_empty() + && site.proof_ids.is_empty() && site.runtime_attestations.is_empty() })); + assert!(manifest.numerical_capabilities.sites.iter().any(|site| { + site.site_id == "wcag22-srgb8-contrast-v1" + && site.evidence_classes == ["canonical-finite-bounded"] + && site.artifact_ids == ["wcag22-srgb8-luminance-q55-v1"] + && site.bound_ids == ["wcag22-srgb8-outward-q55-v1"] + && site.proof_ids == ["wcag22-srgb8-full-domain-q55-v1"] + })); + assert_eq!(manifest.numerical_capabilities.schema_version, 2); // Checksum canonical projection: 8 hex, независимо пересчитываем в core. assert_eq!(manifest.numerical_capabilities.checksum.len(), 8); } #[test] - fn pack_v3_contains_the_exact_source_over_half_tie() { + fn pack_v4_contains_prior_half_tie_and_new_wcag22_family() { // ADR-0004 делает этот байтовый шов частью breaking conformance-контракта: // нормализованный `(byte/255) * alpha * 255` путь ошибочно отдавал - // соседний LSB. Обязательство унаследовано pack v3 (v3 менял только - // схему манифеста: numericalSites → numericalCapabilities, состав - // векторных семейств тот же). Проверка одновременно убивает вакуумные + // соседний LSB. Обязательство унаследовано pack v4; v4 добавляет exact + // WCAG22 family. Проверка одновременно убивает вакуумные // изменения версии/счётчика без обязательного доказательного вектора. let pack = Pack::generate().expect("canonical pack generation"); let manifest = pack.manifest(); - assert_eq!(PACK_VERSION, "3.0.0", "half-tie обязателен начиная с v2"); + assert_eq!(PACK_VERSION, "4.0.0", "WCAG22 family обязана быть pack v4"); assert_eq!(manifest.pack_version, PACK_VERSION); assert_eq!( manifest.core_version, "0.2.0", - "pack v3 наследует векторные семейства, сгенерированные ядром 0.2.0" + "pack v4 наследует прежние семейства и добавляет WCAG22 на core 0.2.0" ); assert_eq!( pack.alpha.len(), @@ -795,9 +973,10 @@ mod tests { ); assert_eq!(manifest.counts.alpha, pack.alpha.len()); assert_eq!( - manifest.counts.total, 82, + manifest.counts.total, 88, "состав векторных семейств изменился" ); + assert_eq!(manifest.counts.wcag22, 6); let half_tie = pack .alpha diff --git a/crates/labcolors-conformance/tests/reference_runner.rs b/crates/labcolors-conformance/tests/reference_runner.rs index fe5350b9..5a07fc1c 100644 --- a/crates/labcolors-conformance/tests/reference_runner.rs +++ b/crates/labcolors-conformance/tests/reference_runner.rs @@ -29,8 +29,8 @@ use std::path::PathBuf; use labcolors_conformance::{ AlphaVector, ContrastVector, DRIFT_TOL, FAMILY_FILES, LadderVector, MANIFEST_FILE, Manifest, - MuddinessVector, Pack, SolveOutcome, SolveVector, generate_alpha, generate_contrasts, - generate_ladders, generate_muddiness, generate_solve, + MuddinessVector, Pack, SolveOutcome, SolveVector, Wcag22Vector, generate_alpha, + generate_contrasts, generate_ladders, generate_muddiness, generate_solve, generate_wcag22, }; use labcolors_core::fnv1a_32; @@ -193,6 +193,19 @@ fn core_reproduces_committed_muddiness() { } } +#[test] +fn core_reproduces_committed_wcag22_exactly() { + let committed: Vec = parse("wcag22.json"); + let fresh = generate_wcag22().expect("canonical WCAG22 vectors"); + assert_eq!(committed, fresh, "wcag22.json: exact family drifted"); + assert!(committed.iter().any(|vector| { + vector.foreground == "#89BB09" + && vector.background == "#8212DB" + && vector.decision == "fail" + && vector.evidence_kind == "canonical-finite-bounded" + })); +} + // ── Слой 2: метаданные манифеста ────────────────────────────────────────────── #[test] diff --git a/crates/labcolors-core/contracts/wcag22-srgb8-q55-proof-v1.json b/crates/labcolors-core/contracts/wcag22-srgb8-q55-proof-v1.json new file mode 100644 index 00000000..6eaa0bae --- /dev/null +++ b/crates/labcolors-core/contracts/wcag22-srgb8-q55-proof-v1.json @@ -0,0 +1 @@ +{"artifact_id":"wcag22-srgb8-luminance-q55-v1","artifact_rust_source_sha256":"af4d23d6b70c45ce6efa839e7dda4bb0a61f6aae43cb805af6fa9b29e6c3bae2","artifact_sha256":"7ff239d9052b346f3c50da01ca65ca2330892ed1a3ff30e190797fcef6f03604","artifact_words":1536,"bound_id":"wcag22-srgb8-outward-q55-v1","colors":16777216,"crate_lib_source_sha256":"40d926da94547201242ef3aaf01db4c7e3912e8034998ab9a11671882057a726","declared_operation_law":"final-srgb8-outward-q55-two-orientation-integer-threshold-v1","facade_id":"wcag22-srgb8-public-facade-v1","facade_normalized_sha256":"8cecfaf660e896c5ac7c377ed286fa0377a0201e83f2b65f858e4136348397ef","full_domain_algorithm":"unique-q55-interval-monotone-boundary-v1","generator_sha256":"7ad72f53e26ebb74ebfc2d8945f833613fa1317bb441d821f518d012c8c09687","integer_replay_envelope":{"carrier":"signed-64","carrier_maximum":9223372036854775807,"headroom":2485986994308513251,"maximum_luminance_upper":36028797018963971,"maximum_threshold_term":6737385042546262556,"next_scale_maximum_threshold_term":13474770085092524572,"next_scale_power":56,"observed_interval_width":3,"outward_interval_width_bound":3},"kernel_id":"wcag22-srgb8-evaluation-kernel-v1","kernel_source_sha256":"c97980c1ca2c7ea9cabff9c8d2fb7282773cca180ae15948391c29c9d6196040","max_color_interval_width":3,"negative_controls":15,"parser_id":"encoded-srgb8-hex-parser-v1","parser_source_sha256":"57cd2605e040a4d206a83c86cf01c5d6935e5bff9c45e556db0e4c6eaede7280","profile_checksum":"152813fe","profile_id":"wcag22-srgb8-contrast-v1","profile_source_sha256":"b4bb7e5f17a99f2c911fdbe3da23a48b049277b796291094950f14680cc3cc7b","proof_id":"wcag22-srgb8-full-domain-q55-v1","proof_payload_sha256":"fa10908a1960e51b122a11ca0413083ab67749c79161c8b5d22a6c0c69ce71fb","q55_scale":36028797018963968,"recommendation":"https://www.w3.org/TR/2024/REC-WCAG22-20241212/","registry_row_id":"wcag22-srgb8-contrast-v1","registry_row_negative_controls":10,"registry_row_sha256":"c91c5e185c432ae4a9fb9ea03e9838bf2565f2aabff56019e190aae97bfaa0f1","row_oracle":{"integer_tightness_cross_checks":768,"maximum_precision_used":72,"precision_schedule":[48,72,108,162],"rows_stable":768},"rows":768,"schema_version":1,"terminal_evidence_id":"wcag22-srgb8-terminal-evidence-v1","terminal_evidence_source_sha256":"3c5a75b07254c6071a64700af208a64987d0f0ea9698eadc54a9e74585ce1f72","thresholds":[{"candidate_checks":0,"integer_law":{"dark_factor":30,"light_factor":10,"offset_factor":1},"minimum_fail_intervals":[{"bounds":[14728116861854887,14728116861854890],"rgb":"#89BB09"},{"bounds":[3708412386652940,3708412386652943],"rgb":"#8212DB"}],"minimum_fail_margin":3268,"minimum_pass_intervals":[{"bounds":[22947817375997342,22947817375997345],"rgb":"#32F120"},{"bounds":[6448312558033241,6448312558033244],"rgb":"#BF39C2"}],"minimum_pass_margin":12132,"threshold":"3.0","unresolved":0},{"candidate_checks":0,"integer_law":{"dark_factor":180,"light_factor":40,"offset_factor":7},"minimum_fail_intervals":[{"bounds":[9920609921116460,9920609921116463],"rgb":"#898CB8"},{"bounds":[803460098399708,803460098399711],"rgb":"#3E2217"}],"minimum_fail_margin":36696,"minimum_pass_intervals":[{"bounds":[9070597544121535,9070597544121538],"rgb":"#BE64DB"},{"bounds":[614568459067254,614568459067257],"rgb":"#480B1D"}],"minimum_pass_margin":7364,"threshold":"4.5","unresolved":0}],"unique_intervals":16777216,"verifier_sha256":"8757b348b99926700c6d9854ed4ae515d4c676b31c7924b0812a3745f2181221"} diff --git a/crates/labcolors-core/contracts/wcag22-srgb8-q55-v1.bin b/crates/labcolors-core/contracts/wcag22-srgb8-q55-v1.bin new file mode 100644 index 00000000..32a86843 Binary files /dev/null and b/crates/labcolors-core/contracts/wcag22-srgb8-q55-v1.bin differ diff --git a/crates/labcolors-core/contracts/wcag22-srgb8-v1.json b/crates/labcolors-core/contracts/wcag22-srgb8-v1.json new file mode 100644 index 00000000..7b393233 --- /dev/null +++ b/crates/labcolors-core/contracts/wcag22-srgb8-v1.json @@ -0,0 +1 @@ +{"blueWeight":"0.0722","channelSplit":"0.04045","contrastOffset":"0.05","encodedExponent":"2.4","encodedOffset":"0.055","encodedScale":"1.055","fixedPointScalePower":55,"greenWeight":"0.7152","largeTextRatio":"3.0","linearDivisor":"12.92","normalTextRatio":"4.5","profileId":"wcag22-srgb8-contrast-v1","recommendation":"https://www.w3.org/TR/2024/REC-WCAG22-20241212/","redWeight":"0.2126","requiredNonTextRatio":"3.0","schemaVersion":1} diff --git a/crates/labcolors-core/src/glow.rs b/crates/labcolors-core/src/glow.rs index 25708e0c..f605e7da 100644 --- a/crates/labcolors-core/src/glow.rs +++ b/crates/labcolors-core/src/glow.rs @@ -218,15 +218,37 @@ impl GlowDecisionProfileV1 { /// точный no-op либо явный registered compatibility-алгоритм. Незаконная /// комбинация (stable + legacy provenance и т. п.) непредставима типами; /// cross-product независимых полей profile/guarantee удалён (#292). +/// +/// Genuine evidence from another registered site cannot be relabelled as a +/// Glow outcome outside Core: +/// +/// ```compile_fail,E0639 +/// use labcolors_core::GlowDecisionOutcomeV1; +/// use labcolors_core::wcag22::{ +/// Wcag22AssessmentV1, Wcag22CriterionV1, evaluate_wcag22_srgb8, +/// }; +/// +/// let wcag = evaluate_wcag22_srgb8( +/// [0, 0, 0], +/// [255, 255, 255], +/// Wcag22CriterionV1::Sc143TextDefault, +/// ).unwrap(); +/// let Wcag22AssessmentV1::Evaluated { evidence, .. } = wcag else { +/// unreachable!() +/// }; +/// let _forged = GlowDecisionOutcomeV1::StableExactNoop { evidence }; +/// ``` #[derive(Debug, Clone, Copy, PartialEq)] #[non_exhaustive] pub enum GlowDecisionOutcomeV1 { /// Stable exact no-op: решение доказано запечатанным BitExact-evidence. + #[non_exhaustive] StableExactNoop { /// Запечатанное registry-owned evidence. evidence: NumericalDecisionEvidenceV1, }, /// Явно выбранный зарегистрированный прежний алгоритм. + #[non_exhaustive] Compatibility { /// Registered release, реально исполнивший invocation. release_id: NumericalCompatibilityReleaseIdV1, diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index 465f55c2..794e57d2 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -1,4 +1,8 @@ pub(crate) mod spaces; +pub(crate) mod srgb8; +pub mod wcag22; +#[doc(hidden)] +pub mod wcag22_evidence; pub(crate) mod accent; pub mod accent_balance; @@ -37,6 +41,9 @@ mod agnostic_gates; #[cfg(test)] mod appearance_graph_tests; +#[cfg(test)] +mod wcag22_tests; + #[cfg(test)] mod one_levelness_tests; @@ -111,20 +118,21 @@ pub use numerical_plan::{ NumericalPlanErrorV1, compile_numerical_plan_v1, }; pub use numerics::{ - LegacyPlatformDependentV1, NUMERICAL_CAPABILITY_SCHEMA_VERSION_V1, NumericalArtifactIdV1, - NumericalBoundStatusV1, NumericalCapabilityChecksumV1, NumericalCapabilityManifestV1, + LegacyPlatformDependentV1, NUMERICAL_CAPABILITY_SCHEMA_VERSION_V2, NumericalArtifactIdV2, + NumericalBoundStatusV2, NumericalCapabilityChecksumV2, NumericalCapabilityManifestV2, NumericalCompatibilityReleaseIdV1, NumericalDecisionEvidenceV1, NumericalDecisionV1, - NumericalErrorBoundIdV1, NumericalEvidenceClassV1, NumericalFallbackStatusV1, - NumericalIndeterminacyV1, NumericalRegistryCoverageV1, NumericalRuntimeAttestationIdV1, - NumericalSiteCapabilityV1, NumericalSiteIdV1, NumericalSiteRecordV1, OutwardIntervalV1, - ReferenceProfileIdV1, StableNumericalOutcomeV1, numerical_capability_manifest_v1, - numerical_registry_v1, + NumericalErrorBoundIdV2, NumericalEvidenceClassV2, NumericalFallbackStatusV1, + NumericalIndeterminacyV1, NumericalProofIdV2, NumericalRegistryCoverageV2, + NumericalRuntimeAttestationIdV2, NumericalSiteCapabilityV2, NumericalSiteIdV1, + NumericalSiteIdV2, NumericalSiteRecordV2, OutwardIntervalV1, ReferenceProfileIdV1, + StableNumericalOutcomeV2, numerical_capability_manifest_v2, numerical_registry_v2, }; pub use semantic::{ GlowIndeterminateResolved, NamedRoleTable, Resolved, RoleChroma, RoleSpec, TextAnchor, TranslucentResolved, measure_contrast, recheck_against, recheck_against_multi, resolve_named_set, }; +pub use wcag22_evidence::CanonicalFiniteBoundedEvidenceV1; // The built-in v1 showcase (`Role`/`RoleTable`/`resolve`/`resolve_set`) is no // longer part of the production API (ADR-0001 PR-c): the agnostic engine ships // only the string-keyed `resolve_named_set` path. It survives ONLY as the diff --git a/crates/labcolors-core/src/numerics.rs b/crates/labcolors-core/src/numerics.rs index a05f55d6..43f14f1f 100644 --- a/crates/labcolors-core/src/numerics.rs +++ b/crates/labcolors-core/src/numerics.rs @@ -15,10 +15,11 @@ //! причина у Indeterminate //! ``` //! -//! Законы первого V1-среза (#292): +//! Законы versioned capability-срезов (#292/#284): //! -//! * `Determinate` несёт только реально минтимое core-ом evidence — `BitExact` -//! (конструктор запечатан и registry-owned); +//! * `Determinate` несёт только реально минтимое core-ом evidence: опубликованный +//! V1 registry допускает `BitExact`, proof-capable V2 также допускает +//! `CanonicalFiniteBounded`; оба конструктора запечатаны и registry-owned; //! * текущий нехарактеризованный legacy-результат — отдельный атомарный вариант //! `Compatibility` с зарегистрированным release ID и provenance-классом //! `LegacyPlatformDependentV1`; он НЕ является determinate evidence и не @@ -51,11 +52,35 @@ //! _seal: labcolors_core::numerics::EvidenceSeal { _private: () }, //! }; //! ``` +//! +//! Подлинное evidence также нельзя переиспользовать для другого site/result: +//! каждый terminal-вариант запечатан целиком, а не только его evidence payload. +//! +//! ```compile_fail,E0639 +//! use labcolors_core::{NumericalDecisionV1, NumericalSiteIdV1}; +//! use labcolors_core::wcag22::{ +//! Wcag22AssessmentV1, Wcag22CriterionV1, evaluate_wcag22_srgb8, +//! }; +//! +//! let genuine = evaluate_wcag22_srgb8( +//! [0, 0, 0], +//! [255, 255, 255], +//! Wcag22CriterionV1::Sc143TextDefault, +//! ).unwrap(); +//! let Wcag22AssessmentV1::Evaluated { evidence, .. } = genuine else { +//! unreachable!() +//! }; +//! let _forged: NumericalDecisionV1<&str> = NumericalDecisionV1::Determinate { +//! site_id: NumericalSiteIdV1::GlowTargetOrMaximumV1, +//! value: "forged cross-site result", +//! evidence, +//! }; +//! ``` /// Stable outcomes admitted for a migrated branch-sensitive site. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] -pub enum StableNumericalOutcomeV1 { +pub(crate) enum StableNumericalOutcomeV1 { /// Determinate branch follows from exact finite/integer/rational evidence. BitExact, /// No semantic branch is selected. @@ -64,7 +89,8 @@ pub enum StableNumericalOutcomeV1 { impl StableNumericalOutcomeV1 { /// Stable manifest key. - pub fn key(self) -> &'static str { + #[cfg(test)] + pub(crate) fn key(self) -> &'static str { match self { Self::BitExact => "bit-exact", Self::Indeterminate => "indeterminate", @@ -75,20 +101,11 @@ impl StableNumericalOutcomeV1 { /// Sound-bound availability for a migrated numerical site. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] -pub enum NumericalBoundStatusV1 { +pub(crate) enum NumericalBoundStatusV1 { /// No sound bound has been admitted. Unavailable, } -impl NumericalBoundStatusV1 { - /// Stable manifest key. - pub fn key(self) -> &'static str { - match self { - Self::Unavailable => "unavailable", - } - } -} - /// Whether a stable profile may silently choose another decision path. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] @@ -128,14 +145,15 @@ impl NumericalCompatibilityReleaseIdV1 { /// Класс evidence, который package способен минтить для site (manifest-уровень). #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] -pub enum NumericalEvidenceClassV1 { +pub(crate) enum NumericalEvidenceClassV1 { /// Точное решение из конечного integer/байтового состояния. BitExact, } impl NumericalEvidenceClassV1 { /// Стабильный manifest key. - pub fn key(self) -> &'static str { + #[cfg(test)] + pub(crate) fn key(self) -> &'static str { match self { Self::BitExact => "bit-exact", } @@ -160,26 +178,27 @@ impl ReferenceProfileIdV1 { } } -/// Идентификатор canonical finite artifact. Ни один artifact не допущен в V1: -/// тип намеренно ненаселён — пустой список в manifest единственно представим, -/// фиктивные IDs невозможны по построению. +/// Internal V1 artifact identity. Тип намеренно ненаселён: adaptive Glow +/// registry не может приписать себе canonical finite artifact. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NumericalArtifactIdV1 {} +pub(crate) enum NumericalArtifactIdV1 {} impl NumericalArtifactIdV1 { /// Стабильный manifest key (недостижимо: тип ненаселён). - pub fn key(self) -> &'static str { + #[cfg(test)] + pub(crate) fn key(self) -> &'static str { match self {} } } /// Идентификатор зарегистрированного error bound. Не допущен в V1 (ненаселён). #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NumericalErrorBoundIdV1 {} +pub(crate) enum NumericalErrorBoundIdV1 {} impl NumericalErrorBoundIdV1 { /// Стабильный manifest key (недостижимо: тип ненаселён). - pub fn key(self) -> &'static str { + #[cfg(test)] + pub(crate) fn key(self) -> &'static str { match self {} } } @@ -187,23 +206,24 @@ impl NumericalErrorBoundIdV1 { /// Идентификатор runtime attestation. Не допущен до immutable attestation /// registry (#258): тип ненаселён, `PlatformCharacterized` непредставим. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NumericalRuntimeAttestationIdV1 {} +pub(crate) enum NumericalRuntimeAttestationIdV1 {} impl NumericalRuntimeAttestationIdV1 { /// Стабильный manifest key (недостижимо: тип ненаселён). - pub fn key(self) -> &'static str { + #[cfg(test)] + pub(crate) fn key(self) -> &'static str { match self {} } } -/// Machine-readable registry row required by research lock #281. +/// Internal adaptive-runtime registry row required by research lock #281. /// /// Текстовые поля (`operations`/`domain`/`branch_effect`/`boundary_corpus`/ -/// `runtime_matrix`) — human-readable research metadata; они НЕ входят в -/// canonical capability checksum preimage (#289). +/// `runtime_matrix`) — human-readable research metadata, не public capability +/// projection и не input runtime-решения. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] -pub struct NumericalSiteRecordV1 { +pub(crate) struct NumericalSiteRecordV1 { /// Stable site identity. pub site_id: NumericalSiteIdV1, /// Branch-sensitive operations (research metadata). @@ -234,82 +254,181 @@ pub struct NumericalSiteRecordV1 { pub fallback_status: NumericalFallbackStatusV1, } -// Enum identity and its registry row are emitted by one declaration. A new -// migrated site therefore cannot exist without machine-readable metadata. -macro_rules! define_numerical_registry_v1 { - ($( - $(#[$variant_meta:meta])* - $variant:ident => { - key: $key:literal, - operations: $operations:literal, - domain: $domain:literal, - branch_effect: $branch_effect:literal, - stable_outcomes: [$($stable_outcome:path),+ $(,)?], - compatibility_releases: [$($release:path),* $(,)?], - evidence_classes: [$($evidence_class:path),* $(,)?], - bound_status: $bound_status:path, - boundary_corpus: $boundary_corpus:literal, - runtime_matrix: $runtime_matrix:literal, - fallback_status: $fallback_status:path $(,)? +// One declaration emits both the internal adaptive-runtime V1 projection and +// the only public proof-capable V2 registry. Shared sites therefore cannot +// drift between runtime/plan validation and the package capability manifest. +macro_rules! define_numerical_registries { + ( + legacy { + $( + $(#[$legacy_meta:meta])* + $legacy_variant:ident => { + key: $legacy_key:literal, + operations: $legacy_operations:literal, + domain: $legacy_domain:literal, + branch_effect: $legacy_branch_effect:literal, + stable_outcomes: [$($legacy_stable:ident),+ $(,)?], + compatibility_releases: [$($legacy_release:path),* $(,)?], + evidence_classes: [$($legacy_evidence:ident),* $(,)?], + bound_status: $legacy_bound:ident, + boundary_corpus: $legacy_corpus:literal, + runtime_matrix: $legacy_matrix:literal, + fallback_status: $legacy_fallback:path $(,)? + } + ),+ $(,)? + } + proof { + $( + $(#[$proof_meta:meta])* + $proof_variant:ident => { + key: $proof_key:literal, + operations: $proof_operations:literal, + domain: $proof_domain:literal, + branch_effect: $proof_branch_effect:literal, + stable_outcomes: [$($proof_stable:ident),+ $(,)?], + compatibility_releases: [$($proof_release:path),* $(,)?], + evidence_classes: [$($proof_evidence:ident),+ $(,)?], + artifact_ids: [$($proof_artifact:path),+ $(,)?], + bound_ids: [$($proof_bound_id:path),+ $(,)?], + proof_ids: [$($proof_id:path),+ $(,)?], + bound_status: $proof_bound:ident, + boundary_corpus: $proof_corpus:literal, + runtime_matrix: $proof_matrix:literal, + fallback_status: $proof_fallback:path $(,)? + } + ),+ $(,)? } - ),+ $(,)?) => { + ) => { /// Зарегистрированный migrated site, где число влияет на semantic branch. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub enum NumericalSiteIdV1 { - $($(#[$variant_meta])* $variant),+ + $($(#[$legacy_meta])* $legacy_variant),+ } impl NumericalSiteIdV1 { /// Стабильный wire/registry key. pub fn key(self) -> &'static str { match self { - $(Self::$variant => $key),+ + $(Self::$legacy_variant => $legacy_key),+ + } + } + } + + /// Registered site in the single public proof-capable registry. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[non_exhaustive] + pub enum NumericalSiteIdV2 { + $($(#[$legacy_meta])* $legacy_variant),+, + $($(#[$proof_meta])* $proof_variant),+ + } + + impl NumericalSiteIdV2 { + /// Stable wire/registry key. + pub fn key(self) -> &'static str { + match self { + $(Self::$legacy_variant => $legacy_key),+, + $(Self::$proof_variant => $proof_key),+ } } } const NUMERICAL_REGISTRY_V1: &[NumericalSiteRecordV1] = &[ $(NumericalSiteRecordV1 { - site_id: NumericalSiteIdV1::$variant, - operations: $operations, - domain: $domain, - branch_effect: $branch_effect, - stable_outcomes: &[$($stable_outcome),+], - compatibility_releases: &[$($release),*], - evidence_classes: &[$($evidence_class),*], + site_id: NumericalSiteIdV1::$legacy_variant, + operations: $legacy_operations, + domain: $legacy_domain, + branch_effect: $legacy_branch_effect, + stable_outcomes: &[$(StableNumericalOutcomeV1::$legacy_stable),+], + compatibility_releases: &[$($legacy_release),*], + evidence_classes: &[$(NumericalEvidenceClassV1::$legacy_evidence),*], artifact_ids: &[], bound_ids: &[], runtime_attestations: &[], - bound_status: $bound_status, - boundary_corpus: $boundary_corpus, - runtime_matrix: $runtime_matrix, - fallback_status: $fallback_status, + bound_status: NumericalBoundStatusV1::$legacy_bound, + boundary_corpus: $legacy_corpus, + runtime_matrix: $legacy_matrix, + fallback_status: $legacy_fallback, + }),+ + ]; + + const NUMERICAL_REGISTRY_V2: &[NumericalSiteRecordV2] = &[ + $(NumericalSiteRecordV2 { + site_id: NumericalSiteIdV2::$legacy_variant, + operations: $legacy_operations, + domain: $legacy_domain, + branch_effect: $legacy_branch_effect, + stable_outcomes: &[$(StableNumericalOutcomeV2::$legacy_stable),+], + compatibility_releases: &[$($legacy_release),*], + evidence_classes: &[$(NumericalEvidenceClassV2::$legacy_evidence),*], + artifact_ids: &[], + bound_ids: &[], + proof_ids: &[], + runtime_attestations: &[], + bound_status: NumericalBoundStatusV2::$legacy_bound, + boundary_corpus: $legacy_corpus, + runtime_matrix: $legacy_matrix, + fallback_status: $legacy_fallback, + }),+, + $(NumericalSiteRecordV2 { + site_id: NumericalSiteIdV2::$proof_variant, + operations: $proof_operations, + domain: $proof_domain, + branch_effect: $proof_branch_effect, + stable_outcomes: &[$(StableNumericalOutcomeV2::$proof_stable),+], + compatibility_releases: &[$($proof_release),*], + evidence_classes: &[$(NumericalEvidenceClassV2::$proof_evidence),+], + artifact_ids: &[$($proof_artifact),+], + bound_ids: &[$($proof_bound_id),+], + proof_ids: &[$($proof_id),+], + runtime_attestations: &[], + bound_status: NumericalBoundStatusV2::$proof_bound, + boundary_corpus: $proof_corpus, + runtime_matrix: $proof_matrix, + fallback_status: $proof_fallback, }),+ ]; }; } -define_numerical_registry_v1! { - /// Glow: первый state, достигший target, либо глобальный максимум. - GlowTargetOrMaximumV1 => { - key: "glow-target-or-maximum-v1", - operations: "CAM16 forward powf; CAM16-UCS J-prime; abs; target >=; maximum ordering", - domain: "encoded sRGB8 point screen states -> diagnostic CAM16-UCS delta J-prime", - branch_effect: "first reached state versus global maximum and reached/unreachable status", - stable_outcomes: [ - StableNumericalOutcomeV1::BitExact, - StableNumericalOutcomeV1::Indeterminate, - ], - compatibility_releases: [ - NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1, - ], - evidence_classes: [NumericalEvidenceClassV1::BitExact], - bound_status: NumericalBoundStatusV1::Unavailable, - boundary_corpus: "glow stable-indeterminate; exact no-op; finite-state compositor; half-tie alpha", - runtime_matrix: "active: native x86_64 + wasm32; native arm64 required before any cross-runtime CAM16 decision claim; exact bytes only for compositor", - fallback_status: NumericalFallbackStatusV1::None, - }, +define_numerical_registries! { + legacy { + /// Glow: первый state, достигший target, либо глобальный максимум. + GlowTargetOrMaximumV1 => { + key: "glow-target-or-maximum-v1", + operations: "CAM16 forward powf; CAM16-UCS J-prime; abs; target >=; maximum ordering", + domain: "encoded sRGB8 point screen states -> diagnostic CAM16-UCS delta J-prime", + branch_effect: "first reached state versus global maximum and reached/unreachable status", + stable_outcomes: [BitExact, Indeterminate], + compatibility_releases: [ + NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1, + ], + evidence_classes: [BitExact], + bound_status: Unavailable, + boundary_corpus: "glow stable-indeterminate; exact no-op; finite-state compositor; half-tie alpha", + runtime_matrix: "active: native x86_64 + wasm32; native arm64 required before any cross-runtime CAM16 decision claim; exact bytes only for compositor", + fallback_status: NumericalFallbackStatusV1::None, + }, + } + proof { + /// WCAG 2.2 assessment of one final sRGB8 pair. + Wcag22Srgb8ContrastV1 => { + key: "wcag22-srgb8-contrast-v1", + operations: "integer threshold laws over Q55 outward luminance bounds; both orientations", + domain: "final foreground/background sRGB8 pair + explicit criterion -> atomic assessment", + branch_effect: "proved Pass versus proved Fail; full-domain proof rejects unresolved artifact", + stable_outcomes: [CanonicalFiniteBounded], + compatibility_releases: [], + evidence_classes: [CanonicalFiniteBounded], + artifact_ids: [NumericalArtifactIdV2::Wcag22Srgb8LuminanceQ55V1], + bound_ids: [NumericalErrorBoundIdV2::Wcag22Srgb8OutwardQ55V1], + proof_ids: [NumericalProofIdV2::Wcag22Srgb8FullDomainQ55V1], + bound_status: Available, + boundary_corpus: "anti-epsilon witnesses; exact 21:1; threshold-equality; full 16.7M domain scan", + runtime_matrix: "native + wasm32 integer-only comparisons; adapters transport terminal results", + fallback_status: NumericalFallbackStatusV1::None, + }, + } } /// Registry уже мигрированных typed-decision sites V1. @@ -317,7 +436,8 @@ define_numerical_registry_v1! { /// Это не заявление о завершённом аудите старых `f64` branches: его владелец — /// #291. Новый site, переводимый на stable typed decision, обязан получить /// строку до изменения runtime behavior. -pub fn numerical_registry_v1() -> &'static [NumericalSiteRecordV1] { +#[cfg(test)] +pub(crate) fn numerical_registry_v1() -> &'static [NumericalSiteRecordV1] { NUMERICAL_REGISTRY_V1 } @@ -328,26 +448,211 @@ pub(crate) fn registry_row(site_id: NumericalSiteIdV1) -> Option<&'static Numeri .find(|row| row.site_id == site_id) } -// ── Package capability manifest (#289) ────────────────────────────────────── +// ── Package capability manifest encoding (#289) ───────────────────────────── + +/// Length-prefixed запись: u32 LE длина + байты. Единый примитив canonical +/// encoding manifest/plan (versioned контракт, не JSON). +pub(crate) fn push_len_prefixed(buffer: &mut Vec, bytes: &[u8]) { + buffer.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + buffer.extend_from_slice(bytes); +} + +/// Отсортированный по UTF-8 bytes список ключей: u32 LE count (явный и для +/// пустого списка) + length-prefixed элементы. Дубликаты запрещены by +/// construction registry (закреплено тестом уникальности). +fn push_sorted_key_list(buffer: &mut Vec, keys: &mut Vec<&'static str>) { + keys.sort_unstable(); + buffer.extend_from_slice(&(keys.len() as u32).to_le_bytes()); + for key in keys.iter() { + push_len_prefixed(buffer, key.as_bytes()); + } +} + +// ── Proof-capable package capability manifest V2 (#284) ──────────────────── + +/// Версия единственной публичной proof-capable capability-схемы. +pub const NUMERICAL_CAPABILITY_SCHEMA_VERSION_V2: u32 = 2; + +/// Домен-сепаратор canonical checksum preimage V2. +const CAPABILITY_CHECKSUM_DOMAIN_V2: &[u8] = b"labcolors.numerical-capability.v2"; + +/// Stable outcomes admitted by the proof-capable V2 registry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum StableNumericalOutcomeV2 { + /// Determinate branch follows from exact finite/integer/rational evidence. + BitExact, + /// Determinate branch follows from a canonical finite outward-bound artifact. + CanonicalFiniteBounded, + /// No semantic branch is selected. + Indeterminate, +} + +impl StableNumericalOutcomeV2 { + /// Stable manifest key. + pub fn key(self) -> &'static str { + match self { + Self::BitExact => "bit-exact", + Self::CanonicalFiniteBounded => "canonical-finite-bounded", + Self::Indeterminate => "indeterminate", + } + } +} + +/// Sound-bound availability in the V2 registry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum NumericalBoundStatusV2 { + /// Registered sound bound is shipped and independently verified. + Available, + /// No sound bound has been admitted. + Unavailable, +} + +impl NumericalBoundStatusV2 { + /// Stable manifest key. + pub fn key(self) -> &'static str { + match self { + Self::Available => "available", + Self::Unavailable => "unavailable", + } + } +} + +/// Evidence classes the V2 package can mint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum NumericalEvidenceClassV2 { + /// Exact decision over finite/integer state. + BitExact, + /// Decision from a registered canonical finite artifact and outward law. + CanonicalFiniteBounded, +} + +impl NumericalEvidenceClassV2 { + /// Stable manifest key. + pub fn key(self) -> &'static str { + match self { + Self::BitExact => "bit-exact", + Self::CanonicalFiniteBounded => "canonical-finite-bounded", + } + } +} + +/// Canonical finite artifact identities admitted by V2. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum NumericalArtifactIdV2 { + /// Q55 outward tables for WCAG 2.2 sRGB8 relative luminance. + Wcag22Srgb8LuminanceQ55V1, +} + +impl NumericalArtifactIdV2 { + /// Stable manifest key. + pub fn key(self) -> &'static str { + match self { + Self::Wcag22Srgb8LuminanceQ55V1 => "wcag22-srgb8-luminance-q55-v1", + } + } +} + +/// Registered error-bound identities admitted by V2. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum NumericalErrorBoundIdV2 { + /// Integer Q55 outward-bound and threshold laws for WCAG 3.0/4.5. + Wcag22Srgb8OutwardQ55V1, +} + +impl NumericalErrorBoundIdV2 { + /// Stable manifest key. + pub fn key(self) -> &'static str { + match self { + Self::Wcag22Srgb8OutwardQ55V1 => "wcag22-srgb8-outward-q55-v1", + } + } +} + +/// Replayable proof identities admitted by V2. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum NumericalProofIdV2 { + /// Full sRGB8-domain proof with zero unresolved WCAG 3.0/4.5 decisions. + Wcag22Srgb8FullDomainQ55V1, +} + +impl NumericalProofIdV2 { + /// Stable manifest key. + pub fn key(self) -> &'static str { + match self { + Self::Wcag22Srgb8FullDomainQ55V1 => "wcag22-srgb8-full-domain-q55-v1", + } + } +} + +/// Runtime attestation identities admitted by V2. Empty until #258. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum NumericalRuntimeAttestationIdV2 {} + +impl NumericalRuntimeAttestationIdV2 { + /// Stable manifest key (unreachable while the type is uninhabited). + pub fn key(self) -> &'static str { + match self {} + } +} -/// Версия capability-схемы. Независима от версий conformance pack и -/// release-manifest (три разных version domain, #289). -pub const NUMERICAL_CAPABILITY_SCHEMA_VERSION_V1: u32 = 1; +/// Machine-readable proof-capable registry row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct NumericalSiteRecordV2 { + /// Stable site identity. + pub site_id: NumericalSiteIdV2, + /// Branch-sensitive operations (research metadata). + pub operations: &'static str, + /// Input/output domain (research metadata). + pub domain: &'static str, + /// Semantic branch affected by the value (research metadata). + pub branch_effect: &'static str, + /// Lawful stable outcomes. + pub stable_outcomes: &'static [StableNumericalOutcomeV2], + /// Registered compatibility releases. + pub compatibility_releases: &'static [NumericalCompatibilityReleaseIdV1], + /// Evidence classes mintable for the site. + pub evidence_classes: &'static [NumericalEvidenceClassV2], + /// Canonical finite artifacts. + pub artifact_ids: &'static [NumericalArtifactIdV2], + /// Registered error bounds. + pub bound_ids: &'static [NumericalErrorBoundIdV2], + /// Replayable proof artifacts. + pub proof_ids: &'static [NumericalProofIdV2], + /// Runtime attestations (empty until #258). + pub runtime_attestations: &'static [NumericalRuntimeAttestationIdV2], + /// Sound-bound availability (research metadata). + pub bound_status: NumericalBoundStatusV2, + /// Executable boundary corpus identifiers (research metadata). + pub boundary_corpus: &'static str, + /// Required cross-runtime comparison scope (research metadata). + pub runtime_matrix: &'static str, + /// Fallback status. + pub fallback_status: NumericalFallbackStatusV1, +} -/// Домен-сепаратор canonical checksum preimage. -const CAPABILITY_CHECKSUM_DOMAIN_V1: &[u8] = b"labcolors.numerical-capability.v1"; +/// Registry of proof-capable typed-decision sites. +pub fn numerical_registry_v2() -> &'static [NumericalSiteRecordV2] { + NUMERICAL_REGISTRY_V2 +} -/// Покрытие registry данным manifest. +/// V2 registry coverage. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] -pub enum NumericalRegistryCoverageV1 { - /// Перечислены только уже мигрированные sites (не весь core). +pub enum NumericalRegistryCoverageV2 { + /// Only migrated sites are listed; this is not a whole-core audit claim. MigratedSitesOnlyV1, } -impl NumericalRegistryCoverageV1 { - /// Стабильный manifest key. `CompleteV1` недоступен до закрытия #291 и - /// потому отсутствует в типе V1. +impl NumericalRegistryCoverageV2 { + /// Stable manifest key. pub fn key(self) -> &'static str { match self { Self::MigratedSitesOnlyV1 => "migrated-sites-only-v1", @@ -355,87 +660,65 @@ impl NumericalRegistryCoverageV1 { } } -/// Capability одного site — проекция registry-строки без research-текстов и -/// без выбранного mode (manifest описывает возможности сборки, не выбор клиента). +/// Proof-capable capability projection for one V2 registry site. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct NumericalSiteCapabilityV1 { +pub struct NumericalSiteCapabilityV2 { /// Site identity. - pub site_id: NumericalSiteIdV1, + pub site_id: NumericalSiteIdV2, /// Lawful stable outcomes. - pub stable_outcomes: &'static [StableNumericalOutcomeV1], + pub stable_outcomes: &'static [StableNumericalOutcomeV2], /// Registered compatibility releases. pub compatibility_releases: &'static [NumericalCompatibilityReleaseIdV1], - /// Минтимые классы evidence. - pub evidence_classes: &'static [NumericalEvidenceClassV1], - /// Canonical finite artifacts (пусто в V1). - pub artifact_ids: &'static [NumericalArtifactIdV1], - /// Registered error bounds (пусто в V1). - pub bound_ids: &'static [NumericalErrorBoundIdV1], - /// Runtime attestations (пусто до #258). - pub runtime_attestations: &'static [NumericalRuntimeAttestationIdV1], + /// Mintable evidence classes. + pub evidence_classes: &'static [NumericalEvidenceClassV2], + /// Canonical finite artifacts. + pub artifact_ids: &'static [NumericalArtifactIdV2], + /// Registered error bounds. + pub bound_ids: &'static [NumericalErrorBoundIdV2], + /// Replayable proof artifacts. + pub proof_ids: &'static [NumericalProofIdV2], + /// Runtime attestations. + pub runtime_attestations: &'static [NumericalRuntimeAttestationIdV2], } -/// Переносимый drift-checksum typed capability projection — НЕ -/// security/certificate/cache identity: exact rows остаются authority, а -/// SHA-256 сырых байтов полного artifact — отдельная integrity-гарантия. +/// Drift checksum for the V2 canonical capability projection. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct NumericalCapabilityChecksumV1(u32); +pub struct NumericalCapabilityChecksumV2(u32); -impl NumericalCapabilityChecksumV1 { - /// FNV-1a-32 canonical preimage (dependency-free, как `packDigest`). +impl NumericalCapabilityChecksumV2 { + /// FNV-1a-32 over the V2 canonical preimage. pub fn from_preimage(preimage: &[u8]) -> Self { Self(crate::hash::fnv1a_32(preimage)) } - /// Каноническая 8-hex запись (lowercase). + /// Canonical lowercase eight-hex representation. pub fn hex(self) -> String { format!("{:08x}", self.0) } } -/// Package capability manifest: статическое свойство сборки. Не содержит -/// выбранного mode; rows генерируются только из core registry SSOT. +/// Proof-capable package manifest generated only from the V2 registry SSOT. #[derive(Debug, Clone, PartialEq)] -pub struct NumericalCapabilityManifestV1 { - /// Версия capability-схемы. +pub struct NumericalCapabilityManifestV2 { + /// Capability schema version. pub schema_version: u32, - /// Покрытие registry. - pub coverage: NumericalRegistryCoverageV1, - /// Capability rows, отсортированные по UTF-8 bytes `site_id.key()`. - pub sites: Vec, - /// Drift-checksum canonical projection. - pub checksum: NumericalCapabilityChecksumV1, + /// Registry coverage. + pub coverage: NumericalRegistryCoverageV2, + /// Canonically sorted site capabilities. + pub sites: Vec, + /// Drift checksum of the canonical projection. + pub checksum: NumericalCapabilityChecksumV2, } -/// Length-prefixed запись: u32 LE длина + байты. Единый примитив canonical -/// encoding manifest/plan (versioned контракт, не JSON). -pub(crate) fn push_len_prefixed(buffer: &mut Vec, bytes: &[u8]) { - buffer.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); - buffer.extend_from_slice(bytes); -} - -/// Отсортированный по UTF-8 bytes список ключей: u32 LE count (явный и для -/// пустого списка) + length-prefixed элементы. Дубликаты запрещены by -/// construction registry (закреплено тестом уникальности). -fn push_sorted_key_list(buffer: &mut Vec, keys: &mut Vec<&'static str>) { - keys.sort_unstable(); - buffer.extend_from_slice(&(keys.len() as u32).to_le_bytes()); - for key in keys.iter() { - push_len_prefixed(buffer, key.as_bytes()); - } -} - -impl NumericalCapabilityManifestV1 { - /// Canonical checksum preimage (#289): versioned length-prefixed binary - /// encoding. В preimage НЕ входят checksum, config/plan, версии - /// core/conformance/release, счётчики векторов, JSON-форматирование и - /// human-readable research-тексты. +impl NumericalCapabilityManifestV2 { + /// Versioned length-prefixed canonical checksum preimage. `proof_ids` + /// кодируются после `bound_ids` и до runtime attestations. pub fn canonical_checksum_preimage(&self) -> Vec { let mut buffer = Vec::new(); - push_len_prefixed(&mut buffer, CAPABILITY_CHECKSUM_DOMAIN_V1); + push_len_prefixed(&mut buffer, CAPABILITY_CHECKSUM_DOMAIN_V2); buffer.extend_from_slice(&self.schema_version.to_le_bytes()); push_len_prefixed(&mut buffer, self.coverage.key().as_bytes()); - let mut sites: Vec<&NumericalSiteCapabilityV1> = self.sites.iter().collect(); + let mut sites: Vec<&NumericalSiteCapabilityV2> = self.sites.iter().collect(); sites.sort_unstable_by_key(|site| site.site_id.key().as_bytes()); buffer.extend_from_slice(&(sites.len() as u32).to_le_bytes()); for site in sites { @@ -464,6 +747,10 @@ impl NumericalCapabilityManifestV1 { &mut buffer, &mut site.bound_ids.iter().map(|v| v.key()).collect(), ); + push_sorted_key_list( + &mut buffer, + &mut site.proof_ids.iter().map(|v| v.key()).collect(), + ); push_sorted_key_list( &mut buffer, &mut site.runtime_attestations.iter().map(|v| v.key()).collect(), @@ -473,30 +760,30 @@ impl NumericalCapabilityManifestV1 { } } -/// Capability manifest текущей сборки — единственная projection core registry -/// SSOT. Adapters не держат рукописной копии. -pub fn numerical_capability_manifest_v1() -> NumericalCapabilityManifestV1 { - let mut sites: Vec = NUMERICAL_REGISTRY_V1 +/// Capability manifest for the proof-capable V2 registry. +pub fn numerical_capability_manifest_v2() -> NumericalCapabilityManifestV2 { + let mut sites: Vec = NUMERICAL_REGISTRY_V2 .iter() - .map(|row| NumericalSiteCapabilityV1 { + .map(|row| NumericalSiteCapabilityV2 { site_id: row.site_id, stable_outcomes: row.stable_outcomes, compatibility_releases: row.compatibility_releases, evidence_classes: row.evidence_classes, artifact_ids: row.artifact_ids, bound_ids: row.bound_ids, + proof_ids: row.proof_ids, runtime_attestations: row.runtime_attestations, }) .collect(); sites.sort_unstable_by_key(|site| site.site_id.key().as_bytes()); - let mut manifest = NumericalCapabilityManifestV1 { - schema_version: NUMERICAL_CAPABILITY_SCHEMA_VERSION_V1, - coverage: NumericalRegistryCoverageV1::MigratedSitesOnlyV1, + let mut manifest = NumericalCapabilityManifestV2 { + schema_version: NUMERICAL_CAPABILITY_SCHEMA_VERSION_V2, + coverage: NumericalRegistryCoverageV2::MigratedSitesOnlyV1, sites, - checksum: NumericalCapabilityChecksumV1(0), + checksum: NumericalCapabilityChecksumV2(0), }; manifest.checksum = - NumericalCapabilityChecksumV1::from_preimage(&manifest.canonical_checksum_preimage()); + NumericalCapabilityChecksumV2::from_preimage(&manifest.canonical_checksum_preimage()); manifest } @@ -508,8 +795,8 @@ pub fn numerical_capability_manifest_v1() -> NumericalCapabilityManifestV1 { /// что истинное значение лежит внутри. Determinate evidence из интервала не /// изготовляется: он живёт только внутри /// [`NumericalIndeterminacyV1::IntervalOverlap`]. Bounded determinate evidence -/// вернётся в #284/#291 только вместе с зарегистрированным verifier и -/// bound/artifact identity. +/// минтится отдельно только для proof-capable registry V2 и никогда не выводится +/// из caller-created диагностического интервала. #[derive(Debug, Clone, Copy, PartialEq)] pub struct OutwardIntervalV1 { lower: f64, @@ -583,10 +870,9 @@ pub struct EvidenceSeal { _private: (), } -/// Запечатанное evidence determinate-решения. В V1 минтится только реально -/// admitted `BitExact`; bounded/canonical-finite варианты появятся вместе с -/// зарегистрированным verifier и bound/artifact identity (#284/#291) — -/// фиктивные IDs запрещены. +/// Запечатанное evidence determinate-решения. Опубликованный BitExact-путь +/// остаётся привязан к registry V1; новый bounded-вариант может ссылаться +/// только на proof-capable identity из registry V2. #[derive(Debug, Clone, Copy, PartialEq)] #[non_exhaustive] pub enum NumericalDecisionEvidenceV1 { @@ -598,6 +884,9 @@ pub enum NumericalDecisionEvidenceV1 { /// Печать: внешняя конструкция невозможна (тип поля приватен). _seal: EvidenceSeal, }, + /// Решение следует из зарегистрированного canonical finite artifact: + /// outward-границы + целочисленные пороговые законы (#284). + CanonicalFiniteBounded(crate::wcag22_evidence::CanonicalFiniteBoundedEvidenceV1), } impl NumericalDecisionEvidenceV1 { @@ -605,6 +894,7 @@ impl NumericalDecisionEvidenceV1 { pub fn class_key(&self) -> &'static str { match self { Self::BitExact { .. } => "bit-exact", + Self::CanonicalFiniteBounded(_) => "canonical-finite-bounded", } } } @@ -658,6 +948,7 @@ fn mint_bit_exact_for_row( #[non_exhaustive] pub enum NumericalDecisionV1 { /// Решение принято под запечатанным evidence. + #[non_exhaustive] Determinate { /// Зарегистрированный site. site_id: NumericalSiteIdV1, @@ -667,6 +958,7 @@ pub enum NumericalDecisionV1 { evidence: NumericalDecisionEvidenceV1, }, /// Явно выбранный зарегистрированный прежний алгоритм. + #[non_exhaustive] Compatibility { /// Зарегистрированный site. site_id: NumericalSiteIdV1, @@ -678,6 +970,7 @@ pub enum NumericalDecisionV1 { provenance: LegacyPlatformDependentV1, }, /// Stable branch не выбран. + #[non_exhaustive] Indeterminate { /// Зарегистрированный site. site_id: NumericalSiteIdV1, @@ -740,6 +1033,42 @@ mod tests { } } + #[test] + fn unified_registry_projects_runtime_glow_and_proof_bound_wcag() { + assert_eq!(numerical_registry_v1().len(), 1); + let rows = numerical_registry_v2(); + assert_eq!(rows.len(), 2); + let mut site_keys = std::collections::HashSet::new(); + for row in rows { + assert!( + site_keys.insert(row.site_id.key()), + "duplicate V2 numerical site wire key: {}", + row.site_id.key() + ); + } + let wcag = rows + .iter() + .find(|row| row.site_id == NumericalSiteIdV2::Wcag22Srgb8ContrastV1) + .expect("WCAG22 site обязан быть зарегистрирован в V2"); + assert_eq!( + wcag.evidence_classes, + [NumericalEvidenceClassV2::CanonicalFiniteBounded] + ); + assert_eq!( + wcag.artifact_ids, + [NumericalArtifactIdV2::Wcag22Srgb8LuminanceQ55V1] + ); + assert_eq!( + wcag.bound_ids, + [NumericalErrorBoundIdV2::Wcag22Srgb8OutwardQ55V1] + ); + assert_eq!( + wcag.proof_ids, + [NumericalProofIdV2::Wcag22Srgb8FullDomainQ55V1] + ); + assert_eq!(wcag.bound_status, NumericalBoundStatusV2::Available); + } + /// Диагностический интервал проверяет только форму. #[test] fn diagnostic_interval_validates_shape_only() { @@ -776,9 +1105,9 @@ mod tests { /// нечувствителен к порядку rows (сортировка внутри preimage). #[test] fn capability_checksum_is_canonical_and_tamper_sensitive() { - let manifest = numerical_capability_manifest_v1(); + let manifest = numerical_capability_manifest_v2(); let recomputed = - NumericalCapabilityChecksumV1::from_preimage(&manifest.canonical_checksum_preimage()); + NumericalCapabilityChecksumV2::from_preimage(&manifest.canonical_checksum_preimage()); assert_eq!(manifest.checksum, recomputed); assert_eq!(manifest.checksum.hex().len(), 8); @@ -786,7 +1115,7 @@ mod tests { let mut tampered = manifest.clone(); tampered.schema_version += 1; assert_ne!( - NumericalCapabilityChecksumV1::from_preimage(&tampered.canonical_checksum_preimage()), + NumericalCapabilityChecksumV2::from_preimage(&tampered.canonical_checksum_preimage()), manifest.checksum ); @@ -794,10 +1123,69 @@ mod tests { let mut emptied = manifest.clone(); emptied.sites.clear(); assert_ne!( - NumericalCapabilityChecksumV1::from_preimage(&emptied.canonical_checksum_preimage()), + NumericalCapabilityChecksumV2::from_preimage(&emptied.canonical_checksum_preimage()), + manifest.checksum + ); + + // Tamper: удаление proof identity меняет checksum независимо от + // остальных capability-полей строки. + let mut proof_tampered = manifest.clone(); + let wcag = proof_tampered + .sites + .iter_mut() + .find(|site| site.site_id == NumericalSiteIdV2::Wcag22Srgb8ContrastV1) + .expect("WCAG22 capability row"); + wcag.proof_ids = &[]; + assert_ne!( + NumericalCapabilityChecksumV2::from_preimage( + &proof_tampered.canonical_checksum_preimage() + ), manifest.checksum ); } + + /// Exact independent oracle for the `proof_ids` list position and bytes. + /// This intentionally does not call either production encoding helper. + #[test] + fn proof_ids_have_independent_canonical_encoding_guard() { + fn push_expected_len_prefixed(buffer: &mut Vec, value: &[u8]) { + buffer.extend_from_slice(&(value.len() as u32).to_le_bytes()); + buffer.extend_from_slice(value); + } + + let manifest = NumericalCapabilityManifestV2 { + schema_version: 2, + coverage: NumericalRegistryCoverageV2::MigratedSitesOnlyV1, + sites: vec![NumericalSiteCapabilityV2 { + site_id: NumericalSiteIdV2::Wcag22Srgb8ContrastV1, + stable_outcomes: &[], + compatibility_releases: &[], + evidence_classes: &[], + artifact_ids: &[], + bound_ids: &[], + proof_ids: &[NumericalProofIdV2::Wcag22Srgb8FullDomainQ55V1], + runtime_attestations: &[], + }], + checksum: NumericalCapabilityChecksumV2(0), + }; + + let mut expected = Vec::new(); + push_expected_len_prefixed(&mut expected, b"labcolors.numerical-capability.v2"); + expected.extend_from_slice(&2_u32.to_le_bytes()); + push_expected_len_prefixed(&mut expected, b"migrated-sites-only-v1"); + expected.extend_from_slice(&1_u32.to_le_bytes()); + push_expected_len_prefixed(&mut expected, b"wcag22-srgb8-contrast-v1"); + // stable outcomes, releases, evidence, artifacts, then bounds. + for _ in 0..5 { + expected.extend_from_slice(&0_u32.to_le_bytes()); + } + expected.extend_from_slice(&1_u32.to_le_bytes()); + push_expected_len_prefixed(&mut expected, b"wcag22-srgb8-full-domain-q55-v1"); + // runtime attestations follow proof IDs. + expected.extend_from_slice(&0_u32.to_le_bytes()); + + assert_eq!(manifest.canonical_checksum_preimage(), expected); + } } #[cfg(test)] @@ -849,37 +1237,46 @@ mod red_292_tests { )); } - /// RED #292/#289: capability manifest — core registry projection с - /// каноническим checksum; coverage MigratedSitesOnlyV1, mode отсутствует. + /// RED #292/#289/#284: единственный capability manifest — proof-capable + /// V2 projection с каноническим checksum; выбранный mode отсутствует. #[test] fn capability_manifest_is_canonical_registry_projection() { - let manifest = numerical_capability_manifest_v1(); + let manifest = numerical_capability_manifest_v2(); assert!(matches!( manifest.coverage, - NumericalRegistryCoverageV1::MigratedSitesOnlyV1 + NumericalRegistryCoverageV2::MigratedSitesOnlyV1 )); - assert_eq!(manifest.sites.len(), 1); - let site = &manifest.sites[0]; - assert_eq!(site.site_id, NumericalSiteIdV1::GlowTargetOrMaximumV1); + assert_eq!(manifest.sites.len(), 2); + let site = manifest + .sites + .iter() + .find(|site| site.site_id == NumericalSiteIdV2::GlowTargetOrMaximumV1) + .expect("Glow capability row"); + assert_eq!(site.site_id, NumericalSiteIdV2::GlowTargetOrMaximumV1); assert_eq!( site.stable_outcomes, [ - StableNumericalOutcomeV1::BitExact, - StableNumericalOutcomeV1::Indeterminate, + StableNumericalOutcomeV2::BitExact, + StableNumericalOutcomeV2::Indeterminate, ] ); assert_eq!( site.compatibility_releases, [NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1,] ); - assert_eq!(site.evidence_classes, [NumericalEvidenceClassV1::BitExact]); + assert_eq!(site.evidence_classes, [NumericalEvidenceClassV2::BitExact]); assert!(site.artifact_ids.is_empty()); assert!(site.bound_ids.is_empty()); + assert!(site.proof_ids.is_empty()); assert!(site.runtime_attestations.is_empty()); // Checksum детерминирован и воспроизводим из canonical preimage. + assert!(manifest.sites.iter().any(|site| { + site.site_id == NumericalSiteIdV2::Wcag22Srgb8ContrastV1 + && site.proof_ids == [NumericalProofIdV2::Wcag22Srgb8FullDomainQ55V1] + })); assert_eq!( manifest.checksum, - NumericalCapabilityChecksumV1::from_preimage(&manifest.canonical_checksum_preimage()) + NumericalCapabilityChecksumV2::from_preimage(&manifest.canonical_checksum_preimage()) ); } diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index 213905a6..029d3846 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -4205,12 +4205,12 @@ mod tests { // Каждый invocation соответствует manifest-supported site: mode/release // объявлены capability-строкой сборки (registry SSOT). - let manifest = crate::numerics::numerical_capability_manifest_v1(); + let manifest = crate::numerics::numerical_capability_manifest_v2(); for inv in plan.invocations() { let site = manifest .sites .iter() - .find(|site| site.site_id == inv.site_id) + .find(|site| site.site_id.key() == inv.site_id.key()) .expect("site каждого invocation присутствует в capability manifest"); match inv.mode { NumericalExecutionModeV1::StableOnly => assert!( diff --git a/crates/labcolors-core/src/spaces/srgb.rs b/crates/labcolors-core/src/spaces/srgb.rs index 2d8b1cb1..8df5aa8e 100644 --- a/crates/labcolors-core/src/spaces/srgb.rs +++ b/crates/labcolors-core/src/spaces/srgb.rs @@ -9,6 +9,8 @@ //! transitive crates) and guarantees exact reproducibility with other //! CSS-based pipelines. +use crate::srgb8::hex_bytes; + /// D65 white point (normalized to Y = 1.0). /// /// Derived from the 4-digit chromaticity (0.3127, 0.3290) of IEC 61966-2-1 / @@ -128,22 +130,6 @@ pub(crate) fn decode_8bit(byte: u8) -> f64 { // Public helpers // ------------------------------------------------------------------ -/// Разбор `#RRGGBB` → три байта. Единственная реализация hex-парсинга — -/// линейный и кодированный варианты различаются только декодом поверх байтов. -fn hex_bytes(hex: &str) -> Result<[u8; 3], String> { - let hex = hex.trim_start_matches('#'); - // `len()` is a *byte* count and the slices below cut on byte indices, so a - // non-ASCII string of 6 bytes (e.g. "€€") would pass a bare length check yet - // slice mid-codepoint and panic. Require ASCII so every byte is a char - // boundary; non-hex ASCII is still rejected by `from_str_radix` below. - if hex.len() != 6 || !hex.is_ascii() { - return Err(format!("expected #RRGGBB, got #{}", hex)); - } - let parse = - |s: &str| u8::from_str_radix(s, 16).map_err(|e| format!("invalid hex '{}': {}", s, e)); - Ok([parse(&hex[0..2])?, parse(&hex[2..4])?, parse(&hex[4..6])?]) -} - /// Parse `#RRGGBB` → linear sRGB `[r, g, b]` in `[0, 1]`. pub fn srgb_from_hex(hex: &str) -> Result<[f64; 3], String> { let [r, g, b] = hex_bytes(hex)?; diff --git a/crates/labcolors-core/src/srgb8.rs b/crates/labcolors-core/src/srgb8.rs new file mode 100644 index 00000000..9249ea2d --- /dev/null +++ b/crates/labcolors-core/src/srgb8.rs @@ -0,0 +1,33 @@ +//! Exact encoded-sRGB8 transport primitives shared by colour math and proofs. + +/// Parse optional-`#` `RRGGBB` into the exact three encoded bytes. +/// +/// Public APIs choose their own transport strictness before calling this SSOT. +/// ASCII is checked before byte slicing, so arbitrary public Unicode input +/// returns `Err` instead of panicking at a non-character boundary. +pub(crate) fn hex_bytes(hex: &str) -> Result<[u8; 3], String> { + let hex = hex.strip_prefix('#').unwrap_or(hex); + if hex.len() != 6 || !hex.is_ascii() { + return Err(format!("expected #RRGGBB, got #{hex}")); + } + let parse = |value: &str| u8::from_str_radix(value, 16).map_err(|error| error.to_string()); + Ok([parse(&hex[0..2])?, parse(&hex[2..4])?, parse(&hex[4..6])?]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wcag22_transport_parser_preserves_rgb_and_rejects_unicode_without_panic() { + assert_eq!(hex_bytes("#1A2B3C").unwrap(), [0x1A, 0x2B, 0x3C]); + for invalid in ["€€", "#€€", "ééé", "##1A2B3C"] { + assert!(hex_bytes(invalid).is_err()); + } + } + + #[test] + fn public_srgb_parser_rejects_a_repeated_hash_prefix() { + assert!(crate::spaces::srgb::srgb_encoded_from_hex("##1A2B3C").is_err()); + } +} diff --git a/crates/labcolors-core/src/wcag22.rs b/crates/labcolors-core/src/wcag22.rs new file mode 100644 index 00000000..7a45de4d --- /dev/null +++ b/crates/labcolors-core/src/wcag22.rs @@ -0,0 +1,427 @@ +//! Нормативная оценка WCAG 2.2 для одной финальной пары sRGB8 (#284). +//! +//! Это additive-путь: он не меняет legacy `crate::wcag`, solver или adaptive +//! runtime. Решение принимается только целочисленными сравнениями над +//! закоммиченными Q55 outward-границами. `f64`, `powf`, epsilon и отображаемое +//! округление в verdict не участвуют. + +use core::fmt; + +use crate::numerics::{ + NumericalArtifactIdV2, NumericalDecisionEvidenceV1, NumericalErrorBoundIdV2, NumericalProofIdV2, +}; + +#[path = "wcag22/kernel.rs"] +mod kernel; +#[path = "wcag22/q55_data.rs"] +mod q55_data; + +pub use kernel::{evaluate_wcag22_hex, evaluate_wcag22_srgb8}; + +const PROFILE_SOURCE_JSON: &str = include_str!("../contracts/wcag22-srgb8-v1.json"); +const PROOF_SOURCE_JSON: &str = include_str!("../contracts/wcag22-srgb8-q55-proof-v1.json"); +const PROOF_SOURCE_SHA256: &str = + "c4a35a902ea49729704d05c2a9a07530a1731ebd4ff7325b5e5baf261fbe7b9e"; +const PROOF_PAYLOAD_SHA256: &str = + "fa10908a1960e51b122a11ca0413083ab67749c79161c8b5d22a6c0c69ce71fb"; +const VERIFIER_SHA256: &str = "8757b348b99926700c6d9854ed4ae515d4c676b31c7924b0812a3745f2181221"; + +/// Идентификатор immutable normative profile. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Wcag22ProfileIdV1 { + /// Project profile applying the WCAG 2.2 Recommendation 2024-12-12 to + /// final encoded-sRGB8 bytes supplied by the client. + Wcag22Srgb8ContrastV1, +} + +impl Wcag22ProfileIdV1 { + /// Стабильный wire key. + pub fn key(self) -> &'static str { + match self { + Self::Wcag22Srgb8ContrastV1 => "wcag22-srgb8-contrast-v1", + } + } +} + +/// Связка normative source, finite artifact и bound law. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Wcag22ProfileV1 { + /// Версия JSON-схемы профиля. + pub schema_version: u32, + /// Semantic identity профиля. + pub profile_id: Wcag22ProfileIdV1, + /// Датированный normative source. + pub recommendation: &'static str, + /// SHA-256 exact bytes [`Self::source_json`]. + pub source_sha256: &'static str, + /// FNV-1a-32 of the typed, length-prefixed profile V1 preimage. + pub profile_checksum: &'static str, + /// Canonical finite Q55 artifact identity. + pub artifact_id: NumericalArtifactIdV2, + /// SHA-256 canonical binary table preimage. + pub artifact_sha256: &'static str, + /// SHA-256 exact generator source that produced the table. + pub generator_sha256: &'static str, + /// Registered outward-bound/decision law. + pub bound_id: NumericalErrorBoundIdV2, + /// Replayable full-domain proof identity. + pub proof_id: NumericalProofIdV2, + /// SHA-256 exact proof file bytes. + pub proof_sha256: &'static str, + /// SHA-256 canonical proof payload before its embedded integrity digest. + pub proof_payload_sha256: &'static str, + /// SHA-256 independent verifier source. + pub verifier_sha256: &'static str, +} + +impl Wcag22ProfileV1 { + /// Raw canonical machine-readable profile for offline audit/replay. + /// + /// Kept behind a callable accessor so consumers that only evaluate colours + /// do not retain the whole document in their final binary. + #[must_use] + pub fn source_json(&self) -> &'static str { + PROFILE_SOURCE_JSON + } + + /// Raw canonical full-domain proof artifact for offline audit/replay. + /// + /// The npm package also ships this byte-identical document under + /// `evidence/`; runtime evaluators need only the IDs and digests above. + #[must_use] + pub fn proof_json(&self) -> &'static str { + PROOF_SOURCE_JSON + } +} + +static WCAG22_PROFILE_V1: Wcag22ProfileV1 = Wcag22ProfileV1 { + schema_version: 1, + profile_id: Wcag22ProfileIdV1::Wcag22Srgb8ContrastV1, + recommendation: "https://www.w3.org/TR/2024/REC-WCAG22-20241212/", + source_sha256: q55_data::PROFILE_SOURCE_SHA256, + profile_checksum: q55_data::PROFILE_CHECKSUM, + artifact_id: NumericalArtifactIdV2::Wcag22Srgb8LuminanceQ55V1, + artifact_sha256: q55_data::ARTIFACT_SHA256, + generator_sha256: q55_data::GENERATOR_SHA256, + bound_id: NumericalErrorBoundIdV2::Wcag22Srgb8OutwardQ55V1, + proof_id: NumericalProofIdV2::Wcag22Srgb8FullDomainQ55V1, + proof_sha256: PROOF_SOURCE_SHA256, + proof_payload_sha256: PROOF_PAYLOAD_SHA256, + verifier_sha256: VERIFIER_SHA256, +}; + +/// Канонический профиль текущего evaluator-а. +#[must_use] +pub fn wcag22_profile_v1() -> &'static Wcag22ProfileV1 { + &WCAG22_PROFILE_V1 +} + +/// Exact WCAG 2.2 success criterion declared for this use occurrence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Wcag22CriterionV1 { + /// SC 1.4.3, ordinary text: 4.5:1. + Sc143TextDefault, + /// SC 1.4.3, explicitly declared large-scale text: 3:1. + Sc143TextLargeScale, + /// SC 1.4.11, required visual information of a UI component/state: 3:1. + Sc1411UiComponentOrState, + /// SC 1.4.11, required visual information of a graphical object: 3:1. + Sc1411GraphicalObject, +} + +impl Wcag22CriterionV1 { + /// Every criterion admitted by this version of the evaluator, in stable + /// wire order. + pub const ALL: [Self; 4] = [ + Self::Sc143TextDefault, + Self::Sc143TextLargeScale, + Self::Sc1411UiComponentOrState, + Self::Sc1411GraphicalObject, + ]; + + /// Stable human-readable menu for boundary error messages. + pub const WIRE_KEY_MENU: &str = "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"; + + /// Stable wire key. + pub const fn key(self) -> &'static str { + match self { + Self::Sc143TextDefault => "sc-1.4.3-text-default", + Self::Sc143TextLargeScale => "sc-1.4.3-text-large-scale", + Self::Sc1411UiComponentOrState => "sc-1.4.11-ui-component-or-state", + Self::Sc1411GraphicalObject => "sc-1.4.11-graphical-object", + } + } + + /// Parse an exact stable wire key without aliases or fallback. + pub fn parse(key: &str) -> Option { + match key { + "sc-1.4.3-text-default" => Some(Self::Sc143TextDefault), + "sc-1.4.3-text-large-scale" => Some(Self::Sc143TextLargeScale), + "sc-1.4.11-ui-component-or-state" => Some(Self::Sc1411UiComponentOrState), + "sc-1.4.11-graphical-object" => Some(Self::Sc1411GraphicalObject), + _ => None, + } + } +} + +/// Q55 outward enclosure of one final colour's relative luminance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Wcag22LuminanceBoundsQ55V1 { + lower: u64, + upper: u64, +} + +impl Wcag22LuminanceBoundsQ55V1 { + /// Inclusive lower bound, scaled by `2^55`. + pub fn lower(self) -> u64 { + self.lower + } + + /// Inclusive upper bound, scaled by `2^55`. + pub fn upper(self) -> u64 { + self.upper + } + + /// Fixed-point scale. + pub fn scale() -> u64 { + q55_data::Q55_SCALE + } +} + +/// Measurement payload retained with the threshold decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Wcag22MeasurementV1 { + /// Final foreground bytes supplied to the evaluator. + pub foreground: [u8; 3], + /// Final background bytes supplied to the evaluator. + pub background: [u8; 3], + /// Foreground relative-luminance enclosure. + pub foreground_luminance: Wcag22LuminanceBoundsQ55V1, + /// Background relative-luminance enclosure. + pub background_luminance: Wcag22LuminanceBoundsQ55V1, +} + +/// Total production decision for the admitted final-sRGB8 domain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Wcag22ApplicableDecisionV1 { + /// Exact threshold law is proved true in one orientation. + Pass, + /// Exact threshold law is proved false in both orientations. + Fail, +} + +/// Explicit report-layer declaration that no WCAG criterion applies. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Wcag22ClientDeclaredNotApplicableV1 { + reason_id: String, +} + +impl Wcag22ClientDeclaredNotApplicableV1 { + /// Build a non-empty opaque declaration. Core does not interpret the ID. + pub fn try_new(reason_id: impl Into) -> Result { + let reason_id = reason_id.into(); + if reason_id.is_empty() { + return Err(Wcag22EvaluationErrorV1::EmptyNotApplicableReason); + } + Ok(Self { reason_id }) + } + + /// Opaque client-owned reason identity. + pub fn reason_id(&self) -> &str { + &self.reason_id + } +} + +/// Atomic evaluation/report result. `NotEvaluated` never carries a decision. +/// The proof-bearing `Evaluated` variant is externally inspectable but cannot +/// be constructed outside Core, so callers cannot reuse genuine evidence with +/// a forged criterion or reversed decision. +/// +/// ```compile_fail +/// use labcolors_core::wcag22::{ +/// Wcag22ApplicableDecisionV1, Wcag22AssessmentV1, Wcag22CriterionV1, +/// evaluate_wcag22_srgb8, +/// }; +/// +/// let genuine = evaluate_wcag22_srgb8( +/// [0, 0, 0], +/// [255, 255, 255], +/// Wcag22CriterionV1::Sc143TextDefault, +/// ).unwrap(); +/// let Wcag22AssessmentV1::Evaluated { +/// profile_id, +/// criterion, +/// measurement, +/// evidence, +/// .. +/// } = genuine else { unreachable!() }; +/// let _forged = Wcag22AssessmentV1::Evaluated { +/// profile_id, +/// criterion, +/// measurement, +/// decision: Wcag22ApplicableDecisionV1::Fail, +/// evidence, +/// }; +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum Wcag22AssessmentV1 { + /// One explicit occurrence criterion was evaluated. + #[non_exhaustive] + Evaluated { + /// Immutable evaluator profile. + profile_id: Wcag22ProfileIdV1, + /// Client-declared criterion; never inferred from a token name. + criterion: Wcag22CriterionV1, + /// Exact finite measurement. + measurement: Wcag22MeasurementV1, + /// Total Pass/Fail decision on the admitted domain. + decision: Wcag22ApplicableDecisionV1, + /// Registry-sealed finite-bound evidence. + evidence: NumericalDecisionEvidenceV1, + }, + /// Report-only branch: the pair evaluator was intentionally not run. + NotEvaluated { + /// Profile the declaration refers to. + profile_id: Wcag22ProfileIdV1, + /// Explicit client-owned declaration. + declaration: Wcag22ClientDeclaredNotApplicableV1, + }, +} + +/// Fail-closed evaluator errors. No variant is a colour decision. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Wcag22EvaluationErrorV1 { + /// Public hex input was not one exact `#RRGGBB` value. + InvalidSrgb8 { + /// Input field identity. + field: &'static str, + /// Parser reason without a fallback value. + reason: String, + }, + /// A report-layer NotApplicable declaration had no identity. + EmptyNotApplicableReason, + /// Shipped bounds failed to separate a threshold; release proof must forbid it. + ArtifactInvariantViolation { + /// Criterion whose threshold was unresolved. + criterion: Wcag22CriterionV1, + /// Exact foreground input. + foreground: [u8; 3], + /// Exact background input. + background: [u8; 3], + }, + /// Registry and evaluator artifact identities drifted. + EvidenceRegistryMismatch(String), +} + +impl fmt::Display for Wcag22EvaluationErrorV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidSrgb8 { field, reason } => { + write!(formatter, "invalid WCAG22 {field}: {reason}") + } + Self::EmptyNotApplicableReason => { + formatter.write_str("WCAG22 NotApplicable reason ID must be non-empty") + } + Self::ArtifactInvariantViolation { + criterion, + foreground, + background, + } => write!( + formatter, + "WCAG22 Q55 artifact failed to separate {criterion:?} for {foreground:?}/{background:?}" + ), + Self::EvidenceRegistryMismatch(message) => { + write!(formatter, "WCAG22 numerical registry mismatch: {message}") + } + } + } +} + +impl std::error::Error for Wcag22EvaluationErrorV1 {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn criterion_wire_keys_are_unique_and_round_trip_exactly() { + let mut keys = std::collections::HashSet::new(); + for criterion in Wcag22CriterionV1::ALL { + assert!(keys.insert(criterion.key())); + assert_eq!(Wcag22CriterionV1::parse(criterion.key()), Some(criterion)); + } + assert_eq!( + Wcag22CriterionV1::ALL + .map(Wcag22CriterionV1::key) + .join(" | "), + Wcag22CriterionV1::WIRE_KEY_MENU + ); + assert_eq!(Wcag22CriterionV1::parse(""), None); + assert_eq!(Wcag22CriterionV1::parse("SC-1.4.3-TEXT-DEFAULT"), None); + } + + #[test] + fn profile_binds_canonical_sources_and_artifact() { + let profile = wcag22_profile_v1(); + assert_eq!(profile.profile_id.key(), "wcag22-srgb8-contrast-v1"); + assert_eq!(profile.source_json(), PROFILE_SOURCE_JSON); + assert_eq!(profile.source_sha256.len(), 64); + assert_eq!(profile.profile_checksum, "152813fe"); + assert_eq!(profile.artifact_sha256.len(), 64); + assert_eq!(profile.generator_sha256.len(), 64); + assert_eq!(profile.proof_json(), PROOF_SOURCE_JSON); + assert_eq!(profile.proof_sha256.len(), 64); + assert_eq!(profile.proof_payload_sha256.len(), 64); + assert_eq!(profile.verifier_sha256.len(), 64); + assert_eq!(profile.schema_version, 1); + } + + #[test] + fn empty_not_applicable_reason_is_rejected() { + assert!(Wcag22ClientDeclaredNotApplicableV1::try_new("").is_err()); + } + + #[test] + fn hex_transport_rejects_invalid_input_without_panic_or_fallback() { + for invalid in ["not-a-colour", "FFFFFF", "##FFFFFF", "###FFFFFF"] { + let error = + evaluate_wcag22_hex(invalid, "#FFFFFF", Wcag22CriterionV1::Sc143TextDefault) + .unwrap_err(); + assert!(matches!( + error, + Wcag22EvaluationErrorV1::InvalidSrgb8 { + field: "foreground", + .. + } + )); + } + } + + #[test] + fn hex_transport_accepts_exact_canonical_input() { + let assessment = + evaluate_wcag22_hex("#000000", "#FFFFFF", Wcag22CriterionV1::Sc143TextDefault) + .expect("exact #RRGGBB transport must reach the proof-bound evaluator"); + assert!(matches!( + assessment, + Wcag22AssessmentV1::Evaluated { + decision: Wcag22ApplicableDecisionV1::Pass, + .. + } + )); + } + + #[test] + fn luminance_bounds_are_ordered_and_fit_the_declared_scale() { + for rgb in [[0, 0, 0], [255, 255, 255], [137, 187, 9], [62, 34, 23]] { + let bounds = kernel::luminance_bounds(rgb); + assert!(bounds.lower <= bounds.upper); + assert!(bounds.upper <= q55_data::Q55_SCALE + 3); + } + } +} diff --git a/crates/labcolors-core/src/wcag22/kernel.rs b/crates/labcolors-core/src/wcag22/kernel.rs new file mode 100644 index 00000000..43fa512b --- /dev/null +++ b/crates/labcolors-core/src/wcag22/kernel.rs @@ -0,0 +1,228 @@ +//! Proof-bound production kernel for WCAG 2.2 final-sRGB8 assessment. +//! +//! The independent verifier pins this whole source file: byte lookup, +//! luminance assembly, criterion mapping, both threshold orientations, +//! terminal decision, strict transport parsing and sealed evidence minting. + +use crate::wcag22_evidence::mint_wcag22_evidence; + +use super::{ + Wcag22ApplicableDecisionV1, Wcag22AssessmentV1, Wcag22CriterionV1, Wcag22EvaluationErrorV1, + Wcag22LuminanceBoundsQ55V1, Wcag22MeasurementV1, wcag22_profile_v1, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ThresholdV1 { + Three, + FourAndHalf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OrientedDecisionV1 { + Pass, + Fail, + Unresolved, +} + +fn threshold(criterion: Wcag22CriterionV1) -> ThresholdV1 { + match criterion { + Wcag22CriterionV1::Sc143TextDefault => ThresholdV1::FourAndHalf, + Wcag22CriterionV1::Sc143TextLargeScale + | Wcag22CriterionV1::Sc1411UiComponentOrState + | Wcag22CriterionV1::Sc1411GraphicalObject => ThresholdV1::Three, + } +} + +pub(super) fn luminance_bounds(rgb: [u8; 3]) -> Wcag22LuminanceBoundsQ55V1 { + let red = super::q55_data::WEIGHTED_CONTRIBUTION_BOUNDS[0][usize::from(rgb[0])]; + let green = super::q55_data::WEIGHTED_CONTRIBUTION_BOUNDS[1][usize::from(rgb[1])]; + let blue = super::q55_data::WEIGHTED_CONTRIBUTION_BOUNDS[2][usize::from(rgb[2])]; + Wcag22LuminanceBoundsQ55V1 { + lower: red[0] + green[0] + blue[0], + upper: red[1] + green[1] + blue[1], + } +} + +fn classify_orientation( + lighter: Wcag22LuminanceBoundsQ55V1, + darker: Wcag22LuminanceBoundsQ55V1, + threshold: ThresholdV1, +) -> OrientedDecisionV1 { + let scale = u128::from(super::q55_data::Q55_SCALE); + let light_lower = u128::from(lighter.lower); + let light_upper = u128::from(lighter.upper); + let dark_lower = u128::from(darker.lower); + let dark_upper = u128::from(darker.upper); + // All products are promoted to u128 before evaluation. Independently, the + // committed verifier proves the conservative worst Q55 term + // 180·(S+3)+7·S fits signed 64-bit with 2_485_986_994_308_513_251 + // headroom, while Q56 does not; Q55 is therefore the maximal binary scale + // that remains replayable without wider integer arithmetic. + // With S = Q55_SCALE, clearing denominators in + // (L + 0.05S) / (D + 0.05S) gives + // 3:1 => 10L >= 30D + S and 4.5:1 => 40L >= 180D + 7S. + // Pass uses L_lower/D_upper; Fail uses the strict reverse inequality with + // L_upper/D_lower, so neither branch relies on rounded display ratios. + let (passes, fails) = match threshold { + ThresholdV1::Three => ( + 10 * light_lower >= 30 * dark_upper + scale, + 10 * light_upper < 30 * dark_lower + scale, + ), + ThresholdV1::FourAndHalf => ( + 40 * light_lower >= 180 * dark_upper + 7 * scale, + 40 * light_upper < 180 * dark_lower + 7 * scale, + ), + }; + match (passes, fails) { + (true, false) => OrientedDecisionV1::Pass, + (false, true) => OrientedDecisionV1::Fail, + (false, false) | (true, true) => OrientedDecisionV1::Unresolved, + } +} + +fn classify_pair( + foreground: Wcag22LuminanceBoundsQ55V1, + background: Wcag22LuminanceBoundsQ55V1, + criterion: Wcag22CriterionV1, +) -> Option { + let threshold = threshold(criterion); + let forward = classify_orientation(foreground, background, threshold); + let reverse = classify_orientation(background, foreground, threshold); + if matches!(forward, OrientedDecisionV1::Pass) || matches!(reverse, OrientedDecisionV1::Pass) { + Some(Wcag22ApplicableDecisionV1::Pass) + } else if matches!(forward, OrientedDecisionV1::Fail) + && matches!(reverse, OrientedDecisionV1::Fail) + { + Some(Wcag22ApplicableDecisionV1::Fail) + } else { + None + } +} + +/// Evaluate one final foreground/background sRGB8 occurrence. +/// +/// Applicability is explicit in `criterion`; Core never infers it from token, +/// role, typography name or polarity. The function is fail-closed and cannot +/// panic for public byte input. +pub fn evaluate_wcag22_srgb8( + foreground: [u8; 3], + background: [u8; 3], + criterion: Wcag22CriterionV1, +) -> Result { + let foreground_luminance = luminance_bounds(foreground); + let background_luminance = luminance_bounds(background); + let decision = classify_pair(foreground_luminance, background_luminance, criterion).ok_or( + Wcag22EvaluationErrorV1::ArtifactInvariantViolation { + criterion, + foreground, + background, + }, + )?; + let profile = wcag22_profile_v1(); + let evidence = + mint_wcag22_evidence().map_err(Wcag22EvaluationErrorV1::EvidenceRegistryMismatch)?; + Ok(Wcag22AssessmentV1::Evaluated { + profile_id: profile.profile_id, + criterion, + measurement: Wcag22MeasurementV1 { + foreground, + background, + foreground_luminance, + background_luminance, + }, + decision, + evidence, + }) +} + +/// Parse two exact `#RRGGBB` transports and evaluate their final byte values. +/// +/// The parser is the core sRGB SSOT; adapters must call this function rather +/// than reconstruct WCAG math or hex parsing in JavaScript/Swift. +pub fn evaluate_wcag22_hex( + foreground: &str, + background: &str, + criterion: Wcag22CriterionV1, +) -> Result { + let parse = |field, value: &str| { + if value.len() != 7 || !value.starts_with('#') { + return Err(Wcag22EvaluationErrorV1::InvalidSrgb8 { + field, + reason: format!("expected exactly #RRGGBB, got {value:?}"), + }); + } + crate::srgb8::hex_bytes(value) + .map_err(|reason| Wcag22EvaluationErrorV1::InvalidSrgb8 { field, reason }) + }; + let foreground = parse("foreground", foreground)?; + let background = parse("background", background)?; + evaluate_wcag22_srgb8(foreground, background, criterion) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn point(value: u64) -> Wcag22LuminanceBoundsQ55V1 { + Wcag22LuminanceBoundsQ55V1 { + lower: value, + upper: value, + } + } + + #[test] + fn synthetic_integer_boundaries_bite_both_threshold_laws() { + let scale = super::super::q55_data::Q55_SCALE; + let black = point(0); + + let first_three_pass = scale.div_ceil(10); + assert_eq!( + classify_orientation(point(first_three_pass), black, ThresholdV1::Three), + OrientedDecisionV1::Pass + ); + assert_eq!( + classify_orientation(point(first_three_pass - 1), black, ThresholdV1::Three), + OrientedDecisionV1::Fail + ); + + let first_four_half_pass = (7 * scale).div_ceil(40); + assert_eq!( + classify_orientation(point(first_four_half_pass), black, ThresholdV1::FourAndHalf,), + OrientedDecisionV1::Pass + ); + assert_eq!( + classify_orientation( + point(first_four_half_pass - 1), + black, + ThresholdV1::FourAndHalf, + ), + OrientedDecisionV1::Fail + ); + } + + #[test] + fn one_failed_and_one_unresolved_orientation_is_not_a_pair_fail() { + let scale = super::super::q55_data::Q55_SCALE; + let black = point(0); + let straddling_three = Wcag22LuminanceBoundsQ55V1 { + lower: scale / 10, + upper: scale.div_ceil(10), + }; + assert_eq!( + classify_orientation(black, straddling_three, ThresholdV1::Three), + OrientedDecisionV1::Fail + ); + assert_eq!( + classify_orientation(straddling_three, black, ThresholdV1::Three), + OrientedDecisionV1::Unresolved + ); + assert_eq!( + classify_pair( + black, + straddling_three, + Wcag22CriterionV1::Sc1411GraphicalObject, + ), + None + ); + } +} diff --git a/crates/labcolors-core/src/wcag22/q55_data.rs b/crates/labcolors-core/src/wcag22/q55_data.rs new file mode 100644 index 00000000..d3c2d41d --- /dev/null +++ b/crates/labcolors-core/src/wcag22/q55_data.rs @@ -0,0 +1,215 @@ +//! Generated WCAG 2.2 sRGB8 Q55 weighted contribution bounds. +//! +//! DO NOT EDIT: regenerate with `python3 scripts/generate_wcag22_q55.py`. +//! Canonical digest covers each lower/upper u64 in channel/code order, +//! little-endian, without Rust formatting. + +pub(crate) const Q55_SCALE: u64 = 36028797018963968; +pub(crate) const PROFILE_CHECKSUM: &str = "152813fe"; +pub(crate) const PROFILE_SOURCE_SHA256: &str = + "b4bb7e5f17a99f2c911fdbe3da23a48b049277b796291094950f14680cc3cc7b"; +pub(crate) const GENERATOR_SHA256: &str = + "7ad72f53e26ebb74ebfc2d8945f833613fa1317bb441d821f518d012c8c09687"; +pub(crate) const ARTIFACT_SHA256: &str = + "7ff239d9052b346f3c50da01ca65ca2330892ed1a3ff30e190797fcef6f03604"; +#[rustfmt::skip] +pub(crate) static WEIGHTED_CONTRIBUTION_BOUNDS: [[[u64; 2]; 256]; 3] = [ + [ // exact weight 1063/5000 + [0, 0], [2324932388220, 2324932388221], [4649864776441, 4649864776442], [6974797164661, 6974797164662], + [9299729552882, 9299729552883], [11624661941103, 11624661941104], [13949594329323, 13949594329324], [16274526717544, 16274526717545], + [18599459105765, 18599459105766], [20924391493985, 20924391493986], [23249323882206, 23249323882207], [25633534438548, 25633534438549], + [28161024938440, 28161024938441], [30828214481363, 30828214481364], [33637226266988, 33637226266989], [36590136845283, 36590136845284], + [39688978754122, 39688978754123], [42935742922746, 42935742922747], [46332380868919, 46332380868920], [49880806713516, 49880806713517], + [53582899032912, 53582899032913], [57440502566704, 57440502566705], [61455429795963, 61455429795964], [65629462405233, 65629462405234], + [69964352639787, 69964352639788], [74461824568279, 74461824568280], [79123575259670, 79123575259671], [83951275882289, 83951275882290], + [88946572731981, 88946572731982], [94111088195535, 94111088195536], [99446421654888, 99446421654889], [104954150337039, 104954150337040], + [110635830114081, 110635830114082], [116492996257320, 116492996257321], [122527164149043, 122527164149044], [128739829955167, 128739829955168], + [135132471261683, 135132471261684], [141706547677534, 141706547677535], [148463501406334, 148463501406335], [155404757789115, 155404757789116], + [162531725820096, 162531725820097], [169845798637288, 169845798637289], [177348353989621, 177348353989622], [185040754682101, 185040754682102], + [192924349000417, 192924349000418], [201000471116285, 201000471116286], [209270441474708, 209270441474709], [217735567164274, 217735567164275], + [226397142271472, 226397142271473], [235256448219998, 235256448219999], [244314754095889, 244314754095890], [253573316959317, 253573316959318], + [263033382143762, 263033382143763], [272696183543294, 272696183543295], [282562943888568, 282562943888569], [292634875012179, 292634875012180], + [302913178103904, 302913178103905], [313399043956375, 313399043956376], [324093653201661, 324093653201662], [334998176539235, 334998176539236], + [346113774955733, 346113774955734], [357441599936925, 357441599936926], [368982793672269, 368982793672270], [380738489252405, 380738489252406], + [392709810859911, 392709810859912], [404897873953657, 404897873953658], [417303785447030, 417303785447031], [429928643880318, 429928643880319], + [442773539587513, 442773539587514], [455839554857782, 455839554857783], [469127764091836, 469127764091837], [482639233953422, 482639233953423], + [496375023516138, 496375023516139], [510336184405778, 510336184405779], [524523760938387, 524523760938388], [538938790254209, 538938790254210], + [553582302447685, 553582302447686], [568455320693672, 568455320693673], [583558861370025, 583558861370026], [598893934176690, 598893934176691], + [614461542251445, 614461542251446], [630262682282413, 630262682282414], [646298344617471, 646298344617472], [662569513370684, 662569513370685], + [679077166525852, 679077166525853], [695822276037306, 695822276037307], [712805807928024, 712805807928025], [730028722385189, 730028722385190], + [747491973853262, 747491973853263], [765196511124677, 765196511124678], [783143277428217, 783143277428218], [801333210515179, 801333210515180], + [819767242743386, 819767242743387], [838446301159124, 838446301159125], [857371307577074, 857371307577075], [876543178658315, 876543178658316], + [895962825986446, 895962825986447], [915631156141901, 915631156141902], [935549070774512, 935549070774513], [955717466674380, 955717466674381], + [976137235841099, 976137235841100], [996809265551401, 996809265551402], [1017734438425251, 1017734438425252], [1038913632490461, 1038913632490462], + [1060347721245859, 1060347721245860], [1082037573723049, 1082037573723050], [1103984054546827, 1103984054546828], [1126188023994270, 1126188023994271], + [1148650338052554, 1148650338052555], [1171371848475527, 1171371848475528], [1194353402839091, 1194353402839092], [1217595844595399, 1217595844595400], + [1241100013125937, 1241100013125938], [1264866743793489, 1264866743793490], [1288896867993045, 1288896867993046], [1313191213201657, 1313191213201658], + [1337750603027298, 1337750603027299], [1362575857256729, 1362575857256730], [1387667791902414, 1387667791902415], [1413027219248504, 1413027219248505], + [1438654947895927, 1438654947895928], [1464551782806582, 1464551782806583], [1490718525346694, 1490718525346695], [1517155973329323, 1517155973329324], + [1543864921056073, 1543864921056074], [1570846159358004, 1570846159358005], [1598100475635777, 1598100475635778], [1625628653899049, 1625628653899050], + [1653431474805142, 1653431474805143], [1681509715696995, 1681509715696996], [1709864150640426, 1709864150640427], [1738495550460719, 1738495550460720], + [1767404682778546, 1767404682778547], [1796592312045255, 1796592312045256], [1826059199577524, 1826059199577525], [1855806103591406, 1855806103591407], + [1885833779235775, 1885833779235776], [1916142978625196, 1916142978625197], [1946734450872220, 1946734450872221], [1977608942119124, 1977608942119125], + [2008767195569113, 2008767195569114], [2040209951516994, 2040209951516995], [2071937947379323, 2071937947379324], [2103951917724065, 2103951917724066], + [2136252594299740, 2136252594299741], [2168840706064105, 2168840706064106], [2201716979212356, 2201716979212357], [2234882137204870, 2234882137204871], + [2268336900794497, 2268336900794498], [2302081988053421, 2302081988053422], [2336118114399572, 2336118114399573], [2370445992622642, 2370445992622643], + [2405066332909664, 2405066332909665], [2439979842870211, 2439979842870212], [2475187227561183, 2475187227561184], [2510689189511223, 2510689189511224], + [2546486428744748, 2546486428744749], [2582579642805609, 2582579642805610], [2618969526780400, 2618969526780401], [2655656773321402, 2655656773321403], + [2692642072669186, 2692642072669187], [2729926112674881, 2729926112674882], [2767509578822099, 2767509578822100], [2805393154248546, 2805393154248547], + [2843577519767309, 2843577519767310], [2882063353887827, 2882063353887828], [2920851332836568, 2920851332836569], [2959942130577393, 2959942130577394], + [2999336418831636, 2999336418831637], [3039034867097890, 3039034867097891], [3079038142671512, 3079038142671513], [3119346910663859, 3119346910663860], + [3159961834021238, 3159961834021239], [3200883573543603, 3200883573543604], [3242112787902986, 3242112787902987], [3283650133661672, 3283650133661673], + [3325496265290124, 3325496265290125], [3367651835184660, 3367651835184661], [3410117493684893, 3410117493684894], [3452893889090929, 3452893889090930], + [3495981667680334, 3495981667680335], [3539381473724875, 3539381473724876], [3583093949507035, 3583093949507036], [3627119735336306, 3627119735336307], + [3671459469565272, 3671459469565273], [3716113788605477, 3716113788605478], [3761083326943079, 3761083326943080], [3806368717154315, 3806368717154316], + [3851970589920748, 3851970589920749], [3897889574044326, 3897889574044327], [3944126296462250, 3944126296462251], [3990681382261647, 3990681382261648], + [4037555454694055, 4037555454694056], [4084749135189730, 4084749135189731], [4132263043371767, 4132263043371768], [4180097797070051, 4180097797070052], + [4228254012335026, 4228254012335027], [4276732303451297, 4276732303451298], [4325533282951065, 4325533282951066], [4374657561627395, 4374657561627396], + [4424105748547319, 4424105748547320], [4473878451064786, 4473878451064787], [4523976274833448, 4523976274833449], [4574399823819295, 4574399823819296], + [4625149700313139, 4625149700313140], [4676226504942944, 4676226504942945], [4727630836686018, 4727630836686019], [4779363292881048, 4779363292881049], + [4831424469240006, 4831424469240007], [4883814959859905, 4883814959859906], [4936535357234423, 4936535357234424], [4989586252265392, 4989586252265393], + [5042968234274146, 5042968234274147], [5096681891012752, 5096681891012753], [5150727808675098, 5150727808675099], [5205106571907864, 5205106571907865], + [5259818763821361, 5259818763821362], [5314864966000253, 5314864966000254], [5370245758514155, 5370245758514156], [5425961719928110, 5425961719928111], + [5482013427312955, 5482013427312956], [5538401456255563, 5538401456255564], [5595126380868980, 5595126380868981], [5652188773802442, 5652188773802443], + [5709589206251288, 5709589206251289], [5767328247966763, 5767328247966764], [5825406467265710, 5825406467265711], [5883824431040161, 5883824431040162], + [5942582704766825, 5942582704766826], [6001681852516468, 6001681852516469], [6061122436963201, 6061122436963202], [6120905019393660, 6120905019393661], + [6181030159716091, 6181030159716092], [6241498416469347, 6241498416469348], [6302310346831775, 6302310346831776], [6363466506630022, 6363466506630023], + [6424967450347743, 6424967450347744], [6486813731134219, 6486813731134220], [6549005900812887, 6549005900812888], [6611544509889783, 6611544509889784], + [6674430107561889, 6674430107561890], [6737663241725408, 6737663241725409], [6801244458983945, 6801244458983946], [6865174304656605, 6865174304656606], + [6929453322786016, 6929453322786017], [6994082056146259, 6994082056146260], [7059061046250734, 7059061046250735], [7124390833359929, 7124390833359930], + [7190071956489129, 7190071956489130], [7256104953416033, 7256104953416034], [7322490360688308, 7322490360688309], [7389228713631060, 7389228713631061], + [7456320546354235, 7456320546354236], [7523766391759945, 7523766391759946], [7591566781549731, 7591566781549732], [7659722246231739, 7659722246231740], + ], + [ // exact weight 447/625 + [0, 0], [7821221279658, 7821221279659], [15642442559317, 15642442559318], [23463663838975, 23463663838976], + [31284885118634, 31284885118635], [39106106398292, 39106106398293], [46927327677951, 46927327677952], [54748548957609, 54748548957610], + [62569770237268, 62569770237269], [70390991516926, 70390991516927], [78212212796585, 78212212796586], [86232849625822, 86232849625823], + [94735489350763, 94735489350764], [103708085592997, 103708085592998], [113157780932034, 113157780932035], [123091561014802, 123091561014803], + [133516263428732, 133516263428733], [144438585787151, 144438585787152], [155865093120654, 155865093120655], [167802224654314, 167802224654315], + [180256300039225, 180256300039226], [193233525097397, 193233525097398], [206739997131107, 206739997131108], [220781709841124, 220781709841125], + [235364557892642, 235364557892643], [250494341162903, 250494341162904], [266176768700452, 266176768700453], [282417462422451, 282417462422452], + [299221960573439, 299221960573440], [316595720966355, 316595720966356], [334544124024348, 334544124024349], [353072475639937, 353072475639938], + [372186009866375, 372186009866376], [391889891454542, 391889891454543], [412189218247393, 412189218247394], [433089023442783, 433089023442784], + [454594277734505, 454594277734506], [476709891340415, 476709891340416], [499440715925729, 499440715925730], [522791546428859, 522791546428860], + [546767122796486, 546767122796487], [571372131634003, 571372131634004], [596611207776940, 596611207776941], [622488935788518, 622488935788519], + [649009851388047, 649009851388048], [676178442814520, 676178442814521], [703999152129405, 703999152129406], [732476376462318, 732476376462319], + [761614469202996, 761614469202997], [791417741142721, 791417741142722], [821890461568110, 821890461568111], [853036859309988, 853036859309989], + [884861123749855, 884861123749856], [917367405786283, 917367405786284], [950559818763424, 950559818763425], [984442439363644, 984442439363645], + [1019019308466193, 1019019308466194], [1054294431973657, 1054294431973658], [1090271781607847, 1090271781607848], [1126955295676675, 1126955295676676], + [1164348879813454, 1164348879813455], [1202456407689975, 1202456407689976], [1241281721704644, 1241281721704645], [1280828633646849, 1280828633646850], + [1321100925338703, 1321100925338704], [1362102349255201, 1362102349255202], [1403836629123783, 1403836629123784], [1446307460504251, 1446307460504252], + [1489518511349903, 1489518511349904], [1533473422550733, 1533473422550734], [1578175808459462, 1578175808459463], [1623629257401166, 1623629257401167], + [1669837332167179, 1669837332167180], [1716803570493943, 1716803570493944], [1764531485527444, 1764531485527445], [1813024566273803, 1813024566273804], + [1862286278036617, 1862286278036618], [1912320062841554, 1912320062841555], [1963129339848738, 1963129339848739], [2014717505753381, 2014717505753382], + [2067087935175136, 2067087935175137], [2120243981036604, 2120243981036605], [2174188974931400, 2174188974931401], [2228926227482188, 2228926227482189], + [2284459028689039, 2284459028689040], [2340790648268492, 2340790648268493], [2397924335983646, 2397924335983647], [2455863321965603, 2455863321965604], + [2514610817026592, 2514610817026593], [2574170012965048, 2574170012965049], [2634544082862939, 2634544082862940], [2695736181375617, 2695736181375618], + [2757749445014440, 2757749445014441], [2820586992422415, 2820586992422416], [2884251924643102, 2884251924643103], [2948747325383008, 2948747325383009], + [3014076261267670, 3014076261267671], [3080241782091665, 3080241782091666], [3147246921062706, 3147246921062707], [3215094695040060, 3215094695040061], + [3283788104767425, 3283788104767426], [3353330135100481, 3353330135100482], [3423723755229255, 3423723755229256], [3494971918895476, 3494971918895477], + [3567077564605073, 3567077564605074], [3640043615835959, 3640043615835960], [3713872981241256, 3713872981241257], [3788568554848082, 3788568554848083], + [3864133216252053, 3864133216252054], [3940569830807607, 3940569830807608], [4017881249814289, 4017881249814290], [4096070310699105, 4096070310699106], + [4175139837195063, 4175139837195064], [4255092639516011, 4255092639516012], [4335931514527873, 4335931514527874], [4417659245916394, 4417659245916395], + [4500278604351477, 4500278604351478], [4583792347648227, 4583792347648228], [4668203220924772, 4668203220924773], [4753513956756964, 4753513956756965], + [4839727275330044, 4839727275330045], [4926845884587337, 4926845884587338], [5014872480376085, 5014872480376086], [5103809746590461, 5103809746590462], + [5193660355311871, 5193660355311872], [5284426966946590, 5284426966946591], [5376112230360809, 5376112230360810], [5468718783013171, 5468718783013172], + [5562249251084843, 5562249251084844], [5656706249607201, 5656706249607202], [5752092382587173, 5752092382587174], [5848410243130322, 5848410243130323], + [5945662413561696, 5945662413561697], [6043851465544529, 6043851465544530], [6142979960196828, 6142979960196829], [6243050448205896, 6243050448205897], + [6344065469940858, 6344065469940859], [6446027555563221, 6446027555563222], [6548939225135523, 6548939225135524], [6652802988728117, 6652802988728118], + [6757621346524131, 6757621346524132], [6863396788922644, 6863396788922645], [6970131796640133, 6970131796640134], [7077828840810213, 7077828840810214], + [7186490383081722, 7186490383081723], [7296118875715185, 7296118875715186], [7406716761677693, 7406716761677694], [7518286474736233, 7518286474736234], + [7630830439549505, 7630830439549506], [7744351071758262, 7744351071758263], [7858850778074198, 7858850778074199], [7974331956367421, 7974331956367422], + [8090796995752550, 8090796995752551], [8208248276673448, 8208248276673449], [8326688170986634, 8326688170986635], [8446119042043402, 8446119042043403], + [8566543244770667, 8566543244770668], [8687963125750573, 8687963125750574], [8810381023298882, 8810381023298883], [8933799267542176, 8933799267542177], + [9058220180493896, 9058220180493897], [9183646076129234, 9183646076129235], [9310079260458916, 9310079260458917], [9437522031601885, 9437522031601886], + [9565976679856911, 9565976679856912], [9695445487773161, 9695445487773162], [9825930730219725, 9825930730219726], [9957434674454148, 9957434674454149], + [10089959580189964, 10089959580189965], [10223507699663269, 10223507699663270], [10358081277698335, 10358081277698336], [10493682551772306, 10493682551772307], + [10630313752078973, 10630313752078974], [10767977101591651, 10767977101591652], [10906674816125192, 10906674816125193], [11046409104397122, 11046409104397123], + [11187182168087943, 11187182168087944], [11328996201900607, 11328996201900608], [11471853393619171, 11471853393619172], [11615755924166664, 11615755924166665], + [11760705967662160, 11760705967662161], [11906705691477098, 11906705691477099], [12053757256290835, 12053757256290836], [12201862816145466, 12201862816145467], + [12351024518499920, 12351024518499921], [12501244504283335, 12501244504283336], [12652524907947745, 12652524907947746], [12804867857520069, 12804867857520070], + [12958275474653429, 12958275474653430], [13112749874677808, 13112749874677809], [13268293166650055, 13268293166650056], [13424907453403247, 13424907453403248], + [13582594831595430, 13582594831595431], [13741357391757737, 13741357391757738], [13901197218341900, 13901197218341901], [14062116389767172, 14062116389767173], + [14224116978466654, 14224116978466655], [14387201050933056, 14387201050933057], [14551370667763886, 14551370667763887], [14716627883706083, 14716627883706084], + [14882974747700108, 14882974747700109], [15050413302923497, 15050413302923498], [15218945586833878, 15218945586833879], [15388573631211478, 15388573631211479], + [15559299462201116, 15559299462201117], [15731125100353688, 15731125100353689], [15904052560667169, 15904052560667170], [16078083852627120, 16078083852627121], + [16253220980246719, 16253220980246720], [16429465942106323, 16429465942106324], [16606820731392568, 16606820731392569], [16785287335937010, 16785287335937011], + [16964867738254325, 16964867738254326], [17145563915580058, 17145563915580059], [17327377839907951, 17327377839907952], [17510311478026832, 17510311478026833], + [17694366791557091, 17694366791557092], [17879545736986741, 17879545736986742], [18065850265707073, 18065850265707074], [18253282324047905, 18253282324047906], + [18441843853312444, 18441843853312445], [18631536789811755, 18631536789811756], [18822363064898846, 18822363064898847], [19014324605002384, 19014324605002385], + [19207423331660027, 19207423331660028], [19401661161551408, 19401661161551409], [19597040006530743, 19597040006530744], [19793561773659094, 19793561773659095], + [19991228365236281, 19991228365236282], [20190041678832447, 20190041678832448], [20390003607319293, 20390003607319294], [20591116038900967, 20591116038900968], + [20793380857144632, 20793380857144633], [20996799941010712, 20996799941010713], [21201375164882812, 21201375164882813], [21407108398597328, 21407108398597329], + [21614001507472746, 21614001507472747], [21822056352338633, 21822056352338634], [22031274789564334, 22031274789564335], [22241658671087361, 22241658671087362], + [22453209844441502, 22453209844441503], [22665930152784630, 22665930152784631], [22879821434926236, 22879821434926237], [23094885525354677, 23094885525354678], + [23311124254264152, 23311124254264153], [23528539447581396, 23528539447581397], [23747132926992122, 23747132926992123], [23966906509967176, 23966906509967177], + [24187862009788453, 24187862009788454], [24410001235574540, 24410001235574541], [24633325992306107, 24633325992306108], [24857838080851056, 24857838080851057], + [25083539297989411, 25083539297989412], [25310431436437974, 25310431436437975], [25538516284874731, 25538516284874732], [25767795627963029, 25767795627963030], + ], + [ // exact weight 361/5000 + [0, 0], [789558412180, 789558412181], [1579116824360, 1579116824361], [2368675236540, 2368675236541], + [3158233648721, 3158233648722], [3947792060901, 3947792060902], [4737350473081, 4737350473082], [5526908885262, 5526908885263], + [6316467297442, 6316467297443], [7106025709622, 7106025709623], [7895584121802, 7895584121803], [8705273689855, 8705273689856], + [9563621827635, 9563621827636], [10469412443812, 10469412443813], [11423366587378, 11423366587379], [12426189464861, 12426189464862], + [13478571336066, 13478571336067], [14581188330302, 14581188330303], [15734703192549, 15734703192550], [16939765967619, 16939765967620], + [18197014629239, 18197014629240], [19507075660000, 19507075660001], [20870564587340, 20870564587341], [22288086480046, 22288086480047], + [23760236409184, 23760236409185], [25287599876903, 25287599876904], [26870753216125, 26870753216126], [28510263963787, 28510263963788], + [30206691210014, 30206691210015], [31960585925294, 31960585925295], [33772491267558, 33772491267559], [35642942870810, 35642942870811], + [37572469116823, 37572469116824], [39561591391244, 39561591391245], [41610824325310, 41610824325311], [43720676024285, 43720676024286], + [45891648283600, 45891648283601], [48124236793593, 48124236793594], [50418931333665, 50418931333666], [52776215956604, 52776215956605], + [55196569163739, 55196569163740], [57680464071553, 57680464071554], [60228368570323, 60228368570324], [62840745475295, 62840745475296], + [65518052670885, 65518052670886], [68260743248333, 68260743248334], [71069265637224, 71069265637225], [73944063731235, 73944063731236], + [76885577008468, 76885577008469], [79894240646678, 79894240646679], [82970485633693, 82970485633694], [86114738873295, 86114738873296], + [89327423286828, 89327423286829], [92608957910751, 92608957910752], [95959757990379, 95959757990380], [99380235069987, 99380235069988], + [102870797079501, 102870797079502], [106431848417922, 106431848417923], [110063790033678, 110063790033679], [113767019502035, 113767019502036], + [117541931099736, 117541931099737], [121388915876980, 121388915876981], [125308361726895, 125308361726896], [129300653452604, 129300653452605], + [133366172832011, 133366172832012], [137505298680404, 137505298680405], [141718406910985, 141718406910986], [146005870593410, 146005870593411], + [150368060010434, 150368060010435], [154805342712755, 154805342712756], [159318083572110, 159318083572111], [163906644832724, 163906644832725], + [168571386161172, 168571386161173], [173312664694718, 173312664694719], [178130835088201, 178130835088202], [183026249559519, 183026249559520], + [187999257933786, 187999257933787], [193050207686186, 193050207686187], [198179443983611, 198179443983612], [203387309725103, 203387309725104], + [208674145581158, 208674145581159], [214040290031939, 214040290031940], [219486079404428, 219486079404429], [225011847908576, 225011847908577], + [230617927672467, 230617927672468], [236304648776545, 236304648776546], [242072339286939, 242072339286940], [247921325287914, 247921325287915], + [253851930913478, 253851930913479], [259864478378183, 259864478378184], [265959288007136, 265959288007137], [272136678265267, 272136678265268], + [278396965785853, 278396965785854], [284740465398347, 284740465398348], [291167490155525, 291167490155526], [297678351359973, 297678351359974], + [304273358589941, 304273358589942], [310952819724578, 310952819724579], [317717040968578, 317717040968579], [324566326876247, 324566326876248], + [331500980375011, 331500980375012], [338521302788387, 338521302788388], [345627593858434, 345627593858435], [352820151767692, 352820151767693], + [360099273160635, 360099273160636], [367465253164648, 367465253164649], [374918385410540, 374918385410541], [382458962052616, 382458962052617], + [390087273788308, 390087273788309], [397803609877389, 397803609877390], [405608258160782, 405608258160783], [413501505078964, 413501505078965], + [421483635689993, 421483635689994], [429554933687158, 429554933687159], [437715681416264, 437715681416265], [445966159892566, 445966159892567], + [454306648817361, 454306648817362], [462737426594242, 462737426594243], [471258770345034, 471258770345035], [479870955925409, 479870955925410], + [488574257940197, 488574257940198], [497368949758397, 497368949758398], [506255303527898, 506255303527899], [515233590189920, 515233590189921], + [524304079493172, 524304079493173], [533467040007751, 533467040007752], [542722739138772, 542722739138773], [552071443139752, 552071443139753], + [561513417125735, 561513417125736], [571048925086185, 571048925086186], [580678229897642, 580678229897643], [590401593336142, 590401593336143], + [600219276089421, 600219276089422], [610131537768896, 610131537768897], [620138636921436, 620138636921437], [630240831040919, 630240831040920], + [640438376579600, 640438376579601], [650731528959262, 650731528959263], [661120542582193, 661120542582194], [671605670841960, 671605670841961], + [682187166134007, 682187166134008], [692865279866072, 692865279866073], [703640262468425, 703640262468426], [714512363403939, 714512363403940], + [725481831177992, 725481831177993], [736548913348205, 736548913348206], [747713856534017, 747713856534018], [758976906426113, 758976906426114], + [770338307795685, 770338307795686], [781798304503560, 781798304503561], [793357139509168, 793357139509169], [805015054879373, 805015054879374], + [816772291797167, 816772291797168], [828629090570222, 828629090570223], [840585690639310, 840585690639311], [852642330586596, 852642330586597], + [864799248143795, 864799248143796], [877056680200211, 877056680200212], [889414862810653, 889414862810654], [901874031203223, 901874031203224], + [914434419786995, 914434419786996], [927096262159578, 927096262159579], [939859791114560, 939859791114561], [952725238648848, 952725238648849], + [965692835969895, 965692835969896], [978762813502827, 978762813502828], [991935400897461, 991935400897462], [1005210827035220, 1005210827035221], + [1018589320035955, 1018589320035956], [1032071107264664, 1032071107264665], [1045656415338114, 1045656415338115], [1059345470131376, 1059345470131377], + [1073138496784258, 1073138496784259], [1087035719707658, 1087035719707659], [1101037362589819, 1101037362589820], [1115143648402505, 1115143648402506], + [1129354799407088, 1129354799407089], [1143671037160547, 1143671037160548], [1158092582521398, 1158092582521399], [1172619655655527, 1172619655655528], + [1187252476041957, 1187252476041958], [1201991262478532, 1201991262478533], [1216836233087525, 1216836233087526], [1231787605321172, 1231787605321173], + [1246845595967133, 1246845595967134], [1262010421153882, 1262010421153883], [1277282296356022, 1277282296356023], [1292661436399537, 1292661436399538], + [1308148055466970, 1308148055466971], [1323742367102541, 1323742367102542], [1339444584217189, 1339444584217190], [1355254919093560, 1355254919093561], + [1371173583390925, 1371173583390926], [1387200788150040, 1387200788150041], [1403336743797937, 1403336743797938], [1419581660152670, 1419581660152671], + [1435935746427981, 1435935746427982], [1452399211237928, 1452399211237929], [1468972262601443, 1468972262601444], [1485655107946838, 1485655107946839], + [1502447954116258, 1502447954116259], [1519351007370073, 1519351007370074], [1536364473391227, 1536364473391228], [1553488557289525, 1553488557289526], + [1570723463605873, 1570723463605874], [1588069396316465, 1588069396316466], [1605526558836926, 1605526558836927], [1623095154026395, 1623095154026396], + [1640775384191573, 1640775384191574], [1658567451090711, 1658567451090712], [1676471555937560, 1676471555937561], [1694487899405274, 1694487899405275], + [1712616681630260, 1712616681630261], [1730858102215995, 1730858102215996], [1749212360236792, 1749212360236793], [1767679654241523, 1767679654241524], + [1786260182257301, 1786260182257302], [1804954141793124, 1804954141793125], [1823761729843471, 1823761729843472], [1842683142891860, 1842683142891861], + [1861718576914371, 1861718576914372], [1880868227383121, 1880868227383122], [1900132289269710, 1900132289269711], [1919510957048618, 1919510957048619], + [1939004424700578, 1939004424700579], [1958612885715900, 1958612885715901], [1978336533097762, 1978336533097763], [1998175559365473, 1998175559365474], + [2018130156557689, 2018130156557690], [2038200516235602, 2038200516235603], [2058386829486092, 2058386829486093], [2078689286924845, 2078689286924846], + [2099108078699444, 2099108078699445], [2119643394492412, 2119643394492413], [2140295423524243, 2140295423524244], [2161064354556385, 2161064354556386], + [2181950375894200, 2181950375894201], [2202953675389890, 2202953675389891], [2224074440445392, 2224074440445393], [2245312858015250, 2245312858015251], + [2266669114609446, 2266669114609447], [2288143396296211, 2288143396296212], [2309735888704801, 2309735888704802], [2331446777028254, 2331446777028255], + [2353276246026107, 2353276246026108], [2375224480027092, 2375224480027093], [2397291662931810, 2397291662931811], [2419477978215366, 2419477978215367], + [2441783608929986, 2441783608929987], [2464208737707608, 2464208737707609], [2486753546762445, 2486753546762446], [2509418217893521, 2509418217893522], + [2532202932487186, 2532202932487187], [2555107871519605, 2555107871519606], [2578133215559222, 2578133215559223], [2601279144769198, 2601279144769199], + ], +]; diff --git a/crates/labcolors-core/src/wcag22_evidence.rs b/crates/labcolors-core/src/wcag22_evidence.rs new file mode 100644 index 00000000..6e9a32dd --- /dev/null +++ b/crates/labcolors-core/src/wcag22_evidence.rs @@ -0,0 +1,154 @@ +//! Proof-bound terminal evidence projection for the WCAG 2.2 evaluator. +//! +//! The independent verifier hashes this whole module. It is deliberately small: +//! one canonical capability row, exact stable keys, mint preconditions and the +//! sealed evidence variant returned by the production kernel. + +use crate::numerics::{ + NumericalArtifactIdV2, NumericalBoundStatusV2, NumericalDecisionEvidenceV1, + NumericalErrorBoundIdV2, NumericalEvidenceClassV2, NumericalFallbackStatusV1, + NumericalProofIdV2, NumericalSiteIdV2, NumericalSiteRecordV2, StableNumericalOutcomeV2, + numerical_registry_v2, +}; + +/// Opaque terminal payload. External callers can inspect the registered typed +/// identities but cannot construct or alter the payload. +/// +/// ```compile_fail +/// use labcolors_core::wcag22::{Wcag22AssessmentV1, Wcag22CriterionV1, evaluate_wcag22_srgb8}; +/// use labcolors_core::{NumericalDecisionEvidenceV1, CanonicalFiniteBoundedEvidenceV1}; +/// +/// let assessment = evaluate_wcag22_srgb8( +/// [0, 0, 0], +/// [255, 255, 255], +/// Wcag22CriterionV1::Sc143TextDefault, +/// ).unwrap(); +/// let Wcag22AssessmentV1::Evaluated { evidence, .. } = assessment else { unreachable!() }; +/// let NumericalDecisionEvidenceV1::CanonicalFiniteBounded(payload) = evidence else { +/// unreachable!() +/// }; +/// let _forged = CanonicalFiniteBoundedEvidenceV1 { +/// artifact_id: payload.artifact_id(), +/// bound_id: payload.bound_id(), +/// proof_id: payload.proof_id(), +/// _private: (), +/// }; +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CanonicalFiniteBoundedEvidenceV1 { + artifact_id: NumericalArtifactIdV2, + bound_id: NumericalErrorBoundIdV2, + proof_id: NumericalProofIdV2, + _private: (), +} + +impl CanonicalFiniteBoundedEvidenceV1 { + /// Canonical finite artifact used by the evaluator. + pub fn artifact_id(self) -> NumericalArtifactIdV2 { + self.artifact_id + } + + /// Registered outward-bound/decision law. + pub fn bound_id(self) -> NumericalErrorBoundIdV2 { + self.bound_id + } + + /// Replayable full-domain proof identity. + pub fn proof_id(self) -> NumericalProofIdV2 { + self.proof_id + } +} + +const SITE_ID: NumericalSiteIdV2 = NumericalSiteIdV2::Wcag22Srgb8ContrastV1; +const ARTIFACT_ID: NumericalArtifactIdV2 = NumericalArtifactIdV2::Wcag22Srgb8LuminanceQ55V1; +const BOUND_ID: NumericalErrorBoundIdV2 = NumericalErrorBoundIdV2::Wcag22Srgb8OutwardQ55V1; +const PROOF_ID: NumericalProofIdV2 = NumericalProofIdV2::Wcag22Srgb8FullDomainQ55V1; + +fn validate_canonical_row(row: &NumericalSiteRecordV2) -> Result<(), String> { + if row.site_id != SITE_ID { + return Err("WCAG22 terminal evidence site identity drifted".to_string()); + } + if row.site_id.key() != "wcag22-srgb8-contrast-v1" { + return Err("WCAG22 terminal evidence site key drifted".to_string()); + } + if row.stable_outcomes != [StableNumericalOutcomeV2::CanonicalFiniteBounded] { + return Err("WCAG22 terminal evidence stable outcomes drifted".to_string()); + } + if !row.compatibility_releases.is_empty() { + return Err("WCAG22 terminal evidence admitted compatibility".to_string()); + } + if row.evidence_classes != [NumericalEvidenceClassV2::CanonicalFiniteBounded] { + return Err("WCAG22 terminal evidence class drifted".to_string()); + } + if row.artifact_ids != [ARTIFACT_ID] { + return Err("WCAG22 terminal evidence artifact identity drifted".to_string()); + } + if ARTIFACT_ID.key() != "wcag22-srgb8-luminance-q55-v1" { + return Err("WCAG22 terminal evidence artifact key drifted".to_string()); + } + if row.bound_ids != [BOUND_ID] { + return Err("WCAG22 terminal evidence bound identity drifted".to_string()); + } + if BOUND_ID.key() != "wcag22-srgb8-outward-q55-v1" { + return Err("WCAG22 terminal evidence bound key drifted".to_string()); + } + if row.proof_ids != [PROOF_ID] { + return Err("WCAG22 terminal evidence proof identity drifted".to_string()); + } + if PROOF_ID.key() != "wcag22-srgb8-full-domain-q55-v1" { + return Err("WCAG22 terminal evidence proof key drifted".to_string()); + } + if !row.runtime_attestations.is_empty() { + return Err("WCAG22 terminal evidence admitted a runtime attestation".to_string()); + } + if row.bound_status != NumericalBoundStatusV2::Available { + return Err("WCAG22 terminal evidence bound status drifted".to_string()); + } + if row.fallback_status != NumericalFallbackStatusV1::None { + return Err("WCAG22 terminal evidence admitted a fallback".to_string()); + } + Ok(()) +} + +/// Mint the only terminal evidence admitted by the proof-bound WCAG kernel. +pub(crate) fn mint_wcag22_evidence() -> Result { + let row = numerical_registry_v2() + .iter() + .find(|row| row.site_id == SITE_ID) + .ok_or_else(|| "WCAG22 site отсутствует в registry V2".to_string())?; + validate_canonical_row(row)?; + Ok(NumericalDecisionEvidenceV1::CanonicalFiniteBounded( + CanonicalFiniteBoundedEvidenceV1 { + artifact_id: ARTIFACT_ID, + bound_id: BOUND_ID, + proof_id: PROOF_ID, + _private: (), + }, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_row_mints_exact_terminal_evidence() { + let evidence = mint_wcag22_evidence().expect("canonical WCAG row must mint evidence"); + assert!(matches!( + evidence, + NumericalDecisionEvidenceV1::CanonicalFiniteBounded(payload) + if payload.artifact_id() == ARTIFACT_ID + && payload.bound_id() == BOUND_ID + && payload.proof_id() == PROOF_ID + )); + } + + #[test] + fn unrelated_registered_row_cannot_mint_wcag_evidence() { + let glow = numerical_registry_v2() + .iter() + .find(|row| row.site_id == NumericalSiteIdV2::GlowTargetOrMaximumV1) + .expect("Glow row"); + assert!(validate_canonical_row(glow).is_err()); + } +} diff --git a/crates/labcolors-core/src/wcag22_tests.rs b/crates/labcolors-core/src/wcag22_tests.rs new file mode 100644 index 00000000..35644c8e --- /dev/null +++ b/crates/labcolors-core/src/wcag22_tests.rs @@ -0,0 +1,244 @@ +//! Контрактные тесты нормативного WCAG 2.2 sRGB8 evaluator-а (#284). +//! +//! Анти-epsilon свидетели — доказанные внешними 100-значными вычислениями +//! (Decimal + Wolfram, зафиксированы в Issue #284) пары СТРОГО ниже порога, +//! которые прежняя логика `ratio + 1e-9 >= floor` ошибочно принимала: +//! +//! ```text +//! #89BB09 / #8212DB → 2.999999999999939562… < 3.0 +//! #898CB8 / #3E2217 → 4.499999999999645330… < 4.5 +//! ``` +//! +//! Решение обязано приниматься ТОЛЬКО целочисленными законами над Q55 +//! outward-интервалами; никакой f64, деление или display-округление в +//! вердикте не участвуют. + +use crate::numerics::NumericalDecisionEvidenceV1; +use crate::wcag22::{ + Wcag22ApplicableDecisionV1, Wcag22AssessmentV1, Wcag22ClientDeclaredNotApplicableV1, + Wcag22CriterionV1, evaluate_wcag22_srgb8, wcag22_profile_v1, +}; + +// This full-period modulo-2^64 LCG provides a reproducible PR-time sample. +// It exercises public invariants; it does not replace the full-domain proof. +const CROSS_COLOUR_CORPUS_SIZE: u32 = 100_000; +const CROSS_COLOUR_CORPUS_SEED: u64 = 0xD1B5_4A32_D192_ED03; +const LCG_MULTIPLIER: u64 = 6_364_136_223_846_793_005; +const LCG_INCREMENT: u64 = 1_442_695_040_888_963_407; + +fn rgb(hex: u32) -> [u8; 3] { + [(hex >> 16) as u8, (hex >> 8) as u8, hex as u8] +} + +/// Достаёт Evaluated-ветвь или падает: NotEvaluated здесь незаконен. +fn evaluated(assessment: &Wcag22AssessmentV1) -> &Wcag22AssessmentV1 { + assert!( + matches!(assessment, Wcag22AssessmentV1::Evaluated { .. }), + "ожидалась Evaluated-ветвь" + ); + assessment +} + +#[test] +fn anti_epsilon_witnesses_are_definite_fail() { + // 2.999999999999939… СТРОГО ниже 3.0: оба SC-1.4.11-критерия обязаны дать + // definite Fail, который прежний `+ 1e-9` превращал в ложный Pass. + for criterion in [ + Wcag22CriterionV1::Sc1411UiComponentOrState, + Wcag22CriterionV1::Sc1411GraphicalObject, + Wcag22CriterionV1::Sc143TextLargeScale, + ] { + let assessment = evaluate_wcag22_srgb8(rgb(0x89BB09), rgb(0x8212DB), criterion) + .expect("admitted sRGB8 domain обязан быть decision-total"); + let Wcag22AssessmentV1::Evaluated { decision, .. } = evaluated(&assessment) else { + unreachable!() + }; + assert_eq!( + *decision, + Wcag22ApplicableDecisionV1::Fail, + "#89BB09/#8212DB ниже 3.0 — обязан быть definite Fail ({criterion:?})" + ); + } + + // 4.499999999999645… СТРОГО ниже 4.5. + let assessment = evaluate_wcag22_srgb8( + rgb(0x898CB8), + rgb(0x3E2217), + Wcag22CriterionV1::Sc143TextDefault, + ) + .expect("admitted sRGB8 domain обязан быть decision-total"); + let Wcag22AssessmentV1::Evaluated { decision, .. } = evaluated(&assessment) else { + unreachable!() + }; + assert_eq!( + *decision, + Wcag22ApplicableDecisionV1::Fail, + "#898CB8/#3E2217 ниже 4.5 — обязан быть definite Fail" + ); +} + +#[test] +fn black_white_is_definite_pass_for_every_criterion_and_symmetric() { + for criterion in [ + Wcag22CriterionV1::Sc143TextDefault, + Wcag22CriterionV1::Sc143TextLargeScale, + Wcag22CriterionV1::Sc1411UiComponentOrState, + Wcag22CriterionV1::Sc1411GraphicalObject, + ] { + for (fg, bg) in [(0x000000, 0xFFFFFF), (0xFFFFFF, 0x000000)] { + let assessment = evaluate_wcag22_srgb8(rgb(fg), rgb(bg), criterion) + .expect("admitted sRGB8 domain обязан быть decision-total"); + let Wcag22AssessmentV1::Evaluated { decision, .. } = evaluated(&assessment) else { + unreachable!() + }; + assert_eq!(*decision, Wcag22ApplicableDecisionV1::Pass); + } + } +} + +#[test] +fn same_pair_may_fail_text_and_pass_a_3_to_1_criterion() { + // #8A8A8A/#FFFFFF ≈ 3.45:1 (между 3.0 и 4.5; величина использована только + // для ВЫБОРА фикстуры — вердикты ниже из целочисленных законов). + let fg = rgb(0x8A8A8A); + let bg = rgb(0xFFFFFF); + let text = evaluate_wcag22_srgb8(fg, bg, Wcag22CriterionV1::Sc143TextDefault) + .expect("admitted sRGB8 domain обязан быть decision-total"); + let ui = evaluate_wcag22_srgb8(fg, bg, Wcag22CriterionV1::Sc1411GraphicalObject) + .expect("admitted sRGB8 domain обязан быть decision-total"); + let Wcag22AssessmentV1::Evaluated { decision: t, .. } = evaluated(&text) else { + unreachable!() + }; + let Wcag22AssessmentV1::Evaluated { decision: u, .. } = evaluated(&ui) else { + unreachable!() + }; + assert_eq!( + *t, + Wcag22ApplicableDecisionV1::Fail, + "≈3.45 ниже текстовых 4.5" + ); + assert_eq!(*u, Wcag22ApplicableDecisionV1::Pass); +} + +#[test] +fn not_evaluated_is_never_pass_and_requires_explicit_declaration() { + let reason_id = "decorative-divider"; + let declaration = Wcag22ClientDeclaredNotApplicableV1::try_new(reason_id).unwrap(); + assert_eq!(declaration.reason_id(), reason_id); + let declared = Wcag22AssessmentV1::NotEvaluated { + profile_id: wcag22_profile_v1().profile_id, + declaration, + }; + // Тип не даёт достать Pass из NotEvaluated: вариант не несёт decision. + assert!(matches!(declared, Wcag22AssessmentV1::NotEvaluated { .. })); +} + +#[test] +fn public_q55_accessors_preserve_exact_endpoint_bounds_and_scale() { + let assessment = evaluate_wcag22_srgb8( + rgb(0x000000), + rgb(0xFFFFFF), + Wcag22CriterionV1::Sc143TextDefault, + ) + .expect("admitted sRGB8 domain обязан быть decision-total"); + let Wcag22AssessmentV1::Evaluated { measurement, .. } = evaluated(&assessment) else { + unreachable!() + }; + let scale = 1_u64 << 55; + assert_eq!(measurement.foreground_luminance.lower(), 0); + assert_eq!(measurement.foreground_luminance.upper(), 0); + // White sums three independently outward-rounded weighted rows; the + // canonical table therefore encloses exact Q as [Q-2, Q+1]. + assert_eq!(measurement.background_luminance.lower(), scale - 2); + assert_eq!(measurement.background_luminance.upper(), scale + 1); + assert_eq!(crate::wcag22::Wcag22LuminanceBoundsQ55V1::scale(), scale); +} + +#[test] +fn evidence_is_sealed_canonical_finite_bounded_with_registered_ids() { + let assessment = evaluate_wcag22_srgb8( + rgb(0x000000), + rgb(0xFFFFFF), + Wcag22CriterionV1::Sc143TextDefault, + ) + .expect("admitted sRGB8 domain обязан быть decision-total"); + let Wcag22AssessmentV1::Evaluated { evidence, .. } = &assessment else { + panic!("ожидалась Evaluated-ветвь"); + }; + assert!(matches!( + evidence, + NumericalDecisionEvidenceV1::CanonicalFiniteBounded(_) + )); +} + +#[test] +fn deterministic_cross_colour_sample_is_total_and_symmetric() { + let mut state = CROSS_COLOUR_CORPUS_SEED; + let mut pass = 0_u32; + let mut fail = 0_u32; + for index in 0..CROSS_COLOUR_CORPUS_SIZE { + state = state + .wrapping_mul(LCG_MULTIPLIER) + .wrapping_add(LCG_INCREMENT); + let first = [state as u8, (state >> 8) as u8, (state >> 16) as u8]; + state = state + .wrapping_mul(LCG_MULTIPLIER) + .wrapping_add(LCG_INCREMENT); + let second = [state as u8, (state >> 8) as u8, (state >> 16) as u8]; + let criterion = if index & 1 == 0 { + Wcag22CriterionV1::Sc143TextDefault + } else { + Wcag22CriterionV1::Sc1411GraphicalObject + }; + let forward = evaluate_wcag22_srgb8(first, second, criterion) + .expect("admitted sRGB8 domain обязан быть decision-total"); + let reverse = evaluate_wcag22_srgb8(second, first, criterion) + .expect("swap обязан оставаться в admitted domain"); + let Wcag22AssessmentV1::Evaluated { + decision: forward_decision, + measurement, + .. + } = forward + else { + unreachable!() + }; + let Wcag22AssessmentV1::Evaluated { + decision: reverse_decision, + .. + } = reverse + else { + unreachable!() + }; + assert_eq!(forward_decision, reverse_decision); + assert_eq!(measurement.foreground, first); + assert_eq!(measurement.background, second); + match forward_decision { + Wcag22ApplicableDecisionV1::Pass => pass += 1, + Wcag22ApplicableDecisionV1::Fail => fail += 1, + } + } + assert!( + pass > 0 && fail > 0, + "corpus обязан кусать обе decision branches" + ); +} + +#[test] +fn identical_colours_fail_every_applicable_criterion() { + for criterion in [ + Wcag22CriterionV1::Sc143TextDefault, + Wcag22CriterionV1::Sc143TextLargeScale, + Wcag22CriterionV1::Sc1411UiComponentOrState, + Wcag22CriterionV1::Sc1411GraphicalObject, + ] { + let assessment = evaluate_wcag22_srgb8([73, 129, 211], [73, 129, 211], criterion) + .expect("identical bytes are a valid total-domain input"); + assert!(matches!( + assessment, + Wcag22AssessmentV1::Evaluated { + decision: Wcag22ApplicableDecisionV1::Fail, + .. + } + )); + } +} diff --git a/crates/labcolors-ffi/src/lib.rs b/crates/labcolors-ffi/src/lib.rs index 0ee6b2f6..ab1a9fc5 100644 --- a/crates/labcolors-ffi/src/lib.rs +++ b/crates/labcolors-ffi/src/lib.rs @@ -23,6 +23,7 @@ //! | [`ladder_alpha`] | `ladders` | `LadderPosition::alpha_pair` | //! | [`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 | //! | [`core_version`] | `manifest` | версия ядра | //! //! [`solve_glow_point`] — отдельный low-level contract test нативной границы: @@ -133,6 +134,99 @@ pub struct Contrast { pub wcag_ratio: f64, } +/// Explicit WCAG 2.2 success criterion for one occurrence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum Wcag22Criterion { + /// SC 1.4.3 ordinary text, 4.5:1. + Sc143TextDefault, + /// SC 1.4.3 explicitly declared large-scale text, 3:1. + Sc143TextLargeScale, + /// SC 1.4.11 required UI component/state information, 3:1. + Sc1411UiComponentOrState, + /// SC 1.4.11 required graphical-object information, 3:1. + Sc1411GraphicalObject, +} + +impl Wcag22Criterion { + fn to_core(self) -> labcolors_core::wcag22::Wcag22CriterionV1 { + use labcolors_core::wcag22::Wcag22CriterionV1 as Core; + match self { + Self::Sc143TextDefault => Core::Sc143TextDefault, + Self::Sc143TextLargeScale => Core::Sc143TextLargeScale, + Self::Sc1411UiComponentOrState => Core::Sc1411UiComponentOrState, + Self::Sc1411GraphicalObject => Core::Sc1411GraphicalObject, + } + } +} + +/// Total decision on the admitted final-sRGB8 domain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum Wcag22Decision { + /// Threshold is proved satisfied. + Pass, + /// Threshold is proved unsatisfied. + Fail, +} + +/// Q55 outward luminance enclosure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Record)] +pub struct Wcag22Q55Bounds { + /// Inclusive lower bound. + pub lower: u64, + /// Inclusive upper bound. + pub upper: u64, +} + +/// Registry-bound numerical evidence transported from Rust core. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct Wcag22Evidence { + /// Evidence class key. + pub kind: String, + /// Canonical artifact identity. + pub artifact_id: String, + /// Canonical binary artifact digest. + pub artifact_sha256: String, + /// Registered bound/threshold-law identity. + pub bound_id: String, + /// Replayable full-domain proof identity. + pub proof_id: String, + /// Exact committed proof-file digest. + pub proof_sha256: String, + /// Canonical proof payload integrity digest. + pub proof_payload_sha256: String, + /// Exact generator source digest. + pub generator_sha256: String, + /// Exact independent verifier source digest. + pub verifier_sha256: String, + /// Typed profile V1 checksum, independent of JSON formatting. + pub profile_checksum: String, + /// Canonical profile-source digest. + pub profile_sha256: String, +} + +/// Atomic WCAG 2.2 assessment; Swift performs no contrast math. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct Wcag22Assessment { + /// Immutable profile identity. + pub profile_id: String, + /// Exact declared occurrence criterion. + pub criterion: Wcag22Criterion, + /// Normalised final foreground bytes as hex. + pub foreground: String, + /// Normalised final background bytes as hex. + pub background: String, + /// Foreground Q55 enclosure. + pub foreground_luminance: Wcag22Q55Bounds, + /// Background Q55 enclosure. + pub background_luminance: Wcag22Q55Bounds, + /// Fixed-point scale (`2^55`). + pub q55_scale: u64, + /// Exact Pass/Fail result. + pub decision: Wcag22Decision, + /// Sealed evidence identities. + pub evidence: Wcag22Evidence, +} + /// Резолвнутый цвет и достигнутые им контрасты. #[derive(Debug, Clone, PartialEq, uniffi::Record)] pub struct Solved { @@ -342,6 +436,118 @@ pub fn contrast(fg: String, bg: String, theme: Theme) -> Result Result { + use labcolors_core::wcag22::{Wcag22ApplicableDecisionV1, Wcag22AssessmentV1}; + + let core = + labcolors_core::wcag22::evaluate_wcag22_hex(&foreground, &background, criterion.to_core()) + .map_err(|error| match error { + labcolors_core::wcag22::Wcag22EvaluationErrorV1::InvalidSrgb8 { field, reason } => { + ColorError::InvalidColor { + reason: format!("{field}: {reason}"), + } + } + other => ColorError::IncompatibleCoreContract { + reason: other.to_string(), + }, + })?; + let Wcag22AssessmentV1::Evaluated { + profile_id, + criterion: assessed_criterion, + measurement, + decision, + evidence, + .. + } = core + else { + return Err(ColorError::IncompatibleCoreContract { + reason: "pair evaluator returned report-only NotEvaluated".to_string(), + }); + }; + let NumericalDecisionEvidenceV1::CanonicalFiniteBounded(evidence_payload) = evidence else { + return Err(ColorError::IncompatibleCoreContract { + reason: "WCAG22 assessment carried a non-bounded evidence class".to_string(), + }); + }; + let artifact_id = evidence_payload.artifact_id(); + let bound_id = evidence_payload.bound_id(); + let proof_id = evidence_payload.proof_id(); + let profile = labcolors_core::wcag22::wcag22_profile_v1(); + if profile.profile_id != profile_id + || profile.artifact_id != artifact_id + || profile.bound_id != bound_id + || profile.proof_id != proof_id + { + return Err(ColorError::IncompatibleCoreContract { + reason: "WCAG22 assessment/profile evidence identities drifted".to_string(), + }); + } + let hex = |bytes: [u8; 3]| format!("#{:02X}{:02X}{:02X}", bytes[0], bytes[1], bytes[2]); + let decision = match decision { + Wcag22ApplicableDecisionV1::Pass => Wcag22Decision::Pass, + Wcag22ApplicableDecisionV1::Fail => Wcag22Decision::Fail, + _ => return Err(incompatible_core_variant("Wcag22ApplicableDecisionV1")), + }; + let mapped_criterion = match assessed_criterion { + labcolors_core::wcag22::Wcag22CriterionV1::Sc143TextDefault => { + Wcag22Criterion::Sc143TextDefault + } + labcolors_core::wcag22::Wcag22CriterionV1::Sc143TextLargeScale => { + Wcag22Criterion::Sc143TextLargeScale + } + labcolors_core::wcag22::Wcag22CriterionV1::Sc1411UiComponentOrState => { + Wcag22Criterion::Sc1411UiComponentOrState + } + labcolors_core::wcag22::Wcag22CriterionV1::Sc1411GraphicalObject => { + Wcag22Criterion::Sc1411GraphicalObject + } + _ => return Err(incompatible_core_variant("Wcag22CriterionV1")), + }; + if mapped_criterion != criterion { + return Err(ColorError::IncompatibleCoreContract { + reason: "WCAG22 assessment criterion drifted from the requested criterion".to_string(), + }); + } + Ok(Wcag22Assessment { + profile_id: profile_id.key().to_string(), + criterion: mapped_criterion, + foreground: hex(measurement.foreground), + background: hex(measurement.background), + foreground_luminance: Wcag22Q55Bounds { + lower: measurement.foreground_luminance.lower(), + upper: measurement.foreground_luminance.upper(), + }, + background_luminance: Wcag22Q55Bounds { + lower: measurement.background_luminance.lower(), + upper: measurement.background_luminance.upper(), + }, + q55_scale: labcolors_core::wcag22::Wcag22LuminanceBoundsQ55V1::scale(), + decision, + evidence: Wcag22Evidence { + kind: "canonical-finite-bounded".to_string(), + artifact_id: artifact_id.key().to_string(), + artifact_sha256: profile.artifact_sha256.to_string(), + bound_id: bound_id.key().to_string(), + proof_id: proof_id.key().to_string(), + proof_sha256: profile.proof_sha256.to_string(), + proof_payload_sha256: profile.proof_payload_sha256.to_string(), + generator_sha256: profile.generator_sha256.to_string(), + verifier_sha256: profile.verifier_sha256.to_string(), + profile_checksum: profile.profile_checksum.to_string(), + profile_sha256: profile.source_sha256.to_string(), + }, + }) +} + /// Перепроверка контрастов многих передних планов на одном фоне под темой. /// Фон платит свой forward один раз на весь батч — примитив реактивного /// рантайма для решения «прошли ли уже резолвнутые цвета против сменившегося @@ -612,7 +818,9 @@ pub fn solve_glow_point( }), } } - NumericalDecisionV1::Indeterminate { site_id, evidence } => { + NumericalDecisionV1::Indeterminate { + site_id, evidence, .. + } => { if profile != GlowDecisionProfile::StableV1 { return Err(ColorError::IncompatibleCoreContract { reason: "legacy Glow profile returned an Indeterminate core outcome" @@ -647,6 +855,51 @@ pub fn muddiness(hex: String) -> Result { mod tests { use super::*; + #[test] + fn wcag22_transport_preserves_core_decision_and_evidence() { + let assessment = evaluate_wcag22( + "#89BB09".into(), + "#8212DB".into(), + Wcag22Criterion::Sc1411GraphicalObject, + ) + .unwrap(); + assert_eq!(assessment.decision, Wcag22Decision::Fail); + assert_eq!(assessment.foreground, "#89BB09"); + assert_eq!(assessment.background, "#8212DB"); + assert_eq!( + assessment.evidence.artifact_id, + "wcag22-srgb8-luminance-q55-v1" + ); + assert_eq!( + assessment.evidence.proof_id, + "wcag22-srgb8-full-domain-q55-v1" + ); + assert_eq!(assessment.q55_scale, 1_u64 << 55); + } + + #[test] + fn wcag22_transport_maps_core_pass() { + let assessment = evaluate_wcag22( + "#000000".into(), + "#FFFFFF".into(), + Wcag22Criterion::Sc143TextDefault, + ) + .unwrap(); + assert_eq!(assessment.decision, Wcag22Decision::Pass); + } + + #[test] + fn wcag22_transport_rejects_invalid_hex_without_fallback() { + assert!(matches!( + evaluate_wcag22( + "invalid".into(), + "#FFFFFF".into(), + Wcag22Criterion::Sc143TextDefault, + ), + Err(ColorError::InvalidColor { .. }) + )); + } + #[test] fn legacy_solve_maps_to_atomic_compatibility_variants() { // Явный compatibility-mode: результат — атомарный LegacyReached/ diff --git a/crates/labcolors-wasm/src/error.rs b/crates/labcolors-wasm/src/error.rs index bcb1c37a..417955a0 100644 --- a/crates/labcolors-wasm/src/error.rs +++ b/crates/labcolors-wasm/src/error.rs @@ -1,14 +1,17 @@ //! Structured, matchable errors for the binding boundary. //! -//! This is a library crate, so errors are `thiserror` enums callers can match -//! on — not opaque strings. They cross into JS as a *structured* error object -//! (a `code` plus a human `message`), never as a thrown panic or an unwound -//! stack. The engine's hot path returns these as values; the only `throw` is at -//! the top-level wasm adapter for whole-call failures (bad hex, unknown theme), -//! which is the JS-idiomatic place for a rejected input. +//! Inside Rust, errors are `thiserror` enums callers can match on. At the JS +//! boundary, whole-call failures become ordinary `Error` objects whose message +//! has the stable `": "` form; there is no separate JS `code` +//! property. The top-level wasm adapter throws those errors for rejected input +//! (bad hex, unknown theme) without unwinding a Rust panic across the boundary. use thiserror::Error; +fn expected_wcag22_criterion_keys() -> &'static str { + labcolors_core::wcag22::Wcag22CriterionV1::WIRE_KEY_MENU +} + /// A reason a binding call could not produce a result. /// /// Per-role unreachability is *not* here — that is a successful resolve whose @@ -63,6 +66,16 @@ pub enum BindingError { /// The unrecognised theme string the caller passed. requested: String, }, + + /// WCAG 2.2 criterion transport is outside the closed public menu. + #[error( + "unknown WCAG22 criterion: '{requested}' (expected {expected})", + expected = expected_wcag22_criterion_keys() + )] + UnknownWcag22Criterion { + /// Unrecognised criterion key. + requested: String, + }, } impl BindingError { @@ -75,6 +88,7 @@ impl BindingError { BindingError::InvalidConfig { .. } => "invalid_config", BindingError::ConfigRequired => "config_required", BindingError::UnknownTheme { .. } => "unknown_theme", + BindingError::UnknownWcag22Criterion { .. } => "unknown_wcag22_criterion", BindingError::Internal { .. } => "internal_error", } } @@ -94,6 +108,9 @@ mod tests { BindingError::UnknownTheme { requested: "x".into(), }, + BindingError::UnknownWcag22Criterion { + requested: "x".into(), + }, BindingError::Internal { reason: "x".into() }, ]; let codes: Vec<_> = errors.iter().map(BindingError::code).collect(); @@ -105,6 +122,7 @@ mod tests { "invalid_config", "config_required", "unknown_theme", + "unknown_wcag22_criterion", "internal_error" ] ); @@ -113,4 +131,17 @@ mod tests { let unique: std::collections::HashSet<_> = codes.iter().collect(); assert_eq!(unique.len(), codes.len(), "error codes must be distinct"); } + + #[test] + fn unknown_wcag22_criterion_lists_the_core_wire_menu() { + let requested = "not-a-criterion"; + let expected = expected_wcag22_criterion_keys(); + let error = BindingError::UnknownWcag22Criterion { + requested: requested.into(), + }; + assert_eq!( + error.to_string(), + format!("unknown WCAG22 criterion: '{requested}' (expected {expected})") + ); + } } diff --git a/crates/labcolors-wasm/src/lib.rs b/crates/labcolors-wasm/src/lib.rs index ef82d817..3388dc42 100644 --- a/crates/labcolors-wasm/src/lib.rs +++ b/crates/labcolors-wasm/src/lib.rs @@ -443,40 +443,62 @@ export interface ThemeConfig { readonly aliases?: ReadonlyArray<{ alias: string; target: string }>; } -/** Capability одного зарегистрированного численного site (#289/#292): что - * сборка УМЕЕТ (registry-проекция), не что выбрал клиент. Пустой массив — - * явное отсутствие evidence, не implicit support. */ -export interface NumericalCapabilitySiteV1 { - /** Stable site identity key, например "glow-target-or-maximum-v1". */ +/** Proof-capable V2 site. Empty arrays explicitly mean no admitted evidence. */ +export interface NumericalCapabilitySiteV2 { readonly siteId: string; - /** Законные stable-исходы site. */ readonly stableOutcomes: ReadonlyArray; - /** Зарегистрированные compatibility-releases. */ readonly compatibilityReleases: ReadonlyArray; - /** Минтимые классы evidence. */ readonly evidenceClasses: ReadonlyArray; - /** Canonical finite artifact IDs (пусто в V1). */ readonly artifactIds: ReadonlyArray; - /** Registered error bound IDs (пусто в V1). */ readonly boundIds: ReadonlyArray; - /** Runtime attestation IDs (пусто до #258). */ + readonly proofIds: ReadonlyArray; readonly runtimeAttestations: ReadonlyArray; } -/** Canonical numerical capability manifest: статическое свойство сборки, - * спроецированное из core registry SSOT. Та же camelCase-форма, что - * numericalCapabilities манифеста conformance-пака. */ -export interface NumericalCapabilityManifestV1 { - /** Версия capability-схемы (независимый домен версий). */ - readonly schemaVersion: number; - /** Покрытие registry: "migrated-sites-only-v1". */ +/** Proof-capable numerical capability manifest used by conformance pack 4. */ +export interface NumericalCapabilityManifestV2 { + readonly schemaVersion: 2; readonly coverage: string; - /** Capability rows, отсортированные по UTF-8 байтам siteId. */ - readonly sites: ReadonlyArray; - /** FNV-1a-32 drift-checksum canonical preimage, 8 lowercase hex. */ + readonly sites: ReadonlyArray; readonly checksum: string; } +export type Wcag22CriterionV1 = + | "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"; +export type Wcag22DecisionV1 = "pass" | "fail"; +export interface Wcag22Q55BoundsV1 { + /** Decimal u64 string: Q55 values exceed JavaScript's safe integer range. */ + readonly lower: string; + readonly upper: string; +} +export interface Wcag22AssessmentV1 { + readonly kind: "evaluated"; + readonly profileId: "wcag22-srgb8-contrast-v1"; + readonly criterion: Wcag22CriterionV1; + readonly foreground: string; + readonly background: string; + readonly foregroundLuminanceQ55: Wcag22Q55BoundsV1; + readonly backgroundLuminanceQ55: Wcag22Q55BoundsV1; + readonly q55Scale: string; + readonly decision: Wcag22DecisionV1; + readonly evidence: { + readonly kind: "canonical-finite-bounded"; + readonly artifactId: "wcag22-srgb8-luminance-q55-v1"; + readonly artifactSha256: string; + readonly boundId: "wcag22-srgb8-outward-q55-v1"; + readonly proofId: "wcag22-srgb8-full-domain-q55-v1"; + readonly proofSha256: string; + readonly proofPayloadSha256: string; + readonly generatorSha256: string; + readonly verifierSha256: string; + readonly profileChecksum: string; + readonly profileSha256: string; + }; +} + /** The full result of resolving one background under one theme. */ export interface ResolvedTheme { readonly theme: ThemeName; @@ -503,18 +525,21 @@ extern "C" { #[wasm_bindgen(typescript_type = "ResolvedTheme")] pub type JsResolvedTheme; - #[wasm_bindgen(typescript_type = "NumericalCapabilityManifestV1")] - pub type JsNumericalCapabilityManifest; + #[wasm_bindgen(typescript_type = "NumericalCapabilityManifestV2")] + pub type JsNumericalCapabilityManifestV2; + + #[wasm_bindgen(typescript_type = "Wcag22AssessmentV1")] + pub type JsWcag22Assessment; } -/// Canonical numerical capability manifest текущей WASM-сборки (#289/#292). +/// Единственный public numerical capability manifest: proof-capable V2. /// /// Свободная функция, а не метод движка: манифест — статическое свойство /// сборки (core registry SSOT), он не зависит ни от загруженного конфига, ни -/// от состояния кэша. Форма — camelCase-проекция `CapabilityManifestProjection` -/// conformance-пака; `checksum` — FNV-1a-32 canonical preimage, 8 lowercase hex. +/// от состояния кэша. До появления клиентов ошибочная промежуточная V1 +/// projection удалена, чтобы не закреплять две конкурирующие поверхности. #[wasm_bindgen(js_name = numericalCapabilityManifest)] -pub fn numerical_capability_manifest() -> Result { +pub fn numerical_capability_manifest() -> Result { // Та же «широкая» схема границы, что у resolveTheme: одна UTF-8 строка + // нативный JSON.parse вместо пообъектной сборки Reflect::set. let json = crate::projection::capability_manifest_json(); @@ -526,6 +551,41 @@ pub fn numerical_capability_manifest() -> Result Result { + use labcolors_core::wcag22::Wcag22CriterionV1 as C; + let criterion = C::parse(criterion).ok_or_else(|| { + to_js_error(BindingError::UnknownWcag22Criterion { + requested: criterion.to_string(), + }) + })?; + let assessment = + labcolors_core::wcag22::evaluate_wcag22_hex(foreground_hex, background_hex, criterion) + .map_err(|error| { + use labcolors_core::wcag22::Wcag22EvaluationErrorV1 as E; + to_js_error(match error { + E::InvalidSrgb8 { field, reason } => BindingError::InvalidColor { + reason: format!("{field}: {reason}"), + }, + other => BindingError::Internal { + reason: other.to_string(), + }, + }) + })?; + let json = crate::projection::wcag22_json(&assessment).map_err(to_js_error)?; + let parsed = js_sys::JSON::parse(&json).map_err(|_| { + to_js_error(BindingError::Internal { + reason: "WCAG22 projection не распарсился как JSON".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 @@ -623,8 +683,9 @@ impl LabColors { /// /// Returns a `Float64Array` of `[lc, wcagRatio]` pairs, interleaved and in the /// order of `fgHexes`: index `2*i` is foreground `i`'s signed `Lc`, `2*i+1` - /// its WCAG ratio. Rejects (structured `{code, message}`) on an invalid hex or - /// an unknown theme. + /// its WCAG ratio. On invalid hex or an unknown theme, rejects with an + /// ordinary JS `Error` whose message starts with the stable + /// `": "` prefix. #[wasm_bindgen(js_name = recheckContrast)] pub fn recheck_contrast( &self, @@ -722,25 +783,32 @@ fn stable_glow_recheck_core_error(reason: String) -> BindingError { mod native_contract_tests { use super::*; - #[test] - fn generated_config_types_cover_the_closed_ladder_menu_and_dto_fields() { - let source = include_str!("lib.rs"); - let types = source + fn custom_types() -> &'static str { + include_str!("lib.rs") .split_once("const TS_RESULT_TYPES: &'static str = r##\"") .and_then(|(_, tail)| tail.split_once("\"##;").map(|(types, _)| types)) - .expect("custom TypeScript section is extractable"); - let block = types - .split_once("export type LadderPositionV1 =") + .expect("custom TypeScript section is extractable") + } + + fn string_union<'a>(types: &'a str, name: &str) -> Vec<&'a str> { + let declaration = format!("export type {name} ="); + types + .split_once(&declaration) .and_then(|(_, tail)| tail.split_once(';').map(|(block, _)| block)) - .expect("LadderPositionV1 union exists"); - let declared: Vec<&str> = block + .unwrap_or_else(|| panic!("{name} union exists")) .lines() .filter_map(|line| { line.trim() .strip_prefix("| \"") .and_then(|value| value.strip_suffix('"')) }) - .collect(); + .collect() + } + + #[test] + fn generated_config_types_cover_the_closed_ladder_menu_and_dto_fields() { + let types = custom_types(); + let declared = string_union(types, "LadderPositionV1"); let declared_set: std::collections::HashSet<&str> = declared.iter().copied().collect(); let core_set: std::collections::HashSet<&str> = labcolors_core::LadderPosition::ALL .iter() @@ -757,6 +825,26 @@ mod native_contract_tests { assert!(types.contains("readonly roles: ReadonlyArray")); } + #[test] + fn generated_wcag22_criterion_type_equals_the_core_wire_menu() { + let declared = string_union(custom_types(), "Wcag22CriterionV1"); + let declared_set: std::collections::HashSet<&str> = declared.iter().copied().collect(); + let core_set: std::collections::HashSet<&str> = + labcolors_core::wcag22::Wcag22CriterionV1::ALL + .iter() + .map(|criterion| criterion.key()) + .collect(); + assert_eq!( + declared.len(), + declared_set.len(), + "duplicate TS WCAG22 criterion literal" + ); + assert_eq!( + declared_set, core_set, + "TS WCAG22 criterion menu must equal core ALL" + ); + } + #[test] fn stable_glow_noop_boundary_normalises_the_resolve_hex_vocabulary() { let colors = LabColors::new(); diff --git a/crates/labcolors-wasm/src/projection.rs b/crates/labcolors-wasm/src/projection.rs index 513e86b9..bddd37ce 100644 --- a/crates/labcolors-wasm/src/projection.rs +++ b/crates/labcolors-wasm/src/projection.rs @@ -28,6 +28,94 @@ use std::fmt::Write as _; use crate::dto::{GlowColor, GlowIndeterminateColor, MaterialColor, ResolvedTheme, RoleOutcome}; use crate::error::BindingError; +/// Project the core-owned WCAG22 assessment without recomputing any math. +pub fn wcag22_json( + assessment: &labcolors_core::wcag22::Wcag22AssessmentV1, +) -> Result { + use labcolors_core::NumericalDecisionEvidenceV1; + use labcolors_core::wcag22::{Wcag22ApplicableDecisionV1, Wcag22AssessmentV1}; + + let Wcag22AssessmentV1::Evaluated { + profile_id, + criterion, + measurement, + decision, + evidence, + .. + } = assessment + else { + return Err(BindingError::Internal { + reason: "pair evaluator returned report-only NotEvaluated".to_string(), + }); + }; + let NumericalDecisionEvidenceV1::CanonicalFiniteBounded(evidence_payload) = evidence else { + return Err(BindingError::Internal { + reason: "WCAG22 assessment carried a non-bounded evidence class".to_string(), + }); + }; + let artifact_id = evidence_payload.artifact_id(); + let bound_id = evidence_payload.bound_id(); + let proof_id = evidence_payload.proof_id(); + let profile = labcolors_core::wcag22::wcag22_profile_v1(); + if profile.profile_id != *profile_id + || profile.artifact_id != artifact_id + || profile.bound_id != bound_id + || profile.proof_id != proof_id + { + return Err(BindingError::Internal { + reason: "WCAG22 assessment/profile evidence identities drifted".to_string(), + }); + } + + let hex = |bytes: [u8; 3]| format!("#{:02X}{:02X}{:02X}", bytes[0], bytes[1], bytes[2]); + let criterion = criterion.key(); + let decision = match decision { + Wcag22ApplicableDecisionV1::Pass => "pass", + Wcag22ApplicableDecisionV1::Fail => "fail", + _ => { + return Err(BindingError::Internal { + reason: "unknown core WCAG22 decision variant".to_string(), + }); + } + }; + + Ok(format!( + concat!( + "{{\"kind\":\"evaluated\",\"profileId\":\"{}\",", + "\"criterion\":\"{}\",\"foreground\":\"{}\",\"background\":\"{}\",", + "\"foregroundLuminanceQ55\":{{\"lower\":\"{}\",\"upper\":\"{}\"}},", + "\"backgroundLuminanceQ55\":{{\"lower\":\"{}\",\"upper\":\"{}\"}},", + "\"q55Scale\":\"{}\",\"decision\":\"{}\",", + "\"evidence\":{{\"kind\":\"canonical-finite-bounded\",", + "\"artifactId\":\"{}\",\"artifactSha256\":\"{}\",", + "\"boundId\":\"{}\",\"proofId\":\"{}\",", + "\"proofSha256\":\"{}\",\"proofPayloadSha256\":\"{}\",", + "\"generatorSha256\":\"{}\",\"verifierSha256\":\"{}\",", + "\"profileChecksum\":\"{}\",\"profileSha256\":\"{}\"}}}}" + ), + profile_id.key(), + criterion, + hex(measurement.foreground), + hex(measurement.background), + measurement.foreground_luminance.lower(), + measurement.foreground_luminance.upper(), + measurement.background_luminance.lower(), + measurement.background_luminance.upper(), + labcolors_core::wcag22::Wcag22LuminanceBoundsQ55V1::scale(), + decision, + artifact_id.key(), + profile.artifact_sha256, + bound_id.key(), + proof_id.key(), + profile.proof_sha256, + profile.proof_payload_sha256, + profile.generator_sha256, + profile.verifier_sha256, + profile.profile_checksum, + profile.source_sha256, + )) +} + /// Сериализовать [`ResolvedTheme`] в JSON, литерально повторяющий форму /// `.d.ts`-контракта: `{ theme, background, vars, roles }`. Построено /// генерически из вектора ролей — ни одна роль здесь не поименована, набор @@ -244,25 +332,16 @@ pub fn resolved_json(resolved: &ResolvedTheme) -> Result { Ok(out) } -/// Проекция canonical numerical capability manifest (#289/#292) в JSON с -/// camelCase-полями ровно той же формы, что `CapabilityManifestProjection` -/// conformance-крейта: `schemaVersion, coverage, sites[{siteId, stableOutcomes, -/// compatibilityReleases, evidenceClasses, artifactIds, boundIds, -/// runtimeAttestations}], checksum`. Построена ИЗ core SSOT -/// (`numerical_capability_manifest_v1`), не из рукописной копии registry: -/// adapter не имеет права держать второй список sites, иначе поверхности -/// расходятся молча. Пустой список эмитится явным `[]` (пусто = отсутствие -/// evidence, не implicit support). +/// Proof-capable V2-проекция. Форма совпадает с `numericalCapabilities` +/// conformance pack 4. Это единственная public adapter projection. pub fn capability_manifest_json() -> String { - let manifest = labcolors_core::numerical_capability_manifest_v1(); - let mut out = String::with_capacity(384); + let manifest = labcolors_core::numerical_capability_manifest_v2(); + let mut out = String::with_capacity(512); out.push_str("{\"schemaVersion\":"); let _ = write!(out, "{}", manifest.schema_version); out.push_str(",\"coverage\":"); push_str_lit(&mut out, manifest.coverage.key()); out.push_str(",\"sites\":["); - // sites уже отсортированы ядром по UTF-8 bytes site key (инвариант - // canonical checksum preimage) — проекция порядок не пересобирает. for (index, site) in manifest.sites.iter().enumerate() { if index > 0 { out.push(','); @@ -290,6 +369,7 @@ pub fn capability_manifest_json() -> String { site.artifact_ids.iter().map(|v| v.key()), ); push_key_array(&mut out, "boundIds", site.bound_ids.iter().map(|v| v.key())); + push_key_array(&mut out, "proofIds", site.proof_ids.iter().map(|v| v.key())); push_key_array( &mut out, "runtimeAttestations", @@ -717,34 +797,47 @@ mod tests { RoleOutcome, SolvedColor, }; - /// Единственный конструируемый снаружи ядра атомарный legacy-исход: - /// registered release + provenance-маркер (оба публичны by design). + /// Берёт атомарный outcome только из полного core-owned product path. Variant + /// sealed: boundary-тест не может переупаковать чужое genuine evidence. + fn core_glow_outcome( + background: &str, + profile: labcolors_core::GlowDecisionProfileV1, + ) -> labcolors_core::glow::GlowDecisionOutcomeV1 { + let tint = + labcolors_core::LadderTint::new([[74.0 / 255.0, 143.0 / 255.0, 1.0]; 4]).unwrap(); + let table = labcolors_core::NamedRoleTable::new( + vec![( + "opaque-client-id".to_string(), + labcolors_core::RoleSpec::Glow { + tint, + step: labcolors_core::glow::GlowStep::Base, + mode: profile.execution_mode(), + }, + )], + Vec::new(), + labcolors_core::RoleChroma::Neutral, + ) + .unwrap(); + let resolved = labcolors_core::resolve_named_set( + &labcolors_core::BgInput::solid(background).unwrap(), + &table, + &labcolors_core::ViewingConditions::srgb(), + ); + let labcolors_core::Resolved::Glow(glow) = &resolved[0].1 else { + panic!("core fixture must resolve to a terminal Glow outcome"); + }; + glow.decision_outcome() + } + fn legacy_outcome() -> labcolors_core::glow::GlowDecisionOutcomeV1 { - labcolors_core::glow::GlowDecisionOutcomeV1::Compatibility { - release_id: - labcolors_core::NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1, - provenance: labcolors_core::LegacyPlatformDependentV1, - } + core_glow_outcome( + "#101012", + labcolors_core::GlowDecisionProfileV1::LegacyPlatformDependentV1, + ) } - /// Stable exact no-op из НАСТОЯЩЕГО core-решения: evidence запечатан - /// (приватная печать), поэтому тест берёт его у солвера на белом фоне — - /// там screen-слой побайтно no-op для любой alpha, решение Determinate. fn stable_exact_noop_outcome() -> labcolors_core::glow::GlowDecisionOutcomeV1 { - let decision = labcolors_core::solve_screen_alpha_for_dj( - "#FFFFFF", - "#FFFFFF", - 2.3006, - labcolors_core::NumericalExecutionModeV1::StableOnly, - &labcolors_core::ViewingConditions::srgb(), - ) - .expect("stable solve на белом обязан вернуть решение"); - match decision { - labcolors_core::NumericalDecisionV1::Determinate { evidence, .. } => { - labcolors_core::glow::GlowDecisionOutcomeV1::StableExactNoop { evidence } - } - other => panic!("белый screen-noop обязан быть Determinate, получено {other:?}"), - } + core_glow_outcome("#FFFFFF", labcolors_core::GlowDecisionProfileV1::StableV1) } fn color_entry(key: &str) -> RoleEntry { @@ -1090,92 +1183,37 @@ mod tests { ); } - /// Capability-manifest проекция несёт camelCase-форму conformance-крейта и - /// checksum ядра (8 lowercase hex) — additive-поверхность WASM не имеет - /// права дрейфовать ни от core SSOT, ни от формы pack-манифеста. #[test] - fn capability_manifest_json_mirrors_the_core_ssot() { + fn capability_manifest_json_mirrors_proof_capable_core_ssot() { let value: serde_json::Value = serde_json::from_str(&capability_manifest_json()).expect("валидный JSON"); - let core = labcolors_core::numerical_capability_manifest_v1(); - - assert_eq!( - value["schemaVersion"].as_u64(), - Some(u64::from(core.schema_version)) - ); + let core = labcolors_core::numerical_capability_manifest_v2(); + assert_eq!(value["schemaVersion"], 2); assert_eq!(value["coverage"], core.coverage.key()); assert_eq!(value["checksum"], core.checksum.hex()); - let checksum = value["checksum"].as_str().unwrap(); - assert_eq!(checksum.len(), 8); - assert!( - checksum - .chars() - .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), - "checksum обязан быть 8 lowercase hex: {checksum}" - ); - let sites = value["sites"].as_array().expect("sites — массив"); assert_eq!(sites.len(), core.sites.len()); for (projected, expected) in sites.iter().zip(core.sites.iter()) { assert_eq!(projected["siteId"], expected.site_id.key()); - let list = |name: &str| -> Vec { - projected[name] - .as_array() - .unwrap_or_else(|| panic!("{name} — массив (пустой = явный [])")) - .iter() - .map(|v| v.as_str().unwrap().to_string()) - .collect() - }; - let keys = |actual: Vec, expected_keys: Vec<&str>, name: &str| { - assert_eq!(actual, expected_keys, "{name}"); - }; - keys( - list("stableOutcomes"), - expected.stable_outcomes.iter().map(|v| v.key()).collect(), - "stableOutcomes", - ); - keys( - list("compatibilityReleases"), - expected - .compatibility_releases - .iter() - .map(|v| v.key()) - .collect(), - "compatibilityReleases", - ); - keys( - list("evidenceClasses"), - expected.evidence_classes.iter().map(|v| v.key()).collect(), - "evidenceClasses", - ); - keys( - list("artifactIds"), - expected.artifact_ids.iter().map(|v| v.key()).collect(), - "artifactIds", - ); - keys( - list("boundIds"), - expected.bound_ids.iter().map(|v| v.key()).collect(), - "boundIds", - ); - keys( - list("runtimeAttestations"), + let proof_ids: Vec<_> = projected["proofIds"] + .as_array() + .expect("V2 proofIds — явный массив") + .iter() + .map(|value| value.as_str().unwrap()) + .collect(); + assert_eq!( + proof_ids, expected - .runtime_attestations + .proof_ids .iter() - .map(|v| v.key()) - .collect(), - "runtimeAttestations", + .map(|value| value.key()) + .collect::>() ); } - - // Non-vacuous: мигрированный glow-site реально присутствует. - assert!( - sites - .iter() - .any(|site| site["siteId"] == "glow-target-or-maximum-v1"), - "manifest обязан покрывать glow site" - ); + assert!(sites.iter().any(|site| { + site["siteId"] == "wcag22-srgb8-contrast-v1" + && site["proofIds"][0] == "wcag22-srgb8-full-domain-q55-v1" + })); } /// Материал (whitepaper §3.7) проецируется в контрактные CSS-переменные: `--lab-` = @@ -1460,6 +1498,33 @@ mod tests { ); } + #[test] + fn wcag22_projection_preserves_exact_ids_bytes_and_u64_as_strings() { + let assessment = labcolors_core::wcag22::evaluate_wcag22_hex( + "#89BB09", + "#8212DB", + labcolors_core::wcag22::Wcag22CriterionV1::Sc1411GraphicalObject, + ) + .unwrap(); + let value: serde_json::Value = + serde_json::from_str(&wcag22_json(&assessment).unwrap()).unwrap(); + assert_eq!(value["decision"], "fail"); + assert_eq!(value["foreground"], "#89BB09"); + assert_eq!(value["background"], "#8212DB"); + assert_eq!( + value["evidence"]["artifactId"], + "wcag22-srgb8-luminance-q55-v1" + ); + assert!(value["q55Scale"].as_str().unwrap().parse::().is_ok()); + assert!( + value["foregroundLuminanceQ55"]["lower"] + .as_str() + .unwrap() + .parse::() + .is_ok() + ); + } + /// НЕ-конечное число — честная структурная ошибка, не невалидный JSON. #[test] fn non_finite_numbers_are_a_structured_error() { diff --git a/crates/labcolors-wasm/tests/wasm_parity.rs b/crates/labcolors-wasm/tests/wasm_parity.rs index 3842891c..245b04bf 100644 --- a/crates/labcolors-wasm/tests/wasm_parity.rs +++ b/crates/labcolors-wasm/tests/wasm_parity.rs @@ -12,7 +12,7 @@ use labcolors_conformance::{ AlphaVector, ContrastVector, DRIFT_TOL, LadderVector, Manifest, MuddinessVector, Pack, - SolveOutcome, SolveVector, + SolveOutcome, SolveVector, Wcag22Vector, }; use labcolors_core::config::ThemeConfig; use labcolors_core::semantic::NamedRoleTable; @@ -123,7 +123,8 @@ fn assert_hex_within_one(actual: &str, expected: &str, context: &str) { } } -/// The committed 82-vector pack is replayed inside the actual wasm32 runtime. +/// Every manifest-declared committed family is replayed inside the actual +/// wasm32 runtime. /// Same-runtime core/boundary parity alone cannot detect a platform-specific /// libm drift, so this anchors wasm32 independently to committed bytes/values. #[wasm_bindgen_test] @@ -206,6 +207,13 @@ fn committed_conformance_pack_replays_in_wasm32() { approx_pack_number(actual.score, committed.score, "muddiness"); } + let wcag22: Vec = + serde_json::from_str(include_str!("../../../conformance/vectors/wcag22.json")).unwrap(); + assert_eq!(wcag22.len(), fresh.wcag22.len()); + for (committed, actual) in wcag22.iter().zip(&fresh.wcag22) { + assert_eq!(actual, committed, "WCAG22 finite assessment drift"); + } + let committed_manifest: Manifest = serde_json::from_str(include_str!("../../../conformance/vectors/manifest.json")).unwrap(); let fresh_manifest = fresh.manifest(); @@ -216,7 +224,16 @@ fn committed_conformance_pack_replays_in_wasm32() { committed_manifest.numerical_capabilities, fresh_manifest.numerical_capabilities ); - assert_eq!(committed_manifest.counts.total, 82, "anti-vacuum pack size"); + let replayed_total = contrasts.len() + + ladders.len() + + alpha.len() + + solve.len() + + muddiness.len() + + wcag22.len(); + assert_eq!( + committed_manifest.counts.total, replayed_total, + "manifest total must equal every replayed committed family" + ); } /// The public WASM compatibility proxy is wired to the same frozen vectors as diff --git a/docs/NAMING.md b/docs/NAMING.md index b640378d..3298eb7c 100644 --- a/docs/NAMING.md +++ b/docs/NAMING.md @@ -16,9 +16,9 @@ | членов workspace (Cargo.toml `members`, глоб развёрнут по ФС) | 6 | | крейтов семейства в crates/ | 5 | | экспорт-субпутей package.json @labpics/colors | 8 | -| python-скриптов scripts/*.py | 2 | +| python-скриптов scripts/*.py | 4 | | маркдаун-доков docs/**/*.md (включая этот канон) | 12 | -| векторов conformance/vectors/*.json (включая manifest) | 6 | +| векторов conformance/vectors/*.json (включая manifest) | 7 | | файлов вне закона имён | 4 | ## Общие принципы (эталон lab-icons) @@ -64,6 +64,9 @@ - snake_case по PEP 8: `golden_ref.py` — эталон colour-science для CIECAM16; `jhk_golden_ref.py` — вариант с суффиксом-основой для J'a'b' (JHK). +- `generate_wcag22_q55.py` детерминированно строит Rust-таблицу и canonical + little-endian Q55 artifact из профиля; `verify_wcag22_q55.py` независимо + сверяет обе формы и full-domain decision-totality. - Скрипты живут только в scripts/; каждый упомянут в этом каноне — появление нового скрипта требует строчки здесь (иначе гейт красный). @@ -76,7 +79,7 @@ - Миграции в docs/migrations/ — kebab-case по предмету breaking-контракта (`exact-alpha-glow.md`); одна дока обязана покрывать upgrade и rollback. - Векторы конформанса — conformance/vectors/`<домен>.json`, домен — одно - kebab-слово (alpha, contrasts, ladders, muddiness, solve) + manifest.json. + kebab-слово (alpha, contrasts, ladders, muddiness, solve, wcag22) + manifest.json. ### Swift-биндинг diff --git a/docs/decisions/0004-finite-alpha-glow-reference.md b/docs/decisions/0004-finite-alpha-glow-reference.md index 5279cb73..ca732f9c 100644 --- a/docs/decisions/0004-finite-alpha-glow-reference.md +++ b/docs/decisions/0004-finite-alpha-glow-reference.md @@ -117,7 +117,7 @@ representable внутренности фактического интервал platform/libm-dependent: точного эталона или sound outward bound для CAM16- ветвления пока нет. Поэтому `stable-v1` на нетривиальном site `glow-target-or-maximum-v1` не выбирает state и возвращает -`NumericalDecisionV1::Indeterminate { site_id, evidence }`, где evidence — +`NumericalDecisionV1::Indeterminate { site_id, evidence, .. }`, где evidence — `SoundBoundUnavailable`. WASM-проекция выводит из этого варианта согласованную пару `reason: sound-bound-unavailable` + `bounds: unavailable`. Legacy не включается как fallback — его обязан явно выбрать клиентский контракт. @@ -125,10 +125,11 @@ platform/libm-dependent: точного эталона или sound outward boun Единственное stable-исключение не является специальной цветовой эвристикой: если point screen-композит не может изменить ни один байт при любой alpha, `ΔJ′ = 0` следует из равенства byte-state. Такой no-op determinate имеет -`bit-exact` guarantee без вызова CAM16. Реестр уже мигрированных -branch-sensitive sites и классов гарантий является публичными данными -`numerical_registry_v1()` (#281); он не объявляет полный аудит исторических -`f64`-ветвлений, которым владеет #291. +`bit-exact` guarantee без вызова CAM16. Публичная проекция уже мигрированных +branch-sensitive sites и классов evidence — +`numerical_capability_manifest_v2()` (#281/#284); internal registry остаётся +Core-owned SSOT и не объявляет полный аудит исторических `f64`-ветвлений, +которым владеет #291. ### 3. Alpha канонизируется внутри выбранного интервала @@ -259,3 +260,17 @@ legacy-исход всё ещё выглядел как «determinate со сл manifest ядра (coverage `migrated-sites-only-v1`, FNV-1a-32 drift-checksum над canonical length-prefixed preimage) вместо прозаического `numericalSites`; release verifier и Swift-тесты пересчитывают checksum независимо. + +## Дополнение 2026-07-13: Core-owned terminal outcomes и capability V2 (#284) + +- Struct-like варианты `NumericalDecisionV1` и `GlowDecisionOutcomeV1` + запечатаны variant-level `#[non_exhaustive]`. Теперь внешний код не может + переупаковать подлинное evidence другого site как Glow/WCAG outcome; он + получает предметный результат только из Core-owned resolver-а. +- Единственная public capability projection — + `numerical_capability_manifest_v2()`. Internal registry остаётся SSOT, а + WCAG admission дополнительно SHA-256-связан с десятью фактическими typed + полями, разрешающими минт bounded evidence. +- Pack 4.0.0 добавляет отдельное `wcag22`-семейство. Это terminal standard + certificate для явно объявленного criterion, а не новая Glow-эвристика и не + замена LPC-перцептивной цели. diff --git a/docs/migrations/exact-alpha-glow.md b/docs/migrations/exact-alpha-glow.md index 1223b2fd..c7b7e654 100644 --- a/docs/migrations/exact-alpha-glow.md +++ b/docs/migrations/exact-alpha-glow.md @@ -229,23 +229,35 @@ normalized expanded запись `alpha·T + (1−alpha)·B` алгебраич ## 4. Обновите Rust Glow API -`solve_screen_alpha_for_dj` теперь принимает обязательный -`GlowDecisionProfileV1` и возвращает `NumericalDecisionV1`: +`solve_screen_alpha_for_dj` принимает обязательный typed execution mode и +возвращает `NumericalDecisionV1`: ```rust let decision = solve_screen_alpha_for_dj( tint, background, target_dj, - GlowDecisionProfileV1::StableV1, + GlowDecisionProfileV1::StableV1.execution_mode(), viewing_conditions, )?; match decision { - NumericalDecisionV1::Determinate { value, guarantee } => { - emit(value.alpha_css(), guarantee); + NumericalDecisionV1::Determinate { value, evidence, .. } => { + emit_stable(value.alpha_css(), evidence.class_key()); } - NumericalDecisionV1::Indeterminate { site_id, evidence } => { + NumericalDecisionV1::Compatibility { + value, + release_id, + provenance, + .. + } => { + emit_compatibility( + value.alpha_css(), + release_id.key(), + provenance.key(), + ); + } + NumericalDecisionV1::Indeterminate { site_id, evidence, .. } => { match evidence { NumericalIndeterminacyV1::SoundBoundUnavailable => { record_unbounded(site_id); @@ -270,8 +282,13 @@ WASM-проекция сохраняет discriminated wire-форму `reason` для `#[non_exhaustive]` enum и трактовать неизвестный вариант как явную несовместимость версии, а не как legacy fallback. -`DecisionGuaranteeV1` тоже `#[non_exhaustive]`. На WASM-границе он сериализуется -tagged object, а не строкой: +`DecisionGuaranteeV1` удалён: generic Core больше не ранжирует +взаимоисключающие исходы по «силе гарантии». `Determinate` несёт sealed +`NumericalDecisionEvidenceV1`, а explicit legacy-путь — отдельный +`Compatibility { release_id, provenance }`. + +На WASM-границе прежний client-facing `decisionGuarantee` остаётся +tagged object и выводится адаптером из атомарного outcome: ```ts type GlowDecisionGuaranteeV1 = @@ -279,10 +296,8 @@ type GlowDecisionGuaranteeV1 = | { readonly kind: "legacy-platform-dependent-v1" }; ``` -Generic core сохраняет `DecisionGuaranteeV1::OutwardIntervalV1` для других -численных sites, но Glow-adapter не умеет построить соответствующий determinate -outcome и возвращает структурную несовместимость. Не принимайте неизвестный -`kind` как `bit-exact` или legacy. +Это wire-type, не generic Rust evidence enum. Не принимайте неизвестный `kind` +как `bit-exact` или legacy. Low-level UniFFI/Swift поверхность не переносит flattened provenance-поля generic wire-формы. Её `GlowPointDecision` — algebraic sum ровно четырёх @@ -297,10 +312,10 @@ composite hex и composite profile/guarantee. Отдельные native-типы Native adapter заранее валидирует tint, background и конечный `targetDj > 0`; только такой public input возвращает `ColorError.InvalidGlowRequest`. Если после -успешной проверки core всё же возвращает `Err`, неизвестный forward variant, -illegal provenance tuple либо generic `DecisionGuaranteeV1::OutwardIntervalV1`, -граница возвращает `ColorError.IncompatibleCoreContract`. Тот же закон действует -для нового неизвестного `Unreachable`: adapter не подменяет его строкой +успешной проверки core всё же возвращает `Err`, неизвестный forward variant +либо illegal site/release/evidence tuple, граница возвращает +`ColorError.IncompatibleCoreContract`. Тот же закон действует для нового +неизвестного `Unreachable`: adapter не подменяет его строкой `"unreachable"`. `NumericalIndeterminacy.intervalOverlap` остаётся в Swift как законное outward evidence для `indeterminate`. @@ -327,7 +342,7 @@ binary64 identity alpha, каноническую CSS-строку и composite цвета. 5. Сравнивайте alpha через `alphaCss` или побитный parse round-trip; не округляйте её до фиксированного числа знаков. -6. Используйте актуальный conformance pack (3.0.0; half-tie введён в 2.0.0 +6. Используйте актуальный conformance pack (4.0.0; half-tie введён в 2.0.0 и обязателен с тех пор). Half-tie `#C0B2FA @ 0.122` над `#000000` обязан дать `#17161F`. Обрабатывайте `generate_solve()` / `Pack::generate()` как `Result`: internal core failure @@ -387,7 +402,10 @@ Rollback выполняется парой runtime + config: - Ни один из этих профилей не сертифицирует реальный browser color-management, HDR/display pipeline, blur, overlap или spatial glow field. -## Migration-note: атомарный `NumericalDecisionV1` и pack 3.0.0 (#292) +## Историческая migration-note: атомарный `NumericalDecisionV1` и pack 3.0.0 (#292) + +> Этот подраздел фиксирует переход #292 до добавления WCAG-семейства. Для +> текущего unreleased-контракта используйте pack 4.0.0 и дополнение ниже. Последующий rework численной границы (см. дополнение ADR-0004 от 2026-07-12) намеренно НЕ меняет wire: прежние ключи сохранены byte-for-byte как @@ -413,3 +431,19 @@ boundary-адаптер, поэтому для JS/TS-потребителей и typed `numericalCapabilities` (coverage `migrated-sites-only-v1`, FNV-1a-32 drift-checksum). Векторные семейства и `packDigest` не изменились; потребители манифеста должны читать новую секцию. + +## Текущий unreleased-контракт: WCAG 2.2 и pack 4.0.0 (#284) + +- Единственный public capability contract — V2; он добавляет proof-capable + `wcag22-srgb8-contrast-v1` с artifact/bound/proof IDs. Временный V1 не + сохраняется compatibility alias-ом до появления клиентов. +- Pack 4.0.0 добавляет `wcag22.json`, поэтому `packDigest` меняется. npm API + добавляет `evaluateWcag22`; profile/table/proof поставляются byte-exact в + `evidence/` и перепроверяются release gate-ом. +- Все struct-like terminal variants `NumericalDecisionV1` и + `GlowDecisionOutcomeV1` sealed variant-level `#[non_exhaustive]`: внешний + Rust-код матчится с `..` и не может переупаковать genuine evidence другого + site. +- Raw WCAG JSON читается через `Wcag22ProfileV1::source_json()` и + `proof_json()`. Runtime-профиль хранит только IDs/хэши, поэтому отдельно + поставляемые документы не дублируются в WASM. diff --git a/docs/verification-map.md b/docs/verification-map.md index 6690a043..d3ab5b08 100644 --- a/docs/verification-map.md +++ b/docs/verification-map.md @@ -74,6 +74,21 @@ > есть для КАЖДОГО квантованного цвета обе версии выбирают одну ветвь и > линеаризуют идентично; расхождение только на суб-квантовых величинах. +## WCAG 2.2 для финальной sRGB8-пары — `wcag22.rs`, `wcag22/`, `srgb8.rs` (#284) + +Это versioned terminal certificate соответствия объявленному success +criterion, а не замена LPC/APCA-shaped перцептивной цели solver-а. Клиент явно +передаёт criterion; core не выводит размер текста или семантику из имени токена. + +| формула/инвариант | чем верифицирована | оракул | +|---|---|---| +| dated profile: IEC/WCAG EOTF split `0.04045`, веса `0.2126/0.7152/0.0722`, offset `0.05`, пороги `3.0` и `4.5` | immutable `wcag22-srgb8-v1.json`; независимая точная копия `NORMATIVE_PROFILE_V1` в `verify_wcag22_q55.py`; `wcag22_tests::*` | публикация (W3C WCAG 2.2 Recommendation 2024-12-12) | +| 768 outward Q55-вкладов (3 канала × 256 кодов) tight: ширина строки ≤ 1; все threshold terms overflow-safe | adaptive-precision Decimal с directed rounding и устойчивостью на successive precisions; exact-integer/fifth-power проверка каждой строки; verifier доказывает `180·(Q55+3)+7·Q55 < i64::MAX`, фиксирует headroom и отказ Q56 | независимая численная транскрипция + целочисленная проверка tightness/overflow | +| полный домен `256³ = 16 777 216` цветов имеет zero unresolved для обоих пороговых законов | `verify_wcag22_q55.py`: перечисление всех sRGB8-интервалов и monotone boundary scan; committed proof фиксирует минимальные pass/fail margins и witnesses; synthetic overlap обязан сделать verifier RED | полный конечный перебор + mutation oracle | +| production verdict использует только outward Q55 и целочисленные сравнения; kernel/parser/facade/terminal-evidence нельзя подменить отдельно | exact source SHA bindings + semantic guards verifier-а; `anti_epsilon_witnesses_are_definite_fail`, parser panic/property tests | независимый verifier + внутренние boundary witnesses | +| право минтить terminal evidence связано с фактической typed WCAG registry-row | compiled Rust probe читает live row; Python канонизирует 10 mint-relevant полей через length-prefix/SHA-256; 10 field mutations + 2 hex/count transport mutations обязаны отказать | независимая site-local admission binding (в proof: 15 negative controls всего) | +| один verdict/evidence сохраняется через Core → FFI/WASM → JS/Swift/conformance | `wcag22_transport_*`, `wasm_parity`, `wcag22.test.mjs`, Swift conformance, committed pack 4 `wcag22.json`; release verifier повторно проверяет evidence-байты | дифференциальный cross-boundary oracle | + ## LPC (перцептивный контраст) — `lpc.rs` | формула | чем верифицирована | оракул | @@ -104,7 +119,8 @@ ## Численные решения — `numerics.rs`, `numerical_plan.rs` (#292) Три уровня контракта разделены типами: package capability -(`NumericalCapabilityManifestV1`, projection registry SSOT) ≠ compiled +(`NumericalCapabilityManifestV2` — единственная proof-capable projection +registry SSOT) ≠ compiled invocation plan (`CompiledNumericalPlanV1`) ≠ атомарный результат (`NumericalDecisionV1`: `Determinate`/`Compatibility`/`Indeterminate`). Новой математики модуль не вводит — проверяется невозможность повышения @@ -114,9 +130,11 @@ caller-created значений и legacy-исходов до доказател |---|---|---| | registry непустой, ключи уникальны, Glow site покрыт обоими stable outcomes и registered compatibility release | `numerics::tests::migrated_registry_is_non_vacuous_unique_and_covers_glow_site` | внутренняя тождественность | | capability manifest — каноническая projection registry: сортировка по UTF-8 `siteId`, coverage `migrated-sites-only-v1`, без выбранного mode | `numerics::tests::capability_manifest_is_canonical_registry_projection` | внутренняя тождественность | +| единственный public `numericalCapabilityManifest()` возвращает V2 с WCAG artifact/bound/proof IDs; одна декларация проецирует internal runtime и public capability без двух SSOT | `numerics::tests::unified_registry_projects_runtime_glow_and_proof_bound_wcag`, `projection::tests::capability_manifest_json_mirrors_proof_capable_core_ssot`, `packages/colors/test/capability-manifest.test.mjs` | regression pin + дифференциальный adapter/core | | drift-checksum канонический и tamper-чувствителен: смена schema version / удаление row меняет FNV-1a-32 preimage | `numerics::tests::capability_checksum_is_canonical_and_tamper_sensitive`; независимые пересчёты: JS (`scripts/verify-package-release.mjs`) и Swift (`ConformanceTests.testCapabilityManifestChecksumRecomputes`) | внутренняя тождественность + два независимых re-implementation оракула | | legacy-исход — атомарный `Compatibility` с registered release, не determinate evidence | `numerics::tests::legacy_result_is_compatibility_not_determinate_evidence` | тип-уровневая (взаимоисключающие варианты) + внутренняя тождественность | -| BitExact-evidence минтится только registry-owned конструктором для site с объявленным классом; внешняя подделка не компилируется | `numerics::tests::bit_exact_evidence_is_registry_owned_and_sealed`, `bit_exact_mint_is_refused_without_declared_capability` + два compile-fail doctests в шапке `numerics.rs` (импорт удалённого `classify_at_least_v1`; struct-литерал `BitExact` с приватной печатью) | тип-уровневая (компилятор) | +| BitExact/bounded evidence минтится только registry-owned конструктором; внешний код не может ни собрать evidence, ни переупаковать genuine evidence другого site в новый terminal result | `numerics::tests::bit_exact_evidence_is_registry_owned_and_sealed`, `bit_exact_mint_is_refused_without_declared_capability` + три compile-fail doctests в шапке `numerics.rs` (удалённый classifier, приватная evidence-печать, cross-site reuse) | тип-уровневая (компилятор) | +| предметный Glow outcome также Core-owned: generic/WCAG evidence нельзя объявить `StableExactNoop`, а compatibility нельзя собрать вручную | variant-level sealing `GlowDecisionOutcomeV1` + compile-fail doctest в `glow.rs`; WASM-тесты получают оба outcome только через полный `resolve_named_set` path | тип-уровневая + boundary characterization | | диагностический интервал проверяет только форму (конечность, порядок) и не изготовляет determinate evidence | `numerics::tests::diagnostic_interval_validates_shape_only` | внутренняя тождественность | | invocation identity плана канонична: локальные ordinals внутри (node, site), перестановка деклараций не меняет ids/projection | `numerical_plan::tests::mixed_modes_coexist_and_ordinals_are_local`, `declaration_permutation_preserves_ids_and_canonical_projection` | внутренняя тождественность | | план tamper-чувствителен: переименование node/site меняет identity, смена mode меняет checksum; незарегистрированный release — typed ошибка компиляции плана | `numerical_plan::tests::rename_changes_identity_and_mode_mutation_changes_checksum`, `unregistered_release_is_a_typed_compile_error` | внутренняя тождественность | diff --git a/packages/colors/README.md b/packages/colors/README.md index c4d09376..8babebf7 100644 --- a/packages/colors/README.md +++ b/packages/colors/README.md @@ -231,6 +231,54 @@ profile, а не как численную гарантию. Полная миг --- +### `evaluateWcag22(foreground, background, criterion): Wcag22AssessmentV1` + +Проверяет одну **финальную sRGB8-пару** по датированному профилю WCAG 2.2. Core +не угадывает назначение токена или размер текста: клиент явно выбирает критерий +для конкретного использования, а evaluator возвращает строгий `pass | fail`. + +```js +import init, { evaluateWcag22 } from "@labpics/colors"; + +await init(); +const assessment = evaluateWcag22( + "#898CB8", + "#3E2217", + "sc-1.4.3-text-default", +); +// assessment.decision === "fail" — значение строго ниже 4.5:1 +``` + +| `criterion` | Порог | Когда выбирать | +|---|---:|---| +| `sc-1.4.3-text-default` | 4.5:1 | обычный текст | +| `sc-1.4.3-text-large-scale` | 3:1 | только текст, который клиент уже классифицировал как large-scale по WCAG | +| `sc-1.4.11-ui-component-or-state` | 3:1 | необходимая визуальная информация компонента или состояния | +| `sc-1.4.11-graphical-object` | 3:1 | необходимая визуальная информация графического объекта | + +Core не выводит критерий из имени токена, CSS-класса, размера шрифта или +компонента: applicability принадлежит клиентскому контексту. Неверный ключ и +нестрогий цветовой transport отклоняются ошибкой, без fallback. + +Решение не использует epsilon, округлённый display-ratio или отдельную JS- +формулу. Оно приходит из Rust core вместе с identity профиля, Q55-таблицы, +bound-law и воспроизводимого full-domain proof. Файлы доказательства входят в +npm-тарбол в `evidence/`; proof также SHA-256-связан с фактической typed +registry-строкой, разрешающей Core минтить terminal evidence. LPC/APCA-shaped +diagnostics и legacy `wcagRatio` не могут изменить этот вердикт. + +--- + +### `numericalCapabilityManifest(): NumericalCapabilityManifestV2` + +Возвращает статический манифест численных возможностей установленной сборки: +какие solver-sites зарегистрированы и какие artifact/bound/proof IDs они могут +выдать. Это диагностическая поверхность для tooling, CI и ИИ-агентов; она не +выбирает режим и не превращает compatibility-результат в доказанный. Публичная +функция одна: отдельного V1 или `numericalCapabilityManifestV2()` нет. + +--- + ### `engine.recheckContrast(bgHex, fgHexes, theme): Float64Array` Дешёвая покадровая проверка: какие контрасты дают цвета `fgHexes` на фоне `bgHex` под темой `theme`, без полного резолва (один прямой ход модели на фон плюс по одному на каждый передний план). Возвращает `Float64Array` пар `[lc, wcagRatio]` в порядке `fgHexes`: индекс `2·i` — знаковый `Lc` цвета `i`, `2·i+1` — его WCAG-отношение. Это примитив, которым `adaptTheme` решает, пора ли пересчитывать. @@ -353,10 +401,17 @@ replacement принадлежит #283. ## Размер бандла -Размер не закреплён в документации приблизительным числом: оно устаревает при -любом изменении солвера. SSOT — шаг CI `report bundle size (gzip)` в `ci.yml`, -который для каждого коммита печатает точные raw-байты и результат `gzip -9` -отдельно для `labcolors_bg.wasm` и wasm-bindgen-обёртки `labcolors.js`. +Raw-размер WASM — hard gate. Его versioned SSOT — +`bench/wasm-size-budget-v1.json`: точное принятое измерение Issue #284 вместе +с toolchain provenance, SHA-256 измеренного артефакта и ceiling без +произвольного запаса. Канонический артефакт строит 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. Это весь движок: CAM16, солверы контраста, лестницы и граница конфига. `.wasm` поставляется отдельным ассетом. Будет ли его загрузка критическим путём первого diff --git a/packages/colors/bench/wasm-size-budget-v1.json b/packages/colors/bench/wasm-size-budget-v1.json new file mode 100644 index 00000000..91c911aa --- /dev/null +++ b/packages/colors/bench/wasm-size-budget-v1.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "budgetId": "labcolors-wasm-raw-issue-284-v1", + "artifact": "packages/colors/pkg/labcolors_bg.wasm", + "measurement": { + "issue": 284, + "rawBytes": 454385, + "sha256": "94c61c1689fa2e1c10d79817864471f41c623463bd9b5b4e0dac2a850a58f09f", + "rustToolchain": "1.96.0", + "rustcCommit": "ac68faa20", + "wasmPack": "0.13.1", + "wasmBindgen": "0.2.126", + "target": "wasm32-unknown-unknown", + "cargoProfile": "release", + "wasmOpt": "-Oz", + "wasmOptVersion": "117", + "measurementPlatform": "linux-x64", + "rustPathRemap": [ + "GITHUB_WORKSPACE=/workspace/lab-colors", + "CARGO_HOME=/cargo-home" + ], + "command": "CARGO_ENCODED_RUSTFLAGS= wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked" + }, + "policy": { + "maxRawBytes": 454385, + "derivation": "exact-accepted-issue-284-measurement", + "gzip": "diagnostic-only" + } +} diff --git a/packages/colors/index.d.ts b/packages/colors/index.d.ts index 33c06ef8..fb017516 100644 --- a/packages/colors/index.d.ts +++ b/packages/colors/index.d.ts @@ -4,6 +4,11 @@ // `RoleResult` union and the `LabColors` engine) and the vanilla `applyTheme` // helper, so a consumer gets full typing from the package root. +import type { + Wcag22AssessmentV1, + Wcag22CriterionV1, +} from "./pkg/labcolors.js"; + export { default, default as init, @@ -12,6 +17,13 @@ export { numericalCapabilityManifest, } from "./pkg/labcolors.js"; +/** Exact WCAG 2.2 assessment for one canonical final-sRGB8 occurrence. */ +export declare function evaluateWcag22( + foreground: string, + background: string, + criterion: Wcag22CriterionV1, +): Wcag22AssessmentV1; + // Curated public schema/result surface. wasm-bindgen's InitOutput and raw // __wbg_* ABI helpers remain implementation details. export type { @@ -53,8 +65,12 @@ export type { RoleRecipe, ThemeConfig, ResolvedTheme, - NumericalCapabilitySiteV1, - NumericalCapabilityManifestV1, + NumericalCapabilitySiteV2, + NumericalCapabilityManifestV2, + Wcag22CriterionV1, + Wcag22DecisionV1, + Wcag22Q55BoundsV1, + Wcag22AssessmentV1, } from "./pkg/labcolors.js"; export { applyTheme } from "./apply-theme.js"; diff --git a/packages/colors/index.js b/packages/colors/index.js index d6e0d6ff..8403f22c 100644 --- a/packages/colors/index.js +++ b/packages/colors/index.js @@ -11,6 +11,7 @@ export { default as init, initSync, LabColors, + evaluateWcag22, numericalCapabilityManifest, } from "./pkg/labcolors.js"; diff --git a/packages/colors/package.json b/packages/colors/package.json index 06876103..e63a50dd 100644 --- a/packages/colors/package.json +++ b/packages/colors/package.json @@ -52,6 +52,9 @@ "adapt-theme.d.ts", "effective-bg.js", "effective-bg.d.ts", + "evidence/wcag22-srgb8-v1.json", + "evidence/wcag22-srgb8-q55-v1.bin", + "evidence/wcag22-srgb8-q55-proof-v1.json", "pkg/labcolors.js", "pkg/labcolors.d.ts", "pkg/labcolors_bg.wasm", diff --git a/packages/colors/test/capability-manifest.test.mjs b/packages/colors/test/capability-manifest.test.mjs index a62eeb42..b90cf3de 100644 --- a/packages/colors/test/capability-manifest.test.mjs +++ b/packages/colors/test/capability-manifest.test.mjs @@ -1,10 +1,6 @@ -// Additive-поверхность #292: `numericalCapabilityManifest()` — canonical -// numerical capability manifest сборки, спроецированный из core registry SSOT -// (не рукописная копия). Тест пинит ФОРМУ (camelCase-поля проекции -// conformance-пака), покрытие мигрированного glow-site и формат checksum -// (FNV-1a-32, 8 lowercase hex), но НЕ значение checksum: значение принадлежит -// registry и меняется вместе с ним законно — дрейф формы был бы дефектом -// границы, дрейф значения — свойством ядра. +// Capability schema is independently versioned. Before public clients exist, +// the one public projection moves atomically to proof-capable V2 rather than +// preserving two competing entrypoints. // // Requires the built `pkg/` (CI runs `npm test` after `wasm-pack build`). // Skips cleanly if the wasm bundle is absent, matching the other wasm tests. @@ -21,12 +17,12 @@ const gluePath = resolve(here, "../pkg/labcolors.js"); const haveWasm = existsSync(wasmPath) && existsSync(gluePath); -test("numericalCapabilityManifest projects the core capability SSOT", async (t) => { +test("numericalCapabilityManifest publishes the single proof-capable V2 contract", async (t) => { if (!haveWasm) { t.skip("pkg/ not built — run `npm run build` first (CI builds before `npm test`)"); return; } - const { initSync, numericalCapabilityManifest } = await import( + const { initSync, numericalCapabilityManifest, numericalCapabilityManifestV2 } = await import( pathToFileURL(gluePath).href ); initSync({ module: readFileSync(wasmPath) }); @@ -39,7 +35,7 @@ test("numericalCapabilityManifest projects the core capability SSOT", async (t) ["checksum", "coverage", "schemaVersion", "sites"], "верхний уровень несёт ровно четыре canonical-поля", ); - assert.equal(manifest.schemaVersion, 1, "capability schema V1"); + assert.equal(manifest.schemaVersion, 2, "capability schema V2"); assert.equal(manifest.coverage, "migrated-sites-only-v1"); assert.match( manifest.checksum, @@ -48,9 +44,12 @@ test("numericalCapabilityManifest projects the core capability SSOT", async (t) ); // Rows отсортированы по UTF-8 байтам siteId (инвариант canonical preimage). - assert.ok(Array.isArray(manifest.sites) && manifest.sites.length > 0); + assert.ok(Array.isArray(manifest.sites)); const ids = manifest.sites.map((site) => site.siteId); - assert.deepEqual(ids, [...ids].sort(), "sites отсортированы по siteId"); + assert.deepEqual(ids, [ + "glow-target-or-maximum-v1", + "wcag22-srgb8-contrast-v1", + ]); // Мигрированный glow-site: точное содержимое registry-строки. Пустые // массивы обязаны быть явными [] — пусто значит «нет evidence», не @@ -66,11 +65,12 @@ test("numericalCapabilityManifest projects the core capability SSOT", async (t) "boundIds", "compatibilityReleases", "evidenceClasses", + "proofIds", "runtimeAttestations", "siteId", "stableOutcomes", ], - "row несёт ровно семь canonical-полей", + "V2 row несёт ровно восемь canonical-полей", ); assert.deepEqual(glow.stableOutcomes, ["bit-exact", "indeterminate"]); assert.deepEqual(glow.compatibilityReleases, [ @@ -79,8 +79,16 @@ test("numericalCapabilityManifest projects the core capability SSOT", async (t) assert.deepEqual(glow.evidenceClasses, ["bit-exact"]); assert.deepEqual(glow.artifactIds, []); assert.deepEqual(glow.boundIds, []); + assert.deepEqual(glow.proofIds, []); assert.deepEqual(glow.runtimeAttestations, []); + const wcag22 = manifest.sites[1]; + assert.deepEqual(wcag22.stableOutcomes, ["canonical-finite-bounded"]); + assert.deepEqual(wcag22.evidenceClasses, ["canonical-finite-bounded"]); + assert.deepEqual(wcag22.artifactIds, ["wcag22-srgb8-luminance-q55-v1"]); + assert.deepEqual(wcag22.boundIds, ["wcag22-srgb8-outward-q55-v1"]); + assert.deepEqual(wcag22.proofIds, ["wcag22-srgb8-full-domain-q55-v1"]); + // Манифест — статическое свойство сборки: повторный вызов идентичен. assert.deepEqual(numericalCapabilityManifest(), manifest); @@ -88,4 +96,6 @@ test("numericalCapabilityManifest projects the core capability SSOT", async (t) const root = await import(pathToFileURL(resolve(here, "../index.js")).href); assert.equal(typeof root.numericalCapabilityManifest, "function"); assert.deepEqual(root.numericalCapabilityManifest(), manifest); + assert.equal(numericalCapabilityManifestV2, undefined); + assert.equal(root.numericalCapabilityManifestV2, undefined); }); diff --git a/packages/colors/test/release-contract.test.mjs b/packages/colors/test/release-contract.test.mjs index 0c97657b..0bdbc398 100644 --- a/packages/colors/test/release-contract.test.mjs +++ b/packages/colors/test/release-contract.test.mjs @@ -18,6 +18,8 @@ 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"; + const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, "../../.."); const read = (...parts) => readFileSync(join(root, ...parts), "utf8"); @@ -196,6 +198,20 @@ test("MSRV and packaged Rust crate gates are executable CI contracts", () => { assert.doesNotMatch(ci, /chromedriver-bb6facf4ea9511f6|Pre-seeded wasm-pack/); assert.match(ci, /CHROME_ROOT="\$RUNNER_TEMP\/chrome-\$GITHUB_JOB"/); assert.match(ci, /DEPS_DIR="\$RUNNER_TEMP\/chrome-deps-\$GITHUB_JOB"/); + assert.match(ci, /APT_LISTS="\$DEPS_DIR\/apt-lists"/); + assert.match(ci, /APT_CACHE="\$DEPS_DIR\/apt-cache"/); + assert.match(ci, /Dir::State::lists=\$APT_LISTS/); + assert.match(ci, /Dir::State::status=\/var\/lib\/dpkg\/status/); + assert.match(ci, /Dir::Cache=\$APT_CACHE/); + assert.match(ci, /Dir::Cache::archives=\$APT_CACHE\/archives/); + assert.match(ci, /Debug::NoLocking=1/); + assert.match(ci, /Acquire::Retries=3/); + const aptUpdate = ci.indexOf('apt-get "${APT_OPTIONS[@]}" update'); + const aptDownload = ci.indexOf('apt-get "${APT_OPTIONS[@]}" download'); + assert.ok( + aptUpdate >= 0 && aptDownload >= 0 && aptUpdate < aptDownload, + "Chrome dependency download must use a fresh isolated APT index", + ); assert.match(ci, /CHROME_BIN_DIR="\$RUNNER_TEMP\/chrome-bin-\$GITHUB_JOB"/); assert.doesNotMatch(ci, /\$HOME|~\//, "WASM/Chrome state must not leak into shared HOME"); assert.match( @@ -720,6 +736,11 @@ test("release verifier performs an independent byte-for-byte reproduction pass", assert.match(verifier, /familySetSha256: sha256\(Buffer\.concat\(familyBuffers\)\)/); assert.match(verifier, /sha256: sha256\(familyBuffers\[index\]\)/); assert.match(verifier, /numericalCapabilities: conformance\.numericalCapabilities/); + assert.match( + verifier, + /CAPABILITY_CHECKSUM_DOMAIN_V2 = "labcolors\.numerical-capability\.v2"/, + ); + assert.match(verifier, /capabilities\.schemaVersion !== 2/); assert.doesNotMatch( verifier, /numericalCapabilities:\s*\{\s*"/, @@ -727,6 +748,282 @@ 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"); + const checkerPath = join(root, "scripts", "check-wasm-size-budget.mjs"); + const budget = JSON.parse(readFileSync(budgetPath, "utf8")); + + assert.equal(budget.schemaVersion, 1); + 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); + assert.equal( + budget.measurement.sha256, + "94c61c1689fa2e1c10d79817864471f41c623463bd9b5b4e0dac2a850a58f09f", + ); + assert.equal(budget.measurement.measurementPlatform, "linux-x64"); + assert.deepEqual(budget.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 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/); + 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); + assert.match(wasmJob, /CARGO_ENCODED_RUSTFLAGS/u); + assert.match(wasmJob, /GITHUB_WORKSPACE=\/workspace\/lab-colors/u); + assert.match(wasmJob, /CARGO_HOME=\/cargo-home/u); + + const builtWasm = readFileSync( + join(root, "packages", "colors", "pkg", "labcolors_bg.wasm"), + ).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); + + const temporary = mkdtempSync(join(tmpdir(), "labcolors-wasm-budget-")); + try { + const wasm = join(temporary, "fixture.wasm"); + const fixtureBudget = join(temporary, "budget.json"); + const bytes = Buffer.alloc(8); + 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}`, + }, + policy: { ...budget.policy, maxRawBytes: bytes.length }, + })}\n`, + ); + + const run = () => execFileSync( + process.execPath, + [checkerPath, "--wasm", wasm, "--budget", fixtureBudget], + { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + assert.match(run(), /PASS raw=8B ceiling=8B remaining=0B gzip=\d+B/u); + + const canonicalFixtureBudget = readFileSync(fixtureBudget, "utf8"); + const wrongLengthBudget = JSON.parse(canonicalFixtureBudget); + wrongLengthBudget.measurement.rawBytes = bytes.length + 1; + wrongLengthBudget.policy.maxRawBytes = bytes.length + 1; + writeFileSync(fixtureBudget, `${JSON.stringify(wrongLengthBudget)}\n`); + assert.throws( + run, + (error) => { + assert.match(error.stderr.toString(), /canonical artifact raw-byte mismatch/u); + return true; + }, + "canonical host must reject measurement metadata with the wrong raw length", + ); + writeFileSync(fixtureBudget, canonicalFixtureBudget); + + const sameSizeDifferentArtifact = Buffer.from(bytes); + sameSizeDifferentArtifact[7] = 1; + writeFileSync(wasm, sameSizeDifferentArtifact); + assert.throws( + run, + (error) => { + assert.match(error.stderr.toString(), /canonical artifact SHA-256 mismatch/u); + return true; + }, + "canonical host must reject a same-size artifact with different bytes", + ); + + writeFileSync(wasm, Buffer.concat([bytes, Buffer.from([0])])); + 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", + ); + + const nonCanonicalBudget = JSON.parse(readFileSync(fixtureBudget, "utf8")); + 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/u, + "non-canonical host reports diagnostics without claiming byte identity", + ); + + 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/u, + "non-canonical bytes remain diagnostic even above the canonical host ceiling", + ); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } +}); + +test("runtime WASM does not duplicate separately shipped WCAG22 evidence documents", () => { + const wasm = readFileSync( + join(root, "packages", "colors", "pkg", "labcolors_bg.wasm"), + ); + for (const name of [ + "wcag22-srgb8-v1.json", + "wcag22-srgb8-q55-proof-v1.json", + ]) { + const evidence = readFileSync( + join(root, "crates", "labcolors-core", "contracts", name), + ); + assert.equal( + wasm.indexOf(evidence), + -1, + `${name} belongs in npm evidence/, not the runtime WASM`, + ); + } +}); + +test("npm release carries and re-verifies the exact WCAG22 finite evidence", () => { + const packageJson = JSON.parse(read("packages", "colors", "package.json")); + const evidenceFiles = [ + "evidence/wcag22-srgb8-v1.json", + "evidence/wcag22-srgb8-q55-v1.bin", + "evidence/wcag22-srgb8-q55-proof-v1.json", + ]; + for (const path of evidenceFiles) { + assert.ok(packageJson.files.includes(path), `npm files omits ${path}`); + } + + const artifact = join( + root, + "crates", + "labcolors-core", + "contracts", + "wcag22-srgb8-q55-v1.bin", + ); + assert.ok(existsSync(artifact), "canonical Q55 binary artifact is absent"); + assert.equal(lstatSync(artifact).size, 768 * 2 * 8, "artifact must be 1536 little-endian u64s"); + + const prepare = read("scripts", "prepare-npm-package.mjs"); + for (const name of evidenceFiles.map((path) => path.split("/").at(-1))) { + assert.match(prepare, new RegExp(name.replaceAll(".", "\\."), "u")); + } + + const verifier = read("scripts", "verify-package-release.mjs"); + assert.match(verifier, /verify_wcag22_q55\.py/); + assert.match(verifier, /WCAG22_EVIDENCE_FILES/); + const numericalVerifier = read("scripts", "verify_wcag22_q55.py"); + assert.match(numericalVerifier, /NORMATIVE_PROFILE_V1/); + assert.ok( + numericalVerifier.includes(String.raw`r'\1""'`), + "facade normalization must preserve the literal regex backreference", + ); + assert.ok( + !numericalVerifier.includes(String.raw`rf'\1""'`), + "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, /3\.0\.0 → 4\.0\.0/u); + assert.match(conformanceReadme, /`wcag22\.json`/u); + assert.match( + conformanceReadme, + /contrasts, ladders, alpha, solve, muddiness, wcag22/u, + ); + assert.doesNotMatch(conformanceReadme, /сейчас `3\.0\.0`/u); + const workflow = read(".github", "workflows", "ci.yml"); + assert.match(workflow, /python3 scripts\/verify_wcag22_q55\.py/); +}); + +test("packed and clean-installed WCAG22 evidence stays byte-exact", async () => { + const names = [ + "wcag22-srgb8-v1.json", + "wcag22-srgb8-q55-v1.bin", + "wcag22-srgb8-q55-proof-v1.json", + ]; + const contents = names.map((name) => + readFileSync(join(root, "crates", "labcolors-core", "contracts", name)) + ); + const expected = names.map((name, index) => ({ + path: `evidence/${name}`, + bytes: contents[index].length, + sha256: createHash("sha256").update(contents[index]).digest("hex"), + })); + const temporary = mkdtempSync(join(tmpdir(), "labcolors-evidence-boundary-")); + try { + const evidenceDir = join(temporary, "evidence"); + mkdirSync(evidenceDir); + for (const [index, name] of names.entries()) { + writeFileSync(join(evidenceDir, name), contents[index]); + } + await assert.doesNotReject( + validateWcag22EvidenceArtifacts(temporary, expected, "fixture"), + ); + + const corrupted = Buffer.from(contents[0]); + corrupted[0] ^= 1; + writeFileSync(join(evidenceDir, names[0]), corrupted); + await assert.rejects( + validateWcag22EvidenceArtifacts(temporary, expected, "fixture"), + /fixture WCAG22 evidence bytes differ/u, + "same-length evidence corruption must fail", + ); + + writeFileSync(join(evidenceDir, names[0]), contents[0]); + const wrongDigest = structuredClone(expected); + wrongDigest[0].sha256 = "0".repeat(64); + await assert.rejects( + validateWcag22EvidenceArtifacts(temporary, wrongDigest, "fixture"), + /fixture WCAG22 evidence metadata differs/u, + "expected digest drift must fail independently of the byte comparison", + ); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + + const verifier = read("scripts", "verify-package-release.mjs"); + assert.match( + verifier, + /validatePackedWcag22Evidence\(canonicalPack\.path, wcag22Evidence\.artifacts\)/u, + ); + assert.match( + verifier, + /verifyCleanConsumer\([\s\S]*?wcag22Evidence\.artifacts[\s\S]*?\);/u, + ); +}); + +test("Swift capability mirror transports proof IDs in the canonical checksum order", () => { + const swift = read( + "bindings", + "swift", + "Tests", + "LabColorsConformanceTests", + "ConformanceTests.swift", + ); + assert.match(swift, /let proofIds: \[String\]/); + assert.match( + swift, + /pushSortedKeyList\(site\.boundIds\)\s+pushSortedKeyList\(site\.proofIds\)\s+pushSortedKeyList\(site\.runtimeAttestations\)/u, + ); +}); + test("published build metadata binds source, conformance, and WASM inputs", () => { const packageJson = JSON.parse(read("packages", "colors", "package.json")); assert.equal( diff --git a/packages/colors/test/wcag22.test.mjs b/packages/colors/test/wcag22.test.mjs new file mode 100644 index 00000000..c760abae --- /dev/null +++ b/packages/colors/test/wcag22.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const wasmPath = resolve(here, "../pkg/labcolors_bg.wasm"); +const gluePath = resolve(here, "../pkg/labcolors.js"); +const haveWasm = existsSync(wasmPath) && existsSync(gluePath); + +test("evaluateWcag22 transports exact total core decisions and evidence", async (t) => { + if (!haveWasm) { + t.skip("pkg/ not built — run `npm run build` first"); + return; + } + const { initSync, evaluateWcag22 } = await import(pathToFileURL(gluePath).href); + initSync({ module: readFileSync(wasmPath) }); + const vectors = JSON.parse( + readFileSync(resolve(here, "../../../conformance/vectors/wcag22.json"), "utf8"), + ); + assert.deepEqual( + new Set(vectors.map((vector) => vector.criterion)), + 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", + ]), + ); + for (const vector of vectors) { + const got = evaluateWcag22(vector.foreground, vector.background, vector.criterion); + assert.equal(got.kind, "evaluated"); + assert.equal(got.profileId, vector.profileId); + assert.equal(got.criterion, vector.criterion); + assert.equal(got.foreground, vector.foreground); + assert.equal(got.background, vector.background); + assert.equal(got.decision, vector.decision); + assert.deepEqual(got.foregroundLuminanceQ55, { + lower: vector.foregroundLowerQ55, + upper: vector.foregroundUpperQ55, + }); + assert.deepEqual(got.backgroundLuminanceQ55, { + lower: vector.backgroundLowerQ55, + upper: vector.backgroundUpperQ55, + }); + assert.equal(got.q55Scale, vector.q55Scale); + assert.deepEqual(got.evidence, { + kind: vector.evidenceKind, + artifactId: vector.artifactId, + artifactSha256: vector.artifactSha256, + boundId: vector.boundId, + proofId: vector.proofId, + proofSha256: vector.proofSha256, + proofPayloadSha256: vector.proofPayloadSha256, + generatorSha256: vector.generatorSha256, + verifierSha256: vector.verifierSha256, + profileChecksum: vector.profileChecksum, + profileSha256: vector.profileSha256, + }); + } + + const root = await import(pathToFileURL(resolve(here, "../index.js")).href); + assert.equal(typeof root.evaluateWcag22, "function"); + const belowThree = vectors.find( + (vector) => vector.criterion === "sc-1.4.11-ui-component-or-state", + ); + assert.deepEqual( + root.evaluateWcag22(belowThree.foreground, belowThree.background, belowThree.criterion), + evaluateWcag22(belowThree.foreground, belowThree.background, belowThree.criterion), + ); +}); + +test("evaluateWcag22 rejects invalid criterion and colour without fallback", async (t) => { + if (!haveWasm) { + t.skip("pkg/ not built — run `npm run build` first"); + return; + } + const { initSync, evaluateWcag22 } = await import(pathToFileURL(gluePath).href); + initSync({ module: readFileSync(wasmPath) }); + assert.throws( + () => evaluateWcag22("#000000", "#FFFFFF", "danger"), + /unknown_wcag22_criterion/u, + ); + for (const invalid of ["invalid", "FFFFFF", "##FFFFFF", " #FFFFFF"]) + assert.throws( + () => evaluateWcag22(invalid, "#FFFFFF", "sc-1.4.3-text-default"), + /invalid_color/u, + ); +}); diff --git a/scripts/check-wasm-size-budget.mjs b/scripts/check-wasm-size-budget.mjs new file mode 100644 index 00000000..e4ea4bbb --- /dev/null +++ b/scripts/check-wasm-size-budget.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +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 REPO_ROOT = resolve(SCRIPT_DIR, ".."); +const DEFAULT_WASM = resolve(REPO_ROOT, "packages/colors/pkg/labcolors_bg.wasm"); +const DEFAULT_BUDGET = resolve( + REPO_ROOT, + "packages/colors/bench/wasm-size-budget-v1.json", +); + +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}`); + } + return { wasm, budget }; +} + +function readBudget(path) { + let budget; + try { + budget = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + fail(`cannot read ${path}: ${error.message}`); + } + const measurement = budget?.measurement; + const policy = budget?.policy; + if (budget?.schemaVersion !== 1) fail("supported schemaVersion is exactly 1"); + if (budget?.budgetId !== "labcolors-wasm-raw-issue-284-v1") { + fail("unexpected budgetId"); + } + 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"); + } + if (!/^[0-9a-f]{64}$/u.test(measurement?.sha256 ?? "")) { + fail("measurement.sha256 must identify the exact measured artifact"); + } + 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`); + } + } + 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"); + } + if (!Number.isSafeInteger(policy?.maxRawBytes) || policy.maxRawBytes <= 0) { + fail("policy.maxRawBytes must be a positive safe integer"); + } + if (policy.maxRawBytes !== measurement.rawBytes) { + fail("V1 ceiling must equal the exact accepted measurement (no arbitrary headroom)"); + } + if (policy.derivation !== "exact-accepted-issue-284-measurement") { + fail("unexpected raw-byte ceiling derivation"); + } + if (policy.gzip !== "diagnostic-only") { + fail("gzip must remain diagnostic-only across implementations"); + } + 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}`); +} +if (wasm.length < 8 || !wasm.subarray(0, 4).equals(Buffer.from([0, 97, 115, 109]))) { + fail(`${wasmPath} is not a WebAssembly binary`); +} + +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 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}`, + ); +} +if (isCanonicalPlatform && rawBytes !== budget.measurement.rawBytes) { + fail( + `canonical artifact raw-byte mismatch on ${currentPlatform}: ` + + `expected=${budget.measurement.rawBytes}B actual=${rawBytes}B; ` + + `gzip=${gzipBytes}B diagnostic-only; sha256=${sha256}`, + ); +} +if (isCanonicalPlatform && baselineSha !== "match") { + fail( + `canonical artifact SHA-256 mismatch on ${currentPlatform}: ` + + `expected=${budget.measurement.sha256} actual=${sha256}; ` + + `raw=${rawBytes}B gzip=${gzipBytes}B diagnostic-only`, + ); +} +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}`, +); diff --git a/scripts/generate_wcag22_q55.py b/scripts/generate_wcag22_q55.py new file mode 100644 index 00000000..c9290543 --- /dev/null +++ b/scripts/generate_wcag22_q55.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Generate the canonical WCAG 2.2 sRGB8 Q55 contribution artifact. + +The generator uses only Python integers. It never rounds a floating-point +transfer function: low-branch rows are rational division; high-branch rows use +the exact fifth-power comparison from issue #284. Output is Rust source on +stdout so regeneration can be diffed before replacing the committed artifact. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import struct +from fractions import Fraction +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +PROFILE_PATH = REPO_ROOT / "crates/labcolors-core/contracts/wcag22-srgb8-v1.json" +PROFILE_BYTES = PROFILE_PATH.read_bytes() +PROFILE = json.loads(PROFILE_BYTES) + + +def exact(key: str) -> Fraction: + value = PROFILE[key] + if not isinstance(value, str): + raise TypeError(f"profile field {key} must be an exact decimal string") + return Fraction(value) + + +Q = 1 << int(PROFILE["fixedPointScalePower"]) +SPLIT = exact("channelSplit") +DIVISOR = exact("linearDivisor") +OFFSET = exact("encodedOffset") +ENCODED_SCALE = exact("encodedScale") +EXPONENT = exact("encodedExponent") +if EXPONENT != Fraction(12, 5): + raise ValueError(f"unsupported exact exponent: {EXPONENT}") +WEIGHTS = tuple(exact(key) for key in ("redWeight", "greenWeight", "blueWeight")) +if sum(WEIGHTS, Fraction()) != 1: + raise ValueError("WCAG luminance weights must sum exactly to one") + +PROFILE_CHECKSUM_DOMAIN = b"labcolors.wcag22-srgb8-profile.v1" +PROFILE_CHECKSUM_FIELDS = ( + "profileId", + "recommendation", + "channelSplit", + "linearDivisor", + "encodedOffset", + "encodedScale", + "encodedExponent", + "redWeight", + "greenWeight", + "blueWeight", + "contrastOffset", + "normalTextRatio", + "largeTextRatio", + "requiredNonTextRatio", + "fixedPointScalePower", +) + + +def profile_checksum() -> str: + def framed(value: bytes) -> bytes: + return struct.pack(" tuple[int, int]: + """Return tight integer floor/ceil of Q * weight * linearize(code).""" + encoded = Fraction(code, 255) + if encoded <= SPLIT: + contribution = Q * weight * encoded / DIVISOR + lower, remainder = divmod(contribution.numerator, contribution.denominator) + upper = lower + (remainder != 0) + else: + # For exponent 12/5, compare fifth powers entirely with integers. + base = (encoded + OFFSET) / ENCODED_SCALE + right = Q**5 * weight.numerator**5 * base.numerator**12 + left_factor = weight.denominator**5 * base.denominator**12 + lo, hi = 0, Q + while lo < hi: + midpoint = (lo + hi + 1) // 2 + if midpoint**5 * left_factor <= right: + lo = midpoint + else: + hi = midpoint - 1 + lower = lo + is_exact = lower**5 * left_factor == right + upper = lower if is_exact else lower + 1 + assert lower**5 * left_factor <= right + assert (lower + 1) ** 5 * left_factor > right + + assert 0 <= lower <= upper <= Q + assert upper - lower <= 1 + return lower, upper + + +def generate() -> tuple[list[list[tuple[int, int]]], bytes, str]: + tables = [[weighted_bounds(weight, code) for code in range(256)] for weight in WEIGHTS] + canonical = b"".join( + struct.pack(" None: + tables, canonical, digest = generate() + if artifact_path is not None: + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_bytes(canonical) + profile_digest = hashlib.sha256(PROFILE_BYTES).hexdigest() + generator_digest = hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + print("//! Generated WCAG 2.2 sRGB8 Q55 weighted contribution bounds.") + print("//!") + print("//! DO NOT EDIT: regenerate with `python3 scripts/generate_wcag22_q55.py`.") + print("//! Canonical digest covers each lower/upper u64 in channel/code order,") + print("//! little-endian, without Rust formatting.") + print() + print(f"pub(crate) const Q55_SCALE: u64 = {Q};") + print("pub(crate) const PROFILE_CHECKSUM: &str =") + print(f' "{profile_checksum()}";') + print("pub(crate) const PROFILE_SOURCE_SHA256: &str =") + print(f' "{profile_digest}";') + print("pub(crate) const GENERATOR_SHA256: &str =") + print(f' "{generator_digest}";') + print("pub(crate) const ARTIFACT_SHA256: &str =") + print(f' "{digest}";') + print("#[rustfmt::skip]") + print("pub(crate) static WEIGHTED_CONTRIBUTION_BOUNDS: [[[u64; 2]; 256]; 3] = [") + for weight, table in zip(WEIGHTS, tables): + print(f" [ // exact weight {weight.numerator}/{weight.denominator}") + for offset in range(0, 256, 4): + cells = ", ".join(f"[{lo}, {hi}]" for lo, hi in table[offset : offset + 4]) + print(f" {cells},") + print(" ],") + print("];") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument( + "--artifact", + type=Path, + help="also write the canonical 1536-word little-endian artifact", + ) + emit(parser.parse_args().artifact) diff --git a/scripts/prepare-npm-package.mjs b/scripts/prepare-npm-package.mjs index 14d0835e..e78cd221 100644 --- a/scripts/prepare-npm-package.mjs +++ b/scripts/prepare-npm-package.mjs @@ -13,6 +13,13 @@ export const PACKAGE_DIR = resolve(REPO_ROOT, "packages/colors"); const SOURCE_LICENSE = resolve(REPO_ROOT, "LICENSE"); const PACKED_LICENSE = resolve(PACKAGE_DIR, "LICENSE"); const BUILD_METADATA = resolve(PACKAGE_DIR, "build-metadata.json"); +const WCAG22_CONTRACT_DIR = resolve(REPO_ROOT, "crates/labcolors-core/contracts"); +const PACKED_WCAG22_EVIDENCE_DIR = resolve(PACKAGE_DIR, "evidence"); +const WCAG22_EVIDENCE_FILES = [ + "wcag22-srgb8-v1.json", + "wcag22-srgb8-q55-v1.bin", + "wcag22-srgb8-q55-proof-v1.json", +]; const CONFORMANCE_DIR = resolve(REPO_ROOT, "conformance/vectors"); const CONFORMANCE_FILES = [ "contrasts.json", @@ -20,6 +27,7 @@ const CONFORMANCE_FILES = [ "alpha.json", "solve.json", "muddiness.json", + "wcag22.json", ]; const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); @@ -90,6 +98,17 @@ export async function prepareNpmPackage() { throw new Error("generated npm LICENSE differs from the canonical root LICENSE"); } + await mkdir(PACKED_WCAG22_EVIDENCE_DIR, { recursive: true }); + for (const file of WCAG22_EVIDENCE_FILES) { + const source = await readFile(resolve(WCAG22_CONTRACT_DIR, file)); + if (source.length === 0) throw new Error(`canonical WCAG22 evidence is empty: ${file}`); + const destination = resolve(PACKED_WCAG22_EVIDENCE_DIR, file); + await atomicWrite(destination, source); + if (!(await readFile(destination)).equals(source)) { + throw new Error(`packed WCAG22 evidence differs from canonical source: ${file}`); + } + } + const [packageJsonSource, cargoSource, conformanceSource, wasm, ...familyBytes] = await Promise.all([ readFile(resolve(PACKAGE_DIR, "package.json"), "utf8"), diff --git a/scripts/verify-package-release.mjs b/scripts/verify-package-release.mjs index aef01c5e..d5978bed 100644 --- a/scripts/verify-package-release.mjs +++ b/scripts/verify-package-release.mjs @@ -24,12 +24,19 @@ const BUILD_METADATA = resolve(PACKAGE_DIR, "build-metadata.json"); const ROOT_CARGO = resolve(REPO_ROOT, "Cargo.toml"); const CONFORMANCE_DIR = resolve(REPO_ROOT, "conformance/vectors"); const CONFORMANCE_MANIFEST = resolve(CONFORMANCE_DIR, "manifest.json"); +const WCAG22_CONTRACT_DIR = resolve(REPO_ROOT, "crates/labcolors-core/contracts"); +const WCAG22_EVIDENCE_FILES = [ + "wcag22-srgb8-v1.json", + "wcag22-srgb8-q55-v1.bin", + "wcag22-srgb8-q55-proof-v1.json", +]; const CONFORMANCE_FAMILY_FILES = [ "contrasts.json", "ladders.json", "alpha.json", "solve.json", "muddiness.json", + "wcag22.json", ]; const WASM_PATH = resolve(PACKAGE_DIR, "pkg/labcolors_bg.wasm"); @@ -103,6 +110,130 @@ async function hashedArtifact(path, displayPath) { return { path: displayPath, bytes: bytes.length, sha256: sha256(bytes) }; } +export async function validateWcag22EvidenceArtifacts( + root, + expectedArtifacts, + label, +) { + const allowedPaths = WCAG22_EVIDENCE_FILES.map((file) => `evidence/${file}`); + if (!Array.isArray(expectedArtifacts) || expectedArtifacts.length !== allowedPaths.length) { + fail(`${label} WCAG22 evidence expectation must contain ${allowedPaths.length} artifacts`); + } + const expectedByPath = new Map(); + for (const artifact of expectedArtifacts) { + if ( + !allowedPaths.includes(artifact?.path) || + !Number.isSafeInteger(artifact?.bytes) || + artifact.bytes <= 0 || + !/^[0-9a-f]{64}$/u.test(artifact?.sha256 ?? "") || + expectedByPath.has(artifact.path) + ) { + fail(`${label} has malformed or duplicate WCAG22 evidence metadata`); + } + expectedByPath.set(artifact.path, artifact); + } + + const actualArtifacts = []; + for (const file of WCAG22_EVIDENCE_FILES) { + const displayPath = `evidence/${file}`; + const expected = expectedByPath.get(displayPath); + if (!expected) fail(`${label} lacks expected WCAG22 evidence metadata: ${displayPath}`); + const [canonical, actual] = await Promise.all([ + readFile(resolve(WCAG22_CONTRACT_DIR, file)), + readFile(resolve(root, "evidence", file)), + ]); + if (!actual.equals(canonical)) { + fail(`${label} WCAG22 evidence bytes differ from canonical source: ${displayPath}`); + } + const metadata = { + path: displayPath, + bytes: actual.length, + sha256: sha256(actual), + }; + if (metadata.bytes !== expected.bytes || metadata.sha256 !== expected.sha256) { + fail( + `${label} WCAG22 evidence metadata differs for ${displayPath}: ` + + `expected ${expected.bytes}B/${expected.sha256}, ` + + `actual ${metadata.bytes}B/${metadata.sha256}`, + ); + } + actualArtifacts.push(metadata); + } + return actualArtifacts; +} + +async function validateWcag22Evidence() { + const artifacts = []; + for (const file of WCAG22_EVIDENCE_FILES) { + artifacts.push( + await hashedArtifact( + resolve(WCAG22_CONTRACT_DIR, file), + `evidence/${file}`, + ), + ); + } + await validateWcag22EvidenceArtifacts(PACKAGE_DIR, artifacts, "staged package"); + + const profilePath = resolve(WCAG22_CONTRACT_DIR, WCAG22_EVIDENCE_FILES[0]); + const profileBytes = await readFile(profilePath); + const profile = await readJson(profilePath); + const binary = await readFile(resolve(WCAG22_CONTRACT_DIR, WCAG22_EVIDENCE_FILES[1])); + const proof = await readJson(resolve(WCAG22_CONTRACT_DIR, WCAG22_EVIDENCE_FILES[2])); + if (profile.profileId !== "wcag22-srgb8-contrast-v1" || proof.profile_id !== profile.profileId) { + fail("WCAG22 profile/proof identity drifted"); + } + if (binary.length !== 768 * 2 * 8) { + fail(`WCAG22 Q55 artifact has ${binary.length} bytes, expected 12288`); + } + if (proof.profile_source_sha256 !== sha256(profileBytes)) { + fail("WCAG22 proof does not bind the canonical profile bytes"); + } + if (proof.artifact_sha256 !== sha256(binary)) { + fail("WCAG22 proof does not bind the canonical Q55 artifact bytes"); + } + if ( + proof.artifact_id !== "wcag22-srgb8-luminance-q55-v1" || + proof.bound_id !== "wcag22-srgb8-outward-q55-v1" || + proof.proof_id !== "wcag22-srgb8-full-domain-q55-v1" || + proof.kernel_id !== "wcag22-srgb8-evaluation-kernel-v1" || + proof.terminal_evidence_id !== "wcag22-srgb8-terminal-evidence-v1" || + proof.parser_id !== "encoded-srgb8-hex-parser-v1" || + proof.facade_id !== "wcag22-srgb8-public-facade-v1" || + proof.declared_operation_law !== + "final-srgb8-outward-q55-two-orientation-integer-threshold-v1" + ) { + fail("WCAG22 proof typed identity or operation law drifted"); + } + if (!/^[0-9a-f]{8}$/u.test(proof.profile_checksum ?? "")) { + fail("WCAG22 proof lacks a typed profile checksum"); + } + if (!/^[0-9a-f]{64}$/u.test(proof.crate_lib_source_sha256 ?? "")) { + fail("WCAG22 proof lacks the proof-bound crate-root digest"); + } + if (proof.rows !== 768 || proof.artifact_words !== 1536 || proof.colors !== 16_777_216) { + fail("WCAG22 proof has incomplete row or finite-domain coverage"); + } + if ( + !Array.isArray(proof.thresholds) || + proof.thresholds.length !== 2 || + !proof.thresholds.every((threshold) => threshold.unresolved === 0) + ) { + fail("WCAG22 proof contains an unresolved supported threshold"); + } + return { + profileId: profile.profileId, + profileChecksum: proof.profile_checksum, + artifactId: proof.artifact_id, + boundId: proof.bound_id, + proofId: proof.proof_id, + kernelId: proof.kernel_id, + terminalEvidenceId: proof.terminal_evidence_id, + parserId: proof.parser_id, + facadeId: proof.facade_id, + artifacts, + }; +} + export function validateBuildMetadata( metadata, { packageJson, source, coreVersion, conformanceEvidence, wasm }, @@ -212,6 +343,20 @@ async function packInto(destination, packageJson) { return { path: resolve(destination, tarballName), tarballName }; } +async function validatePackedWcag22Evidence(tarballPath, expectedArtifacts) { + const extracted = await mkdtemp(join(tmpdir(), "labcolors-packed-evidence-")); + try { + command("tar", ["-xzf", tarballPath, "-C", extracted]); + await validateWcag22EvidenceArtifacts( + resolve(extracted, "package"), + expectedArtifacts, + "npm tarball", + ); + } finally { + await rm(extracted, { recursive: true, force: true }); + } +} + function fnv1a32(buffers) { let hash = 0x811c9dc5; for (const buffer of buffers) { @@ -227,15 +372,16 @@ function fnv1a32(buffers) { // labcolors-core/src/numerics.rs, canonical_checksum_preimage). Домен-сепаратор // и length-prefixed кодирование повторены здесь НЕЗАВИСИМО: релизный гейт не // доверяет закоммиченному checksum, а пересчитывает его из тех же typed rows. -const CAPABILITY_CHECKSUM_DOMAIN_V1 = "labcolors.numerical-capability.v1"; +const CAPABILITY_CHECKSUM_DOMAIN_V2 = "labcolors.numerical-capability.v2"; // Поля-списки одного site в каноническом порядке preimage (порядок фиксирован -// схемой v1 и не выводится из JSON, чтобы переименование ключа ломало гейт). +// схемой v2 и не выводится из JSON, чтобы переименование ключа ломало гейт). const CAPABILITY_SITE_LIST_FIELDS = [ "stableOutcomes", "compatibilityReleases", "evidenceClasses", "artifactIds", "boundIds", + "proofIds", "runtimeAttestations", ]; @@ -257,7 +403,7 @@ function compareUtf8(a, b) { function capabilityChecksumPreimage(capabilities) { const chunks = []; - chunks.push(...lenPrefixed(Buffer.from(CAPABILITY_CHECKSUM_DOMAIN_V1, "utf8"))); + chunks.push(...lenPrefixed(Buffer.from(CAPABILITY_CHECKSUM_DOMAIN_V2, "utf8"))); chunks.push(u32le(capabilities.schemaVersion)); chunks.push(...lenPrefixed(Buffer.from(capabilities.coverage, "utf8"))); const sites = [...capabilities.sites].sort((a, b) => compareUtf8(a.siteId, b.siteId)); @@ -283,9 +429,9 @@ function validateCapabilityManifest(capabilities) { if (typeof capabilities !== "object" || capabilities === null || Array.isArray(capabilities)) { fail("conformance manifest has no numericalCapabilities object"); } - if (capabilities.schemaVersion !== 1) { + if (capabilities.schemaVersion !== 2) { fail( - `numericalCapabilities schemaVersion ${capabilities.schemaVersion} is not the supported 1`, + `numericalCapabilities schemaVersion ${capabilities.schemaVersion} is not the supported 2`, ); } if (capabilities.coverage !== "migrated-sites-only-v1") { @@ -325,8 +471,8 @@ function validateCapabilityManifest(capabilities) { } async function validateConformance(conformance) { - if (conformance.packVersion !== "3.0.0") { - fail(`release requires conformance pack 3.0.0, got ${conformance.packVersion}`); + if (conformance.packVersion !== "4.0.0") { + fail(`release requires conformance pack 4.0.0, got ${conformance.packVersion}`); } if (!/^[0-9a-f]{8}$/u.test(conformance.packDigest ?? "")) { fail(`invalid conformance packDigest: ${conformance.packDigest}`); @@ -353,7 +499,7 @@ async function validateConformance(conformance) { fail(`${CONFORMANCE_FAMILY_FILES[index]} is not valid JSON: ${error.message}`); } }); - const countKeys = ["contrasts", "ladders", "alpha", "solve", "muddiness"]; + const countKeys = ["contrasts", "ladders", "alpha", "solve", "muddiness", "wcag22"]; let total = 0; for (const [index, key] of countKeys.entries()) { const actual = families[index].length; @@ -371,6 +517,31 @@ async function validateConformance(conformance) { if (halfTie?.composite !== "#17161F") { fail("conformance pack lacks the exact source-over half-tie #C0B2FA@0.122 -> #17161F"); } + const antiEpsilon = families[5].find( + (entry) => + entry.foreground === "#89BB09" && + 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" || + antiEpsilon?.artifactId !== proof.artifact_id || + antiEpsilon?.artifactSha256 !== proof.artifact_sha256 || + antiEpsilon?.boundId !== proof.bound_id || + antiEpsilon?.proofId !== proof.proof_id || + antiEpsilon?.proofSha256 !== sha256(proofBytes) || + antiEpsilon?.proofPayloadSha256 !== proof.proof_payload_sha256 || + antiEpsilon?.generatorSha256 !== proof.generator_sha256 || + antiEpsilon?.verifierSha256 !== proof.verifier_sha256 || + antiEpsilon?.profileChecksum !== proof.profile_checksum || + antiEpsilon?.profileSha256 !== proof.profile_source_sha256 + ) { + fail("conformance pack lacks the exact proof-bound WCAG22 anti-epsilon witness"); + } validateCapabilityManifest(conformance.numericalCapabilities); const manifestBytes = await readFile(CONFORMANCE_MANIFEST); @@ -414,7 +585,11 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import init, { LabColors } from "@labpics/colors"; +import init, { + LabColors, + evaluateWcag22, + numericalCapabilityManifest, +} from "@labpics/colors"; const require = createRequire(import.meta.url); const wasmPath = require.resolve("@labpics/colors/pkg/labcolors_bg.wasm"); @@ -431,6 +606,22 @@ 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 capability = numericalCapabilityManifest(); +assert.equal(capability.schemaVersion, 2); +assert.ok(capability.sites.some((site) => + site.siteId === "wcag22-srgb8-contrast-v1" && + site.proofIds.includes("wcag22-srgb8-full-domain-q55-v1") +)); + +const exactWcag22 = evaluateWcag22( + "#898CB8", + "#3E2217", + "sc-1.4.3-text-default", +); +assert.equal(exactWcag22.decision, "fail"); +assert.equal(exactWcag22.evidence.profileChecksum, "152813fe"); +assert.match(exactWcag22.evidence.proofSha256, /^[0-9a-f]{64}$/u); + const config = { brand: { light: "#17161F", @@ -600,6 +791,8 @@ function typeSmokeSource() { return String.raw` import init, { LabColors, + evaluateWcag22, + numericalCapabilityManifest, type GlowDecisionGuaranteeV1, type GlowDeterminateRole, type GlowDeterminateRoleBase, @@ -608,16 +801,28 @@ import init, { type LadderPositionV1, type MaterialRole, type MaterialRoleBase, + type NumericalCapabilityManifestV2, type NumericalIndeterminacyV1, type ResolvedTheme, type ThemeConfig, type TranslucentRole, + type Wcag22AssessmentV1, + type Wcag22CriterionV1, } from "@labpics/colors"; const initialise: typeof init = init; const engine = new LabColors(); const fingerprint: string = engine.loadConfig("{}"); const resolved: ResolvedTheme = engine.resolveTheme("#000000", "light"); +const capability: NumericalCapabilityManifestV2 = numericalCapabilityManifest(); +const wcagCriterion: Wcag22CriterionV1 = "sc-1.4.3-text-default"; +const wcagAssessment: Wcag22AssessmentV1 = evaluateWcag22( + "#000000", + "#FFFFFF", + wcagCriterion, +); +// @ts-expect-error criterion is an explicit closed menu, not an opaque string. +evaluateWcag22("#000000", "#FFFFFF", "danger"); const borderPosition: LadderPositionV1 = "border-strong"; const config: ThemeConfig = { brand: { @@ -770,6 +975,8 @@ void [ initialise, fingerprint, resolved, + wcagAssessment, + capability, config, alphaContract, glowContract, @@ -788,6 +995,7 @@ async function verifyCleanConsumer( packageJson, packageLock, expectedBuildMetadata, + expectedWcag22Artifacts, ) { const consumer = await mkdtemp(join(tmpdir(), "labcolors-release-consumer-")); try { @@ -830,6 +1038,12 @@ async function verifyCleanConsumer( ); } + await validateWcag22EvidenceArtifacts( + installed, + expectedWcag22Artifacts, + "clean-installed package", + ); + const installedWasm = await readFile(resolve(installed, "pkg/labcolors_bg.wasm")); if (sha256(installedWasm) !== expectedBuildMetadata.wasm.sha256) { fail("clean-installed WASM bytes differ from the packed release input"); @@ -904,6 +1118,8 @@ export async function smokePackedRuntime(tarballPath) { export async function verifyPackageRelease() { const { sourceSha: source } = await prepareNpmPackage(); + command("python3", ["scripts/verify_wcag22_q55.py"], REPO_ROOT); + const wcag22Evidence = await validateWcag22Evidence(); const [packageJson, packageLock, cargoSource, conformance] = await Promise.all([ readJson(PACKAGE_JSON), @@ -972,6 +1188,8 @@ export async function verifyPackageRelease() { await rm(reproductionDir, { recursive: true, force: true }); } + await validatePackedWcag22Evidence(canonicalPack.path, wcag22Evidence.artifacts); + const tarball = await hashedArtifact( canonicalPack.path, `.release/${canonicalPack.tarballName}`, @@ -981,6 +1199,7 @@ export async function verifyPackageRelease() { packageJson, packageLock, buildMetadataValue, + wcag22Evidence.artifacts, ); const manifest = { @@ -997,6 +1216,7 @@ export async function verifyPackageRelease() { trackingIssue: 258, }, conformance: conformanceEvidence, + normativeEvidence: { wcag22: wcag22Evidence }, sourceSha: source, reproducibility: { method: "two-independent-npm-pack-passes", @@ -1024,6 +1244,7 @@ export async function verifyPackageRelease() { "exact-alpha-srgb8-v1", "exact-screen-composite-srgb8-v1", "typed-glow-indeterminate-v1", + "wcag22-srgb8-contrast-v1", ], numericalCapabilities: conformance.numericalCapabilities, unsupported: [ diff --git a/scripts/verify_wcag22_q55.py b/scripts/verify_wcag22_q55.py new file mode 100644 index 00000000..a6c88861 --- /dev/null +++ b/scripts/verify_wcag22_q55.py @@ -0,0 +1,1326 @@ +#!/usr/bin/env python3 +"""Независимо проверяет WCAG 2.2 sRGB8 Q55 artifact из Issue #284. + +Verifier не импортирует production generator. Primary row oracle использует +adaptive-precision Decimal с directed rounding и stability across precisions; +отдельный integer proof проверяет tightness. Полный sRGB8 domain перечисляется +как 256^3 интервалов, но пары 256^6 не перебираются: монотонный scan +рассматривает только boundary band, где threshold law может быть unresolved. +""" + +from __future__ import annotations + +import hashlib +import heapq +import json +import os +import re +import shutil +import struct +import subprocess +import sys +import tempfile +import textwrap +import time +from array import array +from dataclasses import dataclass +from decimal import Decimal, ROUND_CEILING, ROUND_FLOOR, localcontext +from fractions import Fraction +from math import gcd +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +PROFILE_PATH = REPO_ROOT / "crates/labcolors-core/contracts/wcag22-srgb8-v1.json" +GENERATOR_PATH = REPO_ROOT / "scripts/generate_wcag22_q55.py" +VERIFIER_PATH = Path(__file__).resolve() +RUST_ARTIFACT = REPO_ROOT / "crates/labcolors-core/src/wcag22/q55_data.rs" +KERNEL_SOURCE = REPO_ROOT / "crates/labcolors-core/src/wcag22/kernel.rs" +PARSER_SOURCE = REPO_ROOT / "crates/labcolors-core/src/srgb8.rs" +TERMINAL_EVIDENCE_SOURCE = REPO_ROOT / "crates/labcolors-core/src/wcag22_evidence.rs" +FACADE_SOURCE = REPO_ROOT / "crates/labcolors-core/src/wcag22.rs" +CRATE_LIB_SOURCE = REPO_ROOT / "crates/labcolors-core/src/lib.rs" +EVALUATOR_SOURCE = FACADE_SOURCE +CANONICAL_BINARY_ARTIFACT = ( + REPO_ROOT / "crates/labcolors-core/contracts/wcag22-srgb8-q55-v1.bin" +) +PROOF_PATH = REPO_ROOT / "crates/labcolors-core/contracts/wcag22-srgb8-q55-proof-v1.json" +PROFILE_BYTES = PROFILE_PATH.read_bytes() +PROFILE = json.loads(PROFILE_BYTES) +# Independent immutable expectation for profile V1. The JSON is the production +# SSOT; this second copy is deliberately a test oracle that makes a normative +# value or engineering-scale mutation fail instead of silently defining V1 anew. +NORMATIVE_PROFILE_V1 = { + "schemaVersion": 1, + "profileId": "wcag22-srgb8-contrast-v1", + "recommendation": "https://www.w3.org/TR/2024/REC-WCAG22-20241212/", + "channelSplit": "0.04045", + "linearDivisor": "12.92", + "encodedOffset": "0.055", + "encodedScale": "1.055", + "encodedExponent": "2.4", + "redWeight": "0.2126", + "greenWeight": "0.7152", + "blueWeight": "0.0722", + "contrastOffset": "0.05", + "normalTextRatio": "4.5", + "largeTextRatio": "3.0", + "requiredNonTextRatio": "3.0", + "fixedPointScalePower": 55, +} +if PROFILE != NORMATIVE_PROFILE_V1: + raise ValueError("canonical WCAG22 sRGB8 profile V1 drifted") + +ARTIFACT_ID = "wcag22-srgb8-luminance-q55-v1" +BOUND_ID = "wcag22-srgb8-outward-q55-v1" +PROOF_ID = "wcag22-srgb8-full-domain-q55-v1" +KERNEL_ID = "wcag22-srgb8-evaluation-kernel-v1" +EXPECTED_KERNEL_SHA256 = ( + "c97980c1ca2c7ea9cabff9c8d2fb7282773cca180ae15948391c29c9d6196040" +) +TERMINAL_EVIDENCE_ID = "wcag22-srgb8-terminal-evidence-v1" +EXPECTED_TERMINAL_EVIDENCE_SHA256 = ( + "3c5a75b07254c6071a64700af208a64987d0f0ea9698eadc54a9e74585ce1f72" +) +PARSER_ID = "encoded-srgb8-hex-parser-v1" +EXPECTED_PARSER_SHA256 = ( + "57cd2605e040a4d206a83c86cf01c5d6935e5bff9c45e556db0e4c6eaede7280" +) +FACADE_ID = "wcag22-srgb8-public-facade-v1" +EXPECTED_NORMALIZED_FACADE_SHA256 = ( + "8cecfaf660e896c5ac7c377ed286fa0377a0201e83f2b65f858e4136348397ef" +) +EXPECTED_CRATE_LIB_SHA256 = ( + "40d926da94547201242ef3aaf01db4c7e3912e8034998ab9a11671882057a726" +) +DECLARED_OPERATION_LAW = ( + "final-srgb8-outward-q55-two-orientation-integer-threshold-v1" +) +PROFILE_CHECKSUM_DOMAIN = b"labcolors.wcag22-srgb8-profile.v1" +REGISTRY_ROW_BINDING_DOMAIN = b"labcolors.wcag22-registry-row.v1" +REGISTRY_ROW_BINDING_SCHEMA_VERSION = 1 +EXPECTED_REGISTRY_ROW_SHA256 = ( + "c91c5e185c432ae4a9fb9ea03e9838bf2565f2aabff56019e190aae97bfaa0f1" +) +REGISTRY_ROW_SET_FIELDS = ( + "stable_outcomes", + "compatibility_releases", + "evidence_classes", + "artifact_ids", + "bound_ids", + "proof_ids", + "runtime_attestations", +) +EXPECTED_WCAG_REGISTRY_ROW = { + "site_id": "wcag22-srgb8-contrast-v1", + "stable_outcomes": ("canonical-finite-bounded",), + "compatibility_releases": (), + "evidence_classes": ("canonical-finite-bounded",), + "artifact_ids": (ARTIFACT_ID,), + "bound_ids": (BOUND_ID,), + "proof_ids": (PROOF_ID,), + "runtime_attestations": (), + "bound_status": "available", + "fallback_status": "none", +} +PROFILE_CHECKSUM_FIELDS = ( + "profileId", + "recommendation", + "channelSplit", + "linearDivisor", + "encodedOffset", + "encodedScale", + "encodedExponent", + "redWeight", + "greenWeight", + "blueWeight", + "contrastOffset", + "normalTextRatio", + "largeTextRatio", + "requiredNonTextRatio", + "fixedPointScalePower", +) + + +def exact_decimal(key: str) -> Decimal: + value = PROFILE.get(key) + if not isinstance(value, str): + raise TypeError(f"profile field {key} must be an exact decimal string") + return Decimal(value) + + +def exact_fraction(key: str) -> Fraction: + value = PROFILE.get(key) + if not isinstance(value, str): + raise TypeError(f"profile field {key} must be an exact decimal string") + return Fraction(value) + + +SCALE_POWER = PROFILE.get("fixedPointScalePower") +if not isinstance(SCALE_POWER, int): + raise TypeError("profile fixedPointScalePower must be an integer") +Q = 1 << SCALE_POWER +SPLIT_DECIMAL = exact_decimal("channelSplit") +DIVISOR_DECIMAL = exact_decimal("linearDivisor") +OFFSET_DECIMAL = exact_decimal("encodedOffset") +ENCODED_SCALE_DECIMAL = exact_decimal("encodedScale") +EXPONENT_DECIMAL = exact_decimal("encodedExponent") +SPLIT_FRACTION = exact_fraction("channelSplit") +DIVISOR_FRACTION = exact_fraction("linearDivisor") +OFFSET_FRACTION = exact_fraction("encodedOffset") +ENCODED_SCALE_FRACTION = exact_fraction("encodedScale") +EXPONENT_FRACTION = exact_fraction("encodedExponent") +CONTRAST_OFFSET_FRACTION = exact_fraction("contrastOffset") +WEIGHT_KEYS = ("redWeight", "greenWeight", "blueWeight") +WEIGHT_DECIMALS = tuple(exact_decimal(key) for key in WEIGHT_KEYS) +WEIGHT_FRACTIONS = tuple(exact_fraction(key) for key in WEIGHT_KEYS) +if EXPONENT_FRACTION != Fraction(12, 5): + raise ValueError(f"unsupported exact exponent: {EXPONENT_FRACTION}") +if sum(WEIGHT_FRACTIONS, Fraction()) != 1: + raise ValueError("WCAG luminance weights must sum exactly to one") + +CHANNEL_CODES = 256 +COLOR_COUNT = CHANNEL_CODES**3 +ROW_COUNT = len(WEIGHT_KEYS) * CHANNEL_CODES +PACK_WIDTH_BITS = 2 +PACK_WIDTH_MASK = (1 << PACK_WIDTH_BITS) - 1 +DECIMAL_PRECISIONS = (48, 72, 108, 162) + + +def fnv1a32(data: bytes) -> str: + value = 0x811C9DC5 + for byte in data: + value = ((value ^ byte) * 0x01000193) & 0xFFFFFFFF + return f"{value:08x}" + + +def length_prefixed(value: bytes) -> bytes: + return struct.pack(" str: + preimage = bytearray(length_prefixed(PROFILE_CHECKSUM_DOMAIN)) + preimage.extend(struct.pack(" Threshold: + ratio = exact_fraction(key) + common_denominator = CONTRAST_OFFSET_FRACTION.denominator + light_factor = ratio.denominator * common_denominator + dark_factor = ratio.numerator * common_denominator + offset_factor = ( + ratio.numerator - ratio.denominator + ) * CONTRAST_OFFSET_FRACTION.numerator + common = gcd(gcd(light_factor, dark_factor), offset_factor) + return Threshold( + str(PROFILE[key]), + light_factor // common, + dark_factor // common, + offset_factor // common, + ) + + +if exact_fraction("largeTextRatio") != exact_fraction("requiredNonTextRatio"): + raise ValueError("profile 3:1 criteria no longer share one threshold law") +THRESHOLDS = ( + threshold_from_profile("largeTextRatio"), + threshold_from_profile("normalTextRatio"), +) + + +def verify_signed64_replay_envelope(max_interval_width: int) -> dict[str, int | str]: + """Prove every cleared-denominator Q55 term fits a signed 64-bit replay.""" + outward_width_bound = len(WEIGHT_KEYS) + assert max_interval_width <= outward_width_bound, ( + "Q55 proof exceeds the one-outward-unit-per-channel envelope" + ) + maximum_luminance_upper = Q + outward_width_bound + maximum_threshold_term = max( + 30 * maximum_luminance_upper + Q, + 180 * maximum_luminance_upper + 7 * Q, + ) + signed_64_max = (1 << 63) - 1 + assert maximum_threshold_term <= signed_64_max + + next_scale = Q * 2 + next_scale_maximum_term = 180 * ( + next_scale + outward_width_bound + ) + 7 * next_scale + assert next_scale_maximum_term > signed_64_max, ( + "Q55 is no longer the maximal signed-64-safe binary scale" + ) + return { + "carrier": "signed-64", + "observed_interval_width": max_interval_width, + "outward_interval_width_bound": outward_width_bound, + "maximum_luminance_upper": maximum_luminance_upper, + "maximum_threshold_term": maximum_threshold_term, + "carrier_maximum": signed_64_max, + "headroom": signed_64_max - maximum_threshold_term, + "next_scale_power": SCALE_POWER + 1, + "next_scale_maximum_threshold_term": next_scale_maximum_term, + } + + +def ceil_div(numerator: int, denominator: int) -> int: + return -(-numerator // denominator) + + +def decimal_contribution( + weight: Decimal, code: int, precision: int, rounding: str +) -> Decimal: + """Считает contribution в независимом Decimal backend.""" + with localcontext() as context: + context.prec = precision + context.rounding = rounding + encoded = Decimal(code) / Decimal(255) + if encoded <= SPLIT_DECIMAL: + return Decimal(Q) * weight * encoded / DIVISOR_DECIMAL + base = (encoded + OFFSET_DECIMAL) / ENCODED_SCALE_DECIMAL + return Decimal(Q) * weight * context.power(base, EXPONENT_DECIMAL) + + +def decimal_weighted_bounds(weight: Decimal, code: int) -> tuple[int, int, int]: + """Требует stable floor/ceil в двух successive precision levels.""" + previous: tuple[int, int] | None = None + for precision in DECIMAL_PRECISIONS: + lower_value = decimal_contribution(weight, code, precision, ROUND_FLOOR) + upper_value = decimal_contribution(weight, code, precision, ROUND_CEILING) + assert lower_value <= upper_value + signature = ( + int(lower_value.to_integral_value(rounding=ROUND_FLOOR)), + int(upper_value.to_integral_value(rounding=ROUND_CEILING)), + ) + if signature == previous: + lower, upper = signature + assert 0 <= lower <= upper <= Q + assert upper - lower <= 1 + return lower, upper, precision + previous = signature + raise AssertionError( + f"Decimal bounds did not stabilize: weight={weight}, code={code}, " + f"last={previous}" + ) + + +def integer_tightness_cross_check( + weight: Fraction, code: int, row: tuple[int, int] +) -> None: + """Отдельно доказывает, что committed Decimal-confirmed row tight.""" + lower, upper = row + encoded = Fraction(code, 255) + if encoded <= SPLIT_FRACTION: + contribution = Q * weight * encoded / DIVISOR_FRACTION + expected_lower, remainder = divmod( + contribution.numerator, contribution.denominator + ) + expected_upper = expected_lower + int(remainder != 0) + assert row == (expected_lower, expected_upper) + else: + base = (encoded + OFFSET_FRACTION) / ENCODED_SCALE_FRACTION + numerator = Q**5 * weight.numerator**5 * base.numerator**12 + denominator = weight.denominator**5 * base.denominator**12 + assert lower**5 * denominator <= numerator + assert (lower + 1) ** 5 * denominator > numerator + exact = lower**5 * denominator == numerator + expected_upper = lower if exact else lower + 1 + assert upper == expected_upper + + assert 0 <= lower <= upper <= Q + assert upper - lower <= 1 + + +def parse_committed_artifact( + path: Path, +) -> tuple[dict[str, int | str], list[tuple[int, int]]]: + source = path.read_text(encoding="utf-8") + metadata_patterns = { + "q55_scale": r"Q55_SCALE: u64 = (\d+);", + "profile_checksum": r'PROFILE_CHECKSUM: &str =\s*"([0-9a-f]{8})";', + "profile_source_sha256": ( + r'PROFILE_SOURCE_SHA256: &str =\s*"([0-9a-f]{64})";' + ), + "generator_sha256": r'GENERATOR_SHA256: &str =\s*"([0-9a-f]{64})";', + "artifact_sha256": r'ARTIFACT_SHA256: &str =\s*"([0-9a-f]{64})";', + } + metadata: dict[str, int | str] = {} + for key, pattern in metadata_patterns.items(): + match = re.search(pattern, source) + if match is None: + raise AssertionError(f"missing {key} metadata in {path}") + metadata[key] = int(match.group(1)) if key == "q55_scale" else match.group(1) + table_match = re.search( + r"WEIGHTED_CONTRIBUTION_BOUNDS:.*?= \[(.*)\];\s*$", + source, + flags=re.DOTALL, + ) + if table_match is None: + raise AssertionError(f"не удалось разобрать table из {path}") + rows = [ + (int(lower), int(upper)) + for lower, upper in re.findall(r"\[(\d+),\s*(\d+)\]", table_match.group(1)) + ] + return metadata, rows + + +def canonical_digest(rows: list[tuple[int, int]]) -> str: + digest = hashlib.sha256() + for lower, upper in rows: + digest.update(struct.pack(" bytes: + return b"".join(struct.pack(" tuple[list[list[tuple[int, int]]], dict[str, int | list[int]]]: + assert metadata["q55_scale"] == Q, ( + f"scale drift: {metadata['q55_scale']} != {Q}" + ) + assert metadata["profile_checksum"] == profile_checksum(), ( + "typed profile checksum drift: " + f"artifact={metadata['profile_checksum']}, verifier={profile_checksum()}" + ) + assert len(committed_rows) == ROW_COUNT, ( + f"row-count drift: {len(committed_rows)} != {ROW_COUNT}" + ) + committed_binary = ( + CANONICAL_BINARY_ARTIFACT.read_bytes() + if canonical_binary_artifact is None + else canonical_binary_artifact + ) + expected_binary = canonical_bytes(committed_rows) + assert committed_binary == expected_binary, ( + "canonical binary artifact differs from the production Rust table" + ) + actual_digest = canonical_digest(committed_rows) + assert actual_digest == metadata["artifact_sha256"], ( + "artifact digest drift: " + f"metadata={metadata['artifact_sha256']}, bytes={actual_digest}" + ) + + expected_rows: list[tuple[int, int]] = [] + used_precisions: list[int] = [] + for weight in WEIGHT_DECIMALS: + for code in range(CHANNEL_CODES): + lower, upper, precision = decimal_weighted_bounds(weight, code) + expected_rows.append((lower, upper)) + used_precisions.append(precision) + for index, (committed, expected) in enumerate(zip(committed_rows, expected_rows)): + assert committed == expected, ( + f"row {index} differs from adaptive Decimal oracle: " + f"committed={committed}, expected={expected}" + ) + channel, code = divmod(index, CHANNEL_CODES) + integer_tightness_cross_check(WEIGHT_FRACTIONS[channel], code, committed) + + tables = [ + committed_rows[offset : offset + CHANNEL_CODES] + for offset in range(0, ROW_COUNT, CHANNEL_CODES) + ] + return tables, { + "rows_stable": len(expected_rows), + "precision_schedule": list(DECIMAL_PRECISIONS), + "maximum_precision_used": max(used_precisions), + "integer_tightness_cross_checks": len(expected_rows), + } + + +def pack_interval(lower: int, upper: int) -> int: + width = upper - lower + assert 0 <= width <= PACK_WIDTH_MASK + return (lower << PACK_WIDTH_BITS) | width + + +def unpack_interval(packed: int) -> tuple[int, int]: + lower = packed >> PACK_WIDTH_BITS + return lower, lower + (packed & PACK_WIDTH_MASK) + + +def build_unique_color_intervals( + tables: list[list[tuple[int, int]]], +) -> tuple[array, int]: + """Перечисляет 256^3 colours и merge-сортирует их без giant Python list.""" + red, green, blue = tables + red_green = [ + pack_interval(r_lo + g_lo, r_hi + g_hi) + for r_lo, r_hi in red + for g_lo, g_hi in green + ] + red_green.sort() + + blue_offsets = [pack_interval(lower, upper) for lower, upper in blue] + heap = [ + (red_green[0] + offset, blue_code, 0) + for blue_code, offset in enumerate(blue_offsets) + ] + heapq.heapify(heap) + + unique = array("Q") + previous = -1 + generated = 0 + while heap: + packed, blue_code, rg_index = heap[0] + generated += 1 + if packed != previous: + unique.append(packed) + previous = packed + next_index = rg_index + 1 + if next_index == len(red_green): + heapq.heappop(heap) + else: + heapq.heapreplace( + heap, + ( + red_green[next_index] + blue_offsets[blue_code], + blue_code, + next_index, + ), + ) + + assert generated == COLOR_COUNT + return unique, generated + + +def orientation( + lighter: tuple[int, int], darker: tuple[int, int], threshold: Threshold +) -> str: + light_lower, light_upper = lighter + dark_lower, dark_upper = darker + pass_rhs = threshold.dark_factor * dark_upper + threshold.offset_factor * Q + fail_rhs = threshold.dark_factor * dark_lower + threshold.offset_factor * Q + passes = threshold.light_factor * light_lower >= pass_rhs + fails = threshold.light_factor * light_upper < fail_rhs + if passes and not fails: + return "pass" + if fails and not passes: + return "fail" + return "unresolved" + + +def pair_decision( + first: tuple[int, int], second: tuple[int, int], threshold: Threshold +) -> str: + forward = orientation(first, second, threshold) + reverse = orientation(second, first, threshold) + if forward == "pass" or reverse == "pass": + return "pass" + if forward == "fail" and reverse == "fail": + return "fail" + return "unresolved" + + +def decision_margin( + lighter: tuple[int, int], darker: tuple[int, int], threshold: Threshold +) -> tuple[str, int] | None: + verdict = orientation(lighter, darker, threshold) + light_lower, light_upper = lighter + dark_lower, dark_upper = darker + if verdict == "pass": + margin = ( + threshold.light_factor * light_lower + - threshold.dark_factor * dark_upper + - threshold.offset_factor * Q + ) + return verdict, margin + if verdict == "fail": + # Strict integer '<' means the smallest definite-fail margin is one. + margin = ( + threshold.dark_factor * dark_lower + + threshold.offset_factor * Q + - threshold.light_factor * light_upper + ) + return verdict, margin + return None + + +def scan_threshold( + intervals: array, max_width: int, threshold: Threshold +) -> dict[str, object]: + """Доказывает zero unresolved через полный monotone boundary scan. + + Для darker D orientation может быть unresolved только когда lower(L) лежит + между ceil((q*D.lower+cQ)/p)-max_width и safe upper bound, где + D.upper <= D.lower+max_width. Обе pointer boundaries поэтому монотонны по + D.lower; всё ниже definite Fail, всё выше definite Pass. + """ + count = len(intervals) + light_factor = threshold.light_factor + dark_factor = threshold.dark_factor + offset = threshold.offset_factor * Q + left = 0 + right = 0 + candidate_checks = 0 + unresolved: tuple[int, int] | None = None + best: dict[str, tuple[int, int, int] | None] = {"pass": None, "fail": None} + + for darker_packed in intervals: + dark_lower = darker_packed >> PACK_WIDTH_BITS + dark_upper = dark_lower + (darker_packed & PACK_WIDTH_MASK) + candidate_lower = ( + ceil_div(dark_factor * dark_lower + offset, light_factor) - max_width + ) + candidate_upper = ( + dark_factor * (dark_lower + max_width) + offset - 1 + ) // light_factor + + while left < count and (intervals[left] >> PACK_WIDTH_BITS) < candidate_lower: + left += 1 + if right < left: + right = left + while right < count and (intervals[right] >> PACK_WIDTH_BITS) <= candidate_upper: + right += 1 + + for index in range(left, right): + lighter_packed = intervals[index] + candidate_checks += 1 + if pair_decision( + unpack_interval(lighter_packed), + (dark_lower, dark_upper), + threshold, + ) == "unresolved": + unresolved = (lighter_packed, darker_packed) + break + result = decision_margin( + unpack_interval(lighter_packed), + (dark_lower, dark_upper), + threshold, + ) + if result is not None: + verdict, margin = result + current = best[verdict] + if current is None or margin < current[0]: + best[verdict] = (margin, lighter_packed, darker_packed) + if unresolved is not None: + break + + # Boundary neighbours are sufficient for the minimum definite margins; + # interior points move monotonically farther from the decision boundary. + if left > 0: + lighter_packed = intervals[left - 1] + light_lower = lighter_packed >> PACK_WIDTH_BITS + light_upper = light_lower + (lighter_packed & PACK_WIDTH_MASK) + margin = dark_factor * dark_lower + offset - light_factor * light_upper + assert margin > 0 + current = best["fail"] + if current is None or margin < current[0]: + best["fail"] = (margin, lighter_packed, darker_packed) + if right < count: + lighter_packed = intervals[right] + light_lower = lighter_packed >> PACK_WIDTH_BITS + margin = light_factor * light_lower - dark_factor * dark_upper - offset + assert margin >= 0 + current = best["pass"] + if current is None or margin < current[0]: + best["pass"] = (margin, lighter_packed, darker_packed) + + if unresolved is not None: + lighter, darker = unresolved + raise AssertionError( + f"threshold {threshold.name} unresolved: " + f"lighter={unpack_interval(lighter)}, darker={unpack_interval(darker)}" + ) + assert best["pass"] is not None + assert best["fail"] is not None + return { + "threshold": threshold.name, + "integer_law": { + "light_factor": threshold.light_factor, + "dark_factor": threshold.dark_factor, + "offset_factor": threshold.offset_factor, + }, + "unresolved": 0, + "candidate_checks": candidate_checks, + "minimum_pass_margin": best["pass"][0], + "minimum_pass_intervals": [ + list(unpack_interval(best["pass"][1])), + list(unpack_interval(best["pass"][2])), + ], + "minimum_fail_margin": best["fail"][0], + "minimum_fail_intervals": [ + list(unpack_interval(best["fail"][1])), + list(unpack_interval(best["fail"][2])), + ], + "witness_packed": [ + best["pass"][1], + best["pass"][2], + best["fail"][1], + best["fail"][2], + ], + } + + +def verify_negative_controls( + metadata: dict[str, int | str], + committed_rows: list[tuple[int, int]], +) -> int: + """Доказывает, что verifier действительно кусает digest, row и overlap.""" + controls = 0 + bad_digest_metadata = metadata.copy() + bad_digest_metadata["artifact_sha256"] = "0" * 64 + try: + verify_rows(bad_digest_metadata, committed_rows) + except AssertionError: + pass + else: + raise AssertionError("negative control: digest tampering was accepted") + controls += 1 + + mutated_rows = committed_rows.copy() + lower, upper = mutated_rows[1] + mutated_rows[1] = (lower, upper + 1) + bad_row_metadata = metadata.copy() + bad_row_metadata["artifact_sha256"] = canonical_digest(mutated_rows) + try: + verify_rows( + bad_row_metadata, + mutated_rows, + canonical_binary_artifact=canonical_bytes(mutated_rows), + ) + except AssertionError as error: + expected = "row 1 differs from adaptive Decimal oracle" + if not str(error).startswith(expected): + raise AssertionError( + "negative control: non-tight row missed the row oracle" + ) from error + else: + raise AssertionError("negative control: non-tight row was accepted") + controls += 1 + + # Q mod 10 != 0: этот synthetic L interval пересекает exact 3.0 boundary + # для D=[0,3]. Scan обязан найти unresolved, иначе real zero неверифицируем. + synthetic = array( + "Q", + sorted( + ( + pack_interval(0, 3), + pack_interval(Q // 10, Q // 10 + 1), + ) + ), + ) + try: + scan_threshold(synthetic, 3, THRESHOLDS[0]) + except AssertionError as error: + if "unresolved" not in str(error): + raise + else: + raise AssertionError("negative control: synthetic overlap was accepted") + controls += 1 + return controls + + +def verify_production_kernel() -> str: + """Bind the proof to the exact complete Rust evaluator kernel.""" + source_bytes = KERNEL_SOURCE.read_bytes() + digest = hashlib.sha256(source_bytes).hexdigest() + assert digest == EXPECTED_KERNEL_SHA256, ( + f"production kernel drifted: {digest} != {EXPECTED_KERNEL_SHA256}" + ) + source = source_bytes.decode("utf-8") + compact = re.sub(r"\s+", " ", source) + required = ( + "Wcag22CriterionV1::Sc143TextDefault => ThresholdV1::FourAndHalf", + "Wcag22CriterionV1::Sc143TextLargeScale | Wcag22CriterionV1::Sc1411UiComponentOrState | Wcag22CriterionV1::Sc1411GraphicalObject => ThresholdV1::Three", + "WEIGHTED_CONTRIBUTION_BOUNDS[0][usize::from(rgb[0])]", + "WEIGHTED_CONTRIBUTION_BOUNDS[1][usize::from(rgb[1])]", + "WEIGHTED_CONTRIBUTION_BOUNDS[2][usize::from(rgb[2])]", + "lower: red[0] + green[0] + blue[0]", + "upper: red[1] + green[1] + blue[1]", + "10 * light_lower >= 30 * dark_upper + scale", + "10 * light_upper < 30 * dark_lower + scale", + "40 * light_lower >= 180 * dark_upper + 7 * scale", + "40 * light_upper < 180 * dark_lower + 7 * scale", + "matches!(forward, OrientedDecisionV1::Pass) || matches!(reverse, OrientedDecisionV1::Pass)", + "matches!(forward, OrientedDecisionV1::Fail) && matches!(reverse, OrientedDecisionV1::Fail)", + "let decision = classify_pair(foreground_luminance, background_luminance, criterion)", + "mint_wcag22_evidence()", + "measurement: Wcag22MeasurementV1 { foreground, background, foreground_luminance, background_luminance, }", + "decision, evidence", + "crate::srgb8::hex_bytes(value)", + "evaluate_wcag22_srgb8(foreground, background, criterion)", + ) + for fragment in required: + assert fragment in compact, f"production kernel semantic drift: {fragment}" + for forbidden in ("f64", "powf", "epsilon"): + assert forbidden not in source, f"forbidden {forbidden} in production kernel" + return digest + + +def verify_terminal_evidence() -> str: + """Bind registry validation and sealed terminal evidence to exact source.""" + source_bytes = TERMINAL_EVIDENCE_SOURCE.read_bytes() + digest = hashlib.sha256(source_bytes).hexdigest() + assert digest == EXPECTED_TERMINAL_EVIDENCE_SHA256, ( + "terminal evidence module drifted: " + f"{digest} != {EXPECTED_TERMINAL_EVIDENCE_SHA256}" + ) + compact = re.sub(r"\s+", " ", source_bytes.decode("utf-8")) + required = ( + "row.site_id != SITE_ID", + 'row.site_id.key() != "wcag22-srgb8-contrast-v1"', + "row.stable_outcomes != [StableNumericalOutcomeV2::CanonicalFiniteBounded]", + "!row.compatibility_releases.is_empty()", + "row.evidence_classes != [NumericalEvidenceClassV2::CanonicalFiniteBounded]", + "row.artifact_ids != [ARTIFACT_ID]", + 'ARTIFACT_ID.key() != "wcag22-srgb8-luminance-q55-v1"', + "row.bound_ids != [BOUND_ID]", + 'BOUND_ID.key() != "wcag22-srgb8-outward-q55-v1"', + "row.proof_ids != [PROOF_ID]", + 'PROOF_ID.key() != "wcag22-srgb8-full-domain-q55-v1"', + "!row.runtime_attestations.is_empty()", + "row.bound_status != NumericalBoundStatusV2::Available", + "row.fallback_status != NumericalFallbackStatusV1::None", + "NumericalDecisionEvidenceV1::CanonicalFiniteBounded( CanonicalFiniteBoundedEvidenceV1 { artifact_id: ARTIFACT_ID, bound_id: BOUND_ID, proof_id: PROOF_ID, _private: (), }, )", + ) + for fragment in required: + assert fragment in compact, f"terminal evidence semantic drift: {fragment}" + return digest + + +def verify_srgb8_parser() -> str: + """Bind the public hex transport to the exact shared byte parser.""" + source_bytes = PARSER_SOURCE.read_bytes() + digest = hashlib.sha256(source_bytes).hexdigest() + assert digest == EXPECTED_PARSER_SHA256, ( + f"encoded sRGB8 parser drifted: {digest} != {EXPECTED_PARSER_SHA256}" + ) + compact = re.sub(r"\s+", " ", source_bytes.decode("utf-8")) + required = ( + "hex.strip_prefix('#').unwrap_or(hex)", + "hex.len() != 6 || !hex.is_ascii()", + "parse(&hex[0..2])?", + "parse(&hex[2..4])?", + "parse(&hex[4..6])?", + ) + for fragment in required: + assert fragment in compact, f"encoded sRGB8 parser semantic drift: {fragment}" + assert "trim_start_matches" not in compact, ( + "encoded sRGB8 parser must remove at most one optional hash prefix" + ) + return digest + + +def verify_public_facade() -> tuple[str, str]: + """Bind the public symbols to the kernel without a digest self-cycle.""" + source = FACADE_SOURCE.read_text(encoding="utf-8") + normalized = source + for name in ( + "PROOF_SOURCE_SHA256", + "PROOF_PAYLOAD_SHA256", + "VERIFIER_SHA256", + ): + pattern = rf'({name}: &str =\s*)"[0-9a-f]{{64}}"' + normalized, count = re.subn( + pattern, + r'\1""', + normalized, + ) + assert count == 1, f"public facade has {count} {name} digest literals" + digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest() + assert digest == EXPECTED_NORMALIZED_FACADE_SHA256, ( + "normalized public facade drifted: " + f"{digest} != {EXPECTED_NORMALIZED_FACADE_SHA256}" + ) + + compact = re.sub(r"\s+", " ", source) + export = "pub use kernel::{evaluate_wcag22_hex, evaluate_wcag22_srgb8};" + assert compact.count(export) == 1, "public facade no longer re-exports the kernel exactly" + for forbidden in ("fn evaluate_wcag22_hex", "fn evaluate_wcag22_srgb8"): + assert forbidden not in source, f"public facade shadows kernel with {forbidden}" + + crate_bytes = CRATE_LIB_SOURCE.read_bytes() + crate_digest = hashlib.sha256(crate_bytes).hexdigest() + assert crate_digest == EXPECTED_CRATE_LIB_SHA256, ( + f"crate root drifted: {crate_digest} != {EXPECTED_CRATE_LIB_SHA256}" + ) + crate_source = crate_bytes.decode("utf-8") + assert len(re.findall(r"(?m)^pub mod wcag22;$", crate_source)) == 1, ( + "crate root must export the canonical wcag22 module exactly once" + ) + assert not re.search(r'#\[path\s*=\s*"[^"]+"\]\s*pub mod wcag22;', crate_source), ( + "crate root must not redirect the proof-bound wcag22 facade" + ) + return digest, crate_digest + + +def verify_evaluator_digest_bindings( + proof_sha256: str, proof_payload_sha256: str, verifier_sha256: str +) -> None: + source = EVALUATOR_SOURCE.read_text(encoding="utf-8") + expected = { + "PROOF_SOURCE_SHA256": proof_sha256, + "PROOF_PAYLOAD_SHA256": proof_payload_sha256, + "VERIFIER_SHA256": verifier_sha256, + } + for name, digest in expected.items(): + match = re.search(rf'{name}: &str =\s*"([0-9a-f]{{64}})";', source) + assert match is not None, f"missing evaluator binding {name}" + assert match.group(1) == digest, ( + f"evaluator {name} drift: {match.group(1)} != {digest}" + ) + + +RUST_REGISTRY_PROBE = r""" +use std::fmt::Write; + +use labcolors_core::{NumericalSiteIdV2, numerical_registry_v2}; + +fn hex_key(value: &str) -> String { + let mut encoded = String::with_capacity(value.len() * 2); + for byte in value.bytes() { + write!(&mut encoded, "{byte:02x}").expect("String writes are infallible"); + } + encoded +} + +macro_rules! emit_keys { + ($name:literal, $values:expr) => { + print!(concat!($name, "\t{}"), $values.len()); + for value in $values { + print!("\t{}", hex_key(value.key())); + } + println!(); + }; +} + +fn main() { + let mut matches = numerical_registry_v2() + .iter() + .filter(|row| row.site_id == NumericalSiteIdV2::Wcag22Srgb8ContrastV1); + let row = matches.next().expect("WCAG22 registry row"); + assert!(matches.next().is_none(), "duplicate WCAG22 registry row"); + + println!("site_id\t{}", hex_key(row.site_id.key())); + emit_keys!("stable_outcomes", row.stable_outcomes); + emit_keys!("compatibility_releases", row.compatibility_releases); + emit_keys!("evidence_classes", row.evidence_classes); + emit_keys!("artifact_ids", row.artifact_ids); + emit_keys!("bound_ids", row.bound_ids); + emit_keys!("proof_ids", row.proof_ids); + emit_keys!("runtime_attestations", row.runtime_attestations); + println!("bound_status\t{}", hex_key(row.bound_status.key())); + println!("fallback_status\t{}", hex_key(row.fallback_status.key())); +} +""" + + +def cargo_executable() -> str: + configured = os.environ.get("CARGO") + if configured: + return configured + discovered = shutil.which("cargo") + if discovered: + return discovered + candidates = [Path.home() / ".cargo/bin/cargo"] + candidates.extend( + sorted( + Path("/opt/homebrew/Cellar/rustup").glob("*/bin/cargo"), + reverse=True, + ) + ) + for candidate in candidates: + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) + raise AssertionError( + "typed WCAG registry proof requires Cargo; set CARGO to its executable" + ) + + +def decode_registry_probe_key(encoded: str) -> str: + assert re.fullmatch(r"(?:[0-9a-f]{2})*", encoded), ( + f"non-canonical typed registry key encoding: {encoded!r}" + ) + try: + return bytes.fromhex(encoded).decode("utf-8") + except UnicodeDecodeError as error: + raise AssertionError("typed registry key is not UTF-8") from error + + +def parse_registry_probe_output( + output: str, +) -> dict[str, str | tuple[str, ...]]: + expected_fields = ( + "site_id", + *REGISTRY_ROW_SET_FIELDS, + "bound_status", + "fallback_status", + ) + lines = output.splitlines() + assert len(lines) == len(expected_fields), ( + f"typed registry probe line-count drifted: {len(lines)}" + ) + row: dict[str, str | tuple[str, ...]] = {} + for expected_field, line in zip(expected_fields, lines): + parts = line.split("\t") + assert parts[0] == expected_field, ( + f"typed registry probe field-order drift: {line!r}" + ) + if expected_field in REGISTRY_ROW_SET_FIELDS: + assert len(parts) >= 2 and parts[1].isdigit(), ( + f"malformed typed registry set line: {line!r}" + ) + count = int(parts[1]) + assert parts[1] == str(count) and len(parts) == count + 2, ( + f"typed registry set count drift: {line!r}" + ) + values = tuple(decode_registry_probe_key(value) for value in parts[2:]) + assert len(values) == len(set(values)), ( + f"duplicate typed registry key in {expected_field}: {values!r}" + ) + row[expected_field] = values + else: + assert len(parts) == 2, f"malformed typed registry scalar line: {line!r}" + row[expected_field] = decode_registry_probe_key(parts[1]) + return row + + +def verify_registry_transport_negative_controls(output: str) -> int: + """The probe transport must preserve punctuation and reject extra items.""" + controls = 0 + lines = output.splitlines() + stable_index = 1 + + punctuation = lines.copy() + punctuation[stable_index] += "2c" + punctuation_row = parse_registry_probe_output("\n".join(punctuation) + "\n") + assert punctuation_row["stable_outcomes"] == ( + "canonical-finite-bounded,", + ) + controls += 1 + + trailing_empty = lines.copy() + trailing_empty[stable_index] += "\t" + try: + parse_registry_probe_output("\n".join(trailing_empty) + "\n") + except AssertionError as error: + if not str(error).startswith("typed registry set count drift:"): + raise AssertionError( + "registry transport mutation missed the count guard" + ) from error + else: + raise AssertionError("registry transport accepted a trailing empty item") + controls += 1 + return controls + + +def load_live_registry_row() -> tuple[ + dict[str, str | tuple[str, ...]], int +]: + """Read the runtime-expanded typed row; Python owns the proof encoding.""" + with tempfile.TemporaryDirectory(prefix="labcolors-wcag22-registry-") as temp: + root = Path(temp) + source = root / "src" + source.mkdir() + core_path = REPO_ROOT / "crates/labcolors-core" + (root / "Cargo.toml").write_text( + textwrap.dedent( + f""" + [package] + name = "labcolors-wcag22-registry-probe" + version = "0.0.0" + edition = "2024" + publish = false + + [workspace] + + [dependencies] + labcolors-core = {{ path = {json.dumps(str(core_path))} }} + """ + ).lstrip(), + encoding="utf-8", + ) + (source / "main.rs").write_text(RUST_REGISTRY_PROBE, encoding="utf-8") + environment = os.environ.copy() + environment.setdefault( + "CARGO_TARGET_DIR", + str(REPO_ROOT / "target/wcag22-registry-probe"), + ) + cargo = cargo_executable() + environment["PATH"] = ( + str(Path(cargo).parent) + + os.pathsep + + environment.get("PATH", "") + ) + command = [ + cargo, + "run", + "--quiet", + "--offline", + "--manifest-path", + str(root / "Cargo.toml"), + ] + completed = subprocess.run( + command, + cwd=REPO_ROOT, + env=environment, + check=False, + capture_output=True, + text=True, + timeout=120, + ) + assert completed.returncode == 0, ( + "typed WCAG registry probe failed: " + completed.stderr[-2000:] + ) + + row = parse_registry_probe_output(completed.stdout) + transport_controls = verify_registry_transport_negative_controls( + completed.stdout + ) + return row, transport_controls + + +def canonical_registry_row_preimage( + row: dict[str, str | tuple[str, ...]], +) -> bytes: + preimage = bytearray(length_prefixed(REGISTRY_ROW_BINDING_DOMAIN)) + preimage.extend(struct.pack(" str: + for field, expected in EXPECTED_WCAG_REGISTRY_ROW.items(): + actual = row.get(field) + assert actual == expected, ( + f"WCAG registry admission drift at {field}: " + f"actual={actual!r}, expected={expected!r}" + ) + preimage = canonical_registry_row_preimage(row) + + # Independent byte-law guards: set order is irrelevant and duplicates fail. + synthetic = dict(row) + synthetic["stable_outcomes"] = ("z", "a") + reversed_synthetic = dict(synthetic) + reversed_synthetic["stable_outcomes"] = ("a", "z") + assert canonical_registry_row_preimage(synthetic) == ( + canonical_registry_row_preimage(reversed_synthetic) + ) + duplicate = dict(row) + duplicate["stable_outcomes"] = ("duplicate", "duplicate") + try: + canonical_registry_row_preimage(duplicate) + except AssertionError: + pass + else: + raise AssertionError("registry admission preimage accepted a duplicate key") + + digest = hashlib.sha256(preimage).hexdigest() + assert digest == EXPECTED_REGISTRY_ROW_SHA256, ( + f"WCAG registry admission preimage drifted: " + f"{digest} != {EXPECTED_REGISTRY_ROW_SHA256}" + ) + return digest + + +def verify_registry_negative_controls( + row: dict[str, str | tuple[str, ...]], +) -> int: + """Every mint-relevant field must independently invalidate admission.""" + controls = 0 + for field, value in EXPECTED_WCAG_REGISTRY_ROW.items(): + mutated = dict(row) + if isinstance(value, tuple): + mutated[field] = (*value, "negative-control") + else: + mutated[field] = f"{value}-negative-control" + try: + verify_registry_binding(mutated) + except AssertionError as error: + expected = f"WCAG registry admission drift at {field}:" + if not str(error).startswith(expected): + raise AssertionError( + f"negative control for {field} missed admission comparison" + ) from error + else: + raise AssertionError( + f"negative control: WCAG registry {field} drift was accepted" + ) + controls += 1 + return controls + + +def find_rgb_witnesses( + tables: list[list[tuple[int, int]]], targets: set[int] +) -> dict[int, str]: + witnesses: dict[int, str] = {} + red, green, blue = tables + for r, (r_lo, r_hi) in enumerate(red): + for g, (g_lo, g_hi) in enumerate(green): + rg_lo = r_lo + g_lo + rg_hi = r_hi + g_hi + for b, (b_lo, b_hi) in enumerate(blue): + packed = pack_interval(rg_lo + b_lo, rg_hi + b_hi) + if packed in targets and packed not in witnesses: + witnesses[packed] = f"#{r:02X}{g:02X}{b:02X}" + if len(witnesses) == len(targets): + return witnesses + raise AssertionError(f"не найдены RGB witnesses для {targets - witnesses.keys()}") + + +def main() -> int: + emit_only = sys.argv[1:] == ["--emit"] + if sys.argv[1:] not in ([], ["--emit"]): + raise ValueError("usage: verify_wcag22_q55.py [--emit]") + started = time.perf_counter() + metadata, committed_rows = parse_committed_artifact(RUST_ARTIFACT) + profile_digest = hashlib.sha256(PROFILE_BYTES).hexdigest() + generator_digest = hashlib.sha256(GENERATOR_PATH.read_bytes()).hexdigest() + verifier_digest = hashlib.sha256(VERIFIER_PATH.read_bytes()).hexdigest() + rust_source_digest = hashlib.sha256(RUST_ARTIFACT.read_bytes()).hexdigest() + kernel_digest = verify_production_kernel() + terminal_evidence_digest = verify_terminal_evidence() + parser_digest = verify_srgb8_parser() + facade_digest, crate_lib_digest = verify_public_facade() + registry_row, registry_transport_controls = load_live_registry_row() + registry_row_digest = verify_registry_binding(registry_row) + registry_negative_controls = verify_registry_negative_controls(registry_row) + assert metadata["profile_source_sha256"] == profile_digest, ( + "profile digest drift: " + f"artifact={metadata['profile_source_sha256']}, source={profile_digest}" + ) + assert metadata["generator_sha256"] == generator_digest, ( + "generator digest drift: " + f"artifact={metadata['generator_sha256']}, source={generator_digest}" + ) + + tables, decimal_report = verify_rows(metadata, committed_rows) + numerical_negative_controls = verify_negative_controls(metadata, committed_rows) + rows_elapsed = time.perf_counter() - started + + intervals, generated = build_unique_color_intervals(tables) + domain_elapsed = time.perf_counter() - started + max_width = sum(max(upper - lower for lower, upper in table) for table in tables) + assert max_width <= PACK_WIDTH_MASK + integer_replay_envelope = verify_signed64_replay_envelope(max_width) + + results = [scan_threshold(intervals, max_width, threshold) for threshold in THRESHOLDS] + targets = { + packed + for result in results + for packed in result.pop("witness_packed") + } + witnesses = find_rgb_witnesses(tables, targets) + for result in results: + for field in ("minimum_pass_intervals", "minimum_fail_intervals"): + interval_pairs = result[field] + result[field] = [ + {"bounds": bounds, "rgb": witnesses[pack_interval(*bounds)]} + for bounds in interval_pairs + ] + + payload = { + "schema_version": 1, + "profile_id": PROFILE["profileId"], + "profile_checksum": profile_checksum(), + "recommendation": PROFILE["recommendation"], + "profile_source_sha256": profile_digest, + "artifact_id": ARTIFACT_ID, + "artifact_sha256": metadata["artifact_sha256"], + "artifact_words": len(committed_rows) * 2, + "artifact_rust_source_sha256": rust_source_digest, + "bound_id": BOUND_ID, + "proof_id": PROOF_ID, + "kernel_id": KERNEL_ID, + "kernel_source_sha256": kernel_digest, + "terminal_evidence_id": TERMINAL_EVIDENCE_ID, + "terminal_evidence_source_sha256": terminal_evidence_digest, + "parser_id": PARSER_ID, + "parser_source_sha256": parser_digest, + "facade_id": FACADE_ID, + "facade_normalized_sha256": facade_digest, + "crate_lib_source_sha256": crate_lib_digest, + "registry_row_id": EXPECTED_WCAG_REGISTRY_ROW["site_id"], + "registry_row_sha256": registry_row_digest, + "registry_row_negative_controls": registry_negative_controls, + "declared_operation_law": DECLARED_OPERATION_LAW, + "generator_sha256": generator_digest, + "verifier_sha256": verifier_digest, + "q55_scale": Q, + "rows": len(committed_rows), + "row_oracle": decimal_report, + "colors": generated, + "unique_intervals": len(intervals), + "max_color_interval_width": max_width, + "integer_replay_envelope": integer_replay_envelope, + "negative_controls": ( + numerical_negative_controls + + registry_negative_controls + + registry_transport_controls + ), + "full_domain_algorithm": "unique-q55-interval-monotone-boundary-v1", + "thresholds": results, + } + canonical_payload = json.dumps( + payload, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + proof = { + **payload, + "proof_payload_sha256": hashlib.sha256(canonical_payload).hexdigest(), + } + canonical_proof = json.dumps(proof, sort_keys=True, separators=(",", ":")) + if not emit_only: + committed = PROOF_PATH.read_text(encoding="utf-8") + assert committed == canonical_proof + "\n", ( + "committed full-domain proof drifted; inspect and regenerate explicitly " + "with --emit only after scientific/numerical review" + ) + verify_evaluator_digest_bindings( + hashlib.sha256(PROOF_PATH.read_bytes()).hexdigest(), + proof["proof_payload_sha256"], + verifier_digest, + ) + elapsed = time.perf_counter() - started + + # stdout — canonical proof artifact; status/timing идут в stderr, поэтому + # `python3 ... > proof.json` создаёт непосредственно bindable document. + print(canonical_proof) + print( + "WCAG22 Q55 independent verification: PASS; timing_seconds: " + f"rows={rows_elapsed:.3f}, domain={domain_elapsed:.3f}, " + f"total={elapsed:.3f}", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (AssertionError, OSError, ValueError) as error: + print(f"WCAG22 Q55 independent verification: FAIL: {error}", file=sys.stderr) + raise SystemExit(1) from error