diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 325fbf70..481d4a0a 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -13,6 +13,7 @@ # выживший мутант = молчаливая дыра в наборе (coverage=пол, mutation=правда). examine_globs = [ "crates/labcolors-core/src/alpha.rs", + "crates/labcolors-core/src/appearance.rs", "crates/labcolors-core/src/cleanliness.rs", "crates/labcolors-core/src/glow.rs", "crates/labcolors-core/src/material.rs", diff --git a/README.md b/README.md index 9e59c5f8..44127314 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ NamedRoleTable - **Клиентский словарь.** Имена ролей, тем и алиасов приходят из `ThemeConfig`. Core не выводит смысл из слов `primary`, `danger`, `hover` или имени компонента. - **Контекстный resolve.** Одна таблица решается заново для переданного локального фона и темы. -- **Специализированные зависимости.** Например, `PairLabel` решается против эмитированного композита своей `PairFill`, а не против фона страницы. +- **Вложенные зависимости через generic-компонент.** Например, `PairLabel` решается против объявленной derived-поверхности (точный source-over композит своего тинта над локальным фоном), которую собирает приватный appearance-граф; поверхность не является эмитированным `PairFill`, и роль не решается против фона страницы. - **Несколько видов результата.** Роль может вернуть solid, translucent, material, точечный glow, явное отсутствие значения или недостижимость. - **Версионированные численные свидетельства.** Exact encoded-sRGB8 source-over/screen операции несут проверяемый профиль и `bit-exact` @@ -139,7 +139,8 @@ Core владеет: | `DecorativeLc` | декоративную контрастную величину | нормативную доступность текста | | `Ladder` | клиентский preset тинта и alpha | общий закон уровней core | | `AlphaAnalog` | точечный композит заданной solid-цели | воспринимаемую глубину | -| `PairFill` / `PairLabel` | вложенную пару fill → composite → label | произвольный generic graph | +| `PairFill` | сдвинутую до победы лейбл-стороны солид-заливку | подложку `PairLabel` | +| `PairLabel` | foreground против derived тинт-поверхности (generic source-over компонент) | публичный произвольный graph API | | `Material` | объявленную многослойную точечную композицию | blur, преломление или физическое стекло | | `Glow` | рецепт точечного эффекта | пространственный glow после blur | | `Zero` | явное отсутствие цветового значения | пропущенный ключ | diff --git a/crates/labcolors-core/src/alpha.rs b/crates/labcolors-core/src/alpha.rs index c137ab88..9879201a 100644 --- a/crates/labcolors-core/src/alpha.rs +++ b/crates/labcolors-core/src/alpha.rs @@ -136,9 +136,7 @@ pub fn composite_over_encoded( /// /// `Err`, если `alpha` не конечна или лежит вне `[0,1]`. pub fn composite_over_srgb8(tint: [u8; 3], alpha: f64, bg: [u8; 3]) -> Result<[u8; 3], String> { - if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) { - return Err(format!("alpha вне конечного [0,1]: {alpha}")); - } + validate_alpha(alpha)?; Ok([ source_over_channel_srgb8(tint[0], alpha, bg[0]), source_over_channel_srgb8(tint[1], alpha, bg[1]), @@ -146,14 +144,29 @@ pub fn composite_over_srgb8(tint: [u8; 3], alpha: f64, bg: [u8; 3]) -> Result<[u ]) } -fn encoded_to_srgb8(rgb: [f64; 3], label: &str) -> Result<[u8; 3], String> { +/// SSOT-валидатор домена straight-alpha: конечная и в `[0,1]`. Единый и для +/// композитора, и для графовых bindings — доменный отказ по альфе обязан +/// звучать одинаково на всех путях (тексты отказов публично наблюдаемы). +pub(crate) fn validate_alpha(alpha: f64) -> Result<(), String> { + if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) { + return Err(format!("alpha вне конечного [0,1]: {alpha}")); + } + Ok(()) +} + +/// Квантизация кодированного цвета в эмитируемые sRGB8-байты с доменной +/// проверкой — тот же контракт, которым композитор готовит свои входы. +/// `label` попадает в текст доменного отказа (`tint`/`bg` исторические). +pub(crate) fn encoded_to_srgb8(rgb: [f64; 3], label: &str) -> Result<[u8; 3], String> { if !is_encoded_rgb(rgb) { return Err(format!("{label} вне конечного encoded-sRGB [0,1]: {rgb:?}")); } Ok(rgb.map(|channel| (channel * 255.0).round() as u8)) } -fn hex_from_srgb8(rgb: [u8; 3]) -> String { +/// Форматирование эмитируемых sRGB8-байт в `#RRGGBB` — единая точка формата +/// для композитора и потребителей его байтовых результатов (appearance-граф). +pub(crate) fn hex_from_srgb8(rgb: [u8; 3]) -> String { format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]) } diff --git a/crates/labcolors-core/src/appearance.rs b/crates/labcolors-core/src/appearance.rs new file mode 100644 index 00000000..862d0e24 --- /dev/null +++ b/crates/labcolors-core/src/appearance.rs @@ -0,0 +1,738 @@ +//! Приватный физический appearance-граф (#307): связный компонент +//! «input-слои → derived-поверхности → foreground occurrences». +//! +//! Граф владеет render-топологией, НЕ клиентским словарём: здесь нет имён +//! ролей, сентиментов и позиций лестницы — только непрозрачные typed handles и +//! физические байты. Любое поведение выводится из объявленных рёбер, никогда — +//! из значений ID (ID структурны и не участвуют в физике). +//! +//! Единственная физическая операция модуля — версионированный exact-композитор +//! [`crate::alpha::composite_over_srgb8`] +//! ([`CompositionProfileV1::EncodedSrgb8SourceOverV1`]). Второй композитор +//! запрещён: модуль связывает топологию с существующим SSOT, а не вводит новую +//! численную политику (ни одного нового production-числа, порога или epsilon). +//! +//! Жизненный цикл — две атомарные фазы: +//! +//! ```text +//! AppearanceGraphSpec::compile() — валидация + канонизация + topo (без I/O) +//! CompiledAppearanceGraph::evaluate(bindings) — исполнение по topo, fail closed +//! ``` +//! +//! Compile детерминирован и атомарен: при любой ошибке граф не публикуется +//! частично. Канонизация сортирует декларации по typed ID, поэтому физический +//! результат компонента не зависит от порядка деклараций при тех же +//! handles/рёбрах (инвариант закреплён тестом +//! `compile_is_independent_of_declaration_order_for_the_same_handles`). +//! Hash-map итерация как источник порядка не используется вовсе — все +//! коллекции здесь отсортированные `Vec`. + +use std::collections::BTreeSet; + +/// Непрозрачный handle цветового входа компонента. Значение — только +/// идентичность (структурная ссылка), не позиция и не приоритет. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct ColorInputId(u32); + +impl ColorInputId { + /// Собрать handle из сырого значения клиента графа. + pub(crate) const fn new(raw: u32) -> Self { + Self(raw) + } +} + +/// Непрозрачный handle входа непрозрачности (straight alpha). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct OpacityInputId(u32); + +impl OpacityInputId { + /// Собрать handle из сырого значения клиента графа. + pub(crate) const fn new(raw: u32) -> Self { + Self(raw) + } +} + +/// Непрозрачный handle поверхности (rendered surface node). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct SurfaceId(u32); + +impl SurfaceId { + /// Собрать handle из сырого значения клиента графа. + pub(crate) const fn new(raw: u32) -> Self { + Self(raw) + } +} + +/// Непрозрачный handle foreground occurrence (наблюдение foreground против +/// конкретной отрисованной поверхности). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct OccurrenceId(u32); + +impl OccurrenceId { + /// Собрать handle из сырого значения клиента графа. + pub(crate) const fn new(raw: u32) -> Self { + Self(raw) + } +} + +/// Версионированный профиль композиции ребра. Часть identity сертификата: +/// exact-утверждение живёт только внутри объявленного профиля, а не +/// «в браузерах вообще». +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CompositionProfileV1 { + /// Straight-alpha source-over в encoded-sRGB8 байтовом домене Lab Colors: + /// `bg + α·(src − bg)` на каждый канал, ОДНО финальное округление + /// ([`crate::alpha::composite_over_srgb8`]). Reference-exact внутри этого + /// профиля; универсальный browser/color-management pipeline не обещается. + EncodedSrgb8SourceOverV1, +} + +/// Класс доказательства результата: точная операция объявленного профиля либо +/// охарактеризованное legacy-совместимое поведение. Классы не смешиваются: +/// occurrence, решаемый legacy-солвером, не наследует exact-статус композита. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EvidenceClass { + /// Точный результат reference-операции в её объявленном конечном домене. + ReferenceExact, + /// Охарактеризованное текущее поведение (см. §5.2 ТЗ #307): сохраняется + /// байт-в-байт, но не объявляется новой научной истиной. + LegacyCompatibility, +} + +/// Декларация поверхности: input-слой (цвет из bindings как есть) либо +/// source-over композит поверх другой поверхности. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SurfaceSpec { + /// Поверхность-вход: цвет берётся из bindings без преобразований. + Input { + /// Handle поверхности. + id: SurfaceId, + /// Цветовой вход, чьи байты становятся поверхностью. + color: ColorInputId, + }, + /// Derived-поверхность: `source` при `opacity` поверх `backdrop`. + SourceOver { + /// Handle поверхности. + id: SurfaceId, + /// Цветовой вход верхнего слоя. + source: ColorInputId, + /// Вход непрозрачности верхнего слоя. + opacity: OpacityInputId, + /// Поверхность-подложка (ребро зависимости графа). + backdrop: SurfaceId, + /// Версионированный профиль композиции. + profile: CompositionProfileV1, + }, +} + +impl SurfaceSpec { + /// Handle поверхности — ключ канонизации и зависимости. + fn id(&self) -> SurfaceId { + match self { + SurfaceSpec::Input { id, .. } | SurfaceSpec::SourceOver { id, .. } => *id, + } + } +} + +/// Декларация foreground occurrence: identity-источник foreground наблюдается +/// против конкретной отрисованной поверхности. Именно топология (`against`) +/// задаёт роль «foreground/фон», а не имя токена. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ForegroundOccurrenceSpec { + /// Handle occurrence. + pub(crate) id: OccurrenceId, + /// Цветовой вход — идентичность foreground (то, ЧТО наблюдается). + pub(crate) identity_source: ColorInputId, + /// Поверхность, против которой foreground реально стоит. + pub(crate) against: SurfaceId, + /// Класс доказательства решателя, потребляющего occurrence. + pub(crate) evidence: EvidenceClass, +} + +/// Типизированные ошибки compile/evaluate. Публичный (в пределах crate) вход +/// не паникует: каждый отказ структурирован и различим без парсинга строк. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum GraphError { + /// Один handle цветового входа объявлен дважды. + DuplicateColorInput { input: ColorInputId }, + /// Один handle входа непрозрачности объявлен дважды. + DuplicateOpacityInput { input: OpacityInputId }, + /// Один handle поверхности объявлен дважды. + DuplicateSurface { surface: SurfaceId }, + /// Один handle occurrence объявлен дважды. + DuplicateOccurrence { occurrence: OccurrenceId }, + /// Поверхность ссылается на необъявленный цветовой вход. + MissingSurfaceColorInput { + surface: SurfaceId, + input: ColorInputId, + }, + /// Поверхность ссылается на необъявленный вход непрозрачности. + MissingSurfaceOpacityInput { + surface: SurfaceId, + input: OpacityInputId, + }, + /// Композит ссылается на необъявленную поверхность-подложку. + MissingSurfaceBackdrop { + surface: SurfaceId, + backdrop: SurfaceId, + }, + /// Occurrence ссылается на необъявленный identity-источник. + MissingOccurrenceSource { + occurrence: OccurrenceId, + input: ColorInputId, + }, + /// Occurrence наблюдается против необъявленной поверхности. + MissingOccurrenceBackdrop { + occurrence: OccurrenceId, + surface: SurfaceId, + }, + /// Поверхности образуют цикл зависимостей: перечислены (в каноническом + /// возрастающем порядке) все поверхности, не вошедшие в топологический + /// порядок — участники циклов и их потомки. + SurfaceCycle { surfaces: Vec }, + /// Один цветовой вход связан значением дважды. + DuplicateColorBinding { input: ColorInputId }, + /// Один вход непрозрачности связан значением дважды. + DuplicateOpacityBinding { input: OpacityInputId }, + /// Объявленный цветовой вход не получил значения. + MissingColorBinding { input: ColorInputId }, + /// Объявленный вход непрозрачности не получил значения. + MissingOpacityBinding { input: OpacityInputId }, + /// Значение подано для необъявленного цветового входа. + UnexpectedColorBinding { input: ColorInputId }, + /// Значение подано для необъявленного входа непрозрачности. + UnexpectedOpacityBinding { input: OpacityInputId }, + /// Непрозрачность вне конечного `[0,1]` (NaN/±∞/за границами). `message` — + /// доменная ошибка SSOT-валидатора [`crate::alpha`] дословно, чтобы + /// потребитель мог сохранить прежний публичный текст отказа байт-в-байт. + OpacityOutOfDomain { + input: OpacityInputId, + message: String, + }, + /// Композитор отверг вход. После валидации bindings недостижимо (байты + /// корректны по типу, α проверена), но паника на публично достижимом пути + /// запрещена — отказ остаётся типизированным. + CompositionFailed { surface: SurfaceId, message: String }, +} + +/// Спека компонента до компиляции: плоские списки деклараций. Порядок +/// деклараций НЕ несёт смысла — compile канонизирует его по typed ID. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AppearanceGraphSpec { + color_inputs: Vec, + opacity_inputs: Vec, + surfaces: Vec, + occurrences: Vec, +} + +impl AppearanceGraphSpec { + /// Собрать спеку из деклараций. Валидации здесь нет намеренно: единственная + /// точка отказа — атомарный [`compile`](Self::compile). + pub(crate) fn new( + color_inputs: Vec, + opacity_inputs: Vec, + surfaces: Vec, + occurrences: Vec, + ) -> Self { + Self { + color_inputs, + opacity_inputs, + surfaces, + occurrences, + } + } + + /// Детерминированная атомарная компиляция: дубликаты → ссылки → циклы. + /// + /// Порядок проверок фиксирован и не зависит от порядка деклараций (все + /// проверки идут по канонически отсортированным копиям): при нескольких + /// дефектах сообщается дефект с наименьшим typed ID первого нарушенного + /// класса. Частичный граф при ошибке не публикуется. + /// + /// # Errors + /// + /// Типизированный [`GraphError`] соответствующего класса. + pub(crate) fn compile(&self) -> Result { + // Канонизация: сортировка по typed ID. Дубликаты после сортировки + // смежны — детекция order-independent по построению. + let mut color_inputs = self.color_inputs.clone(); + color_inputs.sort_unstable(); + if let Some(w) = color_inputs.windows(2).find(|w| w[0] == w[1]) { + return Err(GraphError::DuplicateColorInput { input: w[0] }); + } + + let mut opacity_inputs = self.opacity_inputs.clone(); + opacity_inputs.sort_unstable(); + if let Some(w) = opacity_inputs.windows(2).find(|w| w[0] == w[1]) { + return Err(GraphError::DuplicateOpacityInput { input: w[0] }); + } + + let mut surfaces = self.surfaces.clone(); + surfaces.sort_unstable_by_key(SurfaceSpec::id); + if let Some(w) = surfaces.windows(2).find(|w| w[0].id() == w[1].id()) { + return Err(GraphError::DuplicateSurface { surface: w[0].id() }); + } + + let mut occurrences = self.occurrences.clone(); + occurrences.sort_unstable_by_key(|o| o.id); + if let Some(w) = occurrences.windows(2).find(|w| w[0].id == w[1].id) { + return Err(GraphError::DuplicateOccurrence { + occurrence: w[0].id, + }); + } + + // Ссылочная целостность: каждое ребро указывает на объявленный узел. + // Отсутствие ссылки — ошибка структуры, не runtime-вопрос. + let has_color = |id: ColorInputId| color_inputs.binary_search(&id).is_ok(); + let has_opacity = |id: OpacityInputId| opacity_inputs.binary_search(&id).is_ok(); + let surface_index = + |id: SurfaceId| surfaces.binary_search_by_key(&id, SurfaceSpec::id).ok(); + + for spec in &surfaces { + match *spec { + SurfaceSpec::Input { id, color } => { + if !has_color(color) { + return Err(GraphError::MissingSurfaceColorInput { + surface: id, + input: color, + }); + } + } + SurfaceSpec::SourceOver { + id, + source, + opacity, + backdrop, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + } => { + if !has_color(source) { + return Err(GraphError::MissingSurfaceColorInput { + surface: id, + input: source, + }); + } + if !has_opacity(opacity) { + return Err(GraphError::MissingSurfaceOpacityInput { + surface: id, + input: opacity, + }); + } + if surface_index(backdrop).is_none() { + return Err(GraphError::MissingSurfaceBackdrop { + surface: id, + backdrop, + }); + } + } + } + } + + for occurrence in &occurrences { + if !has_color(occurrence.identity_source) { + return Err(GraphError::MissingOccurrenceSource { + occurrence: occurrence.id, + input: occurrence.identity_source, + }); + } + if surface_index(occurrence.against).is_none() { + return Err(GraphError::MissingOccurrenceBackdrop { + occurrence: occurrence.id, + surface: occurrence.against, + }); + } + } + + // Топологический порядок (Кан) с готовым множеством в BTreeSet: + // из готовых всегда берётся наименьший SurfaceId, поэтому порядок + // канонический без какого-либо произвольного iteration limit — + // алгоритм завершается на любом входе за |V| шагов. + let mut dependents: Vec> = vec![Vec::new(); surfaces.len()]; + let mut pending_deps: Vec = vec![0; surfaces.len()]; + for (index, spec) in surfaces.iter().enumerate() { + if let SurfaceSpec::SourceOver { backdrop, .. } = spec { + let backdrop_index = surface_index(*backdrop) + .unwrap_or_else(|| unreachable!("ссылки проверены выше")); + dependents[backdrop_index].push(index); + pending_deps[index] += 1; + } + } + let mut ready: BTreeSet = surfaces + .iter() + .enumerate() + .filter(|(index, _)| pending_deps[*index] == 0) + .map(|(_, spec)| spec.id()) + .collect(); + let mut topo: Vec = Vec::with_capacity(surfaces.len()); + while let Some(id) = ready.pop_first() { + let index = + surface_index(id).unwrap_or_else(|| unreachable!("id из собственного множества")); + topo.push(index); + for &dependent in &dependents[index] { + pending_deps[dependent] -= 1; + if pending_deps[dependent] == 0 { + ready.insert(surfaces[dependent].id()); + } + } + } + if topo.len() != surfaces.len() { + let mut cycle: Vec = surfaces + .iter() + .enumerate() + .filter(|(index, _)| pending_deps[*index] > 0) + .map(|(_, spec)| spec.id()) + .collect(); + cycle.sort_unstable(); + return Err(GraphError::SurfaceCycle { surfaces: cycle }); + } + + Ok(CompiledAppearanceGraph { + color_inputs, + opacity_inputs, + surfaces, + occurrences, + topo, + }) + } +} + +/// Скомпилированный граф: канонические декларации + детерминированный topo. +/// Равенство скомпилированных графов означает равную физику: любые два +/// объявления с теми же handles/рёбрами компилируются в идентичное значение. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct CompiledAppearanceGraph { + /// Отсортированные объявленные цветовые входы. + color_inputs: Vec, + /// Отсортированные объявленные входы непрозрачности. + opacity_inputs: Vec, + /// Поверхности в каноническом порядке (по id). + surfaces: Vec, + /// Occurrences в каноническом порядке (по id). + occurrences: Vec, + /// Индексы `surfaces` в порядке исполнения (канонический Кан). + topo: Vec, +} + +/// Значения входов на один evaluate: цвета — финальные sRGB8-байты, альфы — +/// binary64. Дубликаты/пропуски/лишние значения отвергает `evaluate` +/// (конструктор непадающий — единая точка отказа). +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AppearanceBindings { + colors: Vec<(ColorInputId, [u8; 3])>, + opacities: Vec<(OpacityInputId, f64)>, +} + +impl AppearanceBindings { + /// Собрать значения входов. Валидация — в [`CompiledAppearanceGraph::evaluate`]. + pub(crate) fn new( + colors: Vec<(ColorInputId, [u8; 3])>, + opacities: Vec<(OpacityInputId, f64)>, + ) -> Self { + Self { colors, opacities } + } +} + +/// Счётчики фактически исполненных узлов — доказательство исполнения рёбер +/// (анти-вакуум: тест сверяет счётчики, а не только совпадение результата). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ExecutionTrace { + /// Исполненных input-поверхностей. + pub(crate) input_surfaces: usize, + /// Исполненных source-over рёбер. + pub(crate) source_over_edges: usize, + /// Собранных foreground occurrences. + pub(crate) foreground_occurrences: usize, +} + +/// Replayable-сертификат одной exact source-over операции: все входы и выход +/// в точных представлениях (байты и `to_bits` альфы). Не содержит и не может +/// содержать `Pass` про читаемость/восприятие — это сертификат композиции, +/// а не perception-утверждение. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SourceOverCertificateV1 { + /// Identity версионированного профиля операции. + pub(crate) profile: CompositionProfileV1, + /// Поверхность, чей результат сертифицирован. + pub(crate) surface: SurfaceId, + /// Handle источника верхнего слоя. + pub(crate) source_input: ColorInputId, + /// Байты источника. + pub(crate) source_rgb: [u8; 3], + /// Handle поверхности-подложки. + pub(crate) backdrop_surface: SurfaceId, + /// Финальные байты подложки. + pub(crate) backdrop_rgb: [u8; 3], + /// Handle входа непрозрачности. + pub(crate) opacity_input: OpacityInputId, + /// Точные биты binary64-альфы (без потери представления). + pub(crate) opacity_bits: u64, + /// Финальные байты результата. + pub(crate) output_rgb: [u8; 3], + /// Класс доказательства: всегда [`EvidenceClass::ReferenceExact`] — + /// сертификат существует только для exact-профиля. + pub(crate) evidence: EvidenceClass, +} + +impl SourceOverCertificateV1 { + /// Независимо повторить операцию из данных сертификата. + /// + /// Часть proof-контракта модуля (§6.3 ТЗ #307): потребляется + /// доказательствами (replay-тесты), production-путь один evaluate + /// не дублирует — отсюда allow вне test-сборки. + /// + /// # Errors + /// + /// Доменная ошибка SSOT-композитора, если сертификат собран из + /// невалидных данных (у честно выданного сертификата недостижимо). + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn replay(&self) -> Result<[u8; 3], String> { + match self.profile { + CompositionProfileV1::EncodedSrgb8SourceOverV1 => crate::alpha::composite_over_srgb8( + self.source_rgb, + f64::from_bits(self.opacity_bits), + self.backdrop_rgb, + ), + } + } +} + +/// Разрешённый foreground occurrence: identity-источник, его байты и финальные +/// байты поверхности, против которой foreground реально стоит. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ResolvedOccurrence { + /// Handle occurrence. + pub(crate) id: OccurrenceId, + /// Объявленный identity-источник (ребро идентичности, не копия байт). + pub(crate) identity_source: ColorInputId, + /// Байты identity-источника из bindings. + pub(crate) source: [u8; 3], + /// Поверхность наблюдения (объявленная топологией). + pub(crate) against: SurfaceId, + /// Финальные вычисленные байты этой поверхности. + pub(crate) backdrop: [u8; 3], + /// Класс доказательства решателя-потребителя. + pub(crate) evidence: EvidenceClass, +} + +/// Результат одного evaluate: байты каждой поверхности, occurrences, +/// сертификаты exact-операций и счётчики исполнения. Все коллекции — в +/// каноническом порядке, поэтому результат сравним значением. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AppearanceEvaluation { + /// `(поверхность, финальные байты)` в каноническом порядке (по id). + surfaces: Vec<(SurfaceId, [u8; 3])>, + /// Occurrences в каноническом порядке (по id). + occurrences: Vec, + /// Сертификаты source-over рёбер в порядке исполнения (канонический topo). + certificates: Vec, + /// Счётчики фактического исполнения. + trace: ExecutionTrace, +} + +impl AppearanceEvaluation { + /// Финальные байты поверхности, если она объявлена. Инспекционный API + /// (проверяется доказательствами; production читает occurrence). + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn surface_rgb(&self, id: SurfaceId) -> Option<[u8; 3]> { + self.surfaces + .binary_search_by_key(&id, |(surface, _)| *surface) + .ok() + .map(|index| self.surfaces[index].1) + } + + /// Разрешённый occurrence, если он объявлен. + pub(crate) fn occurrence(&self, id: OccurrenceId) -> Option<&ResolvedOccurrence> { + self.occurrences + .binary_search_by_key(&id, |occurrence| occurrence.id) + .ok() + .map(|index| &self.occurrences[index]) + } + + /// Сертификаты exact-операций этого evaluate (порядок исполнения). + /// Proof-контракт (§6.3 ТЗ #307) — потребляется replay-доказательствами. + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn certificates(&self) -> &[SourceOverCertificateV1] { + &self.certificates + } + + /// Счётчики фактического исполнения — анти-вакуумный proof-контракт. + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn trace(&self) -> ExecutionTrace { + self.trace + } +} + +impl CompiledAppearanceGraph { + /// Исполнить граф на значениях входов: строго по compiled topo, только + /// через SSOT-композитор, fail closed на любом дефекте bindings. + /// + /// Порядок валидации детерминирован: дубликаты → пропуски → лишние → + /// домен α (везде наименьший typed ID первого нарушенного класса). + /// + /// # Errors + /// + /// Типизированный [`GraphError`]; частичный результат не публикуется. + pub(crate) fn evaluate( + &self, + bindings: &AppearanceBindings, + ) -> Result { + // Канонизация значений: сортировка по handle, смежные дубликаты. + let mut colors = bindings.colors.clone(); + colors.sort_unstable_by_key(|(id, _)| *id); + if let Some(w) = colors.windows(2).find(|w| w[0].0 == w[1].0) { + return Err(GraphError::DuplicateColorBinding { input: w[0].0 }); + } + let mut opacities = bindings.opacities.clone(); + opacities.sort_unstable_by_key(|(id, _)| *id); + if let Some(w) = opacities.windows(2).find(|w| w[0].0 == w[1].0) { + return Err(GraphError::DuplicateOpacityBinding { input: w[0].0 }); + } + + // Точное соответствие объявлениям: и пропуск, и лишнее значение — + // отказ (молчаливые дефолты запрещены контрактом продукта). + for declared in &self.color_inputs { + if colors + .binary_search_by_key(declared, |(id, _)| *id) + .is_err() + { + return Err(GraphError::MissingColorBinding { input: *declared }); + } + } + for declared in &self.opacity_inputs { + if opacities + .binary_search_by_key(declared, |(id, _)| *id) + .is_err() + { + return Err(GraphError::MissingOpacityBinding { input: *declared }); + } + } + for (bound, _) in &colors { + if self.color_inputs.binary_search(bound).is_err() { + return Err(GraphError::UnexpectedColorBinding { input: *bound }); + } + } + for (bound, _) in &opacities { + if self.opacity_inputs.binary_search(bound).is_err() { + return Err(GraphError::UnexpectedOpacityBinding { input: *bound }); + } + } + + // Домен α — SSOT-валидатор композитора, чтобы текст доменного отказа + // был единым во всём продукте (сообщение переносится дословно). + for (input, alpha) in &opacities { + if let Err(message) = crate::alpha::validate_alpha(*alpha) { + return Err(GraphError::OpacityOutOfDomain { + input: *input, + message, + }); + } + } + + let color_value = |id: ColorInputId| -> [u8; 3] { + let index = colors + .binary_search_by_key(&id, |(bound, _)| *bound) + .unwrap_or_else(|_| unreachable!("соответствие bindings проверено выше")); + colors[index].1 + }; + let opacity_value = |id: OpacityInputId| -> f64 { + let index = opacities + .binary_search_by_key(&id, |(bound, _)| *bound) + .unwrap_or_else(|_| unreachable!("соответствие bindings проверено выше")); + opacities[index].1 + }; + + // Исполнение строго по compiled topo: подложка каждого source-over + // вычислена раньше по построению порядка. + let mut resolved: Vec> = vec![None; self.surfaces.len()]; + let mut certificates: Vec = Vec::new(); + let mut trace = ExecutionTrace { + input_surfaces: 0, + source_over_edges: 0, + foreground_occurrences: 0, + }; + for &index in &self.topo { + match self.surfaces[index] { + SurfaceSpec::Input { color, .. } => { + trace.input_surfaces += 1; + resolved[index] = Some(color_value(color)); + } + SurfaceSpec::SourceOver { + id, + source, + opacity, + backdrop, + profile: profile @ CompositionProfileV1::EncodedSrgb8SourceOverV1, + } => { + let backdrop_index = self + .surfaces + .binary_search_by_key(&backdrop, SurfaceSpec::id) + .unwrap_or_else(|_| unreachable!("ссылки проверены компиляцией")); + let backdrop_rgb = resolved[backdrop_index] + .unwrap_or_else(|| unreachable!("подложка раньше в topo по построению")); + let source_rgb = color_value(source); + let alpha = opacity_value(opacity); + // ЕДИНСТВЕННАЯ операция композиции модуля — SSOT-композитор. + let output_rgb = + crate::alpha::composite_over_srgb8(source_rgb, alpha, backdrop_rgb) + .map_err(|message| GraphError::CompositionFailed { + surface: id, + message, + })?; + trace.source_over_edges += 1; + certificates.push(SourceOverCertificateV1 { + profile, + surface: id, + source_input: source, + source_rgb, + backdrop_surface: backdrop, + backdrop_rgb, + opacity_input: opacity, + opacity_bits: alpha.to_bits(), + output_rgb, + evidence: EvidenceClass::ReferenceExact, + }); + resolved[index] = Some(output_rgb); + } + } + } + + let surfaces: Vec<(SurfaceId, [u8; 3])> = self + .surfaces + .iter() + .zip(&resolved) + .map(|(spec, bytes)| { + let bytes = + bytes.unwrap_or_else(|| unreachable!("topo покрывает каждую поверхность")); + (spec.id(), bytes) + }) + .collect(); + + let occurrences: Vec = self + .occurrences + .iter() + .map(|spec| { + let backdrop_index = self + .surfaces + .binary_search_by_key(&spec.against, SurfaceSpec::id) + .unwrap_or_else(|_| unreachable!("ссылки проверены компиляцией")); + let backdrop = resolved[backdrop_index] + .unwrap_or_else(|| unreachable!("topo покрывает каждую поверхность")); + trace.foreground_occurrences += 1; + ResolvedOccurrence { + id: spec.id, + identity_source: spec.identity_source, + source: color_value(spec.identity_source), + against: spec.against, + backdrop, + evidence: spec.evidence, + } + }) + .collect(); + + Ok(AppearanceEvaluation { + surfaces, + occurrences, + certificates, + trace, + }) + } +} diff --git a/crates/labcolors-core/src/appearance_graph_tests.rs b/crates/labcolors-core/src/appearance_graph_tests.rs new file mode 100644 index 00000000..a0fd6d4b --- /dev/null +++ b/crates/labcolors-core/src/appearance_graph_tests.rs @@ -0,0 +1,591 @@ +//! Contract tests for the private physical appearance graph (#307). +//! +//! The graph owns render topology, not client vocabulary. These tests therefore +//! use typed opaque handles and final sRGB8 values only. The warning example is +//! a finite counterexample discovered in the existing Lab UI fixture: it proves +//! that observing a foreground against the page instead of the rendered tinted +//! surface changes the legacy conformance result. Its numbers are witness data, +//! never production policy. + +use proptest::prelude::*; + +use crate::appearance::{ + AppearanceBindings, AppearanceGraphSpec, ColorInputId, CompositionProfileV1, EvidenceClass, + ForegroundOccurrenceSpec, GraphError, OccurrenceId, OpacityInputId, SurfaceId, SurfaceSpec, +}; +use crate::solve::Floor; + +const SOURCE: ColorInputId = ColorInputId::new(0); +const CONTEXT: ColorInputId = ColorInputId::new(1); +const OPACITY: OpacityInputId = OpacityInputId::new(0); +const CONTEXT_SURFACE: SurfaceId = SurfaceId::new(0); +const DERIVED_SURFACE: SurfaceId = SurfaceId::new(1); +const FOREGROUND: OccurrenceId = OccurrenceId::new(0); + +fn atomic_component(surface_declarations_reversed: bool) -> AppearanceGraphSpec { + let context = SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }; + let derived = SurfaceSpec::SourceOver { + id: DERIVED_SURFACE, + source: SOURCE, + opacity: OPACITY, + backdrop: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }; + let surfaces = if surface_declarations_reversed { + vec![derived, context] + } else { + vec![context, derived] + }; + + AppearanceGraphSpec::new( + vec![SOURCE, CONTEXT], + vec![OPACITY], + surfaces, + vec![ForegroundOccurrenceSpec { + id: FOREGROUND, + identity_source: SOURCE, + against: DERIVED_SURFACE, + evidence: EvidenceClass::LegacyCompatibility, + }], + ) +} + +fn bindings(source: [u8; 3], opacity: f64, context: [u8; 3]) -> AppearanceBindings { + AppearanceBindings::new( + vec![(SOURCE, source), (CONTEXT, context)], + vec![(OPACITY, opacity)], + ) +} + +#[test] +fn warning_occurrence_targets_the_rendered_surface_not_the_page() { + let graph = atomic_component(false).compile().unwrap(); + let rendered = graph + .evaluate(&bindings([0xFF, 0xA1, 0x00], 0.122, [0xFF; 3])) + .unwrap(); + + assert_eq!( + rendered.surface_rgb(DERIVED_SURFACE), + Some([0xFF, 0xF4, 0xE0]) + ); + let occurrence = rendered.occurrence(FOREGROUND).unwrap(); + assert_eq!(occurrence.against, DERIVED_SURFACE); + assert_eq!(occurrence.backdrop, [0xFF, 0xF4, 0xE0]); + assert_ne!(occurrence.backdrop, [0xFF; 3]); + + // Non-vacuity: the known page-resolved warning foreground clears the legacy + // UI floor on the page but not on the surface it is actually painted over. + let foreground = [0xD2, 0x83, 0x00].map(|channel| f64::from(channel) / 255.0); + let page = [1.0; 3]; + let surface = occurrence + .backdrop + .map(|channel| f64::from(channel) / 255.0); + let legacy_ui_floor = Floor::AaUi.min_ratio().unwrap(); + assert!(crate::wcag::contrast_ratio(foreground, page) >= legacy_ui_floor); + assert!(crate::wcag::contrast_ratio(foreground, surface) < legacy_ui_floor); + + assert_eq!(rendered.trace().input_surfaces, 1); + assert_eq!(rendered.trace().source_over_edges, 1); + assert_eq!(rendered.trace().foreground_occurrences, 1); +} + +#[test] +fn compile_is_independent_of_declaration_order_for_the_same_handles() { + let canonical = atomic_component(false).compile().unwrap(); + let reordered = atomic_component(true).compile().unwrap(); + assert_eq!(canonical, reordered); + + let values = bindings([19, 127, 241], 0.375, [247, 241, 233]); + assert_eq!(canonical.evaluate(&values), reordered.evaluate(&values)); +} + +#[test] +fn unrelated_opaque_handles_do_not_change_the_physics() { + let other_source = ColorInputId::new(700); + let other_context = ColorInputId::new(42); + let other_opacity = OpacityInputId::new(91); + let other_context_surface = SurfaceId::new(800); + let other_derived_surface = SurfaceId::new(12); + let other_occurrence = OccurrenceId::new(501); + let other = AppearanceGraphSpec::new( + vec![other_context, other_source], + vec![other_opacity], + vec![ + SurfaceSpec::SourceOver { + id: other_derived_surface, + source: other_source, + opacity: other_opacity, + backdrop: other_context_surface, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + SurfaceSpec::Input { + id: other_context_surface, + color: other_context, + }, + ], + vec![ForegroundOccurrenceSpec { + id: other_occurrence, + identity_source: other_source, + against: other_derived_surface, + evidence: EvidenceClass::LegacyCompatibility, + }], + ) + .compile() + .unwrap(); + + let source = [13, 89, 233]; + let context = [249, 245, 237]; + let opacity = 0.41; + let first = atomic_component(false) + .compile() + .unwrap() + .evaluate(&bindings(source, opacity, context)) + .unwrap(); + let second = other + .evaluate(&AppearanceBindings::new( + vec![(other_source, source), (other_context, context)], + vec![(other_opacity, opacity)], + )) + .unwrap(); + + assert_eq!( + first.surface_rgb(DERIVED_SURFACE), + second.surface_rgb(other_derived_surface) + ); + assert_eq!( + first.occurrence(FOREGROUND).unwrap().backdrop, + second.occurrence(other_occurrence).unwrap().backdrop + ); +} + +#[test] +fn graph_rejects_missing_occurrence_backdrop_and_cycles() { + let missing = AppearanceGraphSpec::new( + vec![SOURCE], + vec![], + vec![], + vec![ForegroundOccurrenceSpec { + id: FOREGROUND, + identity_source: SOURCE, + against: DERIVED_SURFACE, + evidence: EvidenceClass::LegacyCompatibility, + }], + ) + .compile(); + assert_eq!( + missing, + Err(GraphError::MissingOccurrenceBackdrop { + occurrence: FOREGROUND, + surface: DERIVED_SURFACE, + }) + ); + + let cyclic = AppearanceGraphSpec::new( + vec![SOURCE], + vec![OPACITY], + vec![ + SurfaceSpec::SourceOver { + id: CONTEXT_SURFACE, + source: SOURCE, + opacity: OPACITY, + backdrop: DERIVED_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + SurfaceSpec::SourceOver { + id: DERIVED_SURFACE, + source: SOURCE, + opacity: OPACITY, + backdrop: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + ], + vec![], + ) + .compile(); + assert_eq!( + cyclic, + Err(GraphError::SurfaceCycle { + surfaces: vec![CONTEXT_SURFACE, DERIVED_SURFACE], + }) + ); +} + +proptest! { + #[test] + fn graph_source_over_equals_the_independent_compositor_for_neutral_and_chromatic_inputs( + source in any::<[u8; 3]>(), + context in any::<[u8; 3]>(), + opacity in 0.0f64..=1.0f64, + ) { + let graph = atomic_component(false).compile().unwrap(); + let rendered = graph.evaluate(&bindings(source, opacity, context)).unwrap(); + let oracle = crate::alpha::composite_over_srgb8(source, opacity, context).unwrap(); + prop_assert_eq!(rendered.surface_rgb(DERIVED_SURFACE), Some(oracle)); + prop_assert_eq!(rendered.occurrence(FOREGROUND).unwrap().backdrop, oracle); + } + + // Replayable-сертификат: независимое повторение операции из данных самого + // сертификата даёт те же байты, что записанный выход, — на всём домене. + #[test] + fn source_over_certificate_replays_to_the_exact_recorded_bytes( + source in any::<[u8; 3]>(), + context in any::<[u8; 3]>(), + opacity in 0.0f64..=1.0f64, + ) { + let graph = atomic_component(false).compile().unwrap(); + let rendered = graph.evaluate(&bindings(source, opacity, context)).unwrap(); + let certificates = rendered.certificates(); + prop_assert_eq!(certificates.len(), 1); + let certificate = &certificates[0]; + prop_assert_eq!(certificate.profile, CompositionProfileV1::EncodedSrgb8SourceOverV1); + prop_assert_eq!(certificate.evidence, EvidenceClass::ReferenceExact); + prop_assert_eq!(certificate.surface, DERIVED_SURFACE); + prop_assert_eq!(certificate.source_input, SOURCE); + prop_assert_eq!(certificate.source_rgb, source); + prop_assert_eq!(certificate.backdrop_surface, CONTEXT_SURFACE); + prop_assert_eq!(certificate.backdrop_rgb, context); + prop_assert_eq!(certificate.opacity_input, OPACITY); + prop_assert_eq!(certificate.opacity_bits, opacity.to_bits()); + prop_assert_eq!(certificate.output_rgb, rendered.surface_rgb(DERIVED_SURFACE).unwrap()); + prop_assert_eq!(certificate.replay(), Ok(certificate.output_rgb)); + } +} + +// ── Fail-closed контракт компиляции (§6.1 ТЗ #307) ──────────────────────────── + +#[test] +fn compile_rejects_duplicate_declarations_with_typed_errors() { + let duplicate_color = + AppearanceGraphSpec::new(vec![SOURCE, CONTEXT, SOURCE], vec![OPACITY], vec![], vec![]) + .compile(); + assert_eq!( + duplicate_color, + Err(GraphError::DuplicateColorInput { input: SOURCE }) + ); + + let duplicate_opacity = + AppearanceGraphSpec::new(vec![SOURCE], vec![OPACITY, OPACITY], vec![], vec![]).compile(); + assert_eq!( + duplicate_opacity, + Err(GraphError::DuplicateOpacityInput { input: OPACITY }) + ); + + let duplicate_surface = AppearanceGraphSpec::new( + vec![SOURCE, CONTEXT], + vec![], + vec![ + SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }, + SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: SOURCE, + }, + ], + vec![], + ) + .compile(); + assert_eq!( + duplicate_surface, + Err(GraphError::DuplicateSurface { + surface: CONTEXT_SURFACE, + }) + ); + + let duplicate_occurrence = AppearanceGraphSpec::new( + vec![SOURCE, CONTEXT], + vec![], + vec![SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }], + vec![ + ForegroundOccurrenceSpec { + id: FOREGROUND, + identity_source: SOURCE, + against: CONTEXT_SURFACE, + evidence: EvidenceClass::LegacyCompatibility, + }, + ForegroundOccurrenceSpec { + id: FOREGROUND, + identity_source: CONTEXT, + against: CONTEXT_SURFACE, + evidence: EvidenceClass::LegacyCompatibility, + }, + ], + ) + .compile(); + assert_eq!( + duplicate_occurrence, + Err(GraphError::DuplicateOccurrence { + occurrence: FOREGROUND, + }) + ); +} + +#[test] +fn compile_rejects_every_missing_reference_with_typed_errors() { + let missing_input_color = AppearanceGraphSpec::new( + vec![], + vec![], + vec![SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }], + vec![], + ) + .compile(); + assert_eq!( + missing_input_color, + Err(GraphError::MissingSurfaceColorInput { + surface: CONTEXT_SURFACE, + input: CONTEXT, + }) + ); + + let missing_source = AppearanceGraphSpec::new( + vec![CONTEXT], + vec![OPACITY], + vec![ + SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }, + SurfaceSpec::SourceOver { + id: DERIVED_SURFACE, + source: SOURCE, + opacity: OPACITY, + backdrop: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + ], + vec![], + ) + .compile(); + assert_eq!( + missing_source, + Err(GraphError::MissingSurfaceColorInput { + surface: DERIVED_SURFACE, + input: SOURCE, + }) + ); + + let missing_opacity = AppearanceGraphSpec::new( + vec![SOURCE, CONTEXT], + vec![], + vec![ + SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }, + SurfaceSpec::SourceOver { + id: DERIVED_SURFACE, + source: SOURCE, + opacity: OPACITY, + backdrop: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + ], + vec![], + ) + .compile(); + assert_eq!( + missing_opacity, + Err(GraphError::MissingSurfaceOpacityInput { + surface: DERIVED_SURFACE, + input: OPACITY, + }) + ); + + let missing_backdrop = AppearanceGraphSpec::new( + vec![SOURCE], + vec![OPACITY], + vec![SurfaceSpec::SourceOver { + id: DERIVED_SURFACE, + source: SOURCE, + opacity: OPACITY, + backdrop: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }], + vec![], + ) + .compile(); + assert_eq!( + missing_backdrop, + Err(GraphError::MissingSurfaceBackdrop { + surface: DERIVED_SURFACE, + backdrop: CONTEXT_SURFACE, + }) + ); + + let missing_occurrence_source = AppearanceGraphSpec::new( + vec![CONTEXT], + vec![], + vec![SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }], + vec![ForegroundOccurrenceSpec { + id: FOREGROUND, + identity_source: SOURCE, + against: CONTEXT_SURFACE, + evidence: EvidenceClass::LegacyCompatibility, + }], + ) + .compile(); + assert_eq!( + missing_occurrence_source, + Err(GraphError::MissingOccurrenceSource { + occurrence: FOREGROUND, + input: SOURCE, + }) + ); +} + +// ── Fail-closed контракт исполнения (§6.2 ТЗ #307) ──────────────────────────── + +#[test] +fn evaluate_rejects_duplicate_missing_and_unexpected_bindings() { + let graph = atomic_component(false).compile().unwrap(); + + let duplicate_color = graph.evaluate(&AppearanceBindings::new( + vec![ + (SOURCE, [1, 2, 3]), + (CONTEXT, [4, 5, 6]), + (SOURCE, [7, 8, 9]), + ], + vec![(OPACITY, 0.5)], + )); + assert_eq!( + duplicate_color, + Err(GraphError::DuplicateColorBinding { input: SOURCE }) + ); + + let duplicate_opacity = graph.evaluate(&AppearanceBindings::new( + vec![(SOURCE, [1, 2, 3]), (CONTEXT, [4, 5, 6])], + vec![(OPACITY, 0.5), (OPACITY, 0.6)], + )); + assert_eq!( + duplicate_opacity, + Err(GraphError::DuplicateOpacityBinding { input: OPACITY }) + ); + + let missing_color = graph.evaluate(&AppearanceBindings::new( + vec![(SOURCE, [1, 2, 3])], + vec![(OPACITY, 0.5)], + )); + assert_eq!( + missing_color, + Err(GraphError::MissingColorBinding { input: CONTEXT }) + ); + + let missing_opacity = graph.evaluate(&AppearanceBindings::new( + vec![(SOURCE, [1, 2, 3]), (CONTEXT, [4, 5, 6])], + vec![], + )); + assert_eq!( + missing_opacity, + Err(GraphError::MissingOpacityBinding { input: OPACITY }) + ); + + let unexpected_color = graph.evaluate(&AppearanceBindings::new( + vec![ + (SOURCE, [1, 2, 3]), + (CONTEXT, [4, 5, 6]), + (ColorInputId::new(9), [7, 8, 9]), + ], + vec![(OPACITY, 0.5)], + )); + assert_eq!( + unexpected_color, + Err(GraphError::UnexpectedColorBinding { + input: ColorInputId::new(9), + }) + ); + + let unexpected_opacity = graph.evaluate(&AppearanceBindings::new( + vec![(SOURCE, [1, 2, 3]), (CONTEXT, [4, 5, 6])], + vec![(OPACITY, 0.5), (OpacityInputId::new(9), 0.5)], + )); + assert_eq!( + unexpected_opacity, + Err(GraphError::UnexpectedOpacityBinding { + input: OpacityInputId::new(9), + }) + ); +} + +#[test] +fn evaluate_rejects_non_finite_and_out_of_range_alpha_with_the_ssot_domain_text() { + let graph = atomic_component(false).compile().unwrap(); + for bad_alpha in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.1, 1.5] { + let outcome = graph.evaluate(&bindings([1, 2, 3], bad_alpha, [4, 5, 6])); + // Текст доменного отказа — дословно из SSOT-валидатора композитора: + // потребители переносят его в публичные исходы без переформулировок. + let expected = crate::alpha::composite_over_srgb8([1, 2, 3], bad_alpha, [4, 5, 6]) + .expect_err("домен α обязан отвергаться и композитором"); + assert_eq!( + outcome, + Err(GraphError::OpacityOutOfDomain { + input: OPACITY, + message: expected, + }), + "α={bad_alpha}: типизированный отказ с SSOT-текстом" + ); + } +} + +// ── Identity-ребро occurrence не декоративно ───────────────────────────────── + +/// Occurrence несёт байты именно ОБЪЯВЛЕННОГО identity-источника, а не байты +/// source-входа композита: в этом компоненте они разные входы — подмена ребра +/// идентичности немедленно различима. +#[test] +fn occurrence_source_follows_the_declared_identity_edge_not_the_composite_source() { + let identity = ColorInputId::new(7); + let graph = AppearanceGraphSpec::new( + vec![SOURCE, CONTEXT, identity], + vec![OPACITY], + vec![ + SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }, + SurfaceSpec::SourceOver { + id: DERIVED_SURFACE, + source: SOURCE, + opacity: OPACITY, + backdrop: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + ], + vec![ForegroundOccurrenceSpec { + id: FOREGROUND, + identity_source: identity, + against: DERIVED_SURFACE, + evidence: EvidenceClass::LegacyCompatibility, + }], + ) + .compile() + .unwrap(); + + let rendered = graph + .evaluate(&AppearanceBindings::new( + vec![ + (SOURCE, [10, 20, 30]), + (CONTEXT, [200, 200, 200]), + (identity, [111, 112, 113]), + ], + vec![(OPACITY, 0.25)], + )) + .unwrap(); + let occurrence = rendered.occurrence(FOREGROUND).unwrap(); + assert_eq!(occurrence.identity_source, identity); + assert_eq!(occurrence.source, [111, 112, 113]); + assert_ne!(occurrence.source, [10, 20, 30]); + assert_eq!(occurrence.evidence, EvidenceClass::LegacyCompatibility); +} diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index 4a6a705f..efe7888e 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -544,15 +544,18 @@ pub enum RoleRecipe { source: LadderSource, }, /// Лейбл ТИНТ-бейджа — лейбл-сторона пары «поверхность × лейбл» - /// ([`crate::pair`]), близнец [`PairFill`](Self::PairFill). Семейно-оттеночный - /// лейбл, чей WCAG-пол энфорсится ПРОТИВ тинт-поверхности бейджа (композит - /// семейного тинта при альфе `fill-*-primary` над фоном резолва), а НЕ против - /// фона страницы. Закрывает класс «контраст label↔tinted-fill эмерджентен, не - /// гарантирован»: обычные `label-*`/`fill-*-tinted` роли решаются независимо - /// против фона страницы, и их взаимный контраст на тинт-подложке бейджа никем - /// не констрейнится (для warning/статусных семей на кривой оседает к ~3:1 и - /// ниже). Здесь лейбл решается ШТАТНЫМ законом НА тинт-поверхности, поэтому пол - /// гарантирован против той подложки, на которой лейбл реально стоит. + /// ([`crate::pair`]). Семейно-оттеночный лейбл, чей WCAG-пол энфорсится + /// ПРОТИВ объявленной тинт-поверхности (композит семейного тинта при + /// compatibility-альфе позиции `fill-*-primary` над фоном резолва), а НЕ + /// против фона страницы и НЕ против эмитированного + /// [`PairFill`](Self::PairFill) — тот решается отдельно и не является + /// подложкой лейбла. Закрывает класс «контраст label↔tinted-fill + /// эмерджентен, не гарантирован»: обычные `label-*`/`fill-*-tinted` роли + /// решаются независимо против фона страницы, и их взаимный контраст на + /// тинт-подложке бейджа никем не констрейнится (для warning/статусных семей + /// на кривой оседает к ~3:1 и ниже). Здесь foreground решается ШТАТНЫМ + /// законом НА тинт-поверхности, собранной appearance-графом (#307), поэтому + /// пол гарантирован против той подложки, на которой лейбл реально стоит. /// Недостижимость пола на кривой семьи клампит тон (флаг `compressed`, ADR-0002 /// честный результат) — консервативный дефолт. Компилируется в /// [`RoleSpec::PairLabel`]. @@ -1376,6 +1379,9 @@ impl ThemeConfig { // (@12) над фоном резолва. Альфа берётся из ЗАКРЫТОГО меню позиции // (не литерал), поэтому tinted-badge лейбл и `fill-*-tinted` // заливка всегда садятся на одну и ту же подложку по построению. + // `FillPrimary` здесь — ТОЛЬКО источник legacy-alpha-данных на + // этапе lowering (compatibility datum клиентской калибровки); + // core-семантика и appearance-граф это имя/позицию не знают. let (surface_alpha_light, surface_alpha_dark) = crate::ladder::LadderPosition::FillPrimary.alpha_pair(); Ok(RoleSpec::PairLabel { diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index e652b40c..ae9ca8cf 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -4,6 +4,7 @@ pub(crate) mod accent; pub mod accent_balance; pub mod accent_surface; pub mod alpha; +pub(crate) mod appearance; pub mod cleanliness; pub mod config; pub mod glow; @@ -32,6 +33,9 @@ mod golden_tests; #[cfg(test)] mod agnostic_gates; +#[cfg(test)] +mod appearance_graph_tests; + #[cfg(test)] mod one_levelness_tests; diff --git a/crates/labcolors-core/src/pair_label_tests.rs b/crates/labcolors-core/src/pair_label_tests.rs index 0bf6103a..55129ec9 100644 --- a/crates/labcolors-core/src/pair_label_tests.rs +++ b/crates/labcolors-core/src/pair_label_tests.rs @@ -10,7 +10,7 @@ //! гарантирован по построению; при недостижимости тон клампится (флаг //! `compressed`, ADR-0002), а не молча остаётся нечитаемым. //! -//! Три группы: +//! Четыре группы: //! 1. `shipped_*` — реальный контракт labui (`label--primary` на //! `fill--primary`) держит UI-пол на тинте во всех темах (гвардит то, //! что уже отгружено, — near-black лейбл сейчас даёт 13–18:1). @@ -21,10 +21,21 @@ //! (`label--tertiary`), проваливает 3:1 на тинте у warning/success, //! а `PairLabel` (против поверхности) — держит. Разница ТОЛЬКО в подложке //! резолва: если бы `resolve_pair_label` целил фон страницы, тест бы упал. +//! 4. `migration_*` / `emitted_pair_fill_*` — differential миграции #307: +//! production-путь через appearance-граф байт-идентичен замороженному +//! legacy oracle (матрица 5 семей × 4 режима × 6 фонов + property), включая +//! публичные типизированные отказы; поверхность PairLabel НЕ является +//! эмитированным PairFill (санитарный witness против ложного ребра, #305). + +use proptest::prelude::*; use crate::config::fixture::labui_reference; +use crate::semantic::{resolve_pair_label, resolve_pair_label_legacy_oracle}; use crate::solve::Floor; -use crate::{BgInput, LadderSource, Resolved, RoleRecipe, ViewingConditions, resolve_named_set}; +use crate::{ + BgInput, LadderSource, LadderTint, Resolved, RoleRecipe, RoleSpec, Unreachable, + ViewingConditions, resolve_named_set, +}; /// (имя семьи, источник лестницы) — 5 цветных семей тинт-бейджа labui /// (нейтраль/статики используют `label-primary`/семейный примитив, покрыты @@ -57,11 +68,17 @@ fn themes() -> [(&'static str, &'static str, ViewingConditions); 4] { ] } -/// Юр. пол UI (WCAG 1.4.11, 3:1) — консервативный дефолт порога тинт-бейджа -/// (короткая пилюля-индикатор — UI-объект, не длинный текст; 4.5:1 на светлом -/// тинте вынудил бы near-black и убил бы «цветной» вид). Порог 3:1 vs 4.5:1 — +/// Юр. пол UI (WCAG 1.4.11, 3:1) из SSOT контракта [`Floor::AaUi`] — локальная +/// копия числа запрещена (#307): тест обязан проверять тот же пол, который +/// энфорсит резолвер. Консервативный дефолт порога тинт-бейджа (короткая +/// пилюля-индикатор — UI-объект, не длинный текст; 4.5:1 на светлом тинте +/// вынудил бы near-black и убил бы «цветной» вид). Порог 3:1 vs 4.5:1 — /// открытый вопрос владельцу (см. отчёт task #29). -const UI_FLOOR: f64 = 3.0; +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 валиден") @@ -114,6 +131,7 @@ fn labui_with_badge_labels() -> crate::NamedRoleTable { #[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(); @@ -121,9 +139,9 @@ fn shipped_tinted_badge_label_clears_ui_floor_on_tint_all_families_and_themes() for (fam, _) in families() { let r = ratio_on_tint(&set, &format!("label-{fam}-primary"), fam); assert!( - r >= UI_FLOOR - 1e-9, + r >= floor, "[{tname}] отгруженный тинт-бейдж `label-{fam}-primary` на \ - `fill-{fam}-primary` обязан держать {UI_FLOOR}:1, получено {r:.2}:1" + `fill-{fam}-primary` обязан держать {floor}:1, получено {r:.2}:1" ); } } @@ -133,6 +151,7 @@ fn shipped_tinted_badge_label_clears_ui_floor_on_tint_all_families_and_themes() #[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(); @@ -142,8 +161,8 @@ fn pair_label_clears_ui_floor_against_tinted_surface_all_families_and_themes() { // Решается цветом (не Unreachable/None) и держит пол на тинте. let r = ratio_on_tint(&set, &role, fam); assert!( - r >= UI_FLOOR - 1e-9, - "[{tname}] `{role}` обязан держать {UI_FLOOR}:1 против тинт-поверхности \ + r >= floor, + "[{tname}] `{role}` обязан держать {floor}:1 против тинт-поверхности \ `fill-{fam}-primary`, получено {r:.2}:1" ); } @@ -192,6 +211,7 @@ fn pair_label_stays_hued_not_near_black() { /// «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()); @@ -202,14 +222,14 @@ fn pair_label_beats_page_resolved_label_on_failing_families() { let before = ratio_on_tint(&set, &format!("label-{fam}-tertiary"), fam); let after = ratio_on_tint(&set, &format!("badge-label-{fam}"), fam); assert!( - before < UI_FLOOR, + before < floor, "предпосылка класса: страничный `label-{fam}-tertiary` обязан \ - проваливать {UI_FLOOR}:1 на тинте (иначе тест не о том), получено {before:.2}:1" + проваливать {floor}:1 на тинте (иначе тест не о том), получено {before:.2}:1" ); assert!( - after >= UI_FLOOR - 1e-9, + after >= floor, "`badge-label-{fam}` (тот же контракт, но против поверхности) обязан \ - держать {UI_FLOOR}:1, получено {after:.2}:1" + держать {floor}:1, получено {after:.2}:1" ); assert!( after > before, @@ -218,3 +238,193 @@ fn pair_label_beats_page_resolved_label_on_failing_families() { ); } } + +// ── 4. Differential-матрица миграции #307: граф == замороженный legacy oracle ── + +/// Скомпилированные параметры `RoleSpec::PairLabel` конкретной роли таблицы — +/// вход обоих путей differential-а (production-граф и legacy oracle получают +/// РОВНО одни аргументы, различие только в реализации). +fn pair_label_spec( + table: &crate::NamedRoleTable, + role: &str, +) -> (LadderTint, f64, Floor, f64, f64) { + let spec = table + .entries() + .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 к белому, +/// см. `docs/verification-map.md`); +/// * `#FFF4E0` — хроматический светлый witness: точная warning-поверхность +/// из graph-тестов (`appearance_graph_tests`); +/// * `#0000FF` — насыщенный хроматический угол куба (sRGB primary). +fn migration_backgrounds() -> [&'static str; 6] { + [ + "#000000", "#101012", "#767676", "#FFF4E0", "#0000FF", "#FFFFFF", + ] +} + +/// Полный differential §8.3 ТЗ #307: production-путь (appearance-граф) обязан +/// быть РАВЕН замороженному legacy oracle по всем полям `Resolved` — вариант, +/// финальные байты, флаги, unreachable-причины. Никакого approximate equality: +/// `assert_eq!` по `PartialEq` сравнивает и все числовые поля (одинаковые биты +/// по построению одного downstream-солвера), а hex сверяется отдельно, чтобы +/// байтовая эмиссия оставалась закреплённой даже при эволюции `PartialEq`. +#[test] +fn migration_differential_matrix_matches_frozen_legacy_oracle_exactly() { + let table = labui_with_badge_labels(); + for (tname, _, vc) in themes() { + for bg_hex in migration_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_legacy_oracle( + &bg, + tint, + fraction, + floor, + alpha_light, + alpha_dark, + &vc, + ); + assert_eq!( + production, oracle, + "[{tname}/{bg_hex}] `{role}`: production-граф обязан быть \ + идентичен замороженному legacy oracle по всем полям" + ); + if let (Resolved::Color { solved: p, .. }, Resolved::Color { solved: o, .. }) = + (&production, &oracle) + { + assert_eq!( + p.hex(), + o.hex(), + "[{tname}/{bg_hex}] `{role}`: финальные байты эмиссии" + ); + } + } + } + } +} + +/// Публичные исходы невалидной альфы (RoleSpec публичен — спека, собранная в +/// обход валидатора конфига, обязана давать ПРЕЖНИЙ типизированный отказ): +/// вариант, причина и точный текст заморожены миграцией байт-в-байт. +#[test] +fn migration_preserves_public_invalid_alpha_outcomes_exactly() { + let table = labui_with_badge_labels(); + let (tint, fraction, floor, _, _) = pair_label_spec(&table, "badge-label-warning"); + let bg = BgInput::solid("#FFFFFF").unwrap(); + let vc = ViewingConditions::srgb(); + for bad_alpha in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.1, 1.5] { + let production = resolve_pair_label(&bg, tint, fraction, floor, bad_alpha, bad_alpha, &vc); + let oracle = + resolve_pair_label_legacy_oracle(&bg, tint, fraction, floor, bad_alpha, bad_alpha, &vc); + assert_eq!( + production, oracle, + "публичный тип/текст отказа по α={bad_alpha} обязан быть заморожен" + ); + assert!( + matches!( + production, + Resolved::Unreachable(Unreachable::InvalidInput(_)) + ), + "невалидная α обязана давать типизированный InvalidInput, не панику/кламп" + ); + } +} + +/// Санитарный witness против ложного ребра `PairFill → PairLabel` (regression +/// witness #305 остаётся честным): эмитированный `PairFill` — отдельно +/// сдвинутый солид, он НЕ равен тинт-поверхности (композиту `fill-*-primary`), +/// против которой решается `PairLabel`. Если миграция когда-либо подменит +/// derived backdrop эмитированным PairFill, differential-матрица разойдётся +/// именно потому, что эти значения различны — что и закрепляет этот тест. +#[test] +fn emitted_pair_fill_differs_from_the_pair_label_surface() { + 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()); + 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: произвольные источник/контекст/альфы/доля/пол/режим. +// Единственный источник различий между путями — сама миграция; любые входы в +// объявленном домене обязаны давать идентичный Resolved. +proptest! { + #[test] + fn migration_differential_property_holds_on_arbitrary_inputs( + 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_legacy_oracle( + &bg, tint, fraction, floor, alpha_light, alpha_dark, &vc, + ); + prop_assert_eq!(production, oracle); + } +} diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index e4cce41f..5321ec36 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -680,13 +680,19 @@ pub enum RoleSpec { /// Пер-темный кодированный якорь источника. tint: LadderTint, }, - /// Лейбл ТИНТ-бейджа ([`crate::pair`], лейбл-сторона) — близнец - /// [`PairFill`](Self::PairFill). Семейно-оттеночный лейбл, чей WCAG-пол - /// энфорсится ПРОТИВ тинт-поверхности бейджа (композит `tint` при альфе - /// `fill-*-primary` над фоном резолва), а не против фона страницы. Резолв: - /// построить поверхность, затем решить оттеночный лейбл на ней ШТАТНЫМ законом - /// (`resolve_hued_anchor`) — пол поверхности гарантирован по построению, тон - /// клампится (флаг `compressed`) при недостижимости на кривой семьи. + /// Лейбл ТИНТ-бейджа ([`crate::pair`], лейбл-сторона). Семейно-оттеночный + /// лейбл, чей WCAG-пол энфорсится ПРОТИВ объявленной тинт-поверхности + /// (exact source-over композит declared `tint` при compatibility-альфе + /// позиции `fill-*-primary` над фоном резолва), а НЕ против фона страницы + /// и НЕ против эмитированного [`PairFill`](Self::PairFill) — у того своя, + /// отдельно сдвинутая солид-эмиссия; ребра `PairFill → PairLabel` не + /// существует. Резолв — compatibility-адаптер над одним generic-компонентом + /// appearance-графа (#307): скомпилированный граф точно собирает + /// поверхность и возвращает foreground occurrence против неё, затем + /// оттеночный foreground решается прежним законом (`resolve_hued_anchor…`, + /// статус LegacyCompatibility) — пол поверхности гарантирован по + /// построению, тон клампится (флаг `compressed`) при недостижимости на + /// кривой семьи. PairLabel { /// Пер-темный кодированный тинт-якорь семьи (как у лестницы). tint: LadderTint, @@ -2574,6 +2580,26 @@ fn resolve_hued_anchor( hue_tint: crate::ladder::LadderTint, vc: &ViewingConditions, ctx: &ResolveContext, +) -> Resolved { + resolve_hued_anchor_from_encoded_source(bg, anchor, hue_tint.for_vc(vc), vc, ctx) +} + +/// Тот же цветной резолв, но источник оттенка — уже выбранный (по теме) +/// кодированный стимул, а не [`LadderTint`]-пейлоад. +/// +/// Отдельный вход нужен appearance-графу (#307): foreground occurrence несёт +/// identity-ребро «что наблюдается», и потребитель обязан решать foreground из +/// ВОЗВРАЩЁННОГО occurrence-источника (байты → byte/255 точно), а не повторно +/// читать исходный пейлоад — иначе ребро идентичности было бы декоративным. +/// Для квантованного источника оба пути дают один hex по построению +/// ([`crate::spaces::srgb::hex_from_srgb_encoded`] округляет так же, как +/// квантизация эмиссии), что закреплено differential-тестами миграции. +fn resolve_hued_anchor_from_encoded_source( + bg: &BgInput, + anchor: TextAnchor, + source_encoded: [f64; 3], + vc: &ViewingConditions, + ctx: &ResolveContext, ) -> Resolved { let contract = match ctx.anchored_contract(anchor) { Ok(c) => c, @@ -2583,9 +2609,8 @@ fn resolve_hued_anchor( Ok(iv) => *iv, Err(reason) => return Resolved::Unreachable(reason.clone()), }; - let hue_deg = crate::accent::oklab_hue_of(&crate::spaces::srgb::hex_from_srgb_encoded( - hue_tint.for_vc(vc), - )); + let hue_deg = + crate::accent::oklab_hue_of(&crate::spaces::srgb::hex_from_srgb_encoded(source_encoded)); match solve::solve_in( bg, contract, @@ -2696,22 +2721,102 @@ fn resolve_solid_with_ui_floor( } } -/// Резолв лейбла ТИНТ-бейджа — жёсткий контраст `label ↔ tinted-fill` -/// ([`crate::pair`], лейбл-сторона; близнец [`resolve_solid_with_ui_floor`], но -/// пол энфорсится против ВЫВОДИМОЙ подложки, а не против фона страницы). +/// Непрозрачные структурные handles компонента «derived source-over +/// поверхность и foreground occurrence против неё» ([`crate::appearance`]). +/// Значения произвольны и не участвуют в физике (инвариант закреплён +/// graph-тестами); граф не знает ни одного клиентского имени — привязку к +/// рецепту делает только этот модуль. +const NESTED_SOURCE: crate::appearance::ColorInputId = crate::appearance::ColorInputId::new(0); +const NESTED_CONTEXT: crate::appearance::ColorInputId = crate::appearance::ColorInputId::new(1); +const NESTED_OPACITY: crate::appearance::OpacityInputId = crate::appearance::OpacityInputId::new(0); +const NESTED_CONTEXT_SURFACE: crate::appearance::SurfaceId = crate::appearance::SurfaceId::new(0); +const NESTED_DERIVED_SURFACE: crate::appearance::SurfaceId = crate::appearance::SurfaceId::new(1); +const NESTED_FOREGROUND: crate::appearance::OccurrenceId = crate::appearance::OccurrenceId::new(0); + +/// Один статически скомпилированный generic-компонент вложенного foreground: +/// +/// ```text +/// context input → context surface +/// source + opacity + context surface → exact source-over derived surface +/// foreground occurrence(identity = source) против derived surface +/// ``` /// -/// Поверхность бейджа — композит семейного тинта при альфе `fill-*-primary` над -/// фоном резолва (то же, во что складывается роль `fill-*-tinted`). Оттеночный -/// лейбл решается ШТАТНЫМ законом ([`resolve_hued_anchor`]) НА ЭТОЙ ПОВЕРХНОСТИ: -/// её собственный [`ResolveContext`] задаёт полярность/макс-контраст, поэтому -/// WCAG-пол лейбла гарантирован против той подложки, на которой лейбл реально -/// стоит, а не против белого/чёрного фона страницы (обычные `label-*` роли -/// решаются против страницы, и на тинт-подложке их контраст проседает — класс, -/// который закрывает эта роль). Недостижимость пола на кривой семьи клампит тон -/// (`floor_override` → `compressed`), как у любой контраст-роли (ADR-0002 честный -/// результат) — консервативный дефолт вместо тихой нечитаемости. +/// Компилируется один раз ([`OnceLock`](std::sync::OnceLock)); спека статична, +/// поэтому ошибка компиляции недостижима по построению, но путь остаётся +/// типизированным (RoleSpec публичен, паника на публичном входе запрещена). +fn nested_foreground_component() -> Result< + &'static crate::appearance::CompiledAppearanceGraph, + &'static crate::appearance::GraphError, +> { + use crate::appearance::{ + AppearanceGraphSpec, CompiledAppearanceGraph, CompositionProfileV1, EvidenceClass, + ForegroundOccurrenceSpec, GraphError, SurfaceSpec, + }; + static COMPONENT: std::sync::OnceLock> = + std::sync::OnceLock::new(); + COMPONENT + .get_or_init(|| { + AppearanceGraphSpec::new( + vec![NESTED_SOURCE, NESTED_CONTEXT], + vec![NESTED_OPACITY], + vec![ + SurfaceSpec::Input { + id: NESTED_CONTEXT_SURFACE, + color: NESTED_CONTEXT, + }, + SurfaceSpec::SourceOver { + id: NESTED_DERIVED_SURFACE, + source: NESTED_SOURCE, + opacity: NESTED_OPACITY, + backdrop: NESTED_CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + ], + vec![ForegroundOccurrenceSpec { + id: NESTED_FOREGROUND, + identity_source: NESTED_SOURCE, + against: NESTED_DERIVED_SURFACE, + evidence: EvidenceClass::LegacyCompatibility, + }], + ) + .compile() + }) + .as_ref() +} + +/// Доменный отказ сборки тинт-поверхности — прежний публичный текст дословно +/// (тексты отказов наблюдаемы потребителем и заморожены миграцией #307). +fn pair_label_surface_domain_error(error: &str) -> Resolved { + Resolved::Unreachable(Unreachable::InvalidInput(format!( + "тинт-поверхность бейджа вне encoded-sRGB8 reference-домена: {error}" + ))) +} + +/// Резолв лейбла ТИНТ-бейджа — жёсткий контраст `label ↔ tinted-surface` +/// ([`crate::pair`], лейбл-сторона; родственен [`resolve_solid_with_ui_floor`], +/// но пол энфорсится против ВЫВОДИМОЙ подложки, а не против фона страницы). +/// +/// С миграции #307 это compatibility-адаптер над одним generic-компонентом +/// appearance-графа ([`nested_foreground_component`]): скомпилированный граф +/// точно собирает derived-поверхность (объявленный тинт при compatibility-альфе +/// позиции `fill-*-primary` над локальным фоном резолва — exact source-over в +/// encoded-sRGB8 профиле) и возвращает foreground occurrence именно против неё. +/// Поверхность НЕ является эмитированным [`RoleSpec::PairFill`] — у того своя, +/// отдельно сдвинутая солид-эмиссия; никакого ребра `PairFill → PairLabel` нет. +/// +/// Оттеночный foreground решается ПРЕЖНИМ законом +/// ([`resolve_hued_anchor_from_encoded_source`], статус LegacyCompatibility — +/// не новая научная истина) НА ЭТОЙ ПОВЕРХНОСТИ: её собственный +/// [`ResolveContext`] задаёт полярность/макс-контраст, поэтому WCAG-пол лейбла +/// гарантирован против той подложки, на которой foreground реально стоит +/// (обычные `label-*` роли решаются против страницы, и на тинт-подложке их +/// контраст проседает — класс, который закрывает эта роль). Недостижимость пола +/// на кривой семьи клампит тон (`floor_override` → `compressed`), как у любой +/// контраст-роли (ADR-0002 честный результат) — консервативный дефолт вместо +/// тихой нечитаемости. Занимаемые графом typed handles структурны; клиентские +/// имена в граф не передаются. #[allow(clippy::too_many_arguments)] -fn resolve_pair_label( +pub(crate) fn resolve_pair_label( bg: &BgInput, tint: crate::ladder::LadderTint, fraction: f64, @@ -2726,7 +2831,96 @@ fn resolve_pair_label( surface_alpha_light }; // Тинт квантуется ДО композита: подложка обязана считаться из отдаваемого - // значения в едином encoded-sRGB8 reference-домене. + // значения в едином encoded-sRGB8 reference-домене (контракт не изменён + // миграцией). Байты источника и локального фона готовятся ТЕМ ЖЕ + // квантизационным контрактом alpha-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 pair_label_surface_domain_error(&error), + }; + let context_rgb = match crate::alpha::encoded_to_srgb8(bg.encoded_display(), "bg") { + Ok(bytes) => bytes, + Err(error) => return pair_label_surface_domain_error(&error), + }; + let graph = match nested_foreground_component() { + Ok(graph) => graph, + // Статическая спека не компилируется только при внутреннем дефекте — + // типизированный отказ честнее паники (RoleSpec публичен). + Err(defect) => { + return Resolved::Unreachable(Unreachable::InvalidInput(format!( + "внутренний дефект компиляции компонента тинт-поверхности: {defect:?}" + ))); + } + }; + let bindings = crate::appearance::AppearanceBindings::new( + vec![(NESTED_SOURCE, source_rgb), (NESTED_CONTEXT, context_rgb)], + vec![(NESTED_OPACITY, alpha)], + ); + let evaluation = match graph.evaluate(&bindings) { + Ok(evaluation) => evaluation, + // Доменный отказ по α несёт сообщение SSOT-валидатора дословно — + // публичный текст отказа совпадает со старым путём байт-в-байт. + Err(crate::appearance::GraphError::OpacityOutOfDomain { message, .. }) => { + return pair_label_surface_domain_error(&message); + } + // Прочие ошибки исполнения статического компонента структурно + // недостижимы (bindings собраны из объявленных handles); отказ + // остаётся типизированным вместо паники. + Err(defect) => { + return Resolved::Unreachable(Unreachable::InvalidInput(format!( + "внутренний дефект исполнения компонента тинт-поверхности: {defect:?}" + ))); + } + }; + let Some(occurrence) = evaluation.occurrence(NESTED_FOREGROUND) else { + return Resolved::Unreachable(Unreachable::InvalidInput( + "внутренний дефект компонента тинт-поверхности: occurrence отсутствует".into(), + )); + }; + // Финальные байты РЕАЛЬНО собранной поверхности → прежний контекст резолва. + let surface_hex = crate::alpha::hex_from_srgb8(occurrence.backdrop); + let Ok(surface_bg) = BgInput::solid(&surface_hex) else { + // Композит 8-битных каналов всегда в кубе — недостижимо, но честнее + // отказ, чем правдоподобный мусор (RoleSpec публичен). + return Resolved::Unreachable(Unreachable::InvalidInput( + "тинт-поверхность бейджа вне кодированного домена sRGB".into(), + )); + }; + // Свежий контекст ПОВЕРХНОСТИ: полярность/интервал/макс-контраст берутся от + // тинт-подложки, не от фона страницы — потому пол энфорсится против неё. + let surface_ctx = ResolveContext::new(&surface_bg, vc); + let anchor = TextAnchor::new(fraction, floor); + // Identity-ребро occurrence: foreground решается из ВОЗВРАЩЁННОГО + // источника (byte → byte/255 точно), а не повторного чтения `tint` — + // иначе объявленное ребро идентичности было бы декоративным. + let source_encoded = occurrence.source.map(|channel| f64::from(channel) / 255.0); + resolve_hued_anchor_from_encoded_source(&surface_bg, anchor, source_encoded, vc, &surface_ctx) +} + +/// Замороженная ручная реализация `resolve_pair_label` ДО миграции #307 — +/// независимый differential-oracle графового пути, НЕ production-дубликат. +/// Композиция здесь идёт прежним `composite_hex_from_encoded`-маршрутом, а +/// foreground — через [`resolve_hued_anchor`] по исходному пейлоаду тинта. +/// Любой байтовый/статусный дрейф production-пути от этого оракула — дефект +/// миграции (см. differential-матрицу в тестах PairLabel). +#[cfg(test)] +#[allow(clippy::too_many_arguments)] +pub(crate) fn resolve_pair_label_legacy_oracle( + bg: &BgInput, + tint: crate::ladder::LadderTint, + fraction: f64, + floor: Floor, + surface_alpha_light: f64, + surface_alpha_dark: f64, + vc: &ViewingConditions, +) -> Resolved { + 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()) { @@ -2738,14 +2932,10 @@ fn resolve_pair_label( } }; let Ok(surface_bg) = BgInput::solid(&surface_hex) else { - // Композит 8-битных каналов всегда в кубе — недостижимо, но честнее - // отказ, чем правдоподобный мусор (RoleSpec публичен). return Resolved::Unreachable(Unreachable::InvalidInput( "тинт-поверхность бейджа вне кодированного домена 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) diff --git a/docs/verification-map.md b/docs/verification-map.md index d85689af..732e0961 100644 --- a/docs/verification-map.md +++ b/docs/verification-map.md @@ -83,6 +83,24 @@ | `soft_clamp` / `soft_clamp_inv` | `lpc::tests::soft_clamp_boundaries_are_exact`, `..._matches_reference_bisection` | внутренняя тождественность | | `y_hk_analytic` (обратный `grey_j`) | `lpc::tests::y_hk_analytic_matches_bisection_on_grid` | внутренняя тождественность | +## Appearance-граф — `crates/labcolors-core/src/appearance.rs` + +Приватный компилятор/исполнитель физического компонента (#307). Модуль не несёт +собственной численной политики: единственная операция — SSOT-композитор +`alpha::composite_over_srgb8`; проверяется соответствие топологии и переносу +байтов, а не новая математика. + +| формула/инвариант | чем верифицирована | оракул | +|---|---|---| +| source-over ребро графа ≡ `composite_over_srgb8` (весь домен байтов × α) | `appearance_graph_tests::graph_source_over_equals_the_independent_compositor_for_neutral_and_chromatic_inputs` (property) | дифференциальный (SSOT-композитор, сам верифицирован против reference-векторов `alpha.rs`) | +| replayable-сертификат: независимое повторение операции из полей сертификата побайтно равно записанному выходу | `appearance_graph_tests::source_over_certificate_replays_to_the_exact_recorded_bytes` (property) | внутренняя тождественность | +| канонизация: результат не зависит от порядка деклараций/значений typed handles | `appearance_graph_tests::compile_is_independent_of_declaration_order_for_the_same_handles`, `unrelated_opaque_handles_do_not_change_the_physics` | внутренняя тождественность | +| fail-closed: дубликаты/missing refs/циклы/дефекты bindings/α вне `[0,1]` — типизированные отказы | `appearance_graph_tests::compile_rejects_*`, `evaluate_rejects_*`, `graph_rejects_missing_occurrence_backdrop_and_cycles` | внутренняя тождественность | +| occurrence наблюдается против derived-поверхности, не страницы; identity-ребро не декоративно | `appearance_graph_tests::warning_occurrence_targets_the_rendered_surface_not_the_page` (+ trace-счётчики), `occurrence_source_follows_the_declared_identity_edge_not_the_composite_source` | внутренняя тождественность (witness) | +| production-миграция `PairLabel` байт-идентична замороженному legacy-пути (5 семей × 4 режима × 6 фонов + property + публичные отказы) | `pair_label_tests::migration_*` | дифференциальный (test-only legacy oracle) | + +Мутационный скоуп: модуль включён в `.cargo/mutants.toml` (`examine_globs`). + ## JS-дубликат — `packages/colors/effective-bg.js` | формула | чем верифицирована | оракул | diff --git a/docs/whitepaper.md b/docs/whitepaper.md index af1d041a..7e8f8495 100644 --- a/docs/whitepaper.md +++ b/docs/whitepaper.md @@ -192,6 +192,35 @@ task #29). `RoleRecipe::PairLabel` решает лейбл штатным зак проваливает 3:1 на тинте — `pair_label_beats_page_resolved_label` ([`pair_label_tests.rs`](../crates/labcolors-core/src/pair_label_tests.rs)). +С миграции #307 production-путь `PairLabel` не собирает derived-поверхность +вручную: физическая схема компонента объявлена приватным **appearance-графом** +([`appearance.rs`](../crates/labcolors-core/src/appearance.rs)) — + +```text +непрозрачный источник + локальный контекст + объявленная альфа +→ exact encoded-sRGB8 source-over (versioned profile, replayable certificate) +→ derived-поверхность +→ foreground occurrence против этой поверхности +→ прежний foreground-резолвер +``` + +Граф не знает клиентских имён (`PairLabel`, `Warning` и т. п.): роль +foreground/фон задаётся только топологией typed handles. Точной (Reference +exact) здесь является **только** композиция поверхности в объявленном +encoded-sRGB8 профиле; сам foreground-solve (LPC/якорная доля/семейная кривая) +остаётся статусом **LegacyCompatibility** — охарактеризованное текущее +поведение, не новая научная истина. Лейбл не несёт typography-фактов, поэтому +никакой размер/вес текста здесь не учитывается и не обещается. Старая ручная +композиция заморожена как test-only differential oracle; матрица +5 семей × 4 режима × 6 фонов + property-тесты доказывают байт-идентичность +production-пути ей (`migration_*`, +[`pair_label_tests.rs`](../crates/labcolors-core/src/pair_label_tests.rs)). +Для дизайнера: лейбл не «знает», что он warning или badge — клиент объявляет, +на какой реально нарисованной поверхности стоит foreground; core сначала точно +собирает эту поверхность из слоёв, затем решает foreground относительно неё. +Нейтральный и цветной случаи проходят один физический закон; различается только +входной контракт. Цвета этим PR не изменились — изменилась форма системы. + ### 2.6. Сентименты: категориальные зоны хью Сентимент — семантическая роль (Danger/Warning/Success/Info), отображённая на