diff --git a/.github/workflows/agent-source-export.yml b/.github/workflows/agent-source-export.yml new file mode 100644 index 00000000..faede87a --- /dev/null +++ b/.github/workflows/agent-source-export.yml @@ -0,0 +1,26 @@ +name: Agent source export + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + export: + if: github.event.pull_request.number == 366 + runs-on: [self-hosted, Linux, X64] + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + persist-credentials: false + - name: archive source + run: | + git archive --format=tar.gz --output=lab-colors-source.tar.gz HEAD + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: lab-colors-source + path: lab-colors-source.tar.gz + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/native-conformance.yml b/.github/workflows/native-conformance.yml index ea75e40d..ee6ab549 100644 --- a/.github/workflows/native-conformance.yml +++ b/.github/workflows/native-conformance.yml @@ -1,98 +1,50 @@ -name: Native conformance (Swift) - -# Нативная стратегия (#84): доказательство ДИНАМИЧНОГО рантайм-ядра. -# Swift зовёт ядро Rust В РАНТАЙМЕ через UniFFI; conformance-пак прогоняется -# против выхода FFI. -# -# ВАЖНО (владелец, 2026-07-08): GitHub-hosted macOS-раннеры платные и ИСКЛЮЧЕНЫ -# навсегда — биллинг чиниться не будет. Поэтому Swift валидируется на -# self-hosted Linux-раннере (de-02) в официальном swift-контейнере — тот же -# UniFFI-биндинг, тот же пак, ядро под x86_64-unknown-linux-gnu. Числовые -# семейства пака держатся толерантностью DRIFT_TOL=1e-6 -# (см. conformance/README.md). Glow stable проверяется как типизированный -# Indeterminate; для legacy CAM16 bit parity не заявляется. -# -# Линтеры/аудит/wasm/основной `cargo test --workspace` (включая толерантный -# раннер-референс пака на Linux) — в ci.yml. Здесь — Swift-слой. +name: P1 compatibility diagnostics on: - push: - branches: [main] pull_request: - workflow_dispatch: permissions: contents: read -env: - RUST_TOOLCHAIN: 1.96.0 - SWIFT_CONTAINER: swift:6.1.3@sha256:e1cdaf7ddc9de37d8561da7a260535236694fca8c1b67d3129d47d8b180a9394 - jobs: - # АКТИВНЫЙ ГЕЙТ: Swift-conformance на self-hosted Linux через swift-контейнер. - # Зеркало проверенного локального прогона (bindings/swift/ci/run-conformance.sh). - swift-conformance-linux: - name: swift conformance (self-hosted Linux, swift container) + exact: + if: github.event.pull_request.number == 366 runs-on: [self-hosted, Linux, X64] - # Защита от fork-PR: НЕ гоняем недоверенный код форков на своей инфре. - # Форк-PR пропускается; push в main и same-repo PR — гоняются. - if: >- - github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name == github.repository steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with: persist-credentials: false - - # Официальный Swift 6.1.3 (Ubuntu), pinned по manifest-list digest. - # Внутри ставится pinned Rust, ядро собирается - # под Linux, генерятся Swift-биндинги, swift test прогоняет пак. Скрипт — - # единый источник с локальным прогоном (bindings/swift/ci/run-conformance.sh). - # $GITHUB_WORKSPACE монтируется read-only; сборка идёт в /work в контейнере. - - name: swift conformance in swift:6.1.3 container - run: | - docker run --rm \ - -v "$GITHUB_WORKSPACE":/src:ro \ - "$SWIFT_CONTAINER" \ - bash /src/bindings/swift/ci/run-conformance.sh - - # ССЫЛОЧНАЯ джоба (НЕ активный гейт): нативный macOS/arm64. Требует ПЛАТНЫХ - # GitHub-hosted macOS-минут, которые владелец исключил навсегда — поэтому - # гейтирована `workflow_dispatch` и НЕ запускается на PR/push (не блокирует). - # Оставлена как задокументированный путь на случай появления бесплатного - # macOS-раннера (напр. self-hosted Mac). Активная валидация — job выше. - swift-conformance-macos-reference: - name: swift test (macOS arm64 — ручной запуск, платные раннеры off) - if: github.event_name == 'workflow_dispatch' - runs-on: macos-15 - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master @ 2026-03-27 - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - - name: versions - run: | - swift --version - rustc --version - - name: cargo test (reference runner + ffi units) - run: cargo +${{ env.RUST_TOOLCHAIN }} test -p labcolors-conformance -p labcolors-ffi --locked - - name: build labcolors-ffi (+ bindgen bin) - run: cargo +${{ env.RUST_TOOLCHAIN }} build -p labcolors-ffi --features cli --locked - - name: generate Swift bindings + toolchain: 1.85.0 + - name: capture MSRV + shell: bash run: | - cargo +${{ env.RUST_TOOLCHAIN }} run -p labcolors-ffi --features cli --bin uniffi-bindgen --locked -- \ - generate --library target/debug/liblabcolors.dylib \ - --language swift --out-dir bindings/swift/generated - - name: arrange generated bindings + set +e + cargo check --workspace --all-targets --locked --message-format=short > p1-msrv.log 2>&1 + echo $? > p1-msrv-status.txt + exit 0 + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 + with: + toolchain: 1.96.0 + components: clippy + - name: capture tests and clippy + shell: bash run: | - set -euo pipefail - gen=bindings/swift/generated - cp "$gen/labcolors.swift" bindings/swift/Sources/LabColors/labcolors.swift - cp "$gen/labcolorsFFI.h" bindings/swift/Sources/labcolorsFFI/labcolorsFFI.h - cp "$gen/labcolorsFFI.modulemap" bindings/swift/Sources/labcolorsFFI/module.modulemap - - name: swift test (conformance pack vs FFI) - working-directory: bindings/swift - run: swift test -Xlinker -L"$GITHUB_WORKSPACE/target/debug" + set +e + cargo test --workspace --locked -- --nocapture > p1-test.log 2>&1 + echo $? > p1-test-status.txt + cargo clippy --workspace --all-targets --locked --message-format=short -- -D warnings > p1-clippy.log 2>&1 + echo $? > p1-clippy-status.txt + exit 0 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: p1-compatibility-diagnostics + path: | + p1-msrv.log + p1-msrv-status.txt + p1-test.log + p1-test-status.txt + p1-clippy.log + p1-clippy-status.txt + if-no-files-found: error diff --git a/crates/labcolors-core/src/agnostic_gates.rs b/crates/labcolors-core/src/agnostic_gates.rs index 376cc5bd..258d1da8 100644 --- a/crates/labcolors-core/src/agnostic_gates.rs +++ b/crates/labcolors-core/src/agnostic_gates.rs @@ -419,7 +419,7 @@ fn a_second_company_config_compiles_and_emits_a_valid_system() { ); // Лейбл тинт-бейджа — агностичный жёсткий контраст: решается цветом и - // держит свой UI-пол (3:1) ПРОТИВ тинт-поверхности бренда (композит + // держит свой UI-пол (3:1) против emitted PairFill Surface (композит // brand-fill), а не против фона страницы. Тот же движок, чужой конфиг. let badge_label = set.iter().find(|(n, _)| n == "badge-label").unwrap(); let Resolved::Color { solved, .. } = &badge_label.1 else { diff --git a/crates/labcolors-core/src/appearance.rs b/crates/labcolors-core/src/appearance.rs index 23143b23..97e134bd 100644 --- a/crates/labcolors-core/src/appearance.rs +++ b/crates/labcolors-core/src/appearance.rs @@ -1162,13 +1162,6 @@ impl ModeledSrgb8PointOccurrence { self.visible } - #[cfg_attr( - not(test), - expect( - dead_code, - reason = "shipped exact evaluator reads visible; backdrop is consumed by the test-private WCAG adapter" - ) - )] pub(crate) fn backdrop(self) -> [u8; 3] { self.backdrop } diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index a7797f24..41a337de 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -435,28 +435,27 @@ pub enum RoleRecipe { /// Обязательный numerical-decision profile; implicit legacy запрещён. decision_profile: crate::glow::GlowDecisionProfileV1, }, - /// Переходная solid-эмиссия пары (внутренний модуль `pair`). Текущий heuristic - /// выбирает сторону и при необходимости сдвигает светлоту источника; это не - /// валидированный перцептивный закон. Результат не является поверхностью - /// [`PairLabel`](Self::PairLabel) и удаляется вместе с pair façade. + /// Frozen frontend пары до C7c. Источник становится Paint закрытой позиции + /// `FillPrimary`; её alpha и source-over на page Surface принадлежат общей + /// point graph algebra. Эмитируемый композит — единственная поверхность, + /// которую затем потребляет [`PairLabel`](Self::PairLabel). PairFill { /// Источник якоря: бренд, семейство или нейтраль. source: LadderSource, }, - /// Переходный foreground пары. Он решается против внутренне синтезированной - /// tint-поверхности с alpha закрытой позиции `FillPrimary`, а не против - /// страницы и не против эмитированного [`PairFill`](Self::PairFill). - /// Наличие двух несвязанных поверхностей является известным разрывом SSOT; - /// target occurrence-граф заменяет оба варианта одной композицией. + /// Frozen foreground frontend пары до C7c. Кандидат label решается и + /// проверяется против фактического emitted [`PairFill`](Self::PairFill) + /// Surface через общий joint hard-report и fresh recheck. Отдельной Pair- + /// физики, собственного compositor-а или выбора стороны больше нет. PairLabel { /// Источник физической цветовой идентичности: бренд, семейство или нейтраль. source: LadderSource, - /// Доля максимума контраста тинт-поверхности `(0, 1]` (как у + /// Доля максимума контраста emitted fill Surface `(0, 1]` (как у /// [`TextAnchor`](Self::TextAnchor)): низкая доля оставляет больше места /// для хромы источника у пола, высокая тянет к контрастному пределу. /// Точный серый source при любой доле остаётся нейтральным. fraction: f64, - /// WCAG-пол, энфорсимый ПРОТИВ тинт-поверхности (а не фона страницы). + /// WCAG-пол, проверяемый против emitted fill Surface, не страницы. floor: Floor, }, /// Альфа-аналог solid-источника через точечную композит-инверсию @@ -952,7 +951,7 @@ impl ThemeConfig { *fraction, FRACTION_MIN_EXCLUSIVE, FRACTION_MAX_INCLUSIVE, - "0 < fraction ≤ 1 (доля максимального контраста тинт-поверхности бейджа)", + "0 < fraction ≤ 1 (доля максимального контраста emitted PairFill Surface)", ) } RoleRecipe::AlphaAnalog { of, alpha } => { diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index 5c752af9..0544c71e 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -1312,43 +1312,32 @@ fn value_test_bites_on_alpha_mutation() { ); } -/// Сторона пары — идентичность семьи НА РЕЗОЛВ-УРОВНЕ. Носитель класса — -/// БРЕНД под dark-IC: источник Brand несёт сырые якоря, и его dark-ic -/// (#409CFF, Y = 0.321) пересекает кроссовер 0.30. Семейные якоря (включая -/// info) разведены солвером и порог не straddle-ят — на них мутация -/// «сторона от vc» поведенчески неразличима (выживший мутант M3 -/// верификатора). Мутация semantic.rs srgb→vc обязана уронить ЭТОТ тест. -#[test] -fn pair_side_is_family_stable_across_themes_at_resolve_level() { +/// PairFill больше не классифицирует «сторону семьи» и не двигает source. +/// Его единственная физика — code-owned FillPrimary alpha + общий sRGB8 Over. +#[test] +fn pair_fill_is_canonical_fill_primary_occurrence_across_themes() { let table = labui_reference().compile_named_role_table().unwrap(); - let bg_dark = BgInput::solid("#101012").unwrap(); - let set = resolve_named_set( - &bg_dark, - &table, - &ViewingConditions::dim_surround_high_contrast(), - ) - .expect("валидная pair-side fixture обязана резолвиться"); - let (_, res) = set - .iter() - .find(|(n, _)| n == "badge-fill-brand") - .expect("паспорт несёт badge-fill-brand"); - let fill = res - .translucent() - .expect("заливка пары эмитится лестничной сантехникой"); - // Светлая сторона семьи: тёмная заливка (белый строго выигрывает - // штатную полярность — Y ниже выведенной границы WCAG). - let enc = - crate::spaces::srgb::srgb_encoded_from_hex(fill.tint_hex()).expect("эмиссия валидный hex"); - let lin = [ - crate::spaces::srgb::srgb_gamma_inv(enc[0]), - crate::spaces::srgb::srgb_gamma_inv(enc[1]), - crate::spaces::srgb::srgb_gamma_inv(enc[2]), - ]; - let y = 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; - assert!( - y < 0.17913, - "badge-fill-brand в dark-IC обязан быть утемнён под светлую сторону семьи (Y={y:.4})" - ); + for (bg_hex, vc) in [ + ("#FFFFFF", ViewingConditions::srgb()), + ("#101012", ViewingConditions::dim_surround_high_contrast()), + ] { + let bg = BgInput::solid(bg_hex).unwrap(); + let set = + resolve_named_set(&bg, &table, &vc).expect("canonical PairFill fixture must resolve"); + let fill = set + .iter() + .find(|(name, _)| name == "badge-fill-brand") + .and_then(|(_, resolved)| resolved.translucent()) + .expect("badge-fill-brand is a translucent PairFill"); + let expected_alpha = LadderPosition::FillPrimary.alpha_for_vc(&vc); + assert_eq!(fill.alpha().to_bits(), expected_alpha.to_bits()); + + let tint = crate::srgb8::hex_bytes(fill.tint_hex()).unwrap(); + let backdrop = crate::srgb8::hex_bytes(bg_hex).unwrap(); + let expected = crate::alpha::composite_over_srgb8(tint, expected_alpha, backdrop) + .expect("code-owned Pair inputs are admitted"); + assert_eq!(fill.composite_hex(), crate::Srgb8::new(expected).to_hex()); + } } /// Дубликаты ключей всех словарей отвергаются (повтор имени = неоднозначный diff --git a/crates/labcolors-core/src/constraints/mod.rs b/crates/labcolors-core/src/constraints/mod.rs index 83e39ec3..83a6ee19 100644 --- a/crates/labcolors-core/src/constraints/mod.rs +++ b/crates/labcolors-core/src/constraints/mod.rs @@ -17,15 +17,16 @@ pub(crate) use exact::{ #[cfg(test)] pub(crate) use exact::ExactIdentityPassV1; -#[cfg(test)] mod wcag22; -#[cfg(test)] pub(crate) use wcag22::{ - ApplicableWcag22EvaluationErrorV1, ApplicableWcag22MeasurementV1, Wcag22PassV1, Wcag22Srgb8V1, - Wcag22ViolationV1, + ApplicableWcag22EvaluationErrorV1, Wcag22PassEvidenceV1, Wcag22Srgb8V1, + Wcag22ViolationEvidenceV1, }; +#[cfg(test)] +pub(crate) use wcag22::{ApplicableWcag22MeasurementV1, Wcag22PassV1, Wcag22ViolationV1}; + /// Seals недоступны внешним crate-ам: новые evaluator/classifier families /// добавляются только вместе с code-owned physical adapter-ом. mod private { @@ -90,7 +91,6 @@ impl &self.invocation } - #[cfg(test)] pub(crate) fn measurement(&self) -> &Measurement { &self.measurement } diff --git a/crates/labcolors-core/src/constraints/wcag22.rs b/crates/labcolors-core/src/constraints/wcag22.rs index f866eef4..72b75ec3 100644 --- a/crates/labcolors-core/src/constraints/wcag22.rs +++ b/crates/labcolors-core/src/constraints/wcag22.rs @@ -1,5 +1,8 @@ use crate::appearance::ModeledSrgb8PointOccurrence; -use crate::constraints::{Evaluator, HardClassifier, HardDecision, private}; +use crate::constraints::{ + Evaluator, HardClassifier, HardDecision, VisiblePointPassEvidence, + VisiblePointViolationEvidence, private, +}; use crate::numerics::NumericalDecisionEvidenceV1; use crate::wcag22::{ Wcag22ApplicableDecisionV1, Wcag22AssessmentV1, Wcag22ClientDeclaredNotApplicableV1, @@ -17,7 +20,7 @@ pub(crate) struct Wcag22Srgb8EvaluatorIdentityV1; /// Applicable-only WCAG measurement. Private fields make report-only /// `NotEvaluated` and a mismatched criterion unrepresentable after refinement. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub(crate) struct ApplicableWcag22MeasurementV1 { profile_id: Wcag22ProfileIdV1, criterion: Wcag22CriterionV1, @@ -27,10 +30,12 @@ pub(crate) struct ApplicableWcag22MeasurementV1 { } impl ApplicableWcag22MeasurementV1 { + #[cfg(test)] pub(crate) const fn profile_id(&self) -> Wcag22ProfileIdV1 { self.profile_id } + #[cfg(test)] pub(crate) const fn criterion(&self) -> Wcag22CriterionV1 { self.criterion } @@ -39,10 +44,12 @@ impl ApplicableWcag22MeasurementV1 { &self.measurement } + #[cfg(test)] pub(crate) const fn decision(&self) -> Wcag22ApplicableDecisionV1 { self.decision } + #[cfg(test)] pub(crate) const fn evidence(&self) -> &NumericalDecisionEvidenceV1 { &self.evidence } @@ -106,6 +113,9 @@ pub(crate) struct Wcag22PassV1(()); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct Wcag22ViolationV1(()); +pub(crate) type Wcag22PassEvidenceV1 = VisiblePointPassEvidence; +pub(crate) type Wcag22ViolationEvidenceV1 = VisiblePointViolationEvidence; + impl private::EvaluatorSealed for Wcag22Srgb8V1 {} impl private::HardClassifierSealed for Wcag22Srgb8V1 {} diff --git a/crates/labcolors-core/src/exposure_support.rs b/crates/labcolors-core/src/exposure_support.rs index 8a5e7e74..337d75fc 100644 --- a/crates/labcolors-core/src/exposure_support.rs +++ b/crates/labcolors-core/src/exposure_support.rs @@ -17,7 +17,7 @@ //! * `LABUI_ANCHORS` — 49 РЕАЛЬНЫХ якорей labui (замороженный паспорт //! `crates/labcolors-wasm/tests/data/labui.config.json`). -use crate::spaces::srgb::{srgb_encoded_from_hex, srgb_gamma_inv}; +use crate::spaces::srgb::srgb_encoded_from_hex; /// 49 реальных якорей Lab UI (бренд, семейства и нейтрали) из паспорта. /// Дедуп-набор всех hex-литералов паспорта — «реальные входы» экспозиции. @@ -50,21 +50,6 @@ pub(crate) fn grid_size() -> usize { n * n * n } -/// WCAG-люминанс (Rec.709) кодированного 8-битного цвета. -pub(crate) fn wcag_y(rgb: [u8; 3]) -> f64 { - let e = [ - rgb[0] as f64 / 255.0, - rgb[1] as f64 / 255.0, - rgb[2] as f64 / 255.0, - ]; - let l = [ - srgb_gamma_inv(e[0]), - srgb_gamma_inv(e[1]), - srgb_gamma_inv(e[2]), - ]; - 0.2126 * l[0] + 0.7152 * l[1] + 0.0722 * l[2] -} - /// 8-битные каналы hex-якоря. pub(crate) fn enc_of(hex: &str) -> [u8; 3] { let s = srgb_encoded_from_hex(hex).expect("passport hex valid"); diff --git a/crates/labcolors-core/src/joint.rs b/crates/labcolors-core/src/joint.rs index 324583dd..db9360cd 100644 --- a/crates/labcolors-core/src/joint.rs +++ b/crates/labcolors-core/src/joint.rs @@ -11,10 +11,12 @@ use crate::appearance::{ EncodedPointPaintV1, PaintId, PointOpacityOverSurfaceV1, ResolvedOccurrence, SurfaceInputPortId, }; use crate::constraints::{ - ExactPassEvidenceV1, ExactSrgb8IdentityV1, ExactViolationEvidenceV1, HardDecision, - assess_visible_point_hard, + ApplicableWcag22EvaluationErrorV1, ExactPassEvidenceV1, ExactSrgb8IdentityV1, + ExactViolationEvidenceV1, HardDecision, Wcag22PassEvidenceV1, Wcag22Srgb8V1, + Wcag22ViolationEvidenceV1, assess_visible_point_hard, }; use crate::observation::{RevisionBoundObservationV1, ScenarioId}; +use crate::wcag22::Wcag22CriterionV1; /// Canonical identity одного joint candidate. Число не является declaration /// order, расстоянием или скрытым приоритетом. @@ -65,6 +67,38 @@ pub(crate) enum CandidateSetErrorV1 { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct PointPaintKeyV1 { + id: PaintId, + source: Srgb8, + opacity_bits: u64, +} + +impl From for PointPaintKeyV1 { + fn from(paint: EncodedPointPaintV1) -> Self { + Self { + id: paint.id(), + source: paint.source(), + opacity_bits: paint.opacity().bits(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct JointPhysicalTupleKeyV1 { + lower: PointPaintKeyV1, + upper: PointPaintKeyV1, +} + +impl From for JointPhysicalTupleKeyV1 { + fn from(candidate: JointCandidateTupleV1) -> Self { + Self { + lower: candidate.lower.into(), + upper: candidate.upper.into(), + } + } +} + impl JointCandidateSetV1 { pub(crate) fn new( mut candidates: Vec, @@ -78,17 +112,18 @@ impl JointCandidateSetV1 { return Err(CandidateSetErrorV1::DuplicateOrdinal(pair[0].ordinal)); } } - for (index, first) in candidates.iter().enumerate() { - if let Some(second) = candidates[index + 1..] - .iter() - .find(|second| first.lower == second.lower && first.upper == second.upper) - { + candidates.sort_unstable_by_key(|candidate| { + (JointPhysicalTupleKeyV1::from(*candidate), candidate.ordinal) + }); + for pair in candidates.windows(2) { + if JointPhysicalTupleKeyV1::from(pair[0]) == JointPhysicalTupleKeyV1::from(pair[1]) { return Err(CandidateSetErrorV1::DuplicatePhysicalTuple { - first: first.ordinal, - second: second.ordinal, + first: pair[0].ordinal, + second: pair[1].ordinal, }); } } + candidates.sort_unstable_by_key(|candidate| candidate.ordinal); Ok(Self { candidates: candidates.into_boxed_slice(), }) @@ -116,10 +151,17 @@ pub(crate) enum JointVisibleTargetV1 { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct JointHardConstraintV1 { - id: JointConstraintIdV1, - target: JointVisibleTargetV1, - invocation: Srgb8, +pub(crate) enum JointHardConstraintV1 { + Exact { + id: JointConstraintIdV1, + target: JointVisibleTargetV1, + invocation: Srgb8, + }, + Wcag22 { + id: JointConstraintIdV1, + target: JointVisibleTargetV1, + criterion: Wcag22CriterionV1, + }, } impl JointHardConstraintV1 { @@ -128,18 +170,42 @@ impl JointHardConstraintV1 { target: JointVisibleTargetV1, invocation: Srgb8, ) -> Self { - Self { + Self::Exact { id, target, invocation, } } + + pub(crate) const fn wcag22( + id: JointConstraintIdV1, + target: JointVisibleTargetV1, + criterion: Wcag22CriterionV1, + ) -> Self { + Self::Wcag22 { + id, + target, + criterion, + } + } + + const fn id(self) -> JointConstraintIdV1 { + match self { + Self::Exact { id, .. } | Self::Wcag22 { id, .. } => id, + } + } + + const fn target(self) -> JointVisibleTargetV1 { + match self { + Self::Exact { target, .. } | Self::Wcag22 { target, .. } => target, + } + } } /// Identity первой private joint topology. Она не является public Program ID. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum JointPointProgramIdentityV1 { - TwoPaintDerivedSurfaceExactPointV1, + TwoPaintDerivedSurfacePointV1, } /// Две связанные occurrences над одним observed root backdrop. @@ -171,10 +237,10 @@ impl JointPointProgramV1 { if constraints.is_empty() { return Err(JointProgramErrorV1::EmptyHardConstraintSet); } - constraints.sort_unstable_by_key(|constraint| constraint.id); + constraints.sort_unstable_by_key(|constraint| constraint.id()); for pair in constraints.windows(2) { - if pair[0].id == pair[1].id { - return Err(JointProgramErrorV1::DuplicateConstraint(pair[0].id)); + if pair[0].id() == pair[1].id() { + return Err(JointProgramErrorV1::DuplicateConstraint(pair[0].id())); } } Ok(Self { @@ -186,7 +252,7 @@ impl JointPointProgramV1 { } const fn identity(&self) -> JointPointProgramIdentityV1 { - JointPointProgramIdentityV1::TwoPaintDerivedSurfaceExactPointV1 + JointPointProgramIdentityV1::TwoPaintDerivedSurfacePointV1 } pub(crate) fn evaluate( @@ -288,27 +354,44 @@ impl JointPointProgramV1 { }); for constraint in self.constraints.iter().copied() { - let occurrence = match constraint.target { + let target = constraint.target(); + let occurrence = match target { JointVisibleTargetV1::Lower => &lower, JointVisibleTargetV1::Upper => &upper, }; - let decision = match assess_visible_point_hard( - occurrence, - &ExactSrgb8IdentityV1, - constraint.invocation, - ) { - Ok(HardDecision::Pass(evidence)) => { - JointConstraintDecisionV1::Pass(evidence) + let decision = match constraint { + JointHardConstraintV1::Exact { invocation, .. } => { + match assess_visible_point_hard( + occurrence, + &ExactSrgb8IdentityV1, + invocation, + ) { + Ok(HardDecision::Pass(evidence)) => { + JointConstraintDecisionV1::Pass(evidence) + } + Ok(HardDecision::Violation(evidence)) => { + JointConstraintDecisionV1::Violation(evidence) + } + Err(error) => match error {}, + } } - Ok(HardDecision::Violation(evidence)) => { - JointConstraintDecisionV1::Violation(evidence) + JointHardConstraintV1::Wcag22 { criterion, .. } => { + match assess_visible_point_hard(occurrence, &Wcag22Srgb8V1, criterion) + .map_err(JointReportErrorV1::Evaluator)? + { + HardDecision::Pass(evidence) => { + JointConstraintDecisionV1::Wcag22Pass(evidence) + } + HardDecision::Violation(evidence) => { + JointConstraintDecisionV1::Wcag22Violation(evidence) + } + } } - Err(error) => match error {}, }; cells.push(JointConstraintCellV1 { ordinal: candidate.ordinal, - constraint: constraint.id, - target: constraint.target, + constraint: constraint.id(), + target, case_index, decision, }); @@ -325,7 +408,7 @@ impl JointPointProgramV1 { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum JointReportErrorV1 { MissingRootSurface(SurfaceInputPortId), CandidatePaintMismatch { @@ -334,6 +417,7 @@ pub(crate) enum JointReportErrorV1 { expected: PaintId, actual: PaintId, }, + Evaluator(ApplicableWcag22EvaluationErrorV1), ResourceExhausted, } @@ -351,7 +435,7 @@ pub(crate) fn checked_joint_cardinality( Ok((executions, cells)) } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] struct JointEvaluationMatricesV1 { executions: Box<[JointExecutionRecordV1]>, cells: Box<[JointConstraintCellV1]>, @@ -397,23 +481,39 @@ impl JointExecutionRecordV1 { pub(crate) fn derived_surface_is_exact(&self) -> bool { self.upper.certificate().backdrop_rgb() == self.lower.visible() } + + pub(crate) const fn lower_occurrence(&self) -> &ResolvedOccurrence { + &self.lower + } + + pub(crate) const fn upper_occurrence(&self) -> &ResolvedOccurrence { + &self.upper + } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub(crate) enum JointConstraintDecisionV1 { Pass(ExactPassEvidenceV1), Violation(ExactViolationEvidenceV1), + Wcag22Pass(Wcag22PassEvidenceV1), + Wcag22Violation(Wcag22ViolationEvidenceV1), } impl JointConstraintDecisionV1 { pub(crate) const fn is_pass(&self) -> bool { - matches!(self, Self::Pass(_)) + matches!(self, Self::Pass(_) | Self::Wcag22Pass(_)) } pub(crate) fn actual(&self) -> Srgb8 { match self { Self::Pass(evidence) => evidence.actual(), Self::Violation(evidence) => evidence.actual(), + Self::Wcag22Pass(evidence) => { + Srgb8::new(evidence.measurement().value().measurement().foreground) + } + Self::Wcag22Violation(evidence) => { + Srgb8::new(evidence.measurement().value().measurement().foreground) + } } } @@ -421,11 +521,17 @@ impl JointConstraintDecisionV1 { match self { Self::Pass(evidence) => evidence.target(), Self::Violation(evidence) => evidence.target(), + Self::Wcag22Pass(evidence) => { + Srgb8::new(evidence.measurement().value().measurement().background) + } + Self::Wcag22Violation(evidence) => { + Srgb8::new(evidence.measurement().value().measurement().background) + } } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub(crate) struct JointConstraintCellV1 { ordinal: CandidateOrdinalV1, constraint: JointConstraintIdV1, @@ -458,7 +564,7 @@ impl JointConstraintCellV1 { /// Полная матрица candidate x constraint x unique physical case плюс отдельная /// joint execution matrix candidate x case. Report не знает selection policy. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub(crate) struct FullHardReportV1 { program_identity: JointPointProgramIdentityV1, program: JointPointProgramV1, @@ -520,13 +626,13 @@ impl FullHardReportV1 { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub(crate) enum HardFeasibilityV1 { Infeasible(FullHardReportV1), NonEmpty(NonEmptyFeasibleJointTuplesV1), } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub(crate) struct NonEmptyFeasibleJointTuplesV1 { report: FullHardReportV1, feasible: Box<[CandidateOrdinalV1]>, @@ -603,7 +709,7 @@ pub(crate) enum SelectionPolicyErrorV1 { NotATotalOrder, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub(crate) struct SelectedJointTupleV1 { report: FullHardReportV1, policy: DeclaredTotalOrderV1, @@ -644,6 +750,7 @@ impl SelectedJointTupleV1 { | JointReportErrorV1::CandidatePaintMismatch { .. } => { SelectedRecheckErrorV1::InvariantDrift } + JointReportErrorV1::Evaluator(error) => SelectedRecheckErrorV1::Evaluator(error), })?; if let Some(violation) = matrices .cells @@ -651,7 +758,7 @@ impl SelectedJointTupleV1 { .copied() .find(|cell| !cell.decision.is_pass()) { - return Err(SelectedRecheckErrorV1::Violation(violation)); + return Err(SelectedRecheckErrorV1::Violation(Box::new(violation))); } Ok(RevisionBoundVerifiedSelectionV1 { selected: self, @@ -663,20 +770,21 @@ impl SelectedJointTupleV1 { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub(crate) enum SelectedRecheckErrorV1 { ResourceExhausted, InvariantDrift, - Violation(JointConstraintCellV1), + Evaluator(ApplicableWcag22EvaluationErrorV1), + Violation(Box), } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub(crate) struct FreshJointRecheckV1 { executions: Box<[JointExecutionRecordV1]>, cells: Box<[JointConstraintCellV1]>, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub(crate) struct RevisionBoundVerifiedSelectionV1 { selected: SelectedJointTupleV1, recheck: FreshJointRecheckV1, diff --git a/crates/labcolors-core/src/joint_tests.rs b/crates/labcolors-core/src/joint_tests.rs index 0537d263..71091444 100644 --- a/crates/labcolors-core/src/joint_tests.rs +++ b/crates/labcolors-core/src/joint_tests.rs @@ -12,6 +12,7 @@ use crate::observation::{ ObservedScenarioSetInput, Revision, RevisionBoundObservationV1, ScenarioId, ScenarioInput, SurfaceInputBinding, }; +use crate::wcag22::Wcag22CriterionV1; const ROOT: SurfaceInputPortId = SurfaceInputPortId::new(7); const LOWER: PaintId = PaintId::new(11); @@ -58,6 +59,14 @@ fn exact_lower(id: u32, target: [u8; 3]) -> JointHardConstraintV1 { ) } +fn wcag_upper(id: u32, criterion: Wcag22CriterionV1) -> JointHardConstraintV1 { + JointHardConstraintV1::wcag22( + JointConstraintIdV1::new(id), + JointVisibleTargetV1::Upper, + criterion, + ) +} + fn observation(revision: u64, cases: Vec<(u32, [u8; 3])>) -> RevisionBoundObservationV1 { let mut state = ObservationState::new(STREAM, vec![ROOT]).unwrap(); state @@ -96,7 +105,7 @@ fn linked_candidate_is_selected_only_after_upper_sees_lower_visible_surface() { assert_eq!( report.program_identity(), - JointPointProgramIdentityV1::TwoPaintDerivedSurfaceExactPointV1 + JointPointProgramIdentityV1::TwoPaintDerivedSurfacePointV1 ); assert_eq!(report.executions().len(), 2); assert!( @@ -143,6 +152,42 @@ fn linked_candidate_is_selected_only_after_upper_sees_lower_visible_surface() { assert_eq!(verified.fresh_cells().len(), 1); } +#[test] +fn production_wcag_constraint_classifies_real_upper_on_derived_surface() { + let report = program(vec![wcag_upper(1, Wcag22CriterionV1::Sc143TextDefault)]) + .evaluate( + candidates(vec![ + candidate(0, ([0x20; 3], 1.0), ([0x20; 3], 1.0)), + candidate(1, ([0x20; 3], 1.0), ([0xFF; 3], 1.0)), + ]), + observation(20, vec![(1, [0; 3])]), + ) + .unwrap(); + + assert!(matches!( + report.cells()[0].decision(), + JointConstraintDecisionV1::Wcag22Violation(_) + )); + assert!(matches!( + report.cells()[1].decision(), + JointConstraintDecisionV1::Wcag22Pass(_) + )); + assert_eq!(report.cells()[1].decision().actual(), Srgb8::new([0xFF; 3])); + assert_eq!(report.cells()[1].decision().target(), Srgb8::new([0x20; 3])); + + let HardFeasibilityV1::NonEmpty(feasible) = report.classify() else { + panic!("white label must be feasible on the emitted dark fill"); + }; + let policy = DeclaredTotalOrderV1::new( + feasible.candidate_set(), + vec![CandidateOrdinalV1::new(0), CandidateOrdinalV1::new(1)], + ) + .unwrap(); + let verified = feasible.select(policy).recheck().unwrap(); + assert_eq!(verified.ordinal(), CandidateOrdinalV1::new(1)); + assert!(verified.fresh_executions()[0].derived_surface_is_exact()); +} + #[test] fn every_unique_physical_case_must_pass_without_worst_or_average_reduction() { let observed = observation(2, vec![(1, [0; 3]), (2, [255; 3])]); diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index 7db7ba23..813fe9eb 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -312,7 +312,7 @@ pub struct NoPrematureScalarLpcApi; /// публичный extension point. /// /// ```compile_fail -/// use labcolors_core::pair::pair_side; +/// use labcolors_core::pair::lower_fill; /// ``` #[cfg(doctest)] pub struct NoPublicPairRecipeApi; diff --git a/crates/labcolors-core/src/lpc.rs b/crates/labcolors-core/src/lpc.rs index 32034d5c..4d9443fd 100644 --- a/crates/labcolors-core/src/lpc.rs +++ b/crates/labcolors-core/src/lpc.rs @@ -25,6 +25,7 @@ pub(crate) fn cam16_jch_from_xyz(xyz: [f64; 3], vc: &ViewingConditions) -> (f64, /// Chroma exponent in the Hellwig 2022 H-K lightness term /// `J_HK = J + f(h) * C^0.587` (source: see [`hk_coeff`]). +#[cfg(test)] const HK_CHROMA_EXPONENT: f64 = 0.587; /// Hue-dependent Helmholtz-Kohlrausch coefficient `f(h)`, `h_cam_deg` in degrees. @@ -38,6 +39,7 @@ const HK_CHROMA_EXPONENT: f64 = 0.587; /// `pub(crate)` so the external-reference-vector suite (`reference_vectors_deep`) /// can pin the RHS coefficients directly to the Hellwig 2022 publication; not /// part of the public API. +#[cfg(test)] pub(crate) fn hk_coeff(h_cam_deg: f64) -> f64 { let h_cam = h_cam_deg.to_radians(); -0.160 * h_cam.cos() + 0.132 * (2.0 * h_cam).cos() - 0.405 * h_cam.sin() @@ -49,6 +51,7 @@ pub(crate) fn hk_coeff(h_cam_deg: f64) -> f64 { /// /// LPC supplies an H-K-adjusted `J` target; the physical inversion itself is /// shared appearance geometry in [`cam16::gray_y`]. +#[cfg(test)] pub(crate) fn y_hk(j_hk: f64, vc: &ViewingConditions) -> f64 { cam16::gray_y(j_hk, vc) } @@ -236,6 +239,7 @@ pub(crate) fn contrast_core(y_fg: f64, y_bg: f64) -> f64 { /// Hellwig 2022 H-K-corrected lightness for an XYZ stimulus: /// `J_HK = J + f(h) * C^0.587`, with the chroma correlate `C = M / F_L^0.25`. +#[cfg(test)] pub(crate) fn j_hk_from_xyz(xyz: [f64; 3], vc: &ViewingConditions) -> f64 { let (j, m, h) = cam16_jch_from_xyz(xyz, vc); j_hk_from_cam16(j, m, h, vc) @@ -246,6 +250,7 @@ pub(crate) fn j_hk_from_xyz(xyz: [f64; 3], vc: &ViewingConditions) -> f64 { /// that already ran [`cam16::forward`] (e.g. [`crate::solve`]'s `finish`, which /// also needs the `LcsColor`) derive `J_HK` from the same forward pass instead /// of running a second identical one on the same stimulus. +#[cfg(test)] pub(crate) fn j_hk_from_cam16(j: f64, m: f64, h: f64, vc: &ViewingConditions) -> f64 { // `vc.fl_pow_025` == инлайновый `vc.fl.powf(0.25)` (пер-VC константа, // вынесенная в `ViewingConditions::build`), так что `chroma` байт-идентична. @@ -337,8 +342,8 @@ pub(crate) fn apparent_contrast_candidate_hex_with_vc_for_test( /// /// ADR-0003: Ys candidate score движка /// считает именно в этом домене — `solve::finish`/`meets_floor`, интервал фона, -/// recheck-примитивы (`semantic::measure_contrast`, `recheck_against*`) и белая -/// сторона кроссовера пары (`pair::pair_side`). Сам движок зовёт +/// recheck-примитивы (`semantic::measure_contrast`, `recheck_against*`) и +/// joint WCAG constraints. Сам движок зовёт /// [`contrast_core`] + [`crate::wcag::relative_luminance`] напрямую на уже /// готовых скалярах; эта функция — только test-reference той же формулы (те же /// функции, ноль новых констант). `Y_hk` остаётся отдельной appearance- diff --git a/crates/labcolors-core/src/pair.rs b/crates/labcolors-core/src/pair.rs index d91c5027..43155df0 100644 --- a/crates/labcolors-core/src/pair.rs +++ b/crates/labcolors-core/src/pair.rs @@ -1,822 +1,215 @@ -//! Пара «заливка × лейбл» — выводимая поверхность с перцептивной полярностью. +//! P1: frozen one-way Pair frontend поверх общей point graph algebra. //! -//! # Класс, который закрывает модуль +//! Модуль больше не выбирает «сторону пары», не двигает цвет по Oklab и не +//! содержит собственного solver-а. Он владеет только code-owned lowering одной +//! физической цепочки: //! -//! На статичном фоне полярность текста обязана следовать достижимости -//! WCAG-пола ([`crate::semantic`], `choose_polarity`) — иначе система эмитит -//! нечитаемое по закону. Но у ВЫВОДИМОЙ поверхности (заливка бейджа, кнопки) -//! есть степень свободы, которой нет у фона страницы: заливку можно двинуть. -//! Зона конфликта — фоны с Y ∈ (0.183, ~0.30): перцептивно они ТЁМНЫЕ (белый -//! текст читается лучше — полярностная асимметрия чтения, класс исследований, -//! на которых построен APCA), но белый физически не достигает пола 4.5. -//! Фирменные заливки (синий #007AFF Y≈0.21, красный Y≈0.25) живут ровно там: -//! буква WCAG требовала чернил, глаз и Figma — белого. +//! ```text +//! fill Paint + page Surface +//! → fill-on-page Occurrence +//! → surfaceFrom(fill-on-page) +//! label Paint + emitted fill Surface +//! → label-on-fill Occurrence +//! ``` //! -//! Закон пары: сторона лейбла выбирается ПЕРЦЕПТИВНЫМ кроссовером по якорю, -//! а непрерывный кандидат заливки минимально двигается по светлоте вдоль -//! фиксированных Oklab `a,b`, пока выбранная сторона не начнёт выигрывать штатный -//! `choose_polarity`. Дальше лейбл решается ОБЫЧНЫМ nested resolve на -//! выведенной заливке — пара не изобретает второй текстовый закон, она -//! готовит поверхность, на которой существующий закон даёт перцептивно -//! правильную сторону с легальным полом. Гамут и sRGB8-квантование могут изменить -//! координаты; рецепт не выдаёт это за гарантию сохранения идентичности. -//! -//! # Кроссовер (level-3) -//! -//! Сторона пары — свойство ИДЕНТИЧНОСТИ СЕМЬИ: решается ОДИН РАЗ по -//! каноническому светлому якорю ([`pair_side`], смешанное доменное сравнение -//! достижимого контраста двух архетипов лейбла) и не флипается между -//! темами/IC — иначе «Brand» носил бы белый лейбл в light и чернильный в -//! dark (тёмные якоря labui осветлены и перелезают порог: info dark -//! #5696FF Y=0.31). Пер-режимная заливка двигается ПОД выбранную сторону: -//! светлая сторона — утемнение до строгой победы белого; чернильная — -//! осветление до строгой победы чёрного (IC-якоря warning/success -//! проваливаются под границу: #C93400 Y=0.149 — без осветления штатная -//! полярность отдала бы белый и сторона флипнулась бы в IC). -//! -//! Белая сторона требует строгой победы белого в `choose_polarity`: -//! `(Y + 0.05)² < 1.05 · 0.05`, т.е. Y < 0.17913 (белый ≥ 4.58:1) — граница -//! выведена из самой формулы WCAG, не подобрана. Сдвиг для фирменных якорей -//! мал: #007AFF (0.211 → 0.179) — едва заметное утемнение при том же оттенке. - -use crate::spaces::oklab::{neutral_srgb_linear, oklab_to_srgb_linear, srgb_linear_to_oklab}; -use crate::spaces::srgb::srgb_gamma_inv; -use crate::spaces::vc::ViewingConditions; - -/// Y-порог кроссовера стороны пары «заливка × лейбл» — решается ОДИН РАЗ по -/// каноническому светлому якорю семьи. -/// -/// Терминал **(a) DERIVED** внутри замороженной численной эвристики. -/// -/// # Что этот порог решает -/// Ахроматический терминал решения стороны. С главы #64 (level-3) [`pair_side`] -/// не сравнивает люминанс с порогом напрямую — он сравнивает |Lc| обоих -/// архетипов лейбла той же кривой; на серой оси H-K-член ≈ 0 и правило -/// редуцируется ровно к этому порогу (лок -/// `achromatic_reduction_matches_derived_crossover`). Порог обязан стоять там, -/// где обе ветви frozen Ys candidate-кривой равны по модулю. -/// -/// # Почему именно это число (вывод, не выбор) -/// Используется внутреннее `contrast_core` с замороженным набором SAPC-8 -/// `0.0.98G-4g`. -/// Кроссовер = фон, на котором |Lc| ЧЁРНОГО лейбла догоняет |Lc| БЕЛОГО. Он -/// найден бисекцией (корень единственный на [0.2, 0.6], проверено сменой знака): -/// **Y = 0.341955**. Это математическое свойство текущих экспонент, но не -/// LPC/readability optimum; дрейф любой из них ломает лок -/// `pair_crossover_equals_measured_core_polarity_flip` (пиннинг деривации). -/// -/// # Почему не соседние кандидаты -/// - НЕ `0.179` (`WHITE_WINS_Y`) — это отдельная граница двойной законности WCAG. -/// - НЕ `0.325` (test-only H-K appearance-candidate, байт 155 серой оси) — тот -/// использует другой входной домен, чужой для Ys-решения `pair_side`. -/// -/// # Согласованность и почему это ещё и ЛУЧШЕ прежнего 0.30 -/// 0.341955 лежит ВНУТРИ интервала 10 Figma-якорей labui (0.246, 0.423) — вывод -/// не спорит с палитрой, а совпадает с ней (экспозиция 21.69%, -/// `exposure_pair_crossover`). Прежнее 0.30 было дизайн-тюнингом НИЖЕ модели: при -/// Y ∈ [0.30, 0.342) оно выбирало ink-ветвь там, где frozen curve даёт больший -/// модуль white-ветви. Это согласование одной эвристики, не claim читаемости. -// С главы #64 (level-3) `pair_side` не потребляет этот порог в РАНТАЙМЕ — он -// выводит кроссовер из достижимого |Lc| обоих архетипов лейбла той же кривой. -// Порог остаётся АХРОМАТИЧЕСКИМ ТЕРМИНАЛОМ правила: на серой оси H-K-член ≈ 0 и -// level-3 редуцируется ровно к нему (лок `achromatic_reduction_matches_derived_crossover`), -// а его деривация пиннится `pair_crossover_equals_measured_core_polarity_flip`. -// Константа СОХРАНЕНА живой (не удалена, не test-only): её отслеживает -// docs/empirical-inventory.md (строка 52, (a) DERIVED) и валидируют локи -// `exposure_locks`; `#[allow(dead_code)]` — честная декларация «намеренно не -// рантайм-вход при level-3», не заглушка ошибки. -// SSOT-TRACKED — Y-порог кроссовера, терминал (a) DERIVED (перелом contrast_core, бисекция), см. docs/empirical-inventory.md. -#[allow(dead_code)] -pub(crate) const PAIR_CROSSOVER_Y: f64 = 0.341955; - -/// Строгая граница победы белой стороны в `choose_polarity`: -/// `(Y + 0.05)² < 1.05 · 0.05` ⇒ Y < 0.17913. Выведена из формулы WCAG (не -/// настройка); округлена ВНИЗ (консервативно): ниже неё белый и по ратио, и -/// по tie-break выигрывает штатную полярность. -pub(crate) const WHITE_WINS_Y: f64 = 0.179; - -/// Строгая граница победы чернильной стороны — та же формула с другой -/// стороны: `(Y + 0.05)² > 1.05 · 0.05` ⇒ Y > 0.17913; округлена ВВЕРХ. -pub(crate) const BLACK_WINS_Y: f64 = 0.1795; - -/// Итераций бисекции минимального сдвига: 48 делений пополам ≫ шага 8-битной -/// решётки, на которой квантуется каждый кандидат — сходимость до кванта. -const BISECTION_STEPS: usize = 48; - -// Коэффициенты относительной яркости ITU-R BT.709 / WCAG 2.x (Rec.709 luma). -// Стандарт, не тюнинг: исключены из POLICY-инвентаря by-construction -// (`NUMERIC_METHOD_ALLOWLIST`, INV-3). Извлечены из inline-литералов, чтобы -// pair.rs прошёл GATE-5 без незадекларированных голых чисел (значения целы). -const WCAG_LUMA_R: f64 = 0.2126; -const WCAG_LUMA_G: f64 = 0.7152; -const WCAG_LUMA_B: f64 = 0.0722; - -/// WCAG-люминанс кодированного (byte/255) sRGB. -fn wcag_y_encoded(rgb: [f64; 3]) -> f64 { - let lin = [ - srgb_gamma_inv(rgb[0]), - srgb_gamma_inv(rgb[1]), - srgb_gamma_inv(rgb[2]), - ]; - WCAG_LUMA_R * lin[0] + WCAG_LUMA_G * lin[1] + WCAG_LUMA_B * lin[2] -} - -/// Сторона лейбла пары по перцептивному кроссоверу якоря. +//! `PairFill` и `PairLabel` остаются временными authoring-тегами до C7c, но их +//! исполняемая физика уже принадлежит [`crate::joint`], единственному point +//! compositor-у и общим constraint evaluators. Публичного `Program` здесь нет. + +use crate::Srgb8; +use crate::appearance::{ + EncodedPointPaintV1, PaintId, PointOpacityOverSurfaceV1, ResolvedOccurrence, SurfaceInputPortId, +}; +use crate::composition::AdmittedOpacityV1; +use crate::joint::{ + CandidateOrdinalV1, CandidateSetErrorV1, DeclaredTotalOrderV1, FullHardReportV1, + HardFeasibilityV1, JointCandidateSetV1, JointCandidateTupleV1, JointConstraintIdV1, + JointHardConstraintV1, JointPointProgramV1, JointProgramErrorV1, JointReportErrorV1, + JointVisibleTargetV1, RevisionBoundVerifiedSelectionV1, SelectedRecheckErrorV1, + SelectionPolicyErrorV1, +}; +use crate::observation::{ + ObservationError, ObservationPayloadInput, ObservationState, ObservationStreamId, + ObservationUpdateInput, ObservedScenarioSetInput, PreparedObservationViewV1, Revision, + RevisionBoundObservationV1, ScenarioId, ScenarioInput, SurfaceInputBinding, +}; +use crate::wcag22::Wcag22CriterionV1; + +const ROOT_SURFACE: SurfaceInputPortId = SurfaceInputPortId::new(1); +const FILL_PAINT: PaintId = PaintId::new(1); +const LABEL_PAINT: PaintId = PaintId::new(2); +const CANDIDATE: CandidateOrdinalV1 = CandidateOrdinalV1::new(1); +const LABEL_CONSTRAINT: JointConstraintIdV1 = JointConstraintIdV1::new(1); +const OBSERVATION_STREAM: ObservationStreamId = ObservationStreamId::new(1); +const OBSERVATION_REVISION: Revision = Revision::new(1); +const OBSERVATION_SCENARIO: ScenarioId = ScenarioId::new(1); + +/// Один канонический fill Paint, действительно применённый к page Surface. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum PairSide { - /// Якорь перцептивно тёмный — лейбл светлый, заливка двигается до - /// строгой победы белого в штатной полярности. - Light, - /// Якорь перцептивно светлый — лейбл чернильный, заливка не двигается - /// (тёмная сторона уже выигрывает штатную полярность). - Ink, +pub(crate) struct LoweredPairFillV1 { + paint: EncodedPointPaintV1, + occurrence: ResolvedOccurrence, } -/// Выбрать сторону пары для кодированного якоря — level-3: смешанное доменное -/// сравнение достижимого контраста двух архетипов лейбла. -/// -/// `Ink ⇔ |Lc(чёрный, Y_eff)| > |Lc(белый, Ys)|`, -/// -/// где `Lc` — контраст-кривая SAPC-8 ([`crate::lpc`], `contrast_core`), `Ys` — -/// WCAG-люминанс display-байтов якоря, `Y_eff` — воспринимаемая яркость якоря -/// (Гельмгольц–Кольрауш: `solve::bg_luma`, серый эквивалент J_HK по -/// Hellwig 2022 под каноническим sRGB-окружением). -/// -/// Декомпозиция доменов — следствие ADR-0003, не его обход: -/// -/// - читаемость СВЕТЛОГО лейбла — люминансная величина (Mullen 1985: детальную -/// разборчивость несёт ахроматический канал): белая сторона меряется в Ys, -/// H-K к ней не допущен — класс «H-K топит белый» (15:0, отчёт V3) исключён -/// конструкцией; -/// - «поверхность выглядит светлой → ей место нести чернила» — суждение о -/// ЯРКОСТИ поверхности, законный дом H-K по тому же ADR: чернильная сторона -/// меряется от Y_eff. -/// -/// На ахроматической оси Y_eff ≈ Ys (H-K-член ≈ 0), и правило редуцируется к -/// РАВНО-|Lc| кроссоверу самой кривой Y* ≈ 0.342 — порог не задаётся, а -/// выводится (лок `achromatic_reduction_matches_derived_crossover`). Насыщенные -/// фоны флипают в чернила РАНЬШЕ по Ys — hue-каверна полнотекстов таска #62; -/// величина сдвига следует hue-зависимости H-K (Hellwig f(h)·C^0.587), новых -/// констант нет: обе стороны — существующие функции движка. -/// -/// Тай (равенство скоров) отдан свету: полярностная асимметрия чтения — белый -/// предпочтителен, пока чернила строго не выиграли. -pub(crate) fn pair_side(anchor_encoded: [f64; 3]) -> PairSide { - let ys = wcag_y_encoded(anchor_encoded); - let lin = [ - srgb_gamma_inv(anchor_encoded[0]), - srgb_gamma_inv(anchor_encoded[1]), - srgb_gamma_inv(anchor_encoded[2]), - ]; - let y_eff = crate::solve::bg_luma(lin, &ViewingConditions::srgb()); - let ink_score = crate::lpc::contrast_core(0.0, y_eff).abs(); - let white_score = crate::lpc::contrast_core(1.0, ys).abs(); - if ink_score > white_score { - PairSide::Ink - } else { - PairSide::Light +impl LoweredPairFillV1 { + pub(crate) const fn paint(self) -> EncodedPointPaintV1 { + self.paint } -} -/// Заливка пары: пер-режимный якорь, минимально сдвинутый по L Oklab до -/// строгой победы СТОРОНЫ СЕМЬИ. Непрерывный хроматический кандидат держит -/// исходные Oklab `a,b`; ахроматический идёт по точной neutral ray, чтобы шум -/// матрицы не изобрёл hue. Затем каналы клампятся и квантуются, поэтому -/// финальные байты не несут отдельной гарантии сохранения оттенка. -/// -/// Сторона приходит от канонического светлого якоря семьи ([`pair_side`]) и -/// одна на все режимы; движение пер-режимное: светлая сторона — утемнение до -/// `Y < WHITE_WINS_Y`, чернильная — осветление до `Y > BLACK_WINS_Y` -/// (IC-якоря проваливаются под границу). Якорь, уже дающий победу, не -/// двигается вовсе. -pub(crate) fn pair_fill(anchor_encoded: [f64; 3], side: PairSide) -> [f64; 3] { - let y = wcag_y_encoded(anchor_encoded); - let (needs_move, target_dark) = match side { - PairSide::Light => (y >= WHITE_WINS_Y, true), - PairSide::Ink => (y <= BLACK_WINS_Y, false), - }; - if !needs_move { - return anchor_encoded; + pub(crate) const fn occurrence(&self) -> &ResolvedOccurrence { + &self.occurrence } - let lin = [ - srgb_gamma_inv(anchor_encoded[0]), - srgb_gamma_inv(anchor_encoded[1]), - srgb_gamma_inv(anchor_encoded[2]), - ]; - let lab = srgb_linear_to_oklab(lin); - let source = crate::Srgb8::new( - anchor_encoded.map(|channel| (channel.clamp(0.0, 1.0) * 255.0).round() as u8), - ); - let linear_at = |l: f64| { - if source.is_achromatic() { - neutral_srgb_linear(l) - } else { - oklab_to_srgb_linear([l, lab[1], lab[2]]) - } - }; - let wins = |l: f64| { - let cand = encode_clamped(linear_at(l)); - if target_dark { - wcag_y_encoded(cand) < WHITE_WINS_Y - } else { - wcag_y_encoded(cand) > BLACK_WINS_Y - } - }; - // Бисекция минимального сдвига: инвариант — lo выигрывает, hi нет; - // сходимся к ближайшей к якорю светлоте с победой стороны. - let (mut lo, mut hi) = if target_dark { - (0.0_f64, lab[0]) - } else { - (1.0_f64, lab[0]) - }; - for _ in 0..BISECTION_STEPS { - let mid = 0.5 * (lo + hi); - if wins(mid) { - lo = mid; - } else { - hi = mid; - } + + pub(crate) fn visible(self) -> Srgb8 { + Srgb8::new(self.occurrence.visible()) } - encode_clamped(linear_at(lo)) } -/// Линейный sRGB → кодированный, КВАНТОВАННЫЙ в 8-битную решётку с клампом -/// в куб. Квантование обязательно: `choose_polarity` меряет display-байты, -/// и бисекция обязана сходиться на той же решётке — неквантованный кандидат -/// у границы после округления в hex выталкивался на грань tie-break -/// (красный #FF3B30 давал #E81A17 с Y ровно на границе → чернила). -fn encode_clamped(lin: [f64; 3]) -> [f64; 3] { - let g = |v: f64| (crate::spaces::srgb::srgb_gamma(v).clamp(0.0, 1.0) * 255.0).round() / 255.0; - [g(lin[0]), g(lin[1]), g(lin[2])] +/// Ограничение именно видимого label occurrence. Семантический frontend +/// выбирает criterion; Pair lowering не знает role/family taxonomy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PairLabelRequirementV1 { + Exact(Srgb8), + Wcag22(Wcag22CriterionV1), } -#[cfg(test)] -mod tests { - use super::*; - use crate::spaces::srgb::{hex_from_srgb_encoded, srgb_encoded_from_hex}; - - fn enc(hex: &str) -> [f64; 3] { - srgb_encoded_from_hex(hex).expect("тестовые hex-литералы валидны") - } - - /// Калибровка кроссовера консенсус-сетом якорей labui: перцептивно тёмные - /// семьи получают белую сторону, светлые — чернильную. Красный и зелёный — - /// зажимы интервала (0.246 < порог < 0.423). - #[test] - fn crossover_matches_palette_consensus() { - for hex in ["#007AFF", "#FF3B30", "#3E87FF", "#101012", "#5856D6"] { - assert_eq!(pair_side(enc(hex)), PairSide::Light, "{hex}: белая сторона"); - } - for hex in ["#FFA100", "#34C759", "#FFD000", "#FFFFFF", "#5AC8FA"] { - assert_eq!( - pair_side(enc(hex)), - PairSide::Ink, - "{hex}: чернильная сторона" - ); - } - } - - /// (а) Тёмные/IC-якоря Figma носят чернильный лейбл СТЭНДАЛОН: их Ys - /// (0.31–0.32) чуть выше, но воспринимаемая яркость (0.43–0.47, H-K) - /// далеко за кроссовером — перцептивно это светлая пастель (полнотексты - /// таска #62). Семейные стороны это не трогает (канон решает светлый якорь). - #[test] - fn figma_dark_anchors_take_ink_side() { - for hex in ["#FF6161", "#5696FF", "#FF6482", "#409CFF"] { - assert_eq!( - pair_side(enc(hex)), - PairSide::Ink, - "{hex}: чернильная сторона стэндалон" - ); - } - } - - /// Минимальность сдвига: заливка тёмной стороны кроссовера двигается до - /// строгой победы белого и ни шагом дальше. - #[test] - fn light_side_nudges_minimally() { - for hex in ["#007AFF", "#FF3B30", "#3E87FF"] { - let anchor = enc(hex); - let fill = pair_fill(anchor, PairSide::Light); - let y = wcag_y_encoded(fill); - assert!( - y < WHITE_WINS_Y, - "{hex}: белый выигрывает строго (Y={y:.4})" - ); - assert!( - y > WHITE_WINS_Y - 0.004, - "{hex}: сдвиг минимален (Y={y:.4}, граница {WHITE_WINS_Y})" - ); - } - } - - /// Якоря, уже дающие победу своей стороны, не двигаются вовсе. - #[test] - fn winning_anchors_stay_put() { - for hex in ["#FFA100", "#34C759", "#FFFFFF"] { - let anchor = enc(hex); - assert_eq!( - hex_from_srgb_encoded(pair_fill(anchor, PairSide::Ink)), - hex_from_srgb_encoded(anchor), - "{hex}: чернильная сторона, заливка не тронута" - ); - } - let dark = enc("#101012"); - assert_eq!( - hex_from_srgb_encoded(pair_fill(dark, PairSide::Light)), - hex_from_srgb_encoded(dark), - "тёмный якорь светлой стороны не тронут" - ); - } - - #[test] - fn every_exact_gray_pair_fill_stays_on_the_encoded_gray_axis() { - for byte in 0_u8..=255 { - let channel = f64::from(byte) / 255.0; - let anchor = [channel; 3]; - let fill = pair_fill(anchor, pair_side(anchor)); - let [red, green, blue] = crate::srgb8::hex_bytes(&hex_from_srgb_encoded(fill)).unwrap(); - assert_eq!(red, green, "gray {byte}: red/green direction invented"); - assert_eq!(green, blue, "gray {byte}: green/blue direction invented"); - } - } - - #[test] - fn nearest_chromatic_pair_fill_keeps_a_chromatic_direction() { - let anchor = enc("#808081"); - let fill = pair_fill(anchor, pair_side(anchor)); - let [red, green, blue] = crate::srgb8::hex_bytes(&hex_from_srgb_encoded(fill)).unwrap(); - assert!( - red != green || green != blue, - "one-byte chromatic source was collapsed onto the gray axis" - ); - } - - /// Сторона — идентичность семьи: канонический светлый якорь решает один - /// раз, тёмные/IC-варианты семьи НЕ флипают её (info dark #5696FF Y=0.31 - /// перелезает кроссовер — контрпример оси A). - #[test] - fn side_is_family_canonical_not_per_vc() { - // Канон info (light) — светлая сторона... - assert_eq!(pair_side(enc("#3E87FF")), PairSide::Light); - // ...а его тёмный якорь сам по себе ушёл бы в чернила: фиксируем, - // что при семейной стороне заливка двигается, лейбл остаётся белым. - let fill = pair_fill(enc("#5696FF"), PairSide::Light); - assert!( - wcag_y_encoded(fill) < WHITE_WINS_Y, - "тёмный якорь info утемнён под светлую сторону семьи" - ); - } - - /// Чернильная сторона осветляет провалившиеся под границу IC-якоря - /// (warning light-ic #C93400 Y=0.149): без осветления штатная полярность - /// отдала бы белый и сторона флипнулась бы в IC. - #[test] - fn ink_side_lightens_sunken_ic_anchors() { - let fill = pair_fill(enc("#C93400"), PairSide::Ink); - let y = wcag_y_encoded(fill); - assert!( - y > BLACK_WINS_Y, - "IC-якорь осветлён до победы чернил (Y={y:.4})" - ); - assert!(y < BLACK_WINS_Y + 0.006, "сдвиг минимален (Y={y:.4})"); - } - - /// Сквозной закон: на выведенной заливке ШТАТНАЯ полярность отдаёт - /// светлый лейбл (мост к nested resolve — пара не изобретает второй - /// текстовый закон). - #[test] - fn nudged_fill_wins_light_polarity_in_standard_law() { - use crate::BgInput; - use crate::semantic::{Resolved, Role, RoleTable, resolve}; - use crate::spaces::vc::ViewingConditions; - for hex in ["#007AFF", "#FF3B30"] { - let fill = pair_fill(enc(hex), PairSide::Light); - let fill_hex = hex_from_srgb_encoded(fill); - let bg = BgInput::solid(&fill_hex).unwrap(); - let table = RoleTable::default(); - let label = match resolve(&bg, Role::LabelPrimary, &table, &ViewingConditions::srgb()) { - Ok(Resolved::Color { solved, .. }) => solved, - other => panic!("{hex}: лейбл обязан решиться цветом, получено {other:?}"), - }; - // Светлая полярность: лейбл светлее заливки. - let label_y = wcag_y_encoded(enc(label.hex())); - assert!( - label_y > wcag_y_encoded(fill), - "{hex}: лейбл светлый на выведенной заливке ({} на {fill_hex})", - label.hex() - ); +impl PairLabelRequirementV1 { + fn lower(self) -> JointHardConstraintV1 { + match self { + Self::Exact(target) => { + JointHardConstraintV1::exact(LABEL_CONSTRAINT, JointVisibleTargetV1::Upper, target) + } + Self::Wcag22(criterion) => JointHardConstraintV1::wcag22( + LABEL_CONSTRAINT, + JointVisibleTargetV1::Upper, + criterion, + ), } } } -// ───────────────────────────────────────────────────────────────────────────── -// Научные локи главы #64 (level-3, `level3_locks`): валидация выведенного -// кроссовера и H-K-сдвига стороны (Hellwig f(h)·C^0.587). Ниже — локи -// терминала (a) DERIVED `PAIR_CROSSOVER_Y` (`exposure_locks`): деривация -// пиннится замером, консенсус-сет держит чистый зазор, экспозиция зоны -// флипа замерена. -// ───────────────────────────────────────────────────────────────────────────── -#[cfg(test)] -mod level3_locks { - use super::{PairSide, encode_clamped, pair_side, wcag_y_encoded}; - use crate::exposure_support::LABUI_ANCHORS; - use crate::spaces::oklab::oklab_to_srgb_linear; - use crate::spaces::srgb::srgb_encoded_from_hex; - - /// Отставленный порог level-2 — нужен тестам как БАЗА СРАВНЕНИЯ (карта - /// флипов корпуса против прежнего поведения), не как правило. - const LEVEL2_CROSSOVER_Y_RETIRED: f64 = 0.30; - - fn enc(hex: &str) -> [f64; 3] { - srgb_encoded_from_hex(hex).expect("тестовые hex-литералы валидны") - } +/// Полное fresh evidence одной выбранной Pair-кандидатуры. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct VerifiedPairV1 { + verified: RevisionBoundVerifiedSelectionV1, +} - /// Равно-|Lc| кроссовер контраст-кривой для архетипов белого (1.0) и - /// чёрного (0.0) — бисекция самой кривой, не константа. - fn derived_equal_lc_crossover() -> f64 { - let (mut lo, mut hi) = (0.05_f64, 0.95_f64); - for _ in 0..80 { - let mid = 0.5 * (lo + hi); - let white = crate::lpc::contrast_core(1.0, mid).abs(); - let black = crate::lpc::contrast_core(0.0, mid).abs(); - if white > black { - lo = mid; - } else { - hi = mid; - } - } - 0.5 * (lo + hi) +impl VerifiedPairV1 { + fn execution(&self) -> &crate::joint::JointExecutionRecordV1 { + self.verified + .fresh_executions() + .first() + .unwrap_or_else(|| unreachable!("one Pair candidate over one case has one execution")) } - /// Максимальная хрома Oklch внутри sRGB-куба для (L, h) — бисекция по C. - fn max_chroma(l: f64, h_rad: f64) -> f64 { - let inside = |c: f64| { - let lin = oklab_to_srgb_linear([l, c * h_rad.cos(), c * h_rad.sin()]); - lin.iter().all(|&v| (-1e-9..=1.0 + 1e-9).contains(&v)) - }; - let (mut lo, mut hi) = (0.0_f64, 0.5_f64); - for _ in 0..48 { - let mid = 0.5 * (lo + hi); - if inside(mid) { - lo = mid; - } else { - hi = mid; - } - } - lo + pub(crate) fn fill_paint(&self) -> EncodedPointPaintV1 { + self.execution().lower_paint() } - /// Насыщенная ячейка: (L, h, доля максимальной хромы) → display-байты. - fn swatch(l: f64, h_rad: f64, rel_c: f64) -> [f64; 3] { - let c = rel_c * max_chroma(l, h_rad); - encode_clamped(oklab_to_srgb_linear([l, c * h_rad.cos(), c * h_rad.sin()])) + pub(crate) fn label_paint(&self) -> EncodedPointPaintV1 { + self.execution().upper_paint() } - /// (в) Ахроматическая редукция: на серой оси H-K-член ≈ 0, и флип - /// pair_side обязан совпасть с выведенным равно-|Lc| кроссовером Y* самой - /// кривой (Y* = 0.3420) с точностью остаточной колоримости CAM16 на - /// нейтралях (неполная хроматическая адаптация; задекларированный резидуй - /// `lpc`). Резидуй строго ОДНОнаправлен: C ≥ 0 ⇒ Y_eff ≥ Ys ⇒ флип может - /// лечь только НИЖЕ Y* — измерено 0.3340 (сдвиг 0.008 < 0.01). Обе точки - /// заметно выше отставленного level-2 порога 0.30 — сам подъём кроссовера - /// от резидуя не зависит. - #[test] - fn achromatic_reduction_matches_derived_crossover() { - let y_star = derived_equal_lc_crossover(); - eprintln!("выведенный равно-|Lc| кроссовер Y* = {y_star:.6}"); - let side_at = |g: f64| pair_side([g, g, g]); - assert_eq!(side_at(0.0), PairSide::Light, "чёрный фон — светлый лейбл"); - assert_eq!(side_at(1.0), PairSide::Ink, "белый фон — чернила"); - let (mut lo, mut hi) = (0.0_f64, 1.0_f64); - for _ in 0..48 { - let mid = 0.5 * (lo + hi); - if side_at(mid) == PairSide::Light { - lo = mid; - } else { - hi = mid; - } - } - let flip_ys = crate::spaces::srgb::srgb_gamma_inv(0.5 * (lo + hi)); - eprintln!("флип pair_side на серой оси: Ys = {flip_ys:.6}"); - let residual = y_star - flip_ys; - assert!( - (0.0..0.01).contains(&residual), - "ахроматическая редукция: флип {flip_ys:.4} против Y* {y_star:.4} \ - (резидуй {residual:.4} обязан быть в [0, 0.01))" - ); - assert!( - flip_ys > LEVEL2_CROSSOVER_Y_RETIRED, - "подъём кроссовера против level-2 реален (флип {flip_ys:.4} > 0.30)" - ); + pub(crate) fn fill_occurrence(&self) -> &ResolvedOccurrence { + self.execution().lower_occurrence() } - /// (г) Корпус 49 якорей labui: ЕДИНСТВЕННОЕ расхождение с level-2 — - /// #4A8FFF (Brand dark): Ys=0.283, воспринимаемая яркость 0.42 — - /// перцептивно светлая пастель, чернила стэндалон. Семейная сторона - /// Brand не затронута: её решает канонический светлый #007AFF (Light, - /// пин консенсус-теста). Консенсус-10 и якоря (а) закреплены отдельно. - #[test] - fn corpus_flips_vs_level2_are_named_and_explained() { - let mut flips: Vec<&str> = Vec::new(); - for &hex in LABUI_ANCHORS { - let e = enc(hex); - let old = if wcag_y_encoded(e) < LEVEL2_CROSSOVER_Y_RETIRED { - PairSide::Light - } else { - PairSide::Ink - }; - if old != pair_side(e) { - flips.push(hex); - } - } - eprintln!("флипы корпуса против level-2: {flips:?}"); - assert_eq!( - flips, - vec!["#4A8FFF"], - "ровно один поимённо объяснённый флип" - ); - assert_eq!(pair_side(enc("#4A8FFF")), PairSide::Ink); + pub(crate) fn label_occurrence(&self) -> &ResolvedOccurrence { + self.execution().upper_occurrence() } - /// (д) Свип класса этюда V3 (10 оттенков × 24 средне-светлых тона, - /// максимальная хрома): H-K-сдвиг стороны против чистой Ys-модели идёт - /// ТОЛЬКО в направлении Light→Ink (насыщенная поверхность выглядит - /// светлее — раньше готова нести чернила; f(h) Хеллвига всюду > 0) и не - /// топит ни одну ячейку с ИЗВЕСТНОЙ белой конвенцией — пины V3 #007AFF, - /// #0082FF, #FF0000. Контр-конвенционный класс «H-K топит белый» пуст. - #[test] - fn v3_sweep_hk_shift_never_sinks_conventional_white() { - let y_star = derived_equal_lc_crossover(); - let (mut light_to_ink, mut ink_to_light) = (0usize, 0usize); - for hue_step in 0..10 { - let h = f64::from(hue_step) * std::f64::consts::TAU / 10.0; - for tone in 0..24 { - let l = 0.30 + 0.60 * f64::from(tone) / 23.0; - let cell = swatch(l, h, 1.0); - let pure = if wcag_y_encoded(cell) < y_star { - PairSide::Light - } else { - PairSide::Ink - }; - match (pure, pair_side(cell)) { - (PairSide::Light, PairSide::Ink) => light_to_ink += 1, - (PairSide::Ink, PairSide::Light) => ink_to_light += 1, - _ => {} - } - } - } - eprintln!("свип 240: сдвигов white→ink {light_to_ink}, обратных {ink_to_light}"); - assert_eq!( - ink_to_light, 0, - "H-K не имеет права «затемнять» поверхность" - ); - assert!( - light_to_ink > 0, - "hue-каверна обязана существовать (таск #62)" - ); - for hex in ["#007AFF", "#0082FF", "#FF0000"] { - assert_eq!( - pair_side(enc(hex)), - PairSide::Light, - "{hex}: известная конвенция — белый лейбл" - ); - } - } - - /// (е) Монотонность стороны: вдоль линии одного hue по возрастанию - /// светлоты — не более одного переключения, и только Light→Ink. Три - /// уровня относительной хромы, включая гамут-границу (каспы). - #[test] - fn at_most_one_switch_along_hue_lightness_lines() { - for hue_step in 0..12 { - let h = f64::from(hue_step) * std::f64::consts::TAU / 12.0; - for rel_c in [0.35, 0.70, 1.0] { - let mut prev = None; - let mut switches = 0usize; - for step in 0..=120 { - let l = 0.02 + 0.96 * f64::from(step) / 120.0; - let side = pair_side(swatch(l, h, rel_c)); - if let Some(p) = prev { - if side != p { - switches += 1; - assert_eq!( - (p, side), - (PairSide::Light, PairSide::Ink), - "hue {hue_step}, C_rel {rel_c}: только Light→Ink" - ); - } - } - prev = Some(side); - } - assert!( - switches <= 1, - "hue {hue_step}, C_rel {rel_c}: {switches} переключений стороны" - ); - } - } + #[cfg(test)] + pub(crate) const fn evidence(&self) -> &RevisionBoundVerifiedSelectionV1 { + &self.verified } } -// ───────────────────────────────────────────────────────────────────────────── -// Локи терминала (a) DERIVED `PAIR_CROSSOVER_Y` (после #180: (e)→(a), значение = -// измеренный перелом ядра `contrast_core`). Статус и деривация НЕ трогаются -// главой #64: тесты предъявляют (1) чистый зазор консенсус-сета вокруг порога -// (робастность деривации — сторона инвариантна к точному значению в интервале), -// (2) пиннинг деривации замером (константа РАВНА перелому ядра, не цитата), (3) -// экспозицию зоны флипа; петля консистентности с продакшн-решением учитывает -// level-3 (H-K может только ПОДНЯТЬ якорь в чернила, поимённо). -#[cfg(test)] -mod exposure_locks { - use super::{PAIR_CROSSOVER_Y, PairSide, pair_side, wcag_y_encoded}; - use crate::exposure_support::{LABUI_ANCHORS, band_exposure, enc_of, wcag_y}; - use crate::spaces::srgb::srgb_encoded_from_hex; - - fn y_of(hex: &str) -> f64 { - wcag_y_encoded(srgb_encoded_from_hex(hex).unwrap()) - } +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum PairLoweringErrorV1 { + Observation(ObservationError), + Candidate(CandidateSetErrorV1), + Program(JointProgramErrorV1), + Report(JointReportErrorV1), + Policy(SelectionPolicyErrorV1), + Infeasible(Box), + Recheck(SelectedRecheckErrorV1), +} - /// Sensitivity (робастность деривации): на консенсус-сете labui сторона пары - /// ИНВАРИАНТНА для любого порога в задекларированном интервале. Доказ.: max Y светлой стороны СТРОГО - /// ниже min Y чернильной (чистый зазор), и PAIR_CROSSOVER_Y лежит в нём — значит - /// точное значение внутри зазора нематериально для этой палитры. - #[test] - fn crossover_side_is_invariant_across_palette_gap() { - let light = ["#007AFF", "#FF3B30", "#3E87FF", "#101012", "#5856D6"]; - let ink = ["#FFA100", "#34C759", "#FFD000", "#FFFFFF", "#5AC8FA"]; - let light_max = light.iter().map(|h| y_of(h)).fold(0.0f64, f64::max); - let ink_min = ink.iter().map(|h| y_of(h)).fold(f64::INFINITY, f64::min); - assert!( - light_max < ink_min, - "консенсус-сет должен иметь чистый зазор Y (light_max={light_max:.4} < ink_min={ink_min:.4})" - ); - assert!( - light_max < PAIR_CROSSOVER_Y && PAIR_CROSSOVER_Y < ink_min, - "PAIR_CROSSOVER_Y={PAIR_CROSSOVER_Y} должен лежать в зазоре ({light_max:.4}, {ink_min:.4})" - ); - // Любой порог в зазоре даёт то же разбиение — проверяем на границах зазора. - for theta in [light_max + 1e-6, ink_min - 1e-6, PAIR_CROSSOVER_Y] { - for h in light { - assert!(y_of(h) < theta, "{h}: светлая сторона при θ={theta:.4}"); - } - for h in ink { - assert!(y_of(h) >= theta, "{h}: чернильная сторона при θ={theta:.4}"); - } - } - } +/// Материализовать fill occurrence без role-specific эвристики. +pub(crate) fn lower_fill( + source: Srgb8, + opacity: AdmittedOpacityV1, + backdrop: Srgb8, +) -> LoweredPairFillV1 { + let paint = EncodedPointPaintV1::from_admitted(FILL_PAINT, source, opacity); + let occurrence = + PointOpacityOverSurfaceV1::evaluate_admitted(source.bytes(), opacity, backdrop.bytes()); + LoweredPairFillV1 { paint, occurrence } +} - /// (a)-ЯКОРЬ ИЗМЕРЯЕТСЯ И ПИННИТ КОНСТАНТУ: перелом полярности модели — фон, на - /// котором чёрный лейбл догоняет белый по |Lc|. Два домена: - /// 1. Чистое люминансное ядро ([`crate::lpc::contrast_core`], вход — сырой Y): - /// бисекция. Это домен ахроматического терминала, к которому level-3 - /// `pair_side` редуцируется на серой оси, — потому его перелом = якорь - /// `PAIR_CROSSOVER_Y`. - /// 2. Test-only H-K appearance-candidate на серой оси sRGB8 с - /// CAM16-реконструкцией: вторичная численная характеризация (byte 155, - /// Y≈0.325) — печатается для сравнения, но решению pair.rs чужд, не якорь. - /// - /// Числа печатаются и пиннятся снапшотом: дрейф любой замороженной константы - /// ядра сдвинет перелом и СЛОМАЕТ лок — значит `PAIR_CROSSOVER_Y` не может молча - /// разойтись с деривацией (это и есть пиннинг (a) DERIVED). Перелом лежит внутри - /// интервала 10 якорей (0.246, 0.423) — вывод согласован с палитрой. Провенанс — - /// docs/empirical-inventory.md (строка 52). - #[test] - fn pair_crossover_equals_measured_core_polarity_flip() { - use crate::lpc::{apparent_contrast_candidate_hex_for_test, contrast_core}; - use crate::spaces::srgb::srgb_gamma_inv; - // 1. Ядро: f(Y) = |Lc белого| − |Lc чёрного| меняет знак на [0.2, 0.6]. - let f = |y: f64| contrast_core(1.0, y).abs() - contrast_core(0.0, y).abs(); - assert!( - f(0.2) > 0.0 && f(0.6) < 0.0, - "предпосылка бисекции: белый выигрывает на 0.2, чёрный на 0.6" - ); - // Единственность корня: знак f меняется РОВНО один раз на сетке - // интервала — без этого бисекция могла бы сойтись к одному из - // нескольких пересечений (замечание CodeRabbit, PR #177). - let mut sign_changes = 0u32; - let mut prev_positive = true; - let mut y = 0.2_f64; - while y <= 0.6 { - let cur_positive = f(y) > 0.0; - if cur_positive != prev_positive { - sign_changes += 1; - prev_positive = cur_positive; - } - y += 0.002; +/// Проверить одну уже предложенную label-краску через общий joint report, +/// declared total order и обязательный fresh recheck. +pub(crate) fn verify_label( + fill_source: Srgb8, + fill_opacity: AdmittedOpacityV1, + label_source: Srgb8, + backdrop: Srgb8, + requirement: PairLabelRequirementV1, +) -> Result { + let observation = one_case_observation(backdrop).map_err(PairLoweringErrorV1::Observation)?; + let candidates = JointCandidateSetV1::new(vec![JointCandidateTupleV1::new( + CANDIDATE, + EncodedPointPaintV1::from_admitted(FILL_PAINT, fill_source, fill_opacity), + EncodedPointPaintV1::from_admitted(LABEL_PAINT, label_source, AdmittedOpacityV1::OPAQUE), + )]) + .map_err(PairLoweringErrorV1::Candidate)?; + let program = JointPointProgramV1::new( + ROOT_SURFACE, + FILL_PAINT, + LABEL_PAINT, + vec![requirement.lower()], + ) + .map_err(PairLoweringErrorV1::Program)?; + let report = program + .evaluate(candidates, observation) + .map_err(PairLoweringErrorV1::Report)?; + let feasible = match report.classify() { + HardFeasibilityV1::NonEmpty(feasible) => feasible, + HardFeasibilityV1::Infeasible(report) => { + return Err(PairLoweringErrorV1::Infeasible(Box::new(report))); } - assert_eq!( - sign_changes, 1, - "f обязана пересекать ноль ровно один раз на [0.2, 0.6]" - ); - let (mut lo, mut hi) = (0.2_f64, 0.6_f64); - for _ in 0..60 { - let mid = 0.5 * (lo + hi); - if f(mid) > 0.0 { - lo = mid; - } else { - hi = mid; - } - } - let core_x = 0.5 * (lo + hi); - // 2. Test-only H-K appearance-candidate: первый серый байт, где - // black-ветвь обгоняет white-ветвь. - let mut flip_byte = None; - for v in 1..=255u32 { - let hex = format!("#{v:02X}{v:02X}{v:02X}"); - if apparent_contrast_candidate_hex_for_test("#000000", &hex) - .expect("generated grey is valid") - .abs() - >= apparent_contrast_candidate_hex_for_test("#FFFFFF", &hex) - .expect("generated grey is valid") - .abs() - { - flip_byte = Some(v); - break; - } - } - let v = flip_byte.expect("на серой оси перелом обязан существовать"); - let y_at = |b: u32| srgb_gamma_inv(f64::from(b) / 255.0); - let (y_below, y_flip) = (y_at(v - 1), y_at(v)); - eprintln!( - "MODEL CROSSOVER: core Y={core_x:.6}; H-K candidate grey axis: byte {v} (Y in ({y_below:.4}, {y_flip:.4}])" - ); - // Снапшоты замера — регрессионный якорь (a)-деривации (прежний - // rustdoc-клейм «перелом ≈ 0.36» опровергнут этим замером 2026-07-07). - assert!( - (core_x - 0.341955).abs() < 5e-4, - "ядро: перелом ушёл от снапшота 0.3420: {core_x:.6}" - ); - assert_eq!( - v, 155, - "H-K candidate: перелом серой оси ушёл от снапшота (byte {v}, Y={y_flip:.4})" - ); - // (a) DERIVED: константа РАВНА измеренному перелому ядра — якорь = ядро - // `contrast_core`, а не H-K candidate (byte 155, Y≈0.325 несёт - // CAM16/Y_hk-реконструкцию; она остаётся вторичным замером, не якорем). - assert!( - (PAIR_CROSSOVER_Y - core_x).abs() < 1e-5, - "PAIR_CROSSOVER_Y={PAIR_CROSSOVER_Y} обязан РАВНЯТЬСЯ измеренному перелому ядра {core_x:.6}" - ); - // И вывод согласован с палитрой: перелом лежит внутри интервала 10 якорей. - assert!( - (0.246..0.423).contains(&core_x) && (0.246..0.423).contains(&y_flip), - "модельные предсказания обязаны лежать внутри интервала якорей" - ); - } + }; + let policy = DeclaredTotalOrderV1::new(feasible.candidate_set(), vec![CANDIDATE]) + .map_err(PairLoweringErrorV1::Policy)?; + let verified = feasible + .select(policy) + .recheck() + .map_err(PairLoweringErrorV1::Recheck)?; + Ok(VerifiedPairV1 { verified }) +} - /// EXPOSURE: доля гаммы в зоне флипа PAIR_CROSSOVER_Y = доля цветов с Y в - /// задекларированном интервале (0.246, 0.423) [красный/зелёный якоря labui]. - /// Инвариант: 12 якорей 49-якорного паспорта лежат в зоне - /// (тёмные/IC-варианты семей, напр. info dark #5696FF - /// Y=0.31) — это НЕ флип-риск: сторона решается ОДИН РАЗ по каноническому - /// СВЕТЛОМУ якорю семьи, а консенсус-сет канонических якорей имеет чистый - /// зазор (`crossover_side_is_invariant_across_palette_gap`). - #[test] - fn exposure_pair_crossover() { - let (lo, hi) = (0.246, 0.423); - let (grid_pct, labui_hits) = band_exposure(wcag_y, lo, hi); - eprintln!( - "EXPOSURE PAIR_CROSSOVER_Y interval=({lo},{hi}) grid_flip={grid_pct:.2}% labui_in_zone={} {:?}", - labui_hits.len(), - labui_hits - ); - assert_eq!( - labui_hits.len(), - 12, - "в зоне перелома должны оставаться ровно 12 из 49 якорей" - ); - // Консистентность с продакшн-решением при level-3 (глава #64): H-K - // строго однонаправлен — против порога якорь может только ПОДНЯТЬСЯ в - // чернила (цветная поверхность выглядит светлее), никогда — утонуть в - // свет. Промоции поимённые и пиннятся снапшотом. - let mut promoted: Vec<&str> = Vec::new(); - for &h in LABUI_ANCHORS { - let enc = srgb_encoded_from_hex(h).unwrap(); - let prod = pair_side(enc); - let reimpl = if wcag_y(enc_of(h)) < PAIR_CROSSOVER_Y { - PairSide::Light - } else { - PairSide::Ink - }; - match (reimpl, prod) { - (PairSide::Ink, PairSide::Light) => { - panic!("{h}: level-3 утопил чернильный якорь в свет — H-K однонаправлен") - } - (PairSide::Light, PairSide::Ink) => promoted.push(h), - _ => {} - } +fn one_case_observation(backdrop: Srgb8) -> Result { + let mut state = ObservationState::new(OBSERVATION_STREAM, vec![ROOT_SURFACE])?; + let transaction = state.prepare(ObservationUpdateInput { + stream: OBSERVATION_STREAM, + revision: OBSERVATION_REVISION, + payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { + scenarios: vec![ScenarioInput { + id: OBSERVATION_SCENARIO, + bindings: vec![SurfaceInputBinding { + port: ROOT_SURFACE, + value: backdrop, + }], + }], + }), + })?; + let observation = match transaction.view() { + PreparedObservationViewV1::AppliedObserved(observation) => observation, + PreparedObservationViewV1::Idempotent | PreparedObservationViewV1::AppliedUnknown(_) => { + unreachable!("fresh Pair observation is one applied physical case") } - eprintln!("level-3 промоции против порога: {promoted:?}"); - // Снапшот измеренных промоций level-3 (H-K, глава #64): насыщенные - // средне-светлые фоны, у которых чистый Ys < порога, но воспринимаемая - // яркость (H-K) поднимает их над кроссовером → чернильный лейбл. Все - // пять — блю/красно-розовые пастели; ни одна «известная конвенция» - // (#007AFF и т.п.) не затронута (пин `v3_sweep_...`). H-K строго - // однонаправлен: панику «утопил чернила в свет» выше ни один не вызвал. - assert_eq!( - promoted, - vec!["#409CFF", "#4A8FFF", "#5696FF", "#FF6161", "#FF6482"], - "промоции level-3 против ахроматического терминала — поимённый снапшот" - ); - } + }; + let disposition = transaction.commit(); + debug_assert_eq!(disposition, crate::observation::UpdateDisposition::Applied); + Ok(observation) } diff --git a/crates/labcolors-core/src/pair_label_tests.rs b/crates/labcolors-core/src/pair_label_tests.rs index b9441b30..278d5f63 100644 --- a/crates/labcolors-core/src/pair_label_tests.rs +++ b/crates/labcolors-core/src/pair_label_tests.rs @@ -1,456 +1,223 @@ -//! Характеризация boundary-адаптера `RoleSpec::PairLabel`. -//! -//! Класс, который закрывают эти тесты: контраст `label ↔ tinted-fill` у бейджа -//! на тинт-фоне был ЭМЕРДЖЕНТНЫМ, не гарантированным. Обычные `label-*` роли -//! решаются против ФОНА СТРАНИЦЫ и достигают своего WCAG-пола там; тинт-бейдж -//! (`labui/lab-badge.ts`, `type=tinted`) кладёт их на `fill-*-primary` (@12 -//! семейный тинт) — на этой подложке контраст ниже, и для warning/success -//! «цветной» лейбл (доля 0.4757, как `label-*-tertiary`) оседает к ~2.9:1 < 3:1. -//! `PairLabel` решает оттеночный лейбл ПРОТИВ тинт-поверхности, поэтому пол -//! гарантирован по построению; при недостижимости тон клампится (флаг -//! `compressed`), а не молча выдаётся за точное выполнение контракта. -//! -//! Appearance-граф описывает только физическую цепочку заливки: -//! solid paint → opacity paint → occurrence на контексте → derived surface. -//! Downstream-солвер -//! лейбла получает эту подложку без изменения своей математики. Тесты -//! доказывают точность этой границы подложки, но не заявляют наличие -//! финального label paint/occurrence или публичного recipe-контракта. -//! -//! Четыре группы: -//! 1. `shipped_*` — реальный контракт labui (`label--primary` на -//! `fill--primary`) держит UI-пол на тинте во всех темах (гвардит то, -//! что уже отгружено, — near-black лейбл сейчас даёт 13–18:1). -//! 2. `pair_label_*` — новая роль держит пол против тинт-поверхности во всех -//! client-defined families × light/dark (± IC). -//! 3. `pair_label_beats_page_resolved_label` — дифференциальный RED-proof: -//! ТОТ ЖЕ контракт (доля 0.4757, `AaUi`), решённый против страницы -//! (`label--tertiary`), проваливает 3:1 на тинте у warning/success, -//! а `PairLabel` (против поверхности) — держит. Разница ТОЛЬКО в подложке -//! резолва: если бы `resolve_pair_label` целил фон страницы, тест бы упал. -//! 4. `fill_occurrence_backdrop_*` / `pair_fill_output_*` — appearance-путь -//! подложки байт-идентичен замороженному -//! ручному composite-oracle (матрица 5 семей × 4 режима × 6 фонов + property), включая -//! типизированный отказ выбранной alpha-ветви; поверхность PairLabel НЕ является -//! эмитированным PairFill (санитарный witness против ложного ребра). +//! P1 acceptance tests: frozen Pair authoring lowers one-way into the common +//! point graph. These tests intentionally do not reproduce the deleted +//! `PairSide`/Oklab/H-K heuristic or a second compositing oracle. -use proptest::prelude::*; +use crate::Srgb8; +use crate::composition::AdmittedOpacityV1; +use crate::joint::JointConstraintDecisionV1; +use crate::ladder::{LadderPosition, LadderTint}; +use crate::pair::{PairLabelRequirementV1, PairLoweringErrorV1, lower_fill, verify_label}; +use crate::semantic::{NamedRoleTable, Resolved, RoleChroma, RoleSpec, resolve_named_set}; +use crate::solve::{BgInput, Floor, SolveFailure}; +use crate::spaces::vc::ViewingConditions; +use crate::wcag22::Wcag22CriterionV1; -use crate::config::fixture::labui_reference; -use crate::semantic::{resolve_pair_label, resolve_pair_label_manual_composite_oracle}; -use crate::solve::Floor; -use crate::{ - BgInput, LadderSource, LadderTint, Resolved, RoleRecipe, RoleSpec, SolveFailure, - ViewingConditions, resolve_named_set, -}; - -/// (имя семьи, источник лестницы) — 5 цветных семей тинт-бейджа labui -/// (нейтраль/статики используют `label-primary`/семейный примитив, покрыты -/// отдельно) . -fn families() -> [(&'static str, LadderSource); 5] { - [ - ("brand", LadderSource::Brand), - ("danger", LadderSource::Family("red".to_string())), - ("warning", LadderSource::Family("orange".to_string())), - ("success", LadderSource::Family("green".to_string())), - ("info", LadderSource::Family("blue".to_string())), - ] -} - -/// Четыре режима labui: тема (фон) × VC-пресет. -fn themes() -> [(&'static str, &'static str, ViewingConditions); 4] { - [ - ("light", "#FFFFFF", ViewingConditions::srgb()), - ( - "light-ic", - "#FFFFFF", - ViewingConditions::srgb_high_contrast(), - ), - ("dark", "#101012", ViewingConditions::dim_surround()), - ( - "dark-ic", - "#101012", - ViewingConditions::dim_surround_high_contrast(), - ), - ] -} - -/// Объявленный UI-пол из SSOT-контракта [`Floor::AaUi`]. Локальная -/// копия числа запрещена: тест проверяет тот же пол, который энфорсит -/// резолвер, и не дублирует его политику в фикстуре. -fn ui_floor() -> f64 { - Floor::AaUi - .min_ratio() - .expect("AaUi несёт числовой юр. пол") -} - -fn enc(hex: &str) -> [f64; 3] { - crate::spaces::srgb::srgb_encoded_from_hex(hex).expect("тестовый hex валиден") +fn admitted(value: f64) -> AdmittedOpacityV1 { + AdmittedOpacityV1::new(value).expect("test opacity is admitted") } -/// Тинт-поверхность семьи = композит `fill--primary` (то, во что складывается -/// `fill-*-tinted`). -fn surface_hex(set: &[(String, Resolved)], fam: &str) -> String { - set.iter() - .find(|(n, _)| n == &format!("fill-{fam}-primary")) - .and_then(|(_, r)| r.translucent()) - .map(|t| t.composite_hex().to_string()) - .unwrap_or_else(|| panic!("fill-{fam}-primary — тинт-поверхность")) +fn tint(bytes: [u8; 3]) -> LadderTint { + let encoded = bytes.map(|byte| f64::from(byte) / 255.0); + LadderTint::new([encoded; 4]).expect("exact byte tint is valid") } -/// Solved-hex цветной роли (лейбл). -fn solid_hex(set: &[(String, Resolved)], role: &str) -> String { - set.iter() - .find(|(n, _)| n == role) - .and_then(|(_, r)| r.solved()) - .map(|s| s.hex().to_string()) - .unwrap_or_else(|| panic!("роль `{role}` обязана решиться цветом")) +fn bytes(hex: &str) -> Srgb8 { + Srgb8::new(crate::srgb8::hex_bytes(hex).expect("resolver emits #RRGGBB")) } -/// WCAG-контраст роли против тинт-поверхности своей семьи. -fn ratio_on_tint(set: &[(String, Resolved)], role: &str, fam: &str) -> f64 { - crate::wcag::contrast_ratio(enc(&solid_hex(set, role)), enc(&surface_hex(set, fam))) -} +#[test] +fn pair_fill_is_one_real_source_over_occurrence() { + crate::composition::reset_source_over_evaluation_count(); + let fill = lower_fill( + Srgb8::new([255, 0, 0]), + admitted(0.5), + Srgb8::new([0, 0, 255]), + ); -/// Конфиг labui + добавленные роли `badge-label-` (`PairLabel`, доля -/// 0.47572199 «цветного» уровня = `label-*-tertiary`, пол `AaUi`) — форма, -/// которую тинт-бейдж должен потреблять. -fn labui_with_badge_labels() -> crate::NamedRoleTable { - let mut cfg = labui_reference(); - for (fam, source) in families() { - cfg.roles.push(( - format!("badge-label-{fam}"), - RoleRecipe::PairLabel { - source, - fraction: 0.47572199, - floor: Floor::AaUi, - }, - )); - } - cfg.compile_named_role_table() - .expect("labui + badge-label компилируется") + assert_eq!(crate::composition::source_over_evaluation_count(), 1); + assert_eq!(fill.paint().source(), Srgb8::new([255, 0, 0])); + assert_eq!(fill.paint().opacity(), admitted(0.5)); + assert_eq!(fill.occurrence().certificate().backdrop_rgb(), [0, 0, 255]); + assert_eq!(fill.visible(), Srgb8::new([128, 0, 128])); } -// ── 1. Отгруженный контракт: label--primary на fill--primary ──────── - #[test] -fn shipped_tinted_badge_label_clears_ui_floor_on_tint_all_families_and_themes() { - let floor = ui_floor(); - let table = labui_reference().compile_named_role_table().unwrap(); - for (tname, bg_hex, vc) in themes() { - let bg = BgInput::solid(bg_hex).unwrap(); - let set = resolve_named_set(&bg, &table, &vc) - .expect("валидный labui-контракт обязан резолвиться"); - for (fam, _) in families() { - let r = ratio_on_tint(&set, &format!("label-{fam}-primary"), fam); - assert!( - r >= floor, - "[{tname}] отгруженный тинт-бейдж `label-{fam}-primary` на \ - `fill-{fam}-primary` обязан держать {floor}:1, получено {r:.2}:1" - ); - } - } +fn pair_label_fresh_evidence_binds_upper_backdrop_to_emitted_fill() { + crate::composition::reset_source_over_evaluation_count(); + let verified = verify_label( + Srgb8::new([255, 0, 0]), + admitted(0.5), + Srgb8::new([255, 255, 255]), + Srgb8::new([0, 0, 255]), + PairLabelRequirementV1::Exact(Srgb8::new([255, 255, 255])), + ) + .expect("opaque label exactly satisfies identity requirement"); + + // Full report executes lower+upper once; mandatory fresh recheck repeats both. + assert_eq!(crate::composition::source_over_evaluation_count(), 4); + assert_eq!(verified.fill_occurrence().visible(), [128, 0, 128]); + assert_eq!( + verified.label_occurrence().certificate().backdrop_rgb(), + verified.fill_occurrence().visible() + ); + assert_eq!(verified.label_occurrence().visible(), [255, 255, 255]); + assert_eq!(verified.evidence().fresh_executions().len(), 1); + assert_eq!(verified.evidence().fresh_cells().len(), 1); } -// ── 2. PairLabel: жёсткий пол против тинт-поверхности, все семьи × темы ──────── - #[test] -fn pair_label_clears_ui_floor_against_tinted_surface_all_families_and_themes() { - let floor = ui_floor(); - let table = labui_with_badge_labels(); - for (tname, bg_hex, vc) in themes() { - let bg = BgInput::solid(bg_hex).unwrap(); - let set = resolve_named_set(&bg, &table, &vc) - .expect("валидный PairLabel-контракт обязан резолвиться"); - for (fam, _) in families() { - let role = format!("badge-label-{fam}"); - // Решается цветом (не SolveFailure/None) и держит пол на тинте. - let r = ratio_on_tint(&set, &role, fam); - assert!( - r >= floor, - "[{tname}] `{role}` обязан держать {floor}:1 против тинт-поверхности \ - `fill-{fam}-primary`, получено {r:.2}:1" - ); - } - } +fn wcag_is_checked_against_fill_surface_not_page_background() { + // White label would be 21:1 against the black page, but only ~3.95:1 + // against the emitted 50%-white fill. AaText must therefore fail. + let error = verify_label( + Srgb8::new([255, 255, 255]), + admitted(0.5), + Srgb8::new([255, 255, 255]), + Srgb8::new([0, 0, 0]), + PairLabelRequirementV1::Wcag22(Wcag22CriterionV1::Sc143TextDefault), + ) + .expect_err("actual fill surface does not satisfy 4.5:1"); + + let PairLoweringErrorV1::Infeasible(report) = error else { + panic!("valid negative decision must be Infeasible, not a protocol error"); + }; + assert_eq!(report.executions()[0].lower_visible(), Srgb8::new([128; 3])); + assert_eq!(report.cells().len(), 1); + assert!(matches!( + report.cells()[0].decision(), + JointConstraintDecisionV1::Wcag22Violation(_) + )); + assert_eq!( + report.cells()[0].decision().target(), + Srgb8::new([128; 3]), + "WCAG evidence background is the emitted fill" + ); } -/// Лейбл тинт-бейджа эмитит другой байтовый цвет, чем основной label семьи. #[test] -fn pair_label_bytes_differ_from_primary_label() { - let table = labui_with_badge_labels(); - let bg = BgInput::solid("#FFFFFF").unwrap(); - let set = resolve_named_set(&bg, &table, &ViewingConditions::srgb()) - .expect("валидный PairLabel-контракт обязан резолвиться"); - for (fam, _) in families() { - let role = format!("badge-label-{fam}"); - let res = &set.iter().find(|(n, _)| n == &role).unwrap().1; - let Resolved::Color { solved, .. } = res else { - panic!("`{role}` обязан решиться цветом"); - }; - // Здесь проверяется только различие представления; перцептивный порог из - // одного `assert_ne!` не выводится. - let primary = solid_hex(&set, &format!("label-{fam}-primary")); - assert_ne!( - solved.hex(), - primary, - "`{role}` не должен совпасть байт-в-байт с `label-{fam}-primary`" - ); - } +fn exact_mismatch_is_infeasible_not_fault() { + let error = verify_label( + Srgb8::new([0, 0, 0]), + AdmittedOpacityV1::OPAQUE, + Srgb8::new([1, 2, 3]), + Srgb8::new([255, 255, 255]), + PairLabelRequirementV1::Exact(Srgb8::new([9, 9, 9])), + ) + .expect_err("identity mismatch is a lawful hard violation"); + assert!(matches!(error, PairLoweringErrorV1::Infeasible(_))); } -// ── 3. Дифференциальный RED-proof: подложка резолва — и есть констрейнт ──────── - -/// ТОТ ЖЕ контракт (доля 0.4757, `AaUi`), решённый против СТРАНИЦЫ -/// (`label--tertiary`), проваливает 3:1 на тинте у warning/success в light; -/// `PairLabel` (против ПОВЕРХНОСТИ) — держит. Единственная разница — подложка -/// резолва: если `resolve_pair_label` целил бы фон страницы, «after» совпал бы с -/// «before» и упал. Кусающийся тест, не green-from-birth. #[test] -fn pair_label_beats_page_resolved_label_on_failing_families() { - let floor = ui_floor(); - let table = labui_with_badge_labels(); - let bg = BgInput::solid("#FFFFFF").unwrap(); - let set = resolve_named_set(&bg, &table, &ViewingConditions::srgb()) - .expect("валидный PairLabel-контракт обязан резолвиться"); - - // warning/success — семьи, где предпосылку о провале страничного - // `label-*-tertiary` на собственном тинте проверяет первый assert ниже. - for fam in ["warning", "success"] { - let before = ratio_on_tint(&set, &format!("label-{fam}-tertiary"), fam); - let after = ratio_on_tint(&set, &format!("badge-label-{fam}"), fam); - assert!( - before < floor, - "предпосылка класса: страничный `label-{fam}-tertiary` обязан \ - проваливать {floor}:1 на тинте (иначе тест не о том), получено {before:.2}:1" - ); - assert!( - after >= floor, - "`badge-label-{fam}` (тот же контракт, но против поверхности) обязан \ - держать {floor}:1, получено {after:.2}:1" - ); - assert!( - after > before, - "резолв против поверхности обязан ПОДНЯТЬ контраст на тинте: \ - {fam} before={before:.2} after={after:.2}" - ); - } -} - -// ── 4. Fill occurrence-derived backdrop == замороженный oracle ── - -/// Скомпилированные параметры `RoleSpec::PairLabel` конкретной роли таблицы — -/// вход обоих путей differential-а (appearance-путь и ручной oracle получают -/// РОВНО одни аргументы, различие только в реализации). -fn pair_label_spec( - table: &crate::NamedRoleTable, - role: &str, -) -> (LadderTint, f64, Floor, f64, f64) { - let spec = table - .entries() +fn named_pair_frontend_uses_the_same_fill_occurrence_end_to_end() { + let vc = ViewingConditions::srgb(); + let source = Srgb8::new([96; 3]); + let source_tint = tint(source.bytes()); + let (alpha_light, alpha_dark) = LadderPosition::FillPrimary.alpha_pair(); + let table = NamedRoleTable::new( + vec![ + ("pair-fill".into(), RoleSpec::PairFill { tint: source_tint }), + ( + "pair-label".into(), + RoleSpec::PairLabel { + tint: source_tint, + fraction: 0.9, + floor: Floor::AaText, + surface_alpha_light: alpha_light, + surface_alpha_dark: alpha_dark, + }, + ), + ], + vec![], + RoleChroma::Neutral, + ) + .expect("canonical Pair frontend compiles"); + let page = BgInput::solid("#FFFFFF").unwrap(); + let set = resolve_named_set(&page, &table, &vc).expect("canonical Pair resolves"); + + let fill = set .iter() - .find(|(name, _)| name == role) - .map(|(_, spec)| spec) - .unwrap_or_else(|| panic!("роль `{role}` обязана существовать в таблице")); - match spec { - RoleSpec::PairLabel { - tint, - fraction, - floor, - surface_alpha_light, - surface_alpha_dark, - } => ( - *tint, - *fraction, - *floor, - *surface_alpha_light, - *surface_alpha_dark, - ), - other => panic!("`{role}` обязан компилироваться в PairLabel, получено {other:?}"), - } -} - -/// Шесть контекстных фонов differential-матрицы — coverage-выборка с -/// подписанным происхождением каждой точки, НЕ production-правило: -/// -/// * `#000000` / `#FFFFFF` — границы sRGB-куба; белый одновременно является -/// отгруженным фоном светлых labui-тем (классы совпадают в этой дизайн- -/// системе по факту фикстуры); -/// * `#101012` — отгруженный фон тёмных labui-тем (фикстура); -/// * `#767676` — опубликованная WCAG-граница серого (≈4.54:1 к белому), -/// напрямую закреплённая -/// `tests/reference_vectors.rs::wcag_published_ratios_via_public_api`; -/// * `#FFF4E0` — хроматический светлый witness: точная warning-поверхность -/// из graph-тестов (`appearance_graph_tests`); -/// * `#0000FF` — насыщенный хроматический угол куба (sRGB primary). -fn differential_backgrounds() -> [&'static str; 6] { - [ - "#000000", "#101012", "#767676", "#FFF4E0", "#0000FF", "#FFFFFF", - ] + .find(|(name, _)| name == "pair-fill") + .and_then(|(_, resolved)| resolved.translucent()) + .expect("PairFill emits a translucent Paint"); + let label = set + .iter() + .find(|(name, _)| name == "pair-label") + .and_then(|(_, resolved)| resolved.solved()) + .expect("PairLabel emits a verified Color"); + + let opacity = admitted(LadderPosition::FillPrimary.alpha_for_vc(&vc)); + let lowered = lower_fill(source, opacity, Srgb8::new([255; 3])); + assert_eq!(fill.tint_hex(), source.to_hex()); + assert_eq!(fill.alpha().to_bits(), opacity.value().to_bits()); + assert_eq!(fill.composite_hex(), lowered.visible().to_hex()); + assert!(label.wcag_ratio() >= 4.5); + + let verified = verify_label( + source, + opacity, + bytes(label.hex()), + Srgb8::new([255; 3]), + PairLabelRequirementV1::Wcag22(Wcag22CriterionV1::Sc143TextDefault), + ) + .expect("semantic proposal passes the same joint contract"); + assert_eq!(verified.fill_occurrence(), lowered.occurrence()); + assert_eq!(verified.label_paint().source(), bytes(label.hex())); } -/// Appearance-путь вычисляет fill occurrence и derived backdrop, после чего -/// неизменённый downstream-солвер обязан дать тот же `Resolved`, что и -/// замороженный oracle: вариант, -/// финальные байты, флаги, unreachable-причины. Никакого approximate equality: -/// `assert_eq!` по `PartialEq` сравнивает и все числовые поля (одинаковые биты -/// по построению одного downstream-солвера), а hex сверяется отдельно, чтобы -/// байтовая эмиссия оставалась закреплённой даже при эволюции `PartialEq`. -/// Это differential-доказательство границы подложки, а не наличия финального -/// label occurrence в appearance-графе. #[test] -fn fill_occurrence_backdrop_matrix_matches_frozen_pair_label_oracle_exactly() { - let table = labui_with_badge_labels(); - let mut resolved_hits = 0usize; - for (tname, _, vc) in themes() { - for bg_hex in differential_backgrounds() { - let bg = BgInput::solid(bg_hex).unwrap(); - for (fam, _) in families() { - let role = format!("badge-label-{fam}"); - let (tint, fraction, floor, alpha_light, alpha_dark) = - pair_label_spec(&table, &role); - let production = - resolve_pair_label(&bg, tint, fraction, floor, alpha_light, alpha_dark, &vc); - let oracle = resolve_pair_label_manual_composite_oracle( - &bg, - tint, - fraction, - floor, - alpha_light, - alpha_dark, - &vc, - ); - assert_eq!( - production, oracle, - "[{tname}/{bg_hex}] `{role}`: production-граф обязан быть \ - идентичен замороженному ручному composite-oracle по всем полям" - ); - for outcome in [&production, &oracle] { - assert!( - !matches!( - outcome, - Err(SolveFailure::InvalidInput(_) - | SolveFailure::GamutUnsupported - | SolveFailure::InternalInvariant(_)) - ), - "[{tname}/{bg_hex}] `{role}`: a valid sRGB fixture produced a non-physical error: {outcome:?}" - ); - } - if let ( - Ok(Resolved::Color { solved: p, .. }), - Ok(Resolved::Color { solved: o, .. }), - ) = (&production, &oracle) - { - resolved_hits += 1; - assert_eq!( - p.hex(), - o.hex(), - "[{tname}/{bg_hex}] `{role}`: финальные байты эмиссии" - ); - } - } - } - } - assert!( - resolved_hits > 0, - "differential matrix must exercise successful colour resolution" - ); +fn pair_label_transport_alpha_cannot_fork_physical_semantics() { + let source = tint([96; 3]); + let (light, dark) = LadderPosition::FillPrimary.alpha_pair(); + let error = NamedRoleTable::new( + vec![( + "pair-label".into(), + RoleSpec::PairLabel { + tint: source, + fraction: 0.9, + floor: Floor::AaText, + surface_alpha_light: f64::from_bits(light.to_bits() + 1), + surface_alpha_dark: dark, + }, + )], + vec![], + RoleChroma::Neutral, + ) + .expect_err("noncanonical Pair surface alpha must fail at compilation"); + assert!(matches!( + error, + SolveFailure::InvalidInput(message) + if message.contains("code-owned by FillPrimary") + )); } -/// Внутренний resolver обязан типизированно отклонять невалидную выбранную -/// альфу до начала физического поиска. Публичный `NamedRoleTable` дополнительно -/// валидирует обе theme-ветви при компиляции конфига. #[test] -fn fill_occurrence_backdrop_path_rejects_selected_invalid_alpha_exactly() { - let table = labui_with_badge_labels(); - let (tint, fraction, floor, _, _) = pair_label_spec(&table, "badge-label-warning"); - for bad_alpha in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.1, 1.5] { - for (bg_hex, vc, light, dark) in [ - ("#FFFFFF", ViewingConditions::srgb(), bad_alpha, 0.122), +fn pair_frontend_has_only_color_or_translucent_terminal_shapes() { + let source = tint([96; 3]); + let (light, dark) = LadderPosition::FillPrimary.alpha_pair(); + let table = NamedRoleTable::new( + vec![ + ("fill".into(), RoleSpec::PairFill { tint: source }), ( - "#101012", - ViewingConditions::dim_surround(), - 0.122, - bad_alpha, + "label".into(), + RoleSpec::PairLabel { + tint: source, + fraction: 0.9, + floor: Floor::AaText, + surface_alpha_light: light, + surface_alpha_dark: dark, + }, ), - ] { - let bg = BgInput::solid(bg_hex).unwrap(); - let production = resolve_pair_label(&bg, tint, fraction, floor, light, dark, &vc); - let oracle = resolve_pair_label_manual_composite_oracle( - &bg, tint, fraction, floor, light, dark, &vc, - ); - assert_eq!( - production, oracle, - "theme-selected alpha branch must stay exact for alpha={bad_alpha}" - ); - assert!(matches!(production, Err(SolveFailure::InvalidInput(_)))); - } - } -} - -/// Санитарный witness против ложного ребра `PairFill → PairLabel`: эмитированный -/// `PairFill` — отдельно -/// сдвинутый солид, он НЕ равен тинт-поверхности (композиту `fill-*-primary`), -/// против которой решается `PairLabel`. Подмена derived backdrop эмитированным -/// PairFill разойдётся с differential-матрицей именно потому, что эти значения -/// различны. -#[test] -fn pair_fill_output_differs_from_fill_occurrence_derived_backdrop() { - let mut cfg = labui_reference(); - for (fam, source) in families() { - cfg.roles - .push((format!("pair-fill-{fam}"), RoleRecipe::PairFill { source })); - } - let table = cfg - .compile_named_role_table() - .expect("labui + pair-fill компилируется"); - let bg = BgInput::solid("#FFFFFF").unwrap(); - let set = resolve_named_set(&bg, &table, &ViewingConditions::srgb()) - .expect("валидный PairFill-контракт обязан резолвиться"); - for (fam, _) in families() { - let pair_fill_composite = set - .iter() - .find(|(name, _)| name == &format!("pair-fill-{fam}")) - .and_then(|(_, resolved)| resolved.translucent()) - .map(|t| t.composite_hex().to_string()) - .unwrap_or_else(|| panic!("pair-fill-{fam} обязан решиться")); - let surface = surface_hex(&set, fam); - assert_ne!( - pair_fill_composite, surface, - "{fam}: эмитированный PairFill не является поверхностью PairLabel" - ); - } -} - -// Property-differential: произвольные источник/контекст/альфы/доля/пол/режим -// проверяют точную замену пути подложки. Downstream-солвер в обоих путях -// один и тот же. -proptest! { - #[test] - fn fill_occurrence_backdrop_property_matches_frozen_pair_label_oracle( - source in any::<[u8; 3]>(), - context in any::<[u8; 3]>(), - alpha_light in 0.0f64..=1.0, - alpha_dark in 0.0f64..=1.0, - fraction in 0.01f64..1.0, - floor_pick in 0usize..3, - theme_pick in 0usize..4, - ) { - let encoded = source.map(|channel| f64::from(channel) / 255.0); - let tint = LadderTint::new([encoded; 4]).expect("byte/255 всегда в домене"); - let context_hex = format!( - "#{:02X}{:02X}{:02X}", - context[0], context[1], context[2] - ); - let bg = BgInput::solid(&context_hex).expect("байтовый hex валиден"); - let floor = [Floor::AaText, Floor::AaUi, Floor::None][floor_pick]; - let (_, _, vc) = themes()[theme_pick]; - let production = resolve_pair_label( - &bg, tint, fraction, floor, alpha_light, alpha_dark, &vc, - ); - let oracle = resolve_pair_label_manual_composite_oracle( - &bg, tint, fraction, floor, alpha_light, alpha_dark, &vc, - ); - prop_assert_eq!(production, oracle); - } + ], + vec![], + RoleChroma::Neutral, + ) + .unwrap(); + let results = resolve_named_set( + &BgInput::solid("#FFFFFF").unwrap(), + &table, + &ViewingConditions::srgb(), + ) + .unwrap(); + assert!(matches!(results[0].1, Resolved::Translucent(_))); + assert!(matches!(results[1].1, Resolved::Color { .. })); } diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index e5e4d8be..2f206373 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -546,35 +546,22 @@ pub enum RoleSpec { /// path. Wire-ключ — его boundary-проекция, а не декоративный metadata. mode: crate::numerical_plan::NumericalExecutionModeV1, }, - /// Заливка пары (внутренний модуль `pair`): якорь источника, сдвинутый до победы - /// выбранной ветви переходной pair-эвристики; солид-эмиссия. + /// Frozen PairFill frontend: the source tint becomes a fill Paint at + /// code-owned FillPrimary opacity. Its page occurrence is the only emitted + /// fill Surface and the same Surface later consumed by PairLabel. PairFill { - /// Пер-темный кодированный якорь источника. + /// Per-theme exact encoded source identity. tint: LadderTint, }, - /// Лейбл ТИНТ-бейджа (внутренний модуль `pair`, лейбл-сторона). Семейно-оттеночный - /// лейбл, чей WCAG-пол энфорсится ПРОТИВ объявленной тинт-поверхности - /// (exact source-over композит declared `tint` при compatibility-альфе - /// позиции `fill-*-primary` над фоном резолва), а НЕ против фона страницы - /// и НЕ против эмитированного [`PairFill`](Self::PairFill) — у того своя, - /// отдельно сдвинутая солид-эмиссия; ребра `PairFill → PairLabel` не - /// существует. Приватный appearance-граф материализует tint Paint, применяет - /// его к локальной поверхности ровно в одном fill occurrence и отдаёт - /// `surfaceFrom` как фактический фон. Последующий label solve находится вне - /// графа: этот recipe не создаёт итоговые label Paint/occurrence или их - /// доказательный статус. Дифференциальный тест доказывает только точность - /// границы подложки, а не математику label-resolver-а. - /// Тон клампится (флаг `compressed`) при недостижимости на кривой семьи. + /// Frozen PairLabel frontend. The label is proposed on the exact emitted + /// PairFill Surface and admitted through the common joint hard-report. + /// `surface_alpha_*` remain transport fields until C7c but must equal the + /// code-owned FillPrimary pair; callers cannot fork physical semantics. PairLabel { - /// Пер-темный кодированный тинт-якорь семьи (как у лестницы). tint: LadderTint, - /// Доля максимума контраста тинт-поверхности `(0, 1]`. fraction: f64, - /// WCAG-пол против тинт-поверхности. floor: Floor, - /// Альфа поверхности (светлая тема) — из позиции `fill-*-primary`. surface_alpha_light: f64, - /// Альфа поверхности (тёмная тема) — из позиции `fill-*-primary`. surface_alpha_dark: f64, }, Ladder { @@ -2162,15 +2149,7 @@ fn resolve_spec_in( } RoleSpec::Decorative { magnitude } => ctx.decorative_contract(magnitude), RoleSpec::PairFill { tint } => { - // Сторона пары — идентичность СЕМЬИ: решается по каноническому - // светлому якорю и не флипается между темами/IC (тёмные якоря - // labui осветлены и перелезали бы кроссовер). Пер-режимный якорь - // затем двигается ПОД эту сторону; солид — лестничной сантехникой - // (α = 1; композит на фоне резолва замеряется честно). - let side = - crate::pair::pair_side(tint.for_vc(&crate::spaces::vc::ViewingConditions::srgb())); - let fill = crate::pair::pair_fill(tint.for_vc(vc), side); - return resolve_rgba_direct(fill, 1.0, bg, vc); + return lower_pair_fill_frontend(bg, tint, vc); } RoleSpec::PairLabel { tint, @@ -2179,7 +2158,7 @@ fn resolve_spec_in( surface_alpha_light, surface_alpha_dark, } => { - return resolve_pair_label( + return lower_pair_label_frontend( bg, tint, fraction, @@ -2368,6 +2347,146 @@ fn resolve_spec_in( solve_with_chroma(bg, contract, chroma, vc, interval).map(Resolved::color) } +/// Validated page background as the exact root Surface of the point graph. +fn pair_root_surface(bg: &BgInput) -> Result { + crate::alpha::encoded_to_srgb8(bg.encoded_display(), "pair page background") + .map(Srgb8::new) + .map_err(|error| { + SolveFailure::InternalInvariant(format!( + "validated Pair background left encoded-sRGB8 domain: {error}" + )) + }) +} + +/// Pair fill opacity belongs to the closed FillPrimary position. +fn pair_fill_opacity( + vc: &ViewingConditions, +) -> Result { + let alpha = crate::ladder::LadderPosition::FillPrimary.alpha_for_vc(vc); + crate::composition::AdmittedOpacityV1::new(alpha).map_err(|error| { + SolveFailure::InternalInvariant(format!( + "code-owned Pair fill opacity was rejected: {error:?}" + )) + }) +} + +fn lower_pair_fill_occurrence( + bg: &BgInput, + tint: LadderTint, + vc: &ViewingConditions, +) -> Result< + ( + crate::pair::LoweredPairFillV1, + crate::composition::AdmittedOpacityV1, + ), + SolveFailure, +> { + let source = tint.srgb8_for_vc(vc); + let opacity = pair_fill_opacity(vc)?; + let backdrop = pair_root_surface(bg)?; + Ok((crate::pair::lower_fill(source, opacity, backdrop), opacity)) +} + +/// Frozen PairFill tag lowers to the same fill Paint/Occurrence later +/// consumed by PairLabel; there is no family-side heuristic or solid proxy. +fn lower_pair_fill_frontend( + bg: &BgInput, + tint: LadderTint, + vc: &ViewingConditions, +) -> PendingResolution { + let (fill, opacity) = lower_pair_fill_occurrence(bg, tint, vc)?; + finish_rgba_from_certificate( + fill.paint().source(), + opacity.value(), + fill.occurrence().certificate(), + vc, + false, + false, + ) +} + +fn pair_requirement(floor: Floor, label: Srgb8) -> crate::pair::PairLabelRequirementV1 { + match floor { + Floor::AaText => crate::pair::PairLabelRequirementV1::Wcag22( + crate::wcag22::Wcag22CriterionV1::Sc143TextDefault, + ), + Floor::AaUi => crate::pair::PairLabelRequirementV1::Wcag22( + crate::wcag22::Wcag22CriterionV1::Sc1411UiComponentOrState, + ), + Floor::None => crate::pair::PairLabelRequirementV1::Exact(label), + } +} + +/// Frozen PairLabel tag proposes a label on the emitted fill Surface, +/// then admits it only through the joint report and mandatory fresh recheck. +#[allow(clippy::too_many_arguments)] +fn lower_pair_label_frontend( + bg: &BgInput, + tint: LadderTint, + fraction: f64, + floor: Floor, + surface_alpha_light: f64, + surface_alpha_dark: f64, + vc: &ViewingConditions, +) -> PendingResolution { + let canonical = crate::ladder::LadderPosition::FillPrimary.alpha_pair(); + if surface_alpha_light.to_bits() != canonical.0.to_bits() + || surface_alpha_dark.to_bits() != canonical.1.to_bits() + { + return Err(SolveFailure::InvalidInput( + "PairLabel surface alpha is code-owned by FillPrimary".into(), + )); + } + let anchor = TextAnchor::new(fraction, floor)?; + let source = tint.srgb8_for_vc(vc); + let backdrop = pair_root_surface(bg)?; + let (fill, opacity) = lower_pair_fill_occurrence(bg, tint, vc)?; + let surface = fill.visible(); + let surface_bg = BgInput::solid(&surface.to_hex()).map_err(|error| { + SolveFailure::InternalInvariant(format!( + "generated Pair fill Surface was rejected: {error}" + )) + })?; + let surface_ctx = ResolveContext::new(&surface_bg, vc); + let resolved = resolve_hued_anchor_from_srgb8(&surface_bg, anchor, source, vc, &surface_ctx)?; + let label = match &resolved { + Resolved::Color { solved, .. } => crate::srgb8::hex_bytes(solved.hex()) + .map(Srgb8::new) + .map_err(|error| { + SolveFailure::InternalInvariant(format!( + "Pair label solver emitted invalid sRGB8: {error}" + )) + })?, + _ => { + return Err(SolveFailure::InternalInvariant( + "Pair label proposal did not produce a Color".into(), + )); + } + }; + let verified = crate::pair::verify_label( + source, + opacity, + label, + backdrop, + pair_requirement(floor, label), + ) + .map_err(|error| { + SolveFailure::InternalInvariant(format!( + "generated Pair candidate failed joint admission: {error:?}" + )) + })?; + if verified.fill_occurrence() != fill.occurrence() + || verified.fill_paint() != fill.paint() + || verified.label_paint().source() != label + || verified.label_occurrence().visible() != label.bytes() + { + return Err(SolveFailure::InternalInvariant( + "Pair joint evidence drifted from the proposed physical chain".into(), + )); + } + Ok(resolved) +} + /// Лестница: rgba(`tint`, `alpha`) эмитится напрямую; его композит на фоне /// резолва замеряется для контраста. `tint` — кодированный (byte/255) sRGB. /// @@ -2606,142 +2725,6 @@ fn resolve_solid_with_ui_floor( } } -/// Public PairLabel opacity rejected by the graph's SSOT validator. -fn pair_label_opacity_input_error(error: &str) -> PendingResolution { - Err(SolveFailure::InvalidInput(format!( - "тинт-поверхность бейджа вне encoded-sRGB8 reference-домена: {error}" - ))) -} - -/// Резолв лейбла ТИНТ-бейджа — жёсткий контраст `label ↔ tinted-surface` -/// ([`crate::pair`], лейбл-сторона; родственен [`resolve_solid_with_ui_floor`], -/// но пол энфорсится против ВЫВОДИМОЙ подложки, а не против фона страницы). -/// -/// Sealed [`PointOpacityOverSurfaceV1`] материализует tint Paint, применяет его -/// к локальному фону в единственном exact source-over occurrence и проецирует -/// видимый результат в derived Surface. -/// Поверхность НЕ является эмитированным [`RoleSpec::PairFill`] — у того своя, -/// отдельно сдвинутая солид-эмиссия; никакого ребра `PairFill → PairLabel` нет. -/// -/// Затем [`resolve_hued_anchor_from_srgb8`] решает label НА ЭТОЙ ПОВЕРХНОСТИ, -/// вне point-графа. Этот контракт не создаёт label Paint/occurrence или -/// constraint evidence. Собственный [`ResolveContext`] поверхности задаёт -/// полярность/макс-контраст, поэтому -/// WCAG-пол лейбла -/// гарантирован против той подложки, на которой foreground реально стоит -/// (обычные `label-*` роли решаются против страницы, и на тинт-подложке их -/// контраст проседает — класс, который закрывает эта роль). Недостижимость пола -/// на кривой семьи клампит тон (`floor_override` → `compressed`), как у любой -/// контраст-роли; флаг не позволяет выдать нестрогий исход за точное выполнение. -/// Занимаемые графом typed handles структурны; клиентские -/// имена в граф не передаются. -#[allow(clippy::too_many_arguments)] -pub(crate) fn resolve_pair_label( - bg: &BgInput, - tint: crate::ladder::LadderTint, - fraction: f64, - floor: Floor, - surface_alpha_light: f64, - surface_alpha_dark: f64, - vc: &ViewingConditions, -) -> PendingResolution { - let alpha = if vc.is_dark_theme() { - surface_alpha_dark - } else { - surface_alpha_light - }; - // Тинт квантуется ДО композита: подложка обязана считаться из отдаваемого - // значения в едином encoded-sRGB8 reference-домене. Источник и локальный - // фон проходят один quantization SSOT; порядок проверок — tint → bg → α. - let tint_q = quantise_encoded(tint.for_vc(vc)); - let source_rgb = match crate::alpha::encoded_to_srgb8(tint_q, "tint") { - Ok(bytes) => bytes, - Err(error) => { - return Err(SolveFailure::InternalInvariant(format!( - "validated PairLabel tint left encoded sRGB domain: {error}" - ))); - } - }; - let context_rgb = match crate::alpha::encoded_to_srgb8(bg.encoded_display(), "bg") { - Ok(bytes) => bytes, - Err(error) => { - return Err(SolveFailure::InternalInvariant(format!( - "validated background left encoded sRGB domain: {error}" - ))); - } - }; - let surface_occurrence = - match PointOpacityOverSurfaceV1::evaluate(source_rgb, alpha, context_rgb) { - Ok(occurrence) => occurrence, - Err(error) => { - return pair_label_opacity_input_error(error.message()); - } - }; - // Финальные байты реально собранной surface становятся контекстом solve. - let surface_rgb = surface_occurrence.visible(); - let surface_hex = crate::alpha::hex_from_srgb8(surface_rgb); - let Ok(surface_bg) = BgInput::solid(&surface_hex) else { - // Композит 8-битных каналов всегда в кубе — недостижимо, но честнее - // отказ, чем правдоподобный мусор (RoleSpec публичен). - return Err(SolveFailure::InternalInvariant( - "тинт-поверхность бейджа вне кодированного домена sRGB".into(), - )); - }; - // Свежий контекст ПОВЕРХНОСТИ: полярность/интервал/макс-контраст берутся от - // тинт-подложки, не от фона страницы — потому пол энфорсится против неё. - let surface_ctx = ResolveContext::new(&surface_bg, vc); - let anchor = TextAnchor::new(fraction, floor)?; - // Hue anchor остаётся отдельным входом label-solver. Fill occurrence - // вычисляет только surface и не притворяется зависимостью финального label. - resolve_hued_anchor_from_srgb8( - &surface_bg, - anchor, - Srgb8::new(source_rgb), - vc, - &surface_ctx, - ) -} - -/// Ручной differential oracle: композит tint-surface идёт независимым от графа -/// `composite_hex_from_encoded`-маршрутом, а label — через -/// [`resolve_hued_anchor`] по исходному tint. Он изолирует корректность -/// физической композиции, но не является независимым эталоном solver-а. -#[cfg(test)] -#[allow(clippy::too_many_arguments)] -pub(crate) fn resolve_pair_label_manual_composite_oracle( - bg: &BgInput, - tint: crate::ladder::LadderTint, - fraction: f64, - floor: Floor, - surface_alpha_light: f64, - surface_alpha_dark: f64, - vc: &ViewingConditions, -) -> PendingResolution { - let alpha = if vc.is_dark_theme() { - surface_alpha_dark - } else { - surface_alpha_light - }; - let tint_q = quantise_encoded(tint.for_vc(vc)); - let surface_hex = - match crate::alpha::composite_hex_from_encoded(tint_q, alpha, bg.encoded_display()) { - Ok(hex) => hex, - Err(error) => { - return Err(SolveFailure::InvalidInput(format!( - "тинт-поверхность бейджа вне encoded-sRGB8 reference-домена: {error}" - ))); - } - }; - let Ok(surface_bg) = BgInput::solid(&surface_hex) else { - return Err(SolveFailure::InternalInvariant( - "тинт-поверхность бейджа вне кодированного домена sRGB".into(), - )); - }; - let surface_ctx = ResolveContext::new(&surface_bg, vc); - let anchor = TextAnchor::new(fraction, floor)?; - resolve_hued_anchor(&surface_bg, anchor, tint, vc, &surface_ctx) -} - /// Альфа-аналог: солид-цель `solid` (кодированный, по теме) на фоне резолва /// инвертируется в `(tint, фактическая α)`. Перед инверсией цель квантуется до /// эмитируемой sRGB8-сетки; production-композитор обязан побайтно вернуть её. @@ -3299,7 +3282,17 @@ impl RoleSpec { )); } alpha("pair-label light surface alpha", surface_alpha_light)?; - alpha("pair-label dark surface alpha", surface_alpha_dark) + alpha("pair-label dark surface alpha", surface_alpha_dark)?; + let canonical = crate::ladder::LadderPosition::FillPrimary.alpha_pair(); + if surface_alpha_light.to_bits() != canonical.0.to_bits() + || surface_alpha_dark.to_bits() != canonical.1.to_bits() + { + return Err(format!( + "pair-label surface alpha is code-owned by FillPrimary: expected ({}, {}), got ({surface_alpha_light}, {surface_alpha_dark})", + canonical.0, canonical.1 + )); + } + Ok(()) } RoleSpec::Ladder { alpha_light, @@ -3365,8 +3358,8 @@ impl RoleSpec { pub fn legal_floor(&self) -> Option { match self { RoleSpec::Anchor(anchor) => anchor.conformance().min_ratio(), - // Лейбл тинт-бейджа несёт свой пол против тинт-поверхности — семантика - // контракта, как у текст/UI-якоря (иерархия-пасс его не трогает: он + // Лейбл пары несёт свой пол против emitted PairFill Surface — семантика + // контракта, как у text/UI-якоря (иерархия-пасс его не трогает: он // singleton, не ступень лестницы). RoleSpec::PairLabel { floor, .. } => floor.min_ratio(), _ => None, @@ -4519,11 +4512,12 @@ mod tests { )); } + let pair_alpha = crate::ladder::LadderPosition::FillPrimary.alpha_pair(); for spec in [ RoleSpec::DecorativeDj { magnitude_dj: DjMagnitude::new(1.0, 2.0), }, - pair(0.5, 0.25, 1.0), + pair(0.5, pair_alpha.0, pair_alpha.1), ladder(0.25, 1.0), ladder_with_floor(1.0, 1.0, Some(Floor::AaUi)), RoleSpec::AlphaAnalog { diff --git a/crates/labcolors-core/src/solve.rs b/crates/labcolors-core/src/solve.rs index 60e7c18b..4c050706 100644 --- a/crates/labcolors-core/src/solve.rs +++ b/crates/labcolors-core/src/solve.rs @@ -225,7 +225,7 @@ impl BgInput { /// число) and the CAM16-UCS lightness `J'_bg` (needed only by the dJ' /// roles). Бывший третий скаляр — H-K-люминанс `Y_hk` — не входит в /// candidate-score путь и живёт только на яркостной оси - /// ([`bg_luma`]: сторона пары, свечение, семейные цветовые операции). Verified by an + /// ([`bg_luma`]: свечение и явно brightness-scoped семейные операции). Verified by an /// exhaustive trace of every `bg` read on the `resolve_set_live` path. /// The representation stays an interval so a future field background can /// supply bounded endpoints without changing the solver contract. @@ -1340,18 +1340,6 @@ fn meets_floor(solved: &Solved, y_bg: f64, target: f64, _vc: &ViewingConditions) meets_floor_lc(lc, target) } -/// H-K-corrected luminance (`Y_hk`) of a linear-sRGB stimulus — воспринимаемая -/// ЯРКОСТЬ поверхности (Гельмгольц–Кольрауш, серый эквивалент `J_HK`). -/// -/// После ADR-0003 Ys candidate-score путь это НЕ читает: контракт -/// контраста, полы и recheck меряются в `Ys`. Потребители — яркостная ось: -/// выбор стороны внутренней pair-эвристики (`pair_side` — H-K-сдвиг кроссовера на -/// насыщенных фонах) и яркостные подсистемы. -pub(crate) fn bg_luma(rgb: [f64; 3], vc: &ViewingConditions) -> f64 { - let j_hk = lpc::j_hk_from_xyz(srgb_to_xyz(rgb), vc).max(0.0); - lpc::y_hk(j_hk, vc) -} - /// Test-only examined-candidate log (#297 local-search truth): both local /// searches (`solve`'s quantisation walk and `solve_dj`'s separation walk) /// record every on-grid candidate they actually materialize, so tests can diff --git a/crates/labcolors-core/tests/data/labui_emission_golden.txt b/crates/labcolors-core/tests/data/labui_emission_golden.txt index 77cfafd1..371e779b 100644 --- a/crates/labcolors-core/tests/data/labui_emission_golden.txt +++ b/crates/labcolors-core/tests/data/labui_emission_golden.txt @@ -91,13 +91,13 @@ srgb|#FFFFFF|label-danger=rgba(#FF3B30,1) srgb|#FFFFFF|border-accent=rgba(#007AFF,0.2) srgb|#FFFFFF|border-danger=rgba(#FF3B30,0.2) srgb|#FFFFFF|border-focus=rgba(#007AFF,1) -srgb|#FFFFFF|badge-fill-brand=rgba(#006FF3,1) -srgb|#FFFFFF|badge-fill-danger=rgba(#E71A16,1) -srgb|#FFFFFF|badge-fill-warning=rgba(#FFA100,1) -srgb|#FFFFFF|badge-fill-success=rgba(#34C759,1) -srgb|#FFFFFF|badge-fill-info=rgba(#2770E6,1) -srgb|#FFFFFF|badge-fill-static-dark=rgba(#101012,1) -srgb|#FFFFFF|badge-fill-static-light=rgba(#FFFFFF,1) +srgb|#FFFFFF|badge-fill-brand=rgba(#007AFF,0.122) +srgb|#FFFFFF|badge-fill-danger=rgba(#FF3B30,0.122) +srgb|#FFFFFF|badge-fill-warning=rgba(#FFA100,0.122) +srgb|#FFFFFF|badge-fill-success=rgba(#34C759,0.122) +srgb|#FFFFFF|badge-fill-info=rgba(#3E87FF,0.122) +srgb|#FFFFFF|badge-fill-static-dark=rgba(#101012,0.122) +srgb|#FFFFFF|badge-fill-static-light=rgba(#FFFFFF,0.122) srgb|#FFFFFF|fill-neutral-tinted->fill-primary srgb|#FFFFFF|border-neutral->border-base srgb|#FFFFFF|fx-skeleton-base->fill-quaternary @@ -198,13 +198,13 @@ srgb|#F2F2F7|label-danger=rgba(#FF3B30,1) srgb|#F2F2F7|border-accent=rgba(#007AFF,0.2) srgb|#F2F2F7|border-danger=rgba(#FF3B30,0.2) srgb|#F2F2F7|border-focus=rgba(#007AFF,1) -srgb|#F2F2F7|badge-fill-brand=rgba(#006FF3,1) -srgb|#F2F2F7|badge-fill-danger=rgba(#E71A16,1) -srgb|#F2F2F7|badge-fill-warning=rgba(#FFA100,1) -srgb|#F2F2F7|badge-fill-success=rgba(#34C759,1) -srgb|#F2F2F7|badge-fill-info=rgba(#2770E6,1) -srgb|#F2F2F7|badge-fill-static-dark=rgba(#101012,1) -srgb|#F2F2F7|badge-fill-static-light=rgba(#FFFFFF,1) +srgb|#F2F2F7|badge-fill-brand=rgba(#007AFF,0.122) +srgb|#F2F2F7|badge-fill-danger=rgba(#FF3B30,0.122) +srgb|#F2F2F7|badge-fill-warning=rgba(#FFA100,0.122) +srgb|#F2F2F7|badge-fill-success=rgba(#34C759,0.122) +srgb|#F2F2F7|badge-fill-info=rgba(#3E87FF,0.122) +srgb|#F2F2F7|badge-fill-static-dark=rgba(#101012,0.122) +srgb|#F2F2F7|badge-fill-static-light=rgba(#FFFFFF,0.122) srgb|#F2F2F7|fill-neutral-tinted->fill-primary srgb|#F2F2F7|border-neutral->border-base srgb|#F2F2F7|fx-skeleton-base->fill-quaternary @@ -305,13 +305,13 @@ srgb|#7F7F7F|label-danger=rgba(#FF3B30,1) srgb|#7F7F7F|border-accent=rgba(#007AFF,0.2) srgb|#7F7F7F|border-danger=rgba(#FF3B30,0.2) srgb|#7F7F7F|border-focus=rgba(#007AFF,1) -srgb|#7F7F7F|badge-fill-brand=rgba(#006FF3,1) -srgb|#7F7F7F|badge-fill-danger=rgba(#E71A16,1) -srgb|#7F7F7F|badge-fill-warning=rgba(#FFA100,1) -srgb|#7F7F7F|badge-fill-success=rgba(#34C759,1) -srgb|#7F7F7F|badge-fill-info=rgba(#2770E6,1) -srgb|#7F7F7F|badge-fill-static-dark=rgba(#101012,1) -srgb|#7F7F7F|badge-fill-static-light=rgba(#FFFFFF,1) +srgb|#7F7F7F|badge-fill-brand=rgba(#007AFF,0.122) +srgb|#7F7F7F|badge-fill-danger=rgba(#FF3B30,0.122) +srgb|#7F7F7F|badge-fill-warning=rgba(#FFA100,0.122) +srgb|#7F7F7F|badge-fill-success=rgba(#34C759,0.122) +srgb|#7F7F7F|badge-fill-info=rgba(#3E87FF,0.122) +srgb|#7F7F7F|badge-fill-static-dark=rgba(#101012,0.122) +srgb|#7F7F7F|badge-fill-static-light=rgba(#FFFFFF,0.122) srgb|#7F7F7F|fill-neutral-tinted->fill-primary srgb|#7F7F7F|border-neutral->border-base srgb|#7F7F7F|fx-skeleton-base->fill-quaternary @@ -412,13 +412,13 @@ srgb|#1C1C1E|label-danger=rgba(#FF3B30,1) srgb|#1C1C1E|border-accent=rgba(#007AFF,0.2) srgb|#1C1C1E|border-danger=rgba(#FF3B30,0.2) srgb|#1C1C1E|border-focus=rgba(#007AFF,1) -srgb|#1C1C1E|badge-fill-brand=rgba(#006FF3,1) -srgb|#1C1C1E|badge-fill-danger=rgba(#E71A16,1) -srgb|#1C1C1E|badge-fill-warning=rgba(#FFA100,1) -srgb|#1C1C1E|badge-fill-success=rgba(#34C759,1) -srgb|#1C1C1E|badge-fill-info=rgba(#2770E6,1) -srgb|#1C1C1E|badge-fill-static-dark=rgba(#101012,1) -srgb|#1C1C1E|badge-fill-static-light=rgba(#FFFFFF,1) +srgb|#1C1C1E|badge-fill-brand=rgba(#007AFF,0.122) +srgb|#1C1C1E|badge-fill-danger=rgba(#FF3B30,0.122) +srgb|#1C1C1E|badge-fill-warning=rgba(#FFA100,0.122) +srgb|#1C1C1E|badge-fill-success=rgba(#34C759,0.122) +srgb|#1C1C1E|badge-fill-info=rgba(#3E87FF,0.122) +srgb|#1C1C1E|badge-fill-static-dark=rgba(#101012,0.122) +srgb|#1C1C1E|badge-fill-static-light=rgba(#FFFFFF,0.122) srgb|#1C1C1E|fill-neutral-tinted->fill-primary srgb|#1C1C1E|border-neutral->border-base srgb|#1C1C1E|fx-skeleton-base->fill-quaternary @@ -519,13 +519,13 @@ srgb|#101012|label-danger=rgba(#FF3B30,1) srgb|#101012|border-accent=rgba(#007AFF,0.2) srgb|#101012|border-danger=rgba(#FF3B30,0.2) srgb|#101012|border-focus=rgba(#007AFF,1) -srgb|#101012|badge-fill-brand=rgba(#006FF3,1) -srgb|#101012|badge-fill-danger=rgba(#E71A16,1) -srgb|#101012|badge-fill-warning=rgba(#FFA100,1) -srgb|#101012|badge-fill-success=rgba(#34C759,1) -srgb|#101012|badge-fill-info=rgba(#2770E6,1) -srgb|#101012|badge-fill-static-dark=rgba(#101012,1) -srgb|#101012|badge-fill-static-light=rgba(#FFFFFF,1) +srgb|#101012|badge-fill-brand=rgba(#007AFF,0.122) +srgb|#101012|badge-fill-danger=rgba(#FF3B30,0.122) +srgb|#101012|badge-fill-warning=rgba(#FFA100,0.122) +srgb|#101012|badge-fill-success=rgba(#34C759,0.122) +srgb|#101012|badge-fill-info=rgba(#3E87FF,0.122) +srgb|#101012|badge-fill-static-dark=rgba(#101012,0.122) +srgb|#101012|badge-fill-static-light=rgba(#FFFFFF,0.122) srgb|#101012|fill-neutral-tinted->fill-primary srgb|#101012|border-neutral->border-base srgb|#101012|fx-skeleton-base->fill-quaternary @@ -626,13 +626,13 @@ srgb|#3478F6|label-danger=rgba(#FF3B30,1) srgb|#3478F6|border-accent=rgba(#007AFF,0.2) srgb|#3478F6|border-danger=rgba(#FF3B30,0.2) srgb|#3478F6|border-focus=rgba(#007AFF,1) -srgb|#3478F6|badge-fill-brand=rgba(#006FF3,1) -srgb|#3478F6|badge-fill-danger=rgba(#E71A16,1) -srgb|#3478F6|badge-fill-warning=rgba(#FFA100,1) -srgb|#3478F6|badge-fill-success=rgba(#34C759,1) -srgb|#3478F6|badge-fill-info=rgba(#2770E6,1) -srgb|#3478F6|badge-fill-static-dark=rgba(#101012,1) -srgb|#3478F6|badge-fill-static-light=rgba(#FFFFFF,1) +srgb|#3478F6|badge-fill-brand=rgba(#007AFF,0.122) +srgb|#3478F6|badge-fill-danger=rgba(#FF3B30,0.122) +srgb|#3478F6|badge-fill-warning=rgba(#FFA100,0.122) +srgb|#3478F6|badge-fill-success=rgba(#34C759,0.122) +srgb|#3478F6|badge-fill-info=rgba(#3E87FF,0.122) +srgb|#3478F6|badge-fill-static-dark=rgba(#101012,0.122) +srgb|#3478F6|badge-fill-static-light=rgba(#FFFFFF,0.122) srgb|#3478F6|fill-neutral-tinted->fill-primary srgb|#3478F6|border-neutral->border-base srgb|#3478F6|fx-skeleton-base->fill-quaternary @@ -733,13 +733,13 @@ dim|#FFFFFF|label-danger=rgba(#FF3A3A,1) dim|#FFFFFF|border-accent=rgba(#4A8FFF,0.2) dim|#FFFFFF|border-danger=rgba(#FF3A3A,0.2) dim|#FFFFFF|border-focus=rgba(#4A8FFF,1) -dim|#FFFFFF|badge-fill-brand=rgba(#2D71DF,1) -dim|#FFFFFF|badge-fill-danger=rgba(#E71824,1) -dim|#FFFFFF|badge-fill-warning=rgba(#FF9008,1) -dim|#FFFFFF|badge-fill-success=rgba(#30D158,1) -dim|#FFFFFF|badge-fill-info=rgba(#3472D8,1) -dim|#FFFFFF|badge-fill-static-dark=rgba(#101012,1) -dim|#FFFFFF|badge-fill-static-light=rgba(#FFFFFF,1) +dim|#FFFFFF|badge-fill-brand=rgba(#4A8FFF,0.122) +dim|#FFFFFF|badge-fill-danger=rgba(#FF3A3A,0.122) +dim|#FFFFFF|badge-fill-warning=rgba(#FF9008,0.122) +dim|#FFFFFF|badge-fill-success=rgba(#30D158,0.122) +dim|#FFFFFF|badge-fill-info=rgba(#5696FF,0.122) +dim|#FFFFFF|badge-fill-static-dark=rgba(#101012,0.122) +dim|#FFFFFF|badge-fill-static-light=rgba(#FFFFFF,0.122) dim|#FFFFFF|fill-neutral-tinted->fill-primary dim|#FFFFFF|border-neutral->border-base dim|#FFFFFF|fx-skeleton-base->fill-quaternary @@ -840,13 +840,13 @@ dim|#F2F2F7|label-danger=rgba(#FF3A3A,1) dim|#F2F2F7|border-accent=rgba(#4A8FFF,0.2) dim|#F2F2F7|border-danger=rgba(#FF3A3A,0.2) dim|#F2F2F7|border-focus=rgba(#4A8FFF,1) -dim|#F2F2F7|badge-fill-brand=rgba(#2D71DF,1) -dim|#F2F2F7|badge-fill-danger=rgba(#E71824,1) -dim|#F2F2F7|badge-fill-warning=rgba(#FF9008,1) -dim|#F2F2F7|badge-fill-success=rgba(#30D158,1) -dim|#F2F2F7|badge-fill-info=rgba(#3472D8,1) -dim|#F2F2F7|badge-fill-static-dark=rgba(#101012,1) -dim|#F2F2F7|badge-fill-static-light=rgba(#FFFFFF,1) +dim|#F2F2F7|badge-fill-brand=rgba(#4A8FFF,0.122) +dim|#F2F2F7|badge-fill-danger=rgba(#FF3A3A,0.122) +dim|#F2F2F7|badge-fill-warning=rgba(#FF9008,0.122) +dim|#F2F2F7|badge-fill-success=rgba(#30D158,0.122) +dim|#F2F2F7|badge-fill-info=rgba(#5696FF,0.122) +dim|#F2F2F7|badge-fill-static-dark=rgba(#101012,0.122) +dim|#F2F2F7|badge-fill-static-light=rgba(#FFFFFF,0.122) dim|#F2F2F7|fill-neutral-tinted->fill-primary dim|#F2F2F7|border-neutral->border-base dim|#F2F2F7|fx-skeleton-base->fill-quaternary @@ -947,13 +947,13 @@ dim|#7F7F7F|label-danger=rgba(#FF3A3A,1) dim|#7F7F7F|border-accent=rgba(#4A8FFF,0.2) dim|#7F7F7F|border-danger=rgba(#FF3A3A,0.2) dim|#7F7F7F|border-focus=rgba(#4A8FFF,1) -dim|#7F7F7F|badge-fill-brand=rgba(#2D71DF,1) -dim|#7F7F7F|badge-fill-danger=rgba(#E71824,1) -dim|#7F7F7F|badge-fill-warning=rgba(#FF9008,1) -dim|#7F7F7F|badge-fill-success=rgba(#30D158,1) -dim|#7F7F7F|badge-fill-info=rgba(#3472D8,1) -dim|#7F7F7F|badge-fill-static-dark=rgba(#101012,1) -dim|#7F7F7F|badge-fill-static-light=rgba(#FFFFFF,1) +dim|#7F7F7F|badge-fill-brand=rgba(#4A8FFF,0.122) +dim|#7F7F7F|badge-fill-danger=rgba(#FF3A3A,0.122) +dim|#7F7F7F|badge-fill-warning=rgba(#FF9008,0.122) +dim|#7F7F7F|badge-fill-success=rgba(#30D158,0.122) +dim|#7F7F7F|badge-fill-info=rgba(#5696FF,0.122) +dim|#7F7F7F|badge-fill-static-dark=rgba(#101012,0.122) +dim|#7F7F7F|badge-fill-static-light=rgba(#FFFFFF,0.122) dim|#7F7F7F|fill-neutral-tinted->fill-primary dim|#7F7F7F|border-neutral->border-base dim|#7F7F7F|fx-skeleton-base->fill-quaternary @@ -1054,13 +1054,13 @@ dim|#1C1C1E|label-danger=rgba(#FF3A3A,1) dim|#1C1C1E|border-accent=rgba(#4A8FFF,0.2) dim|#1C1C1E|border-danger=rgba(#FF3A3A,0.2) dim|#1C1C1E|border-focus=rgba(#4A8FFF,1) -dim|#1C1C1E|badge-fill-brand=rgba(#2D71DF,1) -dim|#1C1C1E|badge-fill-danger=rgba(#E71824,1) -dim|#1C1C1E|badge-fill-warning=rgba(#FF9008,1) -dim|#1C1C1E|badge-fill-success=rgba(#30D158,1) -dim|#1C1C1E|badge-fill-info=rgba(#3472D8,1) -dim|#1C1C1E|badge-fill-static-dark=rgba(#101012,1) -dim|#1C1C1E|badge-fill-static-light=rgba(#FFFFFF,1) +dim|#1C1C1E|badge-fill-brand=rgba(#4A8FFF,0.122) +dim|#1C1C1E|badge-fill-danger=rgba(#FF3A3A,0.122) +dim|#1C1C1E|badge-fill-warning=rgba(#FF9008,0.122) +dim|#1C1C1E|badge-fill-success=rgba(#30D158,0.122) +dim|#1C1C1E|badge-fill-info=rgba(#5696FF,0.122) +dim|#1C1C1E|badge-fill-static-dark=rgba(#101012,0.122) +dim|#1C1C1E|badge-fill-static-light=rgba(#FFFFFF,0.122) dim|#1C1C1E|fill-neutral-tinted->fill-primary dim|#1C1C1E|border-neutral->border-base dim|#1C1C1E|fx-skeleton-base->fill-quaternary @@ -1161,13 +1161,13 @@ dim|#101012|label-danger=rgba(#FF3A3A,1) dim|#101012|border-accent=rgba(#4A8FFF,0.2) dim|#101012|border-danger=rgba(#FF3A3A,0.2) dim|#101012|border-focus=rgba(#4A8FFF,1) -dim|#101012|badge-fill-brand=rgba(#2D71DF,1) -dim|#101012|badge-fill-danger=rgba(#E71824,1) -dim|#101012|badge-fill-warning=rgba(#FF9008,1) -dim|#101012|badge-fill-success=rgba(#30D158,1) -dim|#101012|badge-fill-info=rgba(#3472D8,1) -dim|#101012|badge-fill-static-dark=rgba(#101012,1) -dim|#101012|badge-fill-static-light=rgba(#FFFFFF,1) +dim|#101012|badge-fill-brand=rgba(#4A8FFF,0.122) +dim|#101012|badge-fill-danger=rgba(#FF3A3A,0.122) +dim|#101012|badge-fill-warning=rgba(#FF9008,0.122) +dim|#101012|badge-fill-success=rgba(#30D158,0.122) +dim|#101012|badge-fill-info=rgba(#5696FF,0.122) +dim|#101012|badge-fill-static-dark=rgba(#101012,0.122) +dim|#101012|badge-fill-static-light=rgba(#FFFFFF,0.122) dim|#101012|fill-neutral-tinted->fill-primary dim|#101012|border-neutral->border-base dim|#101012|fx-skeleton-base->fill-quaternary @@ -1268,13 +1268,13 @@ dim|#3478F6|label-danger=rgba(#FF3A3A,1) dim|#3478F6|border-accent=rgba(#4A8FFF,0.2) dim|#3478F6|border-danger=rgba(#FF3A3A,0.2) dim|#3478F6|border-focus=rgba(#4A8FFF,1) -dim|#3478F6|badge-fill-brand=rgba(#2D71DF,1) -dim|#3478F6|badge-fill-danger=rgba(#E71824,1) -dim|#3478F6|badge-fill-warning=rgba(#FF9008,1) -dim|#3478F6|badge-fill-success=rgba(#30D158,1) -dim|#3478F6|badge-fill-info=rgba(#3472D8,1) -dim|#3478F6|badge-fill-static-dark=rgba(#101012,1) -dim|#3478F6|badge-fill-static-light=rgba(#FFFFFF,1) +dim|#3478F6|badge-fill-brand=rgba(#4A8FFF,0.122) +dim|#3478F6|badge-fill-danger=rgba(#FF3A3A,0.122) +dim|#3478F6|badge-fill-warning=rgba(#FF9008,0.122) +dim|#3478F6|badge-fill-success=rgba(#30D158,0.122) +dim|#3478F6|badge-fill-info=rgba(#5696FF,0.122) +dim|#3478F6|badge-fill-static-dark=rgba(#101012,0.122) +dim|#3478F6|badge-fill-static-light=rgba(#FFFFFF,0.122) dim|#3478F6|fill-neutral-tinted->fill-primary dim|#3478F6|border-neutral->border-base dim|#3478F6|fx-skeleton-base->fill-quaternary diff --git a/docs/decisions/0003-hk-scope.md b/docs/decisions/0003-hk-scope.md index fd4a589f..f6e45f76 100644 --- a/docs/decisions/0003-hk-scope.md +++ b/docs/decisions/0003-hk-scope.md @@ -16,9 +16,8 @@ LPC/readability verdict; - H-K сохраняется во внутренней appearance-математике и её характеризации; - brightness-matching семейных цветов сохраняет H-K; -- внутренний `pair_side` использует `Ys`-кривую и H-K appearance-координату в - разных членах своей переходной эвристики. Ни одна из них не выдаётся за - универсальную модель разборчивости. +- Pair после P1 не использует H-K: его fill/label occurrences и WCAG constraints + исполняются общей point graph algebra. H-K не влияет на скрытый выбор стороны. ## Почему diff --git a/docs/empirical-inventory.md b/docs/empirical-inventory.md index 75f934a2..622f76e8 100644 --- a/docs/empirical-inventory.md +++ b/docs/empirical-inventory.md @@ -43,6 +43,5 @@ Terminal classes описывают, почему число может нахо | 49 | `HUE_PURITY_EXPONENT` | `0.6` | `neutral.rs` | SSOT-TRACKED | **(e) DESIGN-CHOICE** — показатель кривой чистоты `(mp/mp_ref)^0.6`. Магнитуда — свободная ручка. ПРОВЕНАНС ФОРМЫ (не значения): «не доверяй тону у нейтрали» мотивировано двумя сходящимися причинами — численной (atan2 ill-conditioned у серой оси, оттенок near-серых из шума) и перцептивной (эффект Abney: тон монохроматического стимула сдвигается при разбавлении белым — Abney 1909 Proc. R. Soc. Lond. A 83, 120–127, DOI 10.1098/rspa.1909.0085; сдвиг растёт с падением колориметрической чистоты — Kurtenbach-Sternheim-Spillmann 1984 JOSA A 1(4), 365–372, DOI 10.1364/JOSAA.1.000365; обе цитаты перепроверены живьём 2026-07-07: DOI резолвятся, реквизиты совпадают). ⚠️ Перцептивный Abney ОТДЕЛЕН от численного atan2-шума; 0.6/1.5 НЕ выведены из данных Abney (эта кривая эффект Abney не моделирует, issue #27) — поэтому честный терминал (e), не (b). Робастность: хроматические якоря инвариантны (purity = 1 при mp ≥ mp_ref); свип показателя [0.4, 0.9] — max\|Δpurity\| = 0.148 только у near-нейтралей (непрерывный дрейф, не флип). Терминал закрыт декларацией (2026-07-07). Локи: `hue_purity_curve_shape_is_pinned`, `exposure_hue_purity_curve`. | | 50 | `QUANT_BUDGET` | `1.0` | `solve.rs` | SSOT-TRACKED | **(c) INTERVAL-INSENSITIVE** (ре-аудит `science/reclassify-e-buckets`, 2026-07-07; ранее (e)) — Допуск приёмки Lc квант-солвера в единицах шага выходной сетки: цвет принят, когда измеренный Lc в пределах ±QUANT_BUDGET от цели. Замер: медианный Lc-шаг одного 8-бит кванта серых ≈0.44 (максимум ≈7.85 — обрыв мягкого клампа APCA, не шаг сетки); 1.0 Lc ≈ 2.3× типичного шага — контракт «±1 Lc» (`quant_budget_is_a_couple_of_grid_steps`). **Экспозиция:** 1.84% целей флипают приёмку при свипе ±50% (`exposure_quant_and_dj_budgets`) — дискретность 8-бит сетки поглощает почти весь свип. | | 51 | `DJ_BUDGET` | `0.6` | `solve.rs` | SSOT-TRACKED | **(c) INTERVAL-INSENSITIVE** (ре-аудит `science/reclassify-e-buckets`, 2026-07-07; ранее (e)) — Допуск приёмки dJ' декоративного солвера (J'-единицы CAM16-UCS), dJ'-аналог `QUANT_BUDGET`. Замер: медианный dJ'-шаг 8-бит кванта серых ≈0.39; 0.6 J' ≈ 1.5× типичного шага — чуть больше одного шага сетки (`dj_budget_tracks_grid_step`). **Экспозиция:** 1.55% (`exposure_quant_and_dj_budgets`). | -| 52 | `PAIR_CROSSOVER_Y` | `0.341955` | `pair.rs` | SSOT-TRACKED | **(a) DERIVED** — Y-порог кроссовера переходной pair-эвристики. **Вывод:** корень равенства модулей двух ветвей замороженной SAPC-shaped кривой для чёрного и белого foreground; бисекция на `[0.2, 0.6]` даёт **0.341955**. Это математическое свойство frozen Ys candidate-score функции, не LPC/readability optimum и не психофизический закон. Пиннится ре-деривацией `pair_crossover_equals_measured_core_polarity_flip`. Значение лежит внутри пустого интервала 10 Figma-якорей `(0.246, 0.423)`, поэтому смена прежнего 0.30 байт-идентична на этом наборе; экспозиция вне набора — 21.69% (`exposure_pair_crossover`). H-K в этот порог не входит (ADR-0003). Сосед `WHITE_WINS_Y = 0.179` — отдельная граница двойной законности WCAG. | | 53 | `QUANT_GUARD` | `0.2` | `semantic.rs` | SSOT-TRACKED | **(c) INTERVAL-INSENSITIVE** (ре-аудит `science/reclassify-e-buckets`, 2026-07-07; ранее (e)) — квант-guard над `MODEL_LC_FLOOR` (7.3): зазор `DECORATIVE_FLOOR_MIN − MODEL_LC_FLOOR`, держащий декоративный пол строго выше 7.3, чтобы решатель не садился на инфимум клипа и не возвращал нулевой контраст (issue #44). **Доказательство сильнее обычного (c):** `QUANT_GUARD` НЕ используется в продакшене НЕЗАВИСИМО — единственная величина, которую видит решатель, это байт-идентичный литерал `DECORATIVE_FLOOR_MIN` (7.5); guard существует только как провенанс-разложение этого литерала на DERIVED-член (7.3) и задекларированный запас (лок `decorative_floor_is_model_floor_plus_guard`, компайл-тайм `const _`). Замер (`no_pair_emits_contrast_below_model_floor`, скан квантованных пар): фактический минимальный ненулевой контраст = **7.3005** (сидит 0.0005 над модельным инфимумом 7.3, а не у номинальной цели 7.5) — скачок |Lc| одного 8-бит шага у клипа = **7.85** (жёсткий разрыв, guard не покрывает и не должен). Пока guard положителен и цель остаётся достижимой в пределах `QUANT_BUDGET` = 1.0, точная магнитуда 0.2 не меняет ни одного реального эмитируемого контраста; доминантный член 7.3 остаётся DERIVED. | | 54 | `MODEL_LC_FLOOR` | `7.3` | `lpc.rs` | SSOT-TRACKED | **(a) DERIVED** = (LO_CLIP − LO_BOW_OFFSET) × LC_SCALE = (0.1 − 0.027) × 100 = 7.3 — минимальный ненулевой контраст модели (за низкоконтрастным клипом `contrast_core` стартует ровно с него, внутри клипа — ноль; полярности симметричны, `LO_WOB_OFFSET == LO_BOW_OFFSET`). Алгебраическая идентичность из GROUNDED APCA `0.0.98G-4g` набора; хранится литералом 7.3 (чистое число для инвентаря), тождество с формулой пиннится `model_lc_floor_is_the_published_clip_minimum` (дрейф любого из трёх APCA-входов или правка литерала ломает лок). Опора `DECORATIVE_FLOOR_MIN` (строка 1), issue #44. |