diff --git a/README.md b/README.md index 8287094c..27d22760 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,6 @@ ThemeConfig клиента - **Клиентский словарь.** Имена ролей, тем и алиасов приходят из `ThemeConfig`. Core не выводит смысл из слов `primary`, `danger`, `hover` или имени компонента. - **Контекстный resolve.** Одна таблица решается заново для переданного локального фона и темы. -- **Вложенные зависимости.** Приватный appearance-граф уже связывает foreground - с вычисленной поверхностью и проверяет конечный композит. Публичная схема пока - не позволяет клиенту объявить произвольную топологию. - **Несколько форм результата.** Роль может вернуть solid, translucent, material, точечный glow, явное отсутствие значения либо типизированный `Unresolved`, если ограниченный поиск не доказал исход. Доказанный ordinary `Unreachable`, diff --git a/crates/labcolors-core/src/alpha.rs b/crates/labcolors-core/src/alpha.rs index 62ab6e9d..68fd5a05 100644 --- a/crates/labcolors-core/src/alpha.rs +++ b/crates/labcolors-core/src/alpha.rs @@ -137,11 +137,20 @@ 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> { validate_alpha(alpha)?; - Ok([ + Ok(composite_over_srgb8_validated(tint, alpha, bg)) +} + +/// Total-вариант для functional core после admission: вызывающий код обязан +/// уже доказать `alpha ∈ [0,1]`. Произведение двух таких straight-alpha также +/// остаётся в домене, поэтому appearance runtime не несёт недостижимую ветку +/// повторной валидации после проверки всех scalar bindings. +pub(crate) fn composite_over_srgb8_validated(tint: [u8; 3], alpha: f64, bg: [u8; 3]) -> [u8; 3] { + debug_assert!(validate_alpha(alpha).is_ok()); + [ source_over_channel_srgb8(tint[0], alpha, bg[0]), source_over_channel_srgb8(tint[1], alpha, bg[1]), source_over_channel_srgb8(tint[2], alpha, bg[2]), - ]) + ] } /// SSOT-валидатор домена straight-alpha: конечная и в `[0,1]`. Единый и для diff --git a/crates/labcolors-core/src/appearance.rs b/crates/labcolors-core/src/appearance.rs index ea6f45dc..1201d1ac 100644 --- a/crates/labcolors-core/src/appearance.rs +++ b/crates/labcolors-core/src/appearance.rs @@ -1,312 +1,381 @@ -//! Приватный физический appearance-граф (#307): связный компонент -//! «input-слои → derived-поверхности → foreground occurrences». +//! Приватный point render-граф. //! -//! Граф владеет render-топологией, НЕ клиентским словарём: здесь нет имён -//! ролей, семейств и позиций лестницы — только непрозрачные typed handles и -//! физические байты. Любое поведение выводится из объявленных рёбер, никогда — -//! из значений ID (ID структурны и не участвуют в физике). +//! Граф владеет только физической топологией. Paint материализуется без +//! подложки; Occurrence — единственное место, где Paint применяется к Surface; +//! `surfaceFrom` лишь даёт видимому результату occurrence новую Surface- +//! идентичность. Клиентский словарь, recipe-роли и perception-утверждения сюда +//! не входят. //! -//! Единственная физическая операция модуля — версионированный exact-композитор -//! [`crate::alpha::composite_over_srgb8`] -//! ([`CompositionProfileV1::EncodedSrgb8SourceOverV1`]). Второй композитор -//! запрещён: модуль связывает топологию с существующим SSOT, а не вводит новую -//! численную политику (ни одного нового production-числа, порога или epsilon). +//! Единственная операция композиции — версионированный exact-композитор +//! [`crate::alpha::composite_over_srgb8`]. `Opacity` только умножает straight +//! alpha уже материализованного Paint и никогда не композитит промежуточный +//! результат. //! -//! Жизненный цикл — две атомарные фазы: -//! -//! ```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`. +//! Code-owned adapter представлен sealed borrowed IR и исполняется тем же +//! evaluator-ом, что результат декларативной компиляции. Структурное равенство +//! статического IR результату compiler-а закреплено proof-тестом; production- +//! артефакт не содержит admission/topology compiler. +#[cfg(test)] use std::collections::BTreeSet; -/// Непрозрачный handle цветового входа компонента. Значение — только -/// идентичность (структурная ссылка), не позиция и не приоритет. +/// Непрозрачный 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). +/// Непрозрачный 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). +/// Непрозрачный handle Paint-программы. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct PaintId(u32); + +impl PaintId { + pub(crate) const fn new(raw: u32) -> Self { + Self(raw) + } +} + +/// Непрозрачный handle наблюдаемой поверхности. #[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 против -/// конкретной отрисованной поверхности). +/// Непрозрачный handle применения Paint к Surface. #[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-утверждение живёт только внутри объявленного профиля, а не -/// «в браузерах вообще». +/// Версионированная identity математической операции композиции. #[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 не обещается. + /// Straight-alpha source-over в encoded-sRGB8: по одному округлению + /// финального канала. Это exact-профиль Lab Colors, не обещание о + /// произвольном браузерном или HDR pipeline. EncodedSrgb8SourceOverV1, } -/// Декларация поверхности: input-слой (цвет из bindings как есть) либо -/// source-over композит поверх другой поверхности. +/// Paint-конструкторы point-домена. Ни один вариант не знает Surface. +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PaintSpec { + /// Непрозрачный encoded-sRGB8 Paint из цветового входа. + Solid { id: PaintId, color: ColorInputId }, + /// Модуляция straight alpha существующего Paint. + /// + /// Рёбра задают порядок операций: узел вычисляет одно binary64-произведение + /// alpha источника и связанного scalar. Перегруппировка создаёт другую + /// численную программу; алгебраическая ассоциативность f64 не заявляется. + /// Композиции на этом шаге нет. + Opacity { + id: PaintId, + source: PaintId, + opacity: OpacityInputId, + }, +} + +#[cfg(test)] +impl PaintSpec { + fn id(&self) -> PaintId { + match self { + Self::Solid { id, .. } | Self::Opacity { id, .. } => *id, + } + } +} + +/// Surface либо приходит извне как point-вход, либо является видимым +/// результатом объявленного occurrence. +#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SurfaceSpec { - /// Поверхность-вход: цвет берётся из bindings без преобразований. Input { - /// Handle поверхности. id: SurfaceId, - /// Цветовой вход, чьи байты становятся поверхностью. color: ColorInputId, }, - /// Derived-поверхность: `source` при `opacity` поверх `backdrop`. - SourceOver { - /// Handle поверхности. + FromOccurrence { id: SurfaceId, - /// Цветовой вход верхнего слоя. - source: ColorInputId, - /// Вход непрозрачности верхнего слоя. - opacity: OpacityInputId, - /// Поверхность-подложка (ребро зависимости графа). - backdrop: SurfaceId, - /// Версионированный профиль композиции. - profile: CompositionProfileV1, + occurrence: OccurrenceId, }, } +#[cfg(test)] impl SurfaceSpec { - /// Handle поверхности — ключ канонизации и зависимости. fn id(&self) -> SurfaceId { match self { - SurfaceSpec::Input { id, .. } | SurfaceSpec::SourceOver { id, .. } => *id, + Self::Input { id, .. } | Self::FromOccurrence { id, .. } => *id, } } } -/// Декларация foreground occurrence: identity-источник foreground наблюдается -/// против конкретной отрисованной поверхности. Именно топология (`against`) -/// задаёт роль «foreground/фон», а не имя токена. +/// Единственная canonical application Paint к backdrop Surface. +#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct ForegroundOccurrenceSpec { - /// Handle occurrence. +pub(crate) struct OccurrenceSpec { pub(crate) id: OccurrenceId, - /// Цветовой вход — идентичность foreground (то, ЧТО наблюдается). - pub(crate) identity_source: ColorInputId, - /// Поверхность, против которой foreground реально стоит. + pub(crate) subject: PaintId, pub(crate) against: SurfaceId, + pub(crate) profile: CompositionProfileV1, } -/// Типизированные ошибки 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, +/// Ошибки AOT-компиляции декларации. Compiler принадлежит proof-поверхности и +/// не входит в production-артефакт. +#[cfg(test)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CompileError { + DuplicateColorInput { input: ColorInputId, }, - /// Поверхность ссылается на необъявленный вход непрозрачности. - MissingSurfaceOpacityInput { - surface: SurfaceId, + DuplicateOpacityInput { input: OpacityInputId, }, - /// Композит ссылается на необъявленную поверхность-подложку. - MissingSurfaceBackdrop { + DuplicatePaint { + paint: PaintId, + }, + DuplicateSurface { surface: SurfaceId, - backdrop: SurfaceId, }, - /// Occurrence ссылается на необъявленный identity-источник. - MissingOccurrenceSource { + DuplicateOccurrence { occurrence: OccurrenceId, + }, + MissingPaintColorInput { + paint: PaintId, input: ColorInputId, }, - /// Occurrence наблюдается против необъявленной поверхности. + MissingPaintSource { + paint: PaintId, + source: PaintId, + }, + MissingPaintOpacityInput { + paint: PaintId, + input: OpacityInputId, + }, + MissingSurfaceColorInput { + surface: SurfaceId, + input: ColorInputId, + }, + MissingSurfaceOccurrence { + surface: SurfaceId, + occurrence: OccurrenceId, + }, + MissingOccurrencePaint { + occurrence: OccurrenceId, + paint: PaintId, + }, 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`] дословно, чтобы - /// потребитель мог сохранить прежний публичный текст отказа байт-в-байт. + /// Только реальные участники циклов, без зависимых от них узлов. + PaintCycle { + paints: Vec, + }, + /// Только реальные участники циклов, разложенные по typed ID-пространствам. + RenderCycle { + surfaces: Vec, + occurrences: Vec, + }, +} + +/// Ошибки admission runtime bindings. Исполнение начинается только после +/// полной проверки, поэтому частичного результата нет. +#[cfg(test)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BindingError { + DuplicateColorBinding { + input: ColorInputId, + }, + DuplicateOpacityBinding { + input: OpacityInputId, + }, + MissingColorBinding { + input: ColorInputId, + }, + MissingOpacityBinding { + input: OpacityInputId, + }, + UnexpectedColorBinding { + input: ColorInputId, + }, + UnexpectedOpacityBinding { + input: OpacityInputId, + }, OpacityOutOfDomain { input: OpacityInputId, message: String, }, - /// Композитор отверг вход. После валидации bindings недостижимо (байты - /// корректны по типу, α проверена), но паника на публично достижимом пути - /// запрещена — отказ остаётся типизированным. - CompositionFailed { surface: SurfaceId, message: String }, } -/// Спека компонента до компиляции: плоские списки деклараций. Порядок -/// деклараций НЕ несёт смысла — compile канонизирует его по typed ID. +/// Единственный отказ sealed point-adapter-а: невалидная authored alpha. +/// Topology/bindings не представлены во входном типе и потому не могут дать +/// runtime-ошибку. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct PointOpacityError { + message: String, +} + +impl PointOpacityError { + pub(crate) fn message(&self) -> &str { + &self.message + } +} + +/// Плоские декларации до атомарной компиляции. Порядок списков смысла не несёт. +#[cfg(test)] #[derive(Debug, Clone, PartialEq)] pub(crate) struct AppearanceGraphSpec { color_inputs: Vec, opacity_inputs: Vec, + paints: Vec, surfaces: Vec, - occurrences: Vec, + occurrences: Vec, } +#[cfg(test)] impl AppearanceGraphSpec { - /// Собрать спеку из деклараций. Валидации здесь нет намеренно: единственная - /// точка отказа — атомарный [`compile`](Self::compile). pub(crate) fn new( color_inputs: Vec, opacity_inputs: Vec, + paints: Vec, surfaces: Vec, - occurrences: Vec, + occurrences: Vec, ) -> Self { Self { color_inputs, opacity_inputs, + paints, surfaces, occurrences, } } - /// Детерминированная атомарная компиляция: дубликаты → ссылки → циклы. - /// - /// Порядок проверок фиксирован и не зависит от порядка деклараций (все - /// проверки идут по канонически отсортированным копиям): при нескольких - /// дефектах сообщается дефект с наименьшим typed ID первого нарушенного - /// класса. Частичный граф при ошибке не публикуется. - /// - /// # Errors - /// - /// Типизированный [`GraphError`] соответствующего класса. - pub(crate) fn compile(&self) -> Result { - // Канонизация: сортировка по typed ID. Дубликаты после сортировки - // смежны — детекция order-independent по построению. - let mut color_inputs = self.color_inputs.clone(); + /// Канонизировать декларации, проверить каждое typed-ребро и построить два + /// детерминированных topo: Paint DAG и совместный Surface/Occurrence DAG. + pub(crate) fn compile(self) -> Result { + let Self { + mut color_inputs, + mut opacity_inputs, + mut paints, + mut surfaces, + mut occurrences, + } = self; + 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] }); + if let Some(duplicate) = adjacent_duplicate(&color_inputs) { + return Err(CompileError::DuplicateColorInput { input: duplicate }); } - 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] }); + if let Some(duplicate) = adjacent_duplicate(&opacity_inputs) { + return Err(CompileError::DuplicateOpacityInput { input: duplicate }); + } + + paints.sort_unstable_by_key(PaintSpec::id); + if let Some(duplicate) = paints + .windows(2) + .find(|window| window[0].id() == window[1].id()) + { + return Err(CompileError::DuplicatePaint { + paint: duplicate[0].id(), + }); } - 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() }); + if let Some(duplicate) = surfaces + .windows(2) + .find(|window| window[0].id() == window[1].id()) + { + return Err(CompileError::DuplicateSurface { + surface: duplicate[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, + occurrences.sort_unstable_by_key(|occurrence| occurrence.id); + if let Some(duplicate) = occurrences + .windows(2) + .find(|window| window[0].id == window[1].id) + { + return Err(CompileError::DuplicateOccurrence { + occurrence: duplicate[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 paint_index = |id: PaintId| paints.binary_search_by_key(&id, PaintSpec::id).ok(); let surface_index = |id: SurfaceId| surfaces.binary_search_by_key(&id, SurfaceSpec::id).ok(); + let occurrence_index = |id: OccurrenceId| { + occurrences + .binary_search_by_key(&id, |occurrence| occurrence.id) + .ok() + }; - for spec in &surfaces { - match *spec { - SurfaceSpec::Input { id, color } => { + for paint in &paints { + match *paint { + PaintSpec::Solid { id, color } => { if !has_color(color) { - return Err(GraphError::MissingSurfaceColorInput { - surface: id, + return Err(CompileError::MissingPaintColorInput { + paint: id, input: color, }); } } - SurfaceSpec::SourceOver { + PaintSpec::Opacity { id, source, opacity, - backdrop, - profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, } => { - if !has_color(source) { - return Err(GraphError::MissingSurfaceColorInput { - surface: id, - input: source, - }); + if paint_index(source).is_none() { + return Err(CompileError::MissingPaintSource { paint: id, source }); } if !has_opacity(opacity) { - return Err(GraphError::MissingSurfaceOpacityInput { - surface: id, + return Err(CompileError::MissingPaintOpacityInput { + paint: id, input: opacity, }); } - if surface_index(backdrop).is_none() { - return Err(GraphError::MissingSurfaceBackdrop { + } + } + } + + for surface in &surfaces { + match *surface { + SurfaceSpec::Input { id, color } => { + if !has_color(color) { + return Err(CompileError::MissingSurfaceColorInput { surface: id, - backdrop, + input: color, + }); + } + } + SurfaceSpec::FromOccurrence { id, occurrence } => { + if occurrence_index(occurrence).is_none() { + return Err(CompileError::MissingSurfaceOccurrence { + surface: id, + occurrence, }); } } @@ -314,205 +383,643 @@ impl AppearanceGraphSpec { } for occurrence in &occurrences { - if !has_color(occurrence.identity_source) { - return Err(GraphError::MissingOccurrenceSource { + if paint_index(occurrence.subject).is_none() { + return Err(CompileError::MissingOccurrencePaint { occurrence: occurrence.id, - input: occurrence.identity_source, + paint: occurrence.subject, }); } if surface_index(occurrence.against).is_none() { - return Err(GraphError::MissingOccurrenceBackdrop { + return Err(CompileError::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 + let paint_dependencies: Vec> = paints .iter() - .enumerate() - .filter(|(index, _)| pending_deps[*index] == 0) - .map(|(_, spec)| spec.id()) + .map(|paint| match *paint { + PaintSpec::Solid { .. } => None, + PaintSpec::Opacity { source, .. } => paint_index(source), + }) .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()); + let paint_keys: Vec = paints.iter().map(PaintSpec::id).collect(); + let paint_topo = + canonical_functional_topology(&paint_keys, &paint_dependencies).map_err(|members| { + CompileError::PaintCycle { + paints: members.into_iter().map(|index| paint_keys[index]).collect(), } - } - } - if topo.len() != surfaces.len() { - let mut cycle: Vec = surfaces + })?; + + let surface_count = surfaces.len(); + let mut render_keys: Vec = surfaces + .iter() + .map(|surface| RenderKey::Surface(surface.id())) + .collect(); + render_keys.extend( + occurrences .iter() - .enumerate() - .filter(|(index, _)| pending_deps[*index] > 0) - .map(|(_, spec)| spec.id()) - .collect(); - cycle.sort_unstable(); - return Err(GraphError::SurfaceCycle { surfaces: cycle }); - } + .map(|occurrence| RenderKey::Occurrence(occurrence.id)), + ); + let mut render_dependencies: Vec> = surfaces + .iter() + .map(|surface| match *surface { + SurfaceSpec::Input { .. } => None, + SurfaceSpec::FromOccurrence { occurrence, .. } => { + occurrence_index(occurrence).map(|index| surface_count + index) + } + }) + .collect(); + render_dependencies.extend( + occurrences + .iter() + .map(|occurrence| surface_index(occurrence.against)), + ); + let render_topo_indices = canonical_functional_topology(&render_keys, &render_dependencies) + .map_err(|members| { + let mut cyclic_surfaces = Vec::new(); + let mut cyclic_occurrences = Vec::new(); + for index in members { + if index < surface_count { + cyclic_surfaces.push(surfaces[index].id()); + } else { + cyclic_occurrences.push(occurrences[index - surface_count].id); + } + } + cyclic_surfaces.sort_unstable(); + cyclic_occurrences.sort_unstable(); + CompileError::RenderCycle { + surfaces: cyclic_surfaces, + occurrences: cyclic_occurrences, + } + })?; + let render_topo = render_topo_indices + .into_iter() + .map(|index| { + if index < surface_count { + RenderNode::Surface(index) + } else { + RenderNode::Occurrence(index - surface_count) + } + }) + .collect(); + + let compiled_paints = paints + .iter() + .map(|paint| match *paint { + PaintSpec::Solid { id, color } => CompiledPaintSpec::Solid { id, color }, + PaintSpec::Opacity { + id, + source, + opacity, + } => CompiledPaintSpec::Opacity { + id, + source: paint_index(source) + .unwrap_or_else(|| unreachable!("paint links were validated")), + opacity, + }, + }) + .collect(); + let compiled_surfaces = surfaces + .iter() + .map(|surface| match *surface { + SurfaceSpec::Input { id, color } => CompiledSurfaceSpec::Input { id, color }, + SurfaceSpec::FromOccurrence { id, occurrence } => { + CompiledSurfaceSpec::FromOccurrence { + id, + occurrence: occurrence_index(occurrence) + .unwrap_or_else(|| unreachable!("occurrence links were validated")), + } + } + }) + .collect(); + let compiled_occurrences = occurrences + .iter() + .map(|occurrence| CompiledOccurrenceSpec { + id: occurrence.id, + subject_id: occurrence.subject, + subject: paint_index(occurrence.subject) + .unwrap_or_else(|| unreachable!("paint links were validated")), + against_id: occurrence.against, + against: surface_index(occurrence.against) + .unwrap_or_else(|| unreachable!("surface links were validated")), + profile: occurrence.profile, + }) + .collect(); Ok(CompiledAppearanceGraph { color_inputs, opacity_inputs, - surfaces, - occurrences, - topo, + paints: compiled_paints, + surfaces: compiled_surfaces, + occurrences: compiled_occurrences, + paint_topo, + render_topo, }) } } -/// Скомпилированный граф: канонические декларации + детерминированный topo. -/// Равенство скомпилированных графов означает равную физику: любые два -/// объявления с теми же handles/рёбрами компилируются в идентичное значение. -#[derive(Debug, Clone, PartialEq)] +#[cfg(test)] +fn adjacent_duplicate(sorted: &[T]) -> Option { + sorted + .windows(2) + .find(|window| window[0] == window[1]) + .map(|window| window[0]) +} + +/// Topo для functional dependency graph: каждый узел имеет не более одной +/// зависимости. При цикле возвращает только его реальные узлы, а не весь +/// заблокированный Kahn-остаток. +#[cfg(test)] +fn canonical_functional_topology( + keys: &[K], + dependencies: &[Option], +) -> Result, Vec> { + debug_assert_eq!(keys.len(), dependencies.len()); + let mut dependents: Vec> = vec![Vec::new(); keys.len()]; + let mut pending: Vec = vec![0; keys.len()]; + for (node, dependency) in dependencies.iter().enumerate() { + if let Some(dependency) = dependency { + dependents[*dependency].push(node); + pending[node] = 1; + } + } + let mut ready: BTreeSet<(K, usize)> = keys + .iter() + .copied() + .enumerate() + .filter(|(index, _)| pending[*index] == 0) + .map(|(index, key)| (key, index)) + .collect(); + let mut topo = Vec::with_capacity(keys.len()); + while let Some((_, node)) = ready.pop_first() { + topo.push(node); + for &dependent in &dependents[node] { + pending[dependent] -= 1; + if pending[dependent] == 0 { + ready.insert((keys[dependent], dependent)); + } + } + } + if topo.len() == keys.len() { + Ok(topo) + } else { + Err(functional_cycle_members(dependencies)) + } +} + +/// Итеративный functional-cycle detector: O(V), без риска переполнить стек на +/// большом входе и без ложного включения деревьев, ведущих в цикл. +#[cfg(test)] +fn functional_cycle_members(dependencies: &[Option]) -> Vec { + const UNSEEN: u8 = 0; + const ACTIVE: u8 = 1; + const DONE: u8 = 2; + + let mut state = vec![UNSEEN; dependencies.len()]; + let mut position = vec![usize::MAX; dependencies.len()]; + let mut cycles = Vec::new(); + for start in 0..dependencies.len() { + if state[start] != UNSEEN { + continue; + } + let mut path = Vec::new(); + let mut current = Some(start); + while let Some(node) = current { + match state[node] { + UNSEEN => { + state[node] = ACTIVE; + position[node] = path.len(); + path.push(node); + current = dependencies[node]; + } + ACTIVE => { + let cycle_start = position[node]; + if cycle_start != usize::MAX { + cycles.extend_from_slice(&path[cycle_start..]); + } + break; + } + DONE => break, + _ => unreachable!("cycle detector has a closed state set"), + } + } + for node in path { + state[node] = DONE; + position[node] = usize::MAX; + } + } + cycles.sort_unstable(); + cycles.dedup(); + cycles +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[cfg(test)] +enum RenderKey { + Surface(SurfaceId), + Occurrence(OccurrenceId), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RenderNode { + Surface(usize), + Occurrence(usize), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CompiledPaintSpec { + Solid { + id: PaintId, + color: ColorInputId, + }, + Opacity { + id: PaintId, + source: usize, + opacity: OpacityInputId, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CompiledSurfaceSpec { + Input { id: SurfaceId, color: ColorInputId }, + FromOccurrence { id: SurfaceId, occurrence: usize }, +} + +#[cfg(test)] +impl CompiledSurfaceSpec { + fn id(&self) -> SurfaceId { + match self { + Self::Input { id, .. } | Self::FromOccurrence { id, .. } => *id, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CompiledOccurrenceSpec { + id: OccurrenceId, + subject_id: PaintId, + subject: usize, + against_id: SurfaceId, + against: usize, + profile: CompositionProfileV1, +} + +/// Канонический compiled IR с индексными ссылками: после проверки bindings +/// исполнение самих Paint/Surface/Occurrence узлов линейно по их числу. +#[cfg(test)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CompiledAppearanceGraph { - /// Отсортированные объявленные цветовые входы. color_inputs: Vec, - /// Отсортированные объявленные входы непрозрачности. opacity_inputs: Vec, - /// Поверхности в каноническом порядке (по id). - surfaces: Vec, - /// Occurrences в каноническом порядке (по id). - occurrences: Vec, - /// Индексы `surfaces` в порядке исполнения (канонический Кан). - topo: Vec, + paints: Vec, + surfaces: Vec, + occurrences: Vec, + paint_topo: Vec, + render_topo: Vec, +} + +/// Borrowed runtime-представление уже проверенного compiled IR. Оно отделяет +/// исполнение от compiler-а и позволяет статическим внутренним adapter-ам не +/// тащить admission/topology machinery в конечный binary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CompiledAppearanceProgram<'a> { + color_inputs: &'a [ColorInputId], + opacity_inputs: &'a [OpacityInputId], + paints: &'a [CompiledPaintSpec], + surfaces: &'a [CompiledSurfaceSpec], + occurrences: &'a [CompiledOccurrenceSpec], + paint_topo: &'a [usize], + render_topo: &'a [RenderNode], +} + +impl<'a> CompiledAppearanceProgram<'a> { + /// Создаёт view только из IR, уже доказанно эквивалентного результату + /// `AppearanceGraphSpec::compile`. Static adapter обязан защищать это + /// равенство characterization-тестом. + const fn from_validated_parts( + color_inputs: &'a [ColorInputId], + opacity_inputs: &'a [OpacityInputId], + paints: &'a [CompiledPaintSpec], + surfaces: &'a [CompiledSurfaceSpec], + occurrences: &'a [CompiledOccurrenceSpec], + paint_topo: &'a [usize], + render_topo: &'a [RenderNode], + ) -> Self { + Self { + color_inputs, + opacity_inputs, + paints, + surfaces, + occurrences, + paint_topo, + render_topo, + } + } } -/// Значения входов на один evaluate: цвета — финальные sRGB8-байты, альфы — -/// binary64. Дубликаты/пропуски/лишние значения отвергает `evaluate` -/// (конструктор непадающий — единая точка отказа). +const POINT_SOURCE: ColorInputId = ColorInputId::new(0); +const POINT_CONTEXT: ColorInputId = ColorInputId::new(1); +const POINT_OPACITY: OpacityInputId = OpacityInputId::new(0); +const POINT_SOLID_PAINT: PaintId = PaintId::new(0); +const POINT_OPACITY_PAINT: PaintId = PaintId::new(1); +const POINT_CONTEXT_SURFACE: SurfaceId = SurfaceId::new(0); +const POINT_DERIVED_SURFACE: SurfaceId = SurfaceId::new(1); +const POINT_OCCURRENCE: OccurrenceId = OccurrenceId::new(0); + +const POINT_COLOR_INPUTS: [ColorInputId; 2] = [POINT_SOURCE, POINT_CONTEXT]; +const POINT_OPACITY_INPUTS: [OpacityInputId; 1] = [POINT_OPACITY]; +const POINT_PAINTS: [CompiledPaintSpec; 2] = [ + CompiledPaintSpec::Solid { + id: POINT_SOLID_PAINT, + color: POINT_SOURCE, + }, + CompiledPaintSpec::Opacity { + id: POINT_OPACITY_PAINT, + source: 0, + opacity: POINT_OPACITY, + }, +]; +const POINT_SURFACES: [CompiledSurfaceSpec; 2] = [ + CompiledSurfaceSpec::Input { + id: POINT_CONTEXT_SURFACE, + color: POINT_CONTEXT, + }, + CompiledSurfaceSpec::FromOccurrence { + id: POINT_DERIVED_SURFACE, + occurrence: 0, + }, +]; +const POINT_OCCURRENCES: [CompiledOccurrenceSpec; 1] = [CompiledOccurrenceSpec { + id: POINT_OCCURRENCE, + subject_id: POINT_OPACITY_PAINT, + subject: 1, + against_id: POINT_CONTEXT_SURFACE, + against: 0, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, +}]; +const POINT_PAINT_TOPO: [usize; 2] = [0, 1]; +const POINT_RENDER_TOPO: [RenderNode; 3] = [ + RenderNode::Surface(0), + RenderNode::Occurrence(0), + RenderNode::Surface(1), +]; +const POINT_OPACITY_OVER_SURFACE_V1: CompiledAppearanceProgram<'static> = + CompiledAppearanceProgram::from_validated_parts( + &POINT_COLOR_INPUTS, + &POINT_OPACITY_INPUTS, + &POINT_PAINTS, + &POINT_SURFACES, + &POINT_OCCURRENCES, + &POINT_PAINT_TOPO, + &POINT_RENDER_TOPO, + ); + +/// Sealed adapter минимального point-program. Он принимает только физические +/// значения; topology и индексные ссылки нельзя собрать за пределами модуля. +pub(crate) struct PointOpacityOverSurfaceV1; + +impl PointOpacityOverSurfaceV1 { + pub(crate) fn evaluate( + source: [u8; 3], + opacity: f64, + backdrop: [u8; 3], + ) -> Result<[u8; 3], PointOpacityError> { + crate::alpha::validate_alpha(opacity).map_err(|message| PointOpacityError { message })?; + let opacity = if opacity == 0.0 { 0.0 } else { opacity }; + let mut paints = [None; 2]; + let mut surfaces = [None; 2]; + let mut occurrences = [None; 1]; + POINT_OPACITY_OVER_SURFACE_V1.execute_into( + |id| match id { + POINT_SOURCE => source, + POINT_CONTEXT => backdrop, + _ => unreachable!("sealed point program has two ColorInput ports"), + }, + |id| match id { + POINT_OPACITY => opacity, + _ => unreachable!("sealed point program has one OpacityInput port"), + }, + &mut paints, + &mut surfaces, + &mut occurrences, + ); + Ok(surfaces[1].unwrap_or_else(|| { + unreachable!("compiler-verified point program materializes its output Surface") + })) + } +} + +#[cfg(test)] +pub(crate) fn point_opacity_over_surface_declarative_spec() -> AppearanceGraphSpec { + AppearanceGraphSpec::new( + POINT_COLOR_INPUTS.to_vec(), + POINT_OPACITY_INPUTS.to_vec(), + vec![ + PaintSpec::Solid { + id: POINT_SOLID_PAINT, + color: POINT_SOURCE, + }, + PaintSpec::Opacity { + id: POINT_OPACITY_PAINT, + source: POINT_SOLID_PAINT, + opacity: POINT_OPACITY, + }, + ], + vec![ + SurfaceSpec::Input { + id: POINT_CONTEXT_SURFACE, + color: POINT_CONTEXT, + }, + SurfaceSpec::FromOccurrence { + id: POINT_DERIVED_SURFACE, + occurrence: POINT_OCCURRENCE, + }, + ], + vec![OccurrenceSpec { + id: POINT_OCCURRENCE, + subject: POINT_OPACITY_PAINT, + against: POINT_CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }], + ) +} + +#[cfg(test)] +pub(crate) fn point_program_matches(compiled: &CompiledAppearanceGraph) -> bool { + compiled.matches_program(POINT_OPACITY_OVER_SURFACE_V1) +} + +/// Runtime bindings одного атомарного evaluate. +#[cfg(test)] #[derive(Debug, Clone, PartialEq)] pub(crate) struct AppearanceBindings { colors: Vec<(ColorInputId, [u8; 3])>, opacities: Vec<(OpacityInputId, f64)>, } +#[cfg(test)] impl AppearanceBindings { - /// Собрать значения входов. Валидация — в [`CompiledAppearanceGraph::evaluate`]. pub(crate) fn new( - colors: Vec<(ColorInputId, [u8; 3])>, - opacities: Vec<(OpacityInputId, f64)>, + mut colors: Vec<(ColorInputId, [u8; 3])>, + mut opacities: Vec<(OpacityInputId, f64)>, ) -> Self { + colors.sort_unstable_by_key(|(id, _)| *id); + opacities.sort_unstable_by_key(|(id, _)| *id); Self { colors, opacities } } } -/// Счётчики фактически исполненных узлов — доказательство исполнения рёбер -/// (анти-вакуум: тест сверяет счётчики, а не только совпадение результата). +/// Материализованный point Paint. Значение alpha хранится битами, чтобы +/// equality/certificate не теряли binary64 representation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ResolvedPaint { + id: PaintId, + rgb: [u8; 3], + opacity_bits: u64, +} + +impl ResolvedPaint { + #[cfg(test)] + pub(crate) fn rgb(&self) -> [u8; 3] { + self.rgb + } + + #[cfg(test)] + pub(crate) fn opacity_bits(&self) -> u64 { + self.opacity_bits + } +} + +/// Replayable exact point-composite certificate. Он доказывает только +/// заявленную математическую операцию, не readability и не browser pixels. #[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], + profile: CompositionProfileV1, + occurrence: OccurrenceId, + subject: PaintId, + subject_rgb: [u8; 3], + subject_opacity_bits: u64, + backdrop_surface: SurfaceId, + backdrop_rgb: [u8; 3], + output_rgb: [u8; 3], } impl SourceOverCertificateV1 { - /// Независимо повторить операцию из данных сертификата. - /// - /// Часть proof-контракта модуля (§6.3 ТЗ #307): потребляется - /// доказательствами (replay-тесты), production-путь один evaluate - /// не дублирует — отсюда allow вне test-сборки. - /// - /// # Errors - /// - /// Доменная ошибка SSOT-композитора, если сертификат собран из - /// невалидных данных (у честно выданного сертификата недостижимо). - #[cfg_attr(not(test), allow(dead_code))] + #[cfg(test)] 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.subject_rgb, + f64::from_bits(self.subject_opacity_bits), self.backdrop_rgb, ), } } + + #[cfg(test)] + pub(crate) fn profile(&self) -> CompositionProfileV1 { + self.profile + } + + #[cfg(test)] + pub(crate) fn occurrence(&self) -> OccurrenceId { + self.occurrence + } + + #[cfg(test)] + pub(crate) fn subject(&self) -> PaintId { + self.subject + } + + #[cfg(test)] + pub(crate) fn subject_rgb(&self) -> [u8; 3] { + self.subject_rgb + } + + #[cfg(test)] + pub(crate) fn subject_opacity_bits(&self) -> u64 { + self.subject_opacity_bits + } + + #[cfg(test)] + pub(crate) fn backdrop_surface(&self) -> SurfaceId { + self.backdrop_surface + } + + #[cfg(test)] + pub(crate) fn backdrop_rgb(&self) -> [u8; 3] { + self.backdrop_rgb + } + + #[cfg(test)] + pub(crate) fn output_rgb(&self) -> [u8; 3] { + self.output_rgb + } } -/// Разрешённый foreground occurrence: identity-источник, его байты и финальные -/// байты поверхности, против которой foreground реально стоит. +/// Разрешённое применение Paint к Surface. Сертификат структурно принадлежит +/// occurrence; `surfaceFrom` второго сертификата не создаёт. #[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], + id: OccurrenceId, + subject: PaintId, + against: SurfaceId, + backdrop: [u8; 3], + visible: [u8; 3], + certificate: SourceOverCertificateV1, } -/// Результат одного evaluate: байты каждой поверхности, occurrences, -/// сертификаты exact-операций и счётчики исполнения. Все коллекции — в -/// каноническом порядке, поэтому результат сравним значением. -#[derive(Debug, Clone, PartialEq)] +impl ResolvedOccurrence { + #[cfg(test)] + pub(crate) fn id(&self) -> OccurrenceId { + self.id + } + + #[cfg(test)] + pub(crate) fn subject(&self) -> PaintId { + self.subject + } + + #[cfg(test)] + pub(crate) fn against(&self) -> SurfaceId { + self.against + } + + #[cfg(test)] + pub(crate) fn backdrop(&self) -> [u8; 3] { + self.backdrop + } + + pub(crate) fn visible(&self) -> [u8; 3] { + self.visible + } + + #[cfg(test)] + pub(crate) fn certificate(&self) -> &SourceOverCertificateV1 { + &self.certificate + } +} + +/// Полный атомарный результат evaluate в каноническом typed-ID порядке. +#[cfg(test)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AppearanceEvaluation { - /// `(поверхность, финальные байты)` в каноническом порядке (по id). + paints: Vec, surfaces: Vec<(SurfaceId, [u8; 3])>, - /// Occurrences в каноническом порядке (по id). occurrences: Vec, - /// Сертификаты source-over рёбер в порядке исполнения (канонический topo). - certificates: Vec, - /// Счётчики фактического исполнения. - trace: ExecutionTrace, } +#[cfg(test)] impl AppearanceEvaluation { - /// Финальные байты поверхности, если она объявлена. Инспекционный API - /// (проверяется доказательствами; production читает occurrence). - #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn paint(&self, id: PaintId) -> Option<&ResolvedPaint> { + self.paints + .binary_search_by_key(&id, |paint| paint.id) + .ok() + .map(|index| &self.paints[index]) + } + pub(crate) fn surface_rgb(&self, id: SurfaceId) -> Option<[u8; 3]> { self.surfaces .binary_search_by_key(&id, |(surface, _)| *surface) @@ -520,88 +1027,89 @@ impl AppearanceEvaluation { .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 +#[cfg(test)] +impl CompiledAppearanceGraph { + fn program(&self) -> CompiledAppearanceProgram<'_> { + CompiledAppearanceProgram::from_validated_parts( + &self.color_inputs, + &self.opacity_inputs, + &self.paints, + &self.surfaces, + &self.occurrences, + &self.paint_topo, + &self.render_topo, + ) } - /// Счётчики фактического исполнения — анти-вакуумный proof-контракт. - #[cfg_attr(not(test), allow(dead_code))] - pub(crate) fn trace(&self) -> ExecutionTrace { - self.trace + fn matches_program(&self, program: CompiledAppearanceProgram<'_>) -> bool { + self.program() == program + } + + pub(crate) fn evaluate( + &self, + bindings: &AppearanceBindings, + ) -> Result { + self.program().evaluate(bindings) } } -impl CompiledAppearanceGraph { - /// Исполнить граф на значениях входов: строго по compiled topo, только - /// через SSOT-композитор, fail closed на любом дефекте bindings. - /// - /// Порядок валидации детерминирован: дубликаты → пропуски → лишние → - /// домен α (везде наименьший typed ID первого нарушенного класса). - /// - /// # Errors - /// - /// Типизированный [`GraphError`]; частичный результат не публикуется. +impl CompiledAppearanceProgram<'_> { + /// Проверить bindings, материализовать Paint DAG один раз и исполнить + /// Surface/Occurrence DAG один раз. Частичный результат не возвращается. + #[cfg(test)] 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 }); + ) -> Result { + let colors = &bindings.colors; + if let Some(window) = colors.windows(2).find(|window| window[0].0 == window[1].0) { + return Err(BindingError::DuplicateColorBinding { input: window[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 }); + let opacities = &bindings.opacities; + if let Some(window) = opacities + .windows(2) + .find(|window| window[0].0 == window[1].0) + { + return Err(BindingError::DuplicateOpacityBinding { input: window[0].0 }); } - // Точное соответствие объявлениям: и пропуск, и лишнее значение — - // отказ (молчаливые дефолты запрещены контрактом продукта). - for declared in &self.color_inputs { + for declared in self.color_inputs { if colors .binary_search_by_key(declared, |(id, _)| *id) .is_err() { - return Err(GraphError::MissingColorBinding { input: *declared }); + return Err(BindingError::MissingColorBinding { input: *declared }); } } - for declared in &self.opacity_inputs { + for declared in self.opacity_inputs { if opacities .binary_search_by_key(declared, |(id, _)| *id) .is_err() { - return Err(GraphError::MissingOpacityBinding { input: *declared }); + return Err(BindingError::MissingOpacityBinding { input: *declared }); } } - for (bound, _) in &colors { + for (bound, _) in colors { if self.color_inputs.binary_search(bound).is_err() { - return Err(GraphError::UnexpectedColorBinding { input: *bound }); + return Err(BindingError::UnexpectedColorBinding { input: *bound }); } } - for (bound, _) in &opacities { + for (bound, _) in opacities { if self.opacity_inputs.binary_search(bound).is_err() { - return Err(GraphError::UnexpectedOpacityBinding { input: *bound }); + return Err(BindingError::UnexpectedOpacityBinding { input: *bound }); } } - - // Домен α — SSOT-валидатор композитора, чтобы текст доменного отказа - // был единым во всём продукте (сообщение переносится дословно). - for (input, alpha) in &opacities { + for (input, alpha) in opacities { if let Err(message) = crate::alpha::validate_alpha(*alpha) { - return Err(GraphError::OpacityOutOfDomain { + return Err(BindingError::OpacityOutOfDomain { input: *input, message, }); @@ -611,107 +1119,155 @@ impl CompiledAppearanceGraph { let color_value = |id: ColorInputId| -> [u8; 3] { let index = colors .binary_search_by_key(&id, |(bound, _)| *bound) - .unwrap_or_else(|_| unreachable!("соответствие bindings проверено выше")); + .unwrap_or_else(|_| unreachable!("bindings were matched before evaluation")); 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 + .unwrap_or_else(|_| unreachable!("bindings were matched before evaluation")); + let alpha = opacities[index].1; + // Straight alpha — не signed quantity: ±0 описывают один + // физический state и не должны минтить разные certificate bits. + if alpha == 0.0 { 0.0 } else { alpha } }; - // Исполнение строго по 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, - }); - resolved[index] = Some(output_rgb); - } - } - } + let mut resolved_paints: Vec> = vec![None; self.paints.len()]; + let mut resolved_surfaces: Vec> = vec![None; self.surfaces.len()]; + let mut resolved_occurrences: Vec> = + vec![None; self.occurrences.len()]; + self.execute_into( + color_value, + opacity_value, + &mut resolved_paints, + &mut resolved_surfaces, + &mut resolved_occurrences, + ); - let surfaces: Vec<(SurfaceId, [u8; 3])> = self + let paints = resolved_paints + .into_iter() + .map(|paint| paint.unwrap_or_else(|| unreachable!("Paint topo covers every node"))) + .collect(); + let surfaces = self .surfaces .iter() - .zip(&resolved) - .map(|(spec, bytes)| { - let bytes = - bytes.unwrap_or_else(|| unreachable!("topo покрывает каждую поверхность")); - (spec.id(), bytes) + .zip(resolved_surfaces) + .map(|(surface, value)| { + ( + surface.id(), + value.unwrap_or_else(|| unreachable!("render topo covers every Surface")), + ) }) .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, - } + let occurrences = resolved_occurrences + .into_iter() + .map(|occurrence| { + occurrence.unwrap_or_else(|| unreachable!("render topo covers every Occurrence")) }) .collect(); Ok(AppearanceEvaluation { + paints, surfaces, occurrences, - certificates, - trace, }) } + + /// Единственное исполнение compiled IR. Scratch принадлежит caller-у: + /// static adapter использует stack arrays, test-only generic admission — + /// динамические buffers. Алгоритм и сертификат при этом общие. + fn execute_into( + &self, + color_value: C, + opacity_value: O, + resolved_paints: &mut [Option], + resolved_surfaces: &mut [Option<[u8; 3]>], + resolved_occurrences: &mut [Option], + ) where + C: Fn(ColorInputId) -> [u8; 3], + O: Fn(OpacityInputId) -> f64, + { + debug_assert_eq!(resolved_paints.len(), self.paints.len()); + debug_assert_eq!(resolved_surfaces.len(), self.surfaces.len()); + debug_assert_eq!(resolved_occurrences.len(), self.occurrences.len()); + + for &index in self.paint_topo { + let paint = match self.paints[index] { + CompiledPaintSpec::Solid { id, color } => ResolvedPaint { + id, + rgb: color_value(color), + opacity_bits: 1.0f64.to_bits(), + }, + CompiledPaintSpec::Opacity { + id, + source, + opacity, + } => { + let source = resolved_paints[source] + .unwrap_or_else(|| unreachable!("Paint dependency precedes its consumer")); + let effective_alpha = + f64::from_bits(source.opacity_bits) * opacity_value(opacity); + ResolvedPaint { + id, + rgb: source.rgb, + opacity_bits: effective_alpha.to_bits(), + } + } + }; + resolved_paints[index] = Some(paint); + } + + for node in self.render_topo { + match *node { + RenderNode::Surface(index) => { + let value = match self.surfaces[index] { + CompiledSurfaceSpec::Input { color, .. } => color_value(color), + CompiledSurfaceSpec::FromOccurrence { occurrence, .. } => { + resolved_occurrences[occurrence] + .unwrap_or_else(|| { + unreachable!("occurrence precedes surfaceFrom in render topo") + }) + .visible() + } + }; + resolved_surfaces[index] = Some(value); + } + RenderNode::Occurrence(index) => { + let spec = &self.occurrences[index]; + let subject = resolved_paints[spec.subject] + .unwrap_or_else(|| unreachable!("Paint DAG is evaluated first")); + let backdrop = resolved_surfaces[spec.against].unwrap_or_else(|| { + unreachable!("backdrop precedes occurrence in render topo") + }); + let visible = match spec.profile { + CompositionProfileV1::EncodedSrgb8SourceOverV1 => { + crate::alpha::composite_over_srgb8_validated( + subject.rgb, + f64::from_bits(subject.opacity_bits), + backdrop, + ) + } + }; + let certificate = SourceOverCertificateV1 { + profile: spec.profile, + occurrence: spec.id, + subject: spec.subject_id, + subject_rgb: subject.rgb, + subject_opacity_bits: subject.opacity_bits, + backdrop_surface: spec.against_id, + backdrop_rgb: backdrop, + output_rgb: visible, + }; + resolved_occurrences[index] = Some(ResolvedOccurrence { + id: spec.id, + subject: spec.subject_id, + against: spec.against_id, + backdrop, + visible, + certificate, + }); + } + } + } + } } diff --git a/crates/labcolors-core/src/appearance_graph_tests.rs b/crates/labcolors-core/src/appearance_graph_tests.rs index eaa1dcce..360c3adb 100644 --- a/crates/labcolors-core/src/appearance_graph_tests.rs +++ b/crates/labcolors-core/src/appearance_graph_tests.rs @@ -1,93 +1,69 @@ -//! Contract tests for the private physical appearance graph (#307). +//! Контрактные тесты приватного point-render стержня. //! -//! 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. +//! Граф владеет только физической топологией: Paint материализуется независимо +//! от подложки, Occurrence является его единственным применением к Surface, а +//! `surfaceFrom` лишь даёт видимому результату повторно используемую identity. +//! Словарь Pair/role и perception-утверждения сюда не входят. use proptest::prelude::*; use crate::appearance::{ - AppearanceBindings, AppearanceGraphSpec, ColorInputId, CompositionProfileV1, - ForegroundOccurrenceSpec, GraphError, OccurrenceId, OpacityInputId, ResolvedOccurrence, - SourceOverCertificateV1, SurfaceId, SurfaceSpec, + AppearanceBindings, AppearanceGraphSpec, BindingError, ColorInputId, CompileError, + CompositionProfileV1, OccurrenceId, OccurrenceSpec, OpacityInputId, PaintId, PaintSpec, + SurfaceId, SurfaceSpec, }; -use crate::solve::Floor; const SOURCE: ColorInputId = ColorInputId::new(0); const CONTEXT: ColorInputId = ColorInputId::new(1); +const OTHER_SOURCE: ColorInputId = ColorInputId::new(2); 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); +const OTHER_OPACITY: OpacityInputId = OpacityInputId::new(1); +const SOLID_PAINT: PaintId = PaintId::new(70); +const FILL_PAINT: PaintId = PaintId::new(3); +const OTHER_PAINT: PaintId = PaintId::new(41); +const CONTEXT_SURFACE: SurfaceId = SurfaceId::new(90); +const DERIVED_SURFACE: SurfaceId = SurfaceId::new(2); +const FILL_OCCURRENCE: OccurrenceId = OccurrenceId::new(800); -#[test] -fn occurrence_contract_contains_only_physical_facts() { - let graph = AppearanceGraphSpec::new( - vec![SOURCE, CONTEXT], - vec![], - vec![SurfaceSpec::Input { +fn point_component(reverse_paints: bool, reverse_surfaces: bool) -> AppearanceGraphSpec { + let mut paints = vec![ + PaintSpec::Solid { + id: SOLID_PAINT, + color: SOURCE, + }, + PaintSpec::Opacity { + id: FILL_PAINT, + source: SOLID_PAINT, + opacity: OPACITY, + }, + ]; + let mut surfaces = vec![ + SurfaceSpec::Input { id: CONTEXT_SURFACE, color: CONTEXT, - }], - vec![ForegroundOccurrenceSpec { - id: FOREGROUND, - identity_source: SOURCE, - against: CONTEXT_SURFACE, - }], - ) - .compile() - .unwrap(); - - let rendered = graph - .evaluate(&AppearanceBindings::new( - vec![(SOURCE, [1, 2, 3]), (CONTEXT, [4, 5, 6])], - vec![], - )) - .unwrap(); - - let ResolvedOccurrence { - id, - identity_source, - source, - against, - backdrop, - } = *rendered.occurrence(FOREGROUND).unwrap(); - - assert_eq!( - (id, identity_source, source, against, backdrop), - (FOREGROUND, SOURCE, [1, 2, 3], CONTEXT_SURFACE, [4, 5, 6],) - ); -} - -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] - }; + }, + SurfaceSpec::FromOccurrence { + id: DERIVED_SURFACE, + occurrence: FILL_OCCURRENCE, + }, + ]; + if reverse_paints { + paints.reverse(); + } + if reverse_surfaces { + surfaces.reverse(); + } AppearanceGraphSpec::new( vec![SOURCE, CONTEXT], vec![OPACITY], + paints, surfaces, - vec![ForegroundOccurrenceSpec { - id: FOREGROUND, - identity_source: SOURCE, - against: DERIVED_SURFACE, + vec![OccurrenceSpec { + id: FILL_OCCURRENCE, + subject: FILL_PAINT, + against: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, }], ) } @@ -100,534 +76,1127 @@ fn bindings(source: [u8; 3], opacity: f64, context: [u8; 3]) -> AppearanceBindin } #[test] -fn warning_occurrence_targets_the_rendered_surface_not_the_page() { - let graph = atomic_component(false).compile().unwrap(); - let rendered = graph +fn static_runtime_program_is_exactly_the_compiler_output() { + let compiled = crate::appearance::point_opacity_over_surface_declarative_spec() + .compile() + .unwrap(); + assert!(crate::appearance::point_program_matches(&compiled)); +} + +#[test] +fn canonical_paint_occurrence_surface_chain_evaluates_exactly() { + let rendered = point_component(false, false) + .compile() + .unwrap() .evaluate(&bindings([0xFF, 0xA1, 0x00], 0.122, [0xFF; 3])) .unwrap(); + let solid = rendered.paint(SOLID_PAINT).unwrap(); + assert_eq!(solid.rgb(), [0xFF, 0xA1, 0x00]); + assert_eq!(solid.opacity_bits(), 1.0f64.to_bits()); + let fill = rendered.paint(FILL_PAINT).unwrap(); + assert_eq!(fill.rgb(), [0xFF, 0xA1, 0x00]); + assert_eq!(fill.opacity_bits(), 0.122f64.to_bits()); + let occurrence = rendered.occurrence(FILL_OCCURRENCE).unwrap(); + assert_eq!(occurrence.id(), FILL_OCCURRENCE); + assert_eq!(occurrence.subject(), FILL_PAINT); + assert_eq!(occurrence.against(), CONTEXT_SURFACE); + assert_eq!(occurrence.backdrop(), [0xFF; 3]); + assert_eq!(occurrence.visible(), [0xFF, 0xF4, 0xE0]); 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); + assert_eq!( + rendered.surface_rgb(DERIVED_SURFACE), + Some(rendered.occurrence(FILL_OCCURRENCE).unwrap().visible()) + ); + assert_eq!( + rendered + .occurrence(FILL_OCCURRENCE) + .unwrap() + .certificate() + .replay(), + Ok([0xFF, 0xF4, 0xE0]) + ); } #[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); - +fn compile_and_evaluate_ignore_declaration_order() { + let canonical = point_component(false, false).compile().unwrap(); + let paints_reversed = point_component(true, false).compile().unwrap(); + let surfaces_reversed = point_component(false, true).compile().unwrap(); + let both_reversed = point_component(true, true).compile().unwrap(); let values = bindings([19, 127, 241], 0.375, [247, 241, 233]); - assert_eq!(canonical.evaluate(&values), reordered.evaluate(&values)); + let expected = canonical.evaluate(&values).unwrap(); + assert_eq!(expected, paints_reversed.evaluate(&values).unwrap()); + assert_eq!(expected, surfaces_reversed.evaluate(&values).unwrap()); + assert_eq!(expected, both_reversed.evaluate(&values).unwrap()); } #[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], +fn complete_typed_id_renaming_does_not_change_physics() { + let source = ColorInputId::new(700); + let context = ColorInputId::new(42); + let opacity = OpacityInputId::new(91); + let solid = PaintId::new(901); + let fill = PaintId::new(11); + let context_surface = SurfaceId::new(800); + let derived_surface = SurfaceId::new(12); + let occurrence = OccurrenceId::new(501); + let renamed = AppearanceGraphSpec::new( + vec![source, context], + vec![opacity], vec![ - SurfaceSpec::SourceOver { - id: other_derived_surface, - source: other_source, - opacity: other_opacity, - backdrop: other_context_surface, - profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + PaintSpec::Opacity { + id: fill, + source: solid, + opacity, + }, + PaintSpec::Solid { + id: solid, + color: source, + }, + ], + vec![ + SurfaceSpec::FromOccurrence { + id: derived_surface, + occurrence, }, SurfaceSpec::Input { - id: other_context_surface, - color: other_context, + id: context_surface, + color: context, }, ], - vec![ForegroundOccurrenceSpec { - id: other_occurrence, - identity_source: other_source, - against: other_derived_surface, + vec![OccurrenceSpec { + id: occurrence, + subject: fill, + against: context_surface, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, }], ) .compile() .unwrap(); - let source = [13, 89, 233]; - let context = [249, 245, 237]; - let opacity = 0.41; - let first = atomic_component(false) + let source_rgb = [13, 89, 233]; + let context_rgb = [249, 245, 237]; + let alpha = 0.41; + let first = point_component(false, false) .compile() .unwrap() - .evaluate(&bindings(source, opacity, context)) + .evaluate(&bindings(source_rgb, alpha, context_rgb)) .unwrap(); - let second = other + let second = renamed .evaluate(&AppearanceBindings::new( - vec![(other_source, source), (other_context, context)], - vec![(other_opacity, opacity)], + vec![(source, source_rgb), (context, context_rgb)], + vec![(opacity, alpha)], )) .unwrap(); + assert_eq!( + first.paint(FILL_PAINT).unwrap().rgb(), + second.paint(fill).unwrap().rgb() + ); + assert_eq!( + first.paint(FILL_PAINT).unwrap().opacity_bits(), + second.paint(fill).unwrap().opacity_bits() + ); + assert_eq!( + first.occurrence(FILL_OCCURRENCE).unwrap().visible(), + second.occurrence(occurrence).unwrap().visible() + ); assert_eq!( first.surface_rgb(DERIVED_SURFACE), - second.surface_rgb(other_derived_surface) + second.surface_rgb(derived_surface) ); +} + +#[test] +fn occurrence_uses_the_declared_paint_not_an_unrelated_color_input() { + let graph = AppearanceGraphSpec::new( + vec![SOURCE, CONTEXT, OTHER_SOURCE], + vec![OPACITY], + vec![ + PaintSpec::Solid { + id: SOLID_PAINT, + color: SOURCE, + }, + PaintSpec::Opacity { + id: FILL_PAINT, + source: SOLID_PAINT, + opacity: OPACITY, + }, + PaintSpec::Solid { + id: OTHER_PAINT, + color: OTHER_SOURCE, + }, + ], + vec![SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }], + vec![OccurrenceSpec { + id: FILL_OCCURRENCE, + subject: FILL_PAINT, + against: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }], + ) + .compile() + .unwrap(); + let rendered = graph + .evaluate(&AppearanceBindings::new( + vec![ + (SOURCE, [10, 20, 30]), + (CONTEXT, [200, 200, 200]), + (OTHER_SOURCE, [111, 112, 113]), + ], + vec![(OPACITY, 0.25)], + )) + .unwrap(); + let occurrence = rendered.occurrence(FILL_OCCURRENCE).unwrap(); + assert_eq!(occurrence.subject(), FILL_PAINT); assert_eq!( - first.occurrence(FOREGROUND).unwrap().backdrop, - second.occurrence(other_occurrence).unwrap().backdrop + occurrence.visible(), + crate::alpha::composite_over_srgb8([10, 20, 30], 0.25, [200; 3]).unwrap() ); + assert_ne!(occurrence.visible(), [111, 112, 113]); } #[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, +fn nested_opacity_materializes_once_by_multiplying_opacity() { + let nested = PaintId::new(99); + let graph = AppearanceGraphSpec::new( + vec![SOURCE, CONTEXT], + vec![OTHER_OPACITY, OPACITY], + vec![ + PaintSpec::Opacity { + id: nested, + source: FILL_PAINT, + opacity: OTHER_OPACITY, + }, + PaintSpec::Opacity { + id: FILL_PAINT, + source: SOLID_PAINT, + opacity: OPACITY, + }, + PaintSpec::Solid { + id: SOLID_PAINT, + color: SOURCE, + }, + ], + vec![SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }], + vec![OccurrenceSpec { + id: FILL_OCCURRENCE, + subject: nested, + against: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, }], ) - .compile(); + .compile() + .unwrap(); + let rendered = graph + .evaluate(&AppearanceBindings::new( + vec![(SOURCE, [0; 3]), (CONTEXT, [255; 3])], + vec![(OTHER_OPACITY, 0.5), (OPACITY, 0.5)], + )) + .unwrap(); assert_eq!( - missing, - Err(GraphError::MissingOccurrenceBackdrop { - occurrence: FOREGROUND, - surface: DERIVED_SURFACE, - }) + rendered.paint(nested).unwrap().opacity_bits(), + 0.25f64.to_bits() + ); + assert_eq!( + rendered.occurrence(FILL_OCCURRENCE).unwrap().visible(), + [191; 3] + ); + assert_eq!( + rendered + .occurrence(FILL_OCCURRENCE) + .unwrap() + .certificate() + .replay(), + Ok(rendered.occurrence(FILL_OCCURRENCE).unwrap().visible()) ); +} - let cyclic = AppearanceGraphSpec::new( - vec![SOURCE], - vec![OPACITY], +#[test] +fn nested_opacity_preserves_subnormal_and_rounds_underflow_to_positive_zero() { + let nested = PaintId::new(99); + let graph = AppearanceGraphSpec::new( + vec![SOURCE, CONTEXT], + vec![OPACITY, OTHER_OPACITY], vec![ - SurfaceSpec::SourceOver { - id: CONTEXT_SURFACE, - source: SOURCE, + PaintSpec::Solid { + id: SOLID_PAINT, + color: SOURCE, + }, + PaintSpec::Opacity { + id: FILL_PAINT, + source: SOLID_PAINT, opacity: OPACITY, - backdrop: DERIVED_SURFACE, + }, + PaintSpec::Opacity { + id: nested, + source: FILL_PAINT, + opacity: OTHER_OPACITY, + }, + ], + vec![SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }], + vec![OccurrenceSpec { + id: FILL_OCCURRENCE, + subject: nested, + against: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }], + ) + .compile() + .unwrap(); + + // IEEE-754 binary64, round-to-nearest ties-to-even: половина минимального + // normal остаётся точным subnormal, а половина минимального subnormal + // округляется к +0. Эти биты меняются только со сменой численного профиля. + let cases = [ + (f64::MIN_POSITIVE, 0x0008_0000_0000_0000), + (f64::from_bits(1), 0), + ]; + for (inner, expected_bits) in cases { + let rendered = graph + .evaluate(&AppearanceBindings::new( + vec![(SOURCE, [255, 0, 0]), (CONTEXT, [0; 3])], + vec![(OPACITY, inner), (OTHER_OPACITY, 0.5)], + )) + .unwrap(); + let occurrence = rendered.occurrence(FILL_OCCURRENCE).unwrap(); + + assert_eq!( + rendered.paint(nested).unwrap().opacity_bits(), + expected_bits + ); + assert_eq!( + occurrence.certificate().subject_opacity_bits(), + expected_bits + ); + assert_eq!(occurrence.visible(), [0; 3]); + assert_eq!(occurrence.certificate().replay(), Ok([0; 3])); + } +} + +#[test] +fn opacity_constructor_edges_define_binary64_operation_order() { + let alpha_a = OpacityInputId::new(10); + let alpha_b = OpacityInputId::new(11); + let alpha_c = OpacityInputId::new(12); + // Это ULP-смещения +9, -3 и -8 от binary64(cbrt(1.5 / 255)) — границы + // округления красного канала exact encoded-sRGB8 compositor между байтами + // 1 и 2. Свидетель пересчитывается только при смене compositor-профиля, + // его правила округления или контракта порядка binary64-операций. + let values = [ + (alpha_a, f64::from_bits(0x3fc7_1b2a_949a_2779)), + (alpha_b, f64::from_bits(0x3fc7_1b2a_949a_276d)), + (alpha_c, f64::from_bits(0x3fc7_1b2a_949a_2768)), + ]; + let render = |order: [OpacityInputId; 3]| { + let first = PaintId::new(101); + let second = PaintId::new(102); + let third = PaintId::new(103); + AppearanceGraphSpec::new( + vec![SOURCE, CONTEXT], + vec![alpha_a, alpha_b, alpha_c], + vec![ + PaintSpec::Solid { + id: SOLID_PAINT, + color: SOURCE, + }, + PaintSpec::Opacity { + id: first, + source: SOLID_PAINT, + opacity: order[0], + }, + PaintSpec::Opacity { + id: second, + source: first, + opacity: order[1], + }, + PaintSpec::Opacity { + id: third, + source: second, + opacity: order[2], + }, + ], + vec![SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }], + vec![OccurrenceSpec { + id: FILL_OCCURRENCE, + subject: third, + against: CONTEXT_SURFACE, profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }], + ) + .compile() + .unwrap() + .evaluate(&AppearanceBindings::new( + vec![(SOURCE, [255, 0, 0]), (CONTEXT, [0; 3])], + values.to_vec(), + )) + .unwrap() + .occurrence(FILL_OCCURRENCE) + .unwrap() + .visible() + }; + + // Это witness неассоциативности binary64, а не production-константы: + // compiler обязан сохранять объявленный constructor order и не выполнять + // алгебраически выглядящую, но побайтно ложную перегруппировку. + assert_eq!(render([alpha_a, alpha_b, alpha_c]), [2, 0, 0]); + assert_eq!(render([alpha_b, alpha_c, alpha_a]), [1, 0, 0]); +} + +#[test] +fn one_paint_is_surface_agnostic_across_two_occurrences() { + let black = SurfaceId::new(10); + let white = SurfaceId::new(11); + let on_black = OccurrenceId::new(10); + let on_white = OccurrenceId::new(11); + let graph = AppearanceGraphSpec::new( + vec![SOURCE, CONTEXT, OTHER_SOURCE], + vec![OPACITY], + vec![ + PaintSpec::Solid { + id: SOLID_PAINT, + color: SOURCE, }, - SurfaceSpec::SourceOver { - id: DERIVED_SURFACE, - source: SOURCE, + PaintSpec::Opacity { + id: FILL_PAINT, + source: SOLID_PAINT, opacity: OPACITY, - backdrop: CONTEXT_SURFACE, + }, + ], + vec![ + SurfaceSpec::Input { + id: black, + color: CONTEXT, + }, + SurfaceSpec::Input { + id: white, + color: OTHER_SOURCE, + }, + ], + vec![ + OccurrenceSpec { + id: on_black, + subject: FILL_PAINT, + against: black, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + OccurrenceSpec { + id: on_white, + subject: FILL_PAINT, + against: white, profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, }, ], - vec![], ) - .compile(); + .compile() + .unwrap(); + let rendered = graph + .evaluate(&AppearanceBindings::new( + vec![ + (SOURCE, [240, 60, 20]), + (CONTEXT, [0; 3]), + (OTHER_SOURCE, [255; 3]), + ], + vec![(OPACITY, 0.5)], + )) + .unwrap(); + let first = rendered.occurrence(on_black).unwrap(); + let second = rendered.occurrence(on_white).unwrap(); + assert_eq!(first.subject(), FILL_PAINT); + assert_eq!(second.subject(), FILL_PAINT); + assert_ne!(first.visible(), second.visible()); + assert_eq!(first.certificate().replay(), Ok(first.visible())); + assert_eq!(second.certificate().replay(), Ok(second.visible())); +} + +#[test] +fn surface_from_reuses_visible_result_without_recompositing() { + let black_color = ColorInputId::new(10); + let white_color = ColorInputId::new(11); + let red_color = ColorInputId::new(12); + let black_solid = PaintId::new(10); + let black_tint = PaintId::new(11); + let red_solid = PaintId::new(12); + let red_tint = PaintId::new(13); + let black_alpha = OpacityInputId::new(10); + let red_alpha = OpacityInputId::new(11); + let page = SurfaceId::new(10); + let first_surface = SurfaceId::new(11); + let first = OccurrenceId::new(10); + let second = OccurrenceId::new(11); + let colors = vec![black_color, white_color, red_color]; + let opacities = vec![black_alpha, red_alpha]; + let paints = vec![ + PaintSpec::Solid { + id: black_solid, + color: black_color, + }, + PaintSpec::Opacity { + id: black_tint, + source: black_solid, + opacity: black_alpha, + }, + PaintSpec::Solid { + id: red_solid, + color: red_color, + }, + PaintSpec::Opacity { + id: red_tint, + source: red_solid, + opacity: red_alpha, + }, + ]; + let surfaces = vec![ + SurfaceSpec::Input { + id: page, + color: white_color, + }, + SurfaceSpec::FromOccurrence { + id: first_surface, + occurrence: first, + }, + ]; + let occurrences = vec![ + OccurrenceSpec { + id: first, + subject: black_tint, + against: page, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + OccurrenceSpec { + id: second, + subject: red_tint, + against: first_surface, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + ]; + let graph = AppearanceGraphSpec::new( + colors.clone(), + opacities.clone(), + paints.clone(), + surfaces.clone(), + occurrences.clone(), + ) + .compile() + .unwrap(); + let mut reversed_occurrences = occurrences; + reversed_occurrences.reverse(); + let reordered = + AppearanceGraphSpec::new(colors, opacities, paints, surfaces, reversed_occurrences) + .compile() + .unwrap(); + let bindings = AppearanceBindings::new( + vec![ + (black_color, [0; 3]), + (white_color, [255; 3]), + (red_color, [255, 0, 0]), + ], + vec![(black_alpha, 0.25), (red_alpha, 0.5)], + ); + let rendered = graph.evaluate(&bindings).unwrap(); + assert_eq!(rendered, reordered.evaluate(&bindings).unwrap()); + let first_occurrence = rendered.occurrence(first).unwrap(); + let second_occurrence = rendered.occurrence(second).unwrap(); + assert_eq!(first_occurrence.visible(), [191; 3]); assert_eq!( - cyclic, - Err(GraphError::SurfaceCycle { - surfaces: vec![CONTEXT_SURFACE, DERIVED_SURFACE], - }) + rendered.surface_rgb(first_surface), + Some(first_occurrence.visible()) + ); + assert_eq!(second_occurrence.backdrop(), first_occurrence.visible()); + assert_eq!(second_occurrence.visible(), [223, 96, 96]); + assert_ne!(second_occurrence.visible(), [255, 128, 128]); + assert_eq!( + first_occurrence.certificate().replay(), + Ok(first_occurrence.visible()) + ); + assert_eq!( + second_occurrence.certificate().replay(), + Ok(second_occurrence.visible()) ); } proptest! { #[test] - fn graph_source_over_equals_the_independent_compositor_for_neutral_and_chromatic_inputs( + fn occurrence_equals_the_exact_compositor_for_arbitrary_point_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 rendered = point_component(false, false) + .compile() + .unwrap() + .evaluate(&bindings(source, opacity, context)) + .unwrap(); let oracle = crate::alpha::composite_over_srgb8(source, opacity, context).unwrap(); + prop_assert_eq!(rendered.occurrence(FILL_OCCURRENCE).unwrap().visible(), oracle); 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( + fn occurrence_certificate_replays_to_the_visible_result( 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]; - let SourceOverCertificateV1 { - profile, - surface, - source_input, - source_rgb, - backdrop_surface, - backdrop_rgb, - opacity_input, - opacity_bits, - output_rgb, - } = certificate; - prop_assert_eq!(*profile, CompositionProfileV1::EncodedSrgb8SourceOverV1); - prop_assert_eq!(*surface, DERIVED_SURFACE); - prop_assert_eq!(*source_input, SOURCE); - prop_assert_eq!(*source_rgb, source); - prop_assert_eq!(*backdrop_surface, CONTEXT_SURFACE); - prop_assert_eq!(*backdrop_rgb, context); - prop_assert_eq!(*opacity_input, OPACITY); - prop_assert_eq!(*opacity_bits, opacity.to_bits()); - prop_assert_eq!(*output_rgb, rendered.surface_rgb(DERIVED_SURFACE).unwrap()); - prop_assert_eq!(certificate.replay(), Ok(*output_rgb)); + let rendered = point_component(false, false) + .compile() + .unwrap() + .evaluate(&bindings(source, opacity, context)) + .unwrap(); + let certificate = rendered.occurrence(FILL_OCCURRENCE).unwrap().certificate(); + prop_assert_eq!(certificate.profile(), CompositionProfileV1::EncodedSrgb8SourceOverV1); + prop_assert_eq!(certificate.occurrence(), FILL_OCCURRENCE); + prop_assert_eq!(certificate.subject(), FILL_PAINT); + prop_assert_eq!(certificate.subject_rgb(), source); + let canonical_opacity_bits = if opacity == 0.0 { + 0.0f64.to_bits() + } else { + opacity.to_bits() + }; + prop_assert_eq!(certificate.subject_opacity_bits(), canonical_opacity_bits); + prop_assert_eq!(certificate.backdrop_surface(), CONTEXT_SURFACE); + prop_assert_eq!(certificate.backdrop_rgb(), context); + prop_assert_eq!(certificate.output_rgb(), rendered.occurrence(FILL_OCCURRENCE).unwrap().visible()); + prop_assert_eq!(certificate.replay(), Ok(certificate.output_rgb())); + } + + #[test] + fn arbitrary_opacity_chain_is_one_paint_and_one_occurrence_composite( + source in any::<[u8; 3]>(), + context in any::<[u8; 3]>(), + alphas in proptest::collection::vec(0.0f64..=1.0f64, 0..=8), + ) { + let opacity_inputs: Vec = (0..alphas.len()) + .map(|index| OpacityInputId::new(index as u32)) + .collect(); + let mut paints = vec![PaintSpec::Solid { + id: SOLID_PAINT, + color: SOURCE, + }]; + let mut subject = SOLID_PAINT; + for (index, opacity) in opacity_inputs.iter().copied().enumerate() { + let next = PaintId::new(1_000 + index as u32); + paints.push(PaintSpec::Opacity { + id: next, + source: subject, + opacity, + }); + subject = next; + } + let graph = AppearanceGraphSpec::new( + vec![SOURCE, CONTEXT], + opacity_inputs.clone(), + paints, + vec![SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }], + vec![OccurrenceSpec { + id: FILL_OCCURRENCE, + subject, + against: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }], + ) + .compile() + .unwrap(); + let effective = alphas.iter().copied().fold(1.0, |product, alpha| product * alpha); + let rendered = graph + .evaluate(&AppearanceBindings::new( + vec![(SOURCE, source), (CONTEXT, context)], + opacity_inputs + .iter() + .copied() + .zip(alphas.iter().copied()) + .collect(), + )) + .unwrap(); + let occurrence = rendered.occurrence(FILL_OCCURRENCE).unwrap(); + let oracle = crate::alpha::composite_over_srgb8(source, effective, context).unwrap(); + prop_assert_eq!(rendered.paint(subject).unwrap().opacity_bits(), effective.to_bits()); + prop_assert_eq!(occurrence.visible(), oracle); + prop_assert_eq!(occurrence.certificate().replay(), Ok(oracle)); } -} -// ── Fail-closed контракт компиляции (§6.1 ТЗ #307) ──────────────────────────── + #[test] + fn invalid_alpha_is_rejected_at_its_exact_chain_input( + bits in any::(), + invalid_is_outer in any::(), + ) { + let invalid = f64::from_bits(bits); + prop_assume!(!invalid.is_finite() || !(0.0..=1.0).contains(&invalid)); + let outer_paint = PaintId::new(99); + let graph = AppearanceGraphSpec::new( + vec![SOURCE, CONTEXT], + vec![OPACITY, OTHER_OPACITY], + vec![ + PaintSpec::Solid { + id: SOLID_PAINT, + color: SOURCE, + }, + PaintSpec::Opacity { + id: FILL_PAINT, + source: SOLID_PAINT, + opacity: OPACITY, + }, + PaintSpec::Opacity { + id: outer_paint, + source: FILL_PAINT, + opacity: OTHER_OPACITY, + }, + ], + vec![SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }], + vec![OccurrenceSpec { + id: FILL_OCCURRENCE, + subject: outer_paint, + against: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }], + ) + .compile() + .unwrap(); + let (inner, outer, expected_input) = if invalid_is_outer { + (0.5, invalid, OTHER_OPACITY) + } else { + (invalid, 0.5, OPACITY) + }; + let expected_message = crate::alpha::validate_alpha(invalid).unwrap_err(); + prop_assert_eq!( + graph.evaluate(&AppearanceBindings::new( + vec![(SOURCE, [1, 2, 3]), (CONTEXT, [4, 5, 6])], + vec![(OPACITY, inner), (OTHER_OPACITY, outer)], + )), + Err(BindingError::OpacityOutOfDomain { + input: expected_input, + message: expected_message, + }) + ); + } +} #[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 }) + AppearanceGraphSpec::new(vec![SOURCE, SOURCE], vec![], vec![], vec![], vec![]).compile(), + Err(CompileError::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 }) + AppearanceGraphSpec::new(vec![], vec![OPACITY, OPACITY], vec![], vec![], vec![]).compile(), + Err(CompileError::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, + AppearanceGraphSpec::new( + vec![SOURCE, CONTEXT], + vec![], + vec![ + PaintSpec::Solid { + id: SOLID_PAINT, + color: SOURCE, + }, + PaintSpec::Solid { + id: SOLID_PAINT, + color: CONTEXT, + }, + ], + vec![], + vec![] + ) + .compile(), + Err(CompileError::DuplicatePaint { paint: SOLID_PAINT }) + ); + assert_eq!( + AppearanceGraphSpec::new( + vec![SOURCE, CONTEXT], + vec![], + vec![], + vec![ + SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: SOURCE, + }, + SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }, + ], + vec![] + ) + .compile(), + Err(CompileError::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, - }, - ForegroundOccurrenceSpec { - id: FOREGROUND, - identity_source: CONTEXT, - against: CONTEXT_SURFACE, - }, - ], - ) - .compile(); assert_eq!( - duplicate_occurrence, - Err(GraphError::DuplicateOccurrence { - occurrence: FOREGROUND, + AppearanceGraphSpec::new( + vec![], + vec![], + vec![], + vec![], + vec![ + OccurrenceSpec { + id: FILL_OCCURRENCE, + subject: FILL_PAINT, + against: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + OccurrenceSpec { + id: FILL_OCCURRENCE, + subject: OTHER_PAINT, + against: DERIVED_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + ] + ) + .compile(), + Err(CompileError::DuplicateOccurrence { + occurrence: FILL_OCCURRENCE }) ); } #[test] -fn compile_rejects_every_missing_reference_with_typed_errors() { - let missing_input_color = AppearanceGraphSpec::new( +fn compile_rejects_every_dangling_canonical_edge() { + let missing_color = AppearanceGraphSpec::new( vec![], vec![], - vec![SurfaceSpec::Input { - id: CONTEXT_SURFACE, - color: CONTEXT, + vec![PaintSpec::Solid { + id: SOLID_PAINT, + color: SOURCE, }], vec![], + vec![], ) .compile(); assert_eq!( - missing_input_color, - Err(GraphError::MissingSurfaceColorInput { - surface: CONTEXT_SURFACE, - input: CONTEXT, + missing_color, + Err(CompileError::MissingPaintColorInput { + paint: SOLID_PAINT, + input: SOURCE, }) ); - let missing_source = AppearanceGraphSpec::new( - vec![CONTEXT], + let missing_paint = AppearanceGraphSpec::new( + vec![], 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![PaintSpec::Opacity { + id: FILL_PAINT, + source: SOLID_PAINT, + opacity: OPACITY, + }], + vec![], vec![], ) .compile(); assert_eq!( - missing_source, - Err(GraphError::MissingSurfaceColorInput { - surface: DERIVED_SURFACE, - input: SOURCE, + missing_paint, + Err(CompileError::MissingPaintSource { + paint: FILL_PAINT, + source: SOLID_PAINT, }) ); let missing_opacity = AppearanceGraphSpec::new( - vec![SOURCE, CONTEXT], + vec![SOURCE], vec![], vec![ - SurfaceSpec::Input { - id: CONTEXT_SURFACE, - color: CONTEXT, + PaintSpec::Solid { + id: SOLID_PAINT, + color: SOURCE, }, - SurfaceSpec::SourceOver { - id: DERIVED_SURFACE, - source: SOURCE, + PaintSpec::Opacity { + id: FILL_PAINT, + source: SOLID_PAINT, opacity: OPACITY, - backdrop: CONTEXT_SURFACE, - profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, }, ], vec![], + vec![], ) .compile(); assert_eq!( missing_opacity, - Err(GraphError::MissingSurfaceOpacityInput { - surface: DERIVED_SURFACE, + Err(CompileError::MissingPaintOpacityInput { + paint: FILL_PAINT, input: OPACITY, }) ); - let missing_backdrop = AppearanceGraphSpec::new( - vec![SOURCE], - vec![OPACITY], - vec![SurfaceSpec::SourceOver { + let missing_surface_color = AppearanceGraphSpec::new( + vec![], + vec![], + vec![], + vec![SurfaceSpec::Input { + id: CONTEXT_SURFACE, + color: CONTEXT, + }], + vec![], + ) + .compile(); + assert_eq!( + missing_surface_color, + Err(CompileError::MissingSurfaceColorInput { + surface: CONTEXT_SURFACE, + input: CONTEXT, + }) + ); + + let missing_surface_occurrence = AppearanceGraphSpec::new( + vec![], + vec![], + vec![], + vec![SurfaceSpec::FromOccurrence { id: DERIVED_SURFACE, - source: SOURCE, - opacity: OPACITY, - backdrop: CONTEXT_SURFACE, - profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + occurrence: FILL_OCCURRENCE, }], vec![], ) .compile(); assert_eq!( - missing_backdrop, - Err(GraphError::MissingSurfaceBackdrop { + missing_surface_occurrence, + Err(CompileError::MissingSurfaceOccurrence { surface: DERIVED_SURFACE, - backdrop: CONTEXT_SURFACE, + occurrence: FILL_OCCURRENCE, }) ); - let missing_occurrence_source = AppearanceGraphSpec::new( + let missing_occurrence_paint = AppearanceGraphSpec::new( vec![CONTEXT], vec![], + vec![], vec![SurfaceSpec::Input { id: CONTEXT_SURFACE, color: CONTEXT, }], - vec![ForegroundOccurrenceSpec { - id: FOREGROUND, - identity_source: SOURCE, + vec![OccurrenceSpec { + id: FILL_OCCURRENCE, + subject: FILL_PAINT, against: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, }], ) .compile(); assert_eq!( - missing_occurrence_source, - Err(GraphError::MissingOccurrenceSource { - occurrence: FOREGROUND, - input: SOURCE, + missing_occurrence_paint, + Err(CompileError::MissingOccurrencePaint { + occurrence: FILL_OCCURRENCE, + paint: FILL_PAINT, }) ); -} -// ── Fail-closed контракт исполнения (§6.2 ТЗ #307) ──────────────────────────── + let missing_occurrence_backdrop = AppearanceGraphSpec::new( + vec![SOURCE], + vec![], + vec![PaintSpec::Solid { + id: SOLID_PAINT, + color: SOURCE, + }], + vec![], + vec![OccurrenceSpec { + id: FILL_OCCURRENCE, + subject: SOLID_PAINT, + against: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }], + ) + .compile(); + assert_eq!( + missing_occurrence_backdrop, + Err(CompileError::MissingOccurrenceBackdrop { + occurrence: FILL_OCCURRENCE, + surface: CONTEXT_SURFACE, + }) + ); +} #[test] -fn evaluate_rejects_duplicate_missing_and_unexpected_bindings() { - let graph = atomic_component(false).compile().unwrap(); - - let duplicate_color = graph.evaluate(&AppearanceBindings::new( +fn cycle_errors_contain_only_actual_cycle_members() { + let cycle_a = PaintId::new(10); + let cycle_b = PaintId::new(20); + let dependent = PaintId::new(1); + let paint_cycle = AppearanceGraphSpec::new( + vec![], + vec![OPACITY], vec![ - (SOURCE, [1, 2, 3]), - (CONTEXT, [4, 5, 6]), - (SOURCE, [7, 8, 9]), + PaintSpec::Opacity { + id: cycle_a, + source: cycle_b, + opacity: OPACITY, + }, + PaintSpec::Opacity { + id: cycle_b, + source: cycle_a, + opacity: OPACITY, + }, + PaintSpec::Opacity { + id: dependent, + source: cycle_a, + opacity: OPACITY, + }, ], - vec![(OPACITY, 0.5)], - )); + vec![], + vec![], + ) + .compile(); assert_eq!( - duplicate_color, - Err(GraphError::DuplicateColorBinding { input: SOURCE }) + paint_cycle, + Err(CompileError::PaintCycle { + paints: vec![cycle_a, cycle_b], + }) ); - let duplicate_opacity = graph.evaluate(&AppearanceBindings::new( - vec![(SOURCE, [1, 2, 3]), (CONTEXT, [4, 5, 6])], - vec![(OPACITY, 0.5), (OPACITY, 0.6)], - )); + let cycle_surface = SurfaceId::new(10); + let dependent_surface = SurfaceId::new(1); + let cycle_occurrence = OccurrenceId::new(10); + let dependent_occurrence = OccurrenceId::new(1); + let render_cycle = AppearanceGraphSpec::new( + vec![SOURCE], + vec![], + vec![PaintSpec::Solid { + id: SOLID_PAINT, + color: SOURCE, + }], + vec![ + SurfaceSpec::FromOccurrence { + id: cycle_surface, + occurrence: cycle_occurrence, + }, + SurfaceSpec::FromOccurrence { + id: dependent_surface, + occurrence: dependent_occurrence, + }, + ], + vec![ + OccurrenceSpec { + id: cycle_occurrence, + subject: SOLID_PAINT, + against: cycle_surface, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + OccurrenceSpec { + id: dependent_occurrence, + subject: SOLID_PAINT, + against: cycle_surface, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + ], + ) + .compile(); assert_eq!( - duplicate_opacity, - Err(GraphError::DuplicateOpacityBinding { input: OPACITY }) + render_cycle, + Err(CompileError::RenderCycle { + surfaces: vec![cycle_surface], + occurrences: vec![cycle_occurrence], + }) ); +} - let missing_color = graph.evaluate(&AppearanceBindings::new( - vec![(SOURCE, [1, 2, 3])], - vec![(OPACITY, 0.5)], - )); +#[test] +fn evaluate_rejects_duplicate_missing_and_unexpected_bindings() { + let graph = point_component(false, false).compile().unwrap(); assert_eq!( - missing_color, - Err(GraphError::MissingColorBinding { input: CONTEXT }) + graph.evaluate(&AppearanceBindings::new( + vec![ + (SOURCE, [1, 2, 3]), + (CONTEXT, [4, 5, 6]), + (SOURCE, [7, 8, 9]), + ], + vec![(OPACITY, 0.5)], + )), + Err(BindingError::DuplicateColorBinding { input: SOURCE }) ); - - 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 }) + graph.evaluate(&AppearanceBindings::new( + vec![(SOURCE, [1, 2, 3]), (CONTEXT, [4, 5, 6])], + vec![(OPACITY, 0.5), (OPACITY, 0.6)], + )), + Err(BindingError::DuplicateOpacityBinding { input: OPACITY }) + ); + assert_eq!( + graph.evaluate(&AppearanceBindings::new( + vec![(SOURCE, [1, 2, 3])], + vec![(OPACITY, 0.5)], + )), + Err(BindingError::MissingColorBinding { input: CONTEXT }) + ); + assert_eq!( + graph.evaluate(&AppearanceBindings::new( + vec![(SOURCE, [1, 2, 3]), (CONTEXT, [4, 5, 6])], + vec![], + )), + Err(BindingError::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 { + graph.evaluate(&AppearanceBindings::new( + vec![ + (SOURCE, [1, 2, 3]), + (CONTEXT, [4, 5, 6]), + (ColorInputId::new(9), [7, 8, 9]), + ], + vec![(OPACITY, 0.5)], + )), + Err(BindingError::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 { + graph.evaluate(&AppearanceBindings::new( + vec![(SOURCE, [1, 2, 3]), (CONTEXT, [4, 5, 6])], + vec![(OPACITY, 0.5), (OpacityInputId::new(9), 0.5)], + )), + Err(BindingError::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(); +fn evaluate_rejects_invalid_alpha_with_the_ssot_domain_text() { + let graph = point_component(false, 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("домен α обязан отвергаться и композитором"); + .expect_err("композитор обязан отвергать тот же домен alpha"); assert_eq!( - outcome, - Err(GraphError::OpacityOutOfDomain { + graph.evaluate(&bindings([1, 2, 3], bad_alpha, [4, 5, 6])), + Err(BindingError::OpacityOutOfDomain { input: OPACITY, message: expected, }), - "α={bad_alpha}: типизированный отказ с SSOT-текстом" + "alpha={bad_alpha}" ); } } -// ── 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, - }], - ) - .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)], - )) +fn signed_zero_opacity_has_one_canonical_state() { + let graph = point_component(false, false).compile().unwrap(); + let positive = graph + .evaluate(&bindings([1, 2, 3], 0.0, [4, 5, 6])) .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]); + let negative = graph + .evaluate(&bindings([1, 2, 3], -0.0, [4, 5, 6])) + .unwrap(); + assert_eq!(positive, negative); + assert_eq!( + negative.paint(FILL_PAINT).unwrap().opacity_bits(), + 0.0f64.to_bits() + ); } diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index 2022d3bc..c113be45 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -151,8 +151,8 @@ pub use spaces::vc::ViewingConditions; #[doc = include_str!("../README.md")] pub struct ReadmeDoctests; -/// Временные акцентные рецепты — детали реализации, а не контракты клиента. -/// Публичный API источников, целей и ограничений принадлежит occurrence-графу. +/// Закрытая физическая топология и recipe-варианты — детали resolver-а, а не +/// extension points. Публичный API не раскрывает client-authored topology. /// /// ```compile_fail /// use labcolors_core::accent_balance::accent_balanced; @@ -254,8 +254,8 @@ pub struct NoHybridLpcSurfaceMetric; #[cfg(doctest)] pub struct NoPrematureScalarLpcApi; -/// Полярность/fill пары — legacy-механика рецептов, а не extension point. -/// Общий occurrence-граф заменяет её единым совместным solve. +/// Полярность и pair-механика закрыты внутри resolver-а, а не образуют +/// публичный extension point. /// /// ```compile_fail /// use labcolors_core::pair::pair_side; diff --git a/crates/labcolors-core/src/pair_label_tests.rs b/crates/labcolors-core/src/pair_label_tests.rs index 2772df72..b9441b30 100644 --- a/crates/labcolors-core/src/pair_label_tests.rs +++ b/crates/labcolors-core/src/pair_label_tests.rs @@ -1,4 +1,4 @@ -//! Жёсткий контраст-констрейнт ТИНТ-бейджа (`RoleSpec::PairLabel`, task #29). +//! Характеризация boundary-адаптера `RoleSpec::PairLabel`. //! //! Класс, который закрывают эти тесты: контраст `label ↔ tinted-fill` у бейджа //! на тинт-фоне был ЭМЕРДЖЕНТНЫМ, не гарантированным. Обычные `label-*` роли @@ -10,6 +10,13 @@ //! гарантирован по построению; при недостижимости тон клампится (флаг //! `compressed`), а не молча выдаётся за точное выполнение контракта. //! +//! Appearance-граф описывает только физическую цепочку заливки: +//! solid paint → opacity paint → occurrence на контексте → derived surface. +//! Downstream-солвер +//! лейбла получает эту подложку без изменения своей математики. Тесты +//! доказывают точность этой границы подложки, но не заявляют наличие +//! финального label paint/occurrence или публичного recipe-контракта. +//! //! Четыре группы: //! 1. `shipped_*` — реальный контракт labui (`label--primary` на //! `fill--primary`) держит UI-пол на тинте во всех темах (гвардит то, @@ -21,16 +28,16 @@ //! (`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). +//! 4. `fill_occurrence_backdrop_*` / `pair_fill_output_*` — appearance-путь +//! подложки байт-идентичен замороженному +//! ручному composite-oracle (матрица 5 семей × 4 режима × 6 фонов + property), включая +//! типизированный отказ выбранной alpha-ветви; поверхность PairLabel НЕ является +//! эмитированным PairFill (санитарный witness против ложного ребра). use proptest::prelude::*; use crate::config::fixture::labui_reference; -use crate::semantic::{resolve_pair_label, resolve_pair_label_legacy_oracle}; +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, @@ -68,12 +75,9 @@ fn themes() -> [(&'static str, &'static str, ViewingConditions); 4] { ] } -/// Юр. пол 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). +/// Объявленный UI-пол из SSOT-контракта [`Floor::AaUi`]. Локальная +/// копия числа запрещена: тест проверяет тот же пол, который энфорсит +/// резолвер, и не дублирует его политику в фикстуре. fn ui_floor() -> f64 { Floor::AaUi .min_ratio() @@ -210,8 +214,8 @@ fn pair_label_beats_page_resolved_label_on_failing_families() { let set = resolve_named_set(&bg, &table, &ViewingConditions::srgb()) .expect("валидный PairLabel-контракт обязан резолвиться"); - // warning/success — семьи, где страничный `label-*-tertiary` проваливает 3:1 - // на собственном тинте (замер task #29: warning 2.76, success 2.88). + // 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); @@ -233,10 +237,10 @@ fn pair_label_beats_page_resolved_label_on_failing_families() { } } -// ── 4. Differential-матрица миграции #307: граф == замороженный legacy oracle ── +// ── 4. Fill occurrence-derived backdrop == замороженный oracle ── /// Скомпилированные параметры `RoleSpec::PairLabel` конкретной роли таблицы — -/// вход обоих путей differential-а (production-граф и legacy oracle получают +/// вход обоих путей differential-а (appearance-путь и ручной oracle получают /// РОВНО одни аргументы, различие только в реализации). fn pair_label_spec( table: &crate::NamedRoleTable, @@ -279,24 +283,27 @@ fn pair_label_spec( /// * `#FFF4E0` — хроматический светлый witness: точная warning-поверхность /// из graph-тестов (`appearance_graph_tests`); /// * `#0000FF` — насыщенный хроматический угол куба (sRGB primary). -fn migration_backgrounds() -> [&'static str; 6] { +fn differential_backgrounds() -> [&'static str; 6] { [ "#000000", "#101012", "#767676", "#FFF4E0", "#0000FF", "#FFFFFF", ] } -/// Полный differential §8.3 ТЗ #307: production-путь (appearance-граф) обязан -/// быть РАВЕН замороженному legacy oracle по всем полям `Resolved` — вариант, +/// Appearance-путь вычисляет fill occurrence и derived backdrop, после чего +/// неизменённый downstream-солвер обязан дать тот же `Resolved`, что и +/// замороженный oracle: вариант, /// финальные байты, флаги, unreachable-причины. Никакого approximate equality: /// `assert_eq!` по `PartialEq` сравнивает и все числовые поля (одинаковые биты /// по построению одного downstream-солвера), а hex сверяется отдельно, чтобы /// байтовая эмиссия оставалась закреплённой даже при эволюции `PartialEq`. +/// Это differential-доказательство границы подложки, а не наличия финального +/// label occurrence в appearance-графе. #[test] -fn migration_differential_matrix_matches_frozen_legacy_oracle_exactly() { +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 migration_backgrounds() { + for bg_hex in differential_backgrounds() { let bg = BgInput::solid(bg_hex).unwrap(); for (fam, _) in families() { let role = format!("badge-label-{fam}"); @@ -304,7 +311,7 @@ fn migration_differential_matrix_matches_frozen_legacy_oracle_exactly() { 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( + let oracle = resolve_pair_label_manual_composite_oracle( &bg, tint, fraction, @@ -316,7 +323,7 @@ fn migration_differential_matrix_matches_frozen_legacy_oracle_exactly() { assert_eq!( production, oracle, "[{tname}/{bg_hex}] `{role}`: production-граф обязан быть \ - идентичен замороженному legacy oracle по всем полям" + идентичен замороженному ручному composite-oracle по всем полям" ); for outcome in [&production, &oracle] { assert!( @@ -350,38 +357,45 @@ fn migration_differential_matrix_matches_frozen_legacy_oracle_exactly() { ); } -/// Публичные исходы невалидной альфы (RoleSpec публичен — спека, собранная в -/// обход валидатора конфига, обязана давать ПРЕЖНИЙ типизированный отказ): -/// вариант, причина и точный текст заморожены миграцией байт-в-байт. +/// Внутренний resolver обязан типизированно отклонять невалидную выбранную +/// альфу до начала физического поиска. Публичный `NamedRoleTable` дополнительно +/// валидирует обе theme-ветви при компиляции конфига. #[test] -fn migration_preserves_public_invalid_alpha_outcomes_exactly() { +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"); - 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, Err(SolveFailure::InvalidInput(_))), - "невалидная α обязана давать типизированный InvalidInput, не панику/кламп" - ); + for (bg_hex, vc, light, dark) in [ + ("#FFFFFF", ViewingConditions::srgb(), bad_alpha, 0.122), + ( + "#101012", + ViewingConditions::dim_surround(), + 0.122, + bad_alpha, + ), + ] { + 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` (regression -/// witness #305 остаётся честным): эмитированный `PairFill` — отдельно +/// Санитарный witness против ложного ребра `PairFill → PairLabel`: эмитированный +/// `PairFill` — отдельно /// сдвинутый солид, он НЕ равен тинт-поверхности (композиту `fill-*-primary`), -/// против которой решается `PairLabel`. Если миграция когда-либо подменит -/// derived backdrop эмитированным PairFill, differential-матрица разойдётся -/// именно потому, что эти значения различны — что и закрепляет этот тест. +/// против которой решается `PairLabel`. Подмена derived backdrop эмитированным +/// PairFill разойдётся с differential-матрицей именно потому, что эти значения +/// различны. #[test] -fn emitted_pair_fill_differs_from_the_pair_label_surface() { +fn pair_fill_output_differs_from_fill_occurrence_derived_backdrop() { let mut cfg = labui_reference(); for (fam, source) in families() { cfg.roles @@ -408,12 +422,12 @@ fn emitted_pair_fill_differs_from_the_pair_label_surface() { } } -// Property-differential: произвольные источник/контекст/альфы/доля/пол/режим. -// Единственный источник различий между путями — сама миграция; любые входы в -// объявленном домене обязаны давать идентичный Resolved. +// Property-differential: произвольные источник/контекст/альфы/доля/пол/режим +// проверяют точную замену пути подложки. Downstream-солвер в обоих путях +// один и тот же. proptest! { #[test] - fn migration_differential_property_holds_on_arbitrary_inputs( + 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, @@ -434,7 +448,7 @@ proptest! { let production = resolve_pair_label( &bg, tint, fraction, floor, alpha_light, alpha_dark, &vc, ); - let oracle = resolve_pair_label_legacy_oracle( + let oracle = resolve_pair_label_manual_composite_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 18df8b2d..381c8209 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -32,6 +32,7 @@ //! без добавления семантических recipe-кейсов в Core. use crate::Srgb8; +use crate::appearance::PointOpacityOverSurfaceV1; use crate::ladder::LadderTint; use crate::scale; use crate::solve::{ @@ -558,12 +559,12 @@ pub enum RoleSpec { /// позиции `fill-*-primary` над фоном резолва), а НЕ против фона страницы /// и НЕ против эмитированного [`PairFill`](Self::PairFill) — у того своя, /// отдельно сдвинутая солид-эмиссия; ребра `PairFill → PairLabel` не - /// существует. Резолв использует один generic-компонент appearance-графа: - /// скомпилированный граф собирает поверхность и возвращает физические факты - /// foreground occurrence против неё. Доказательный статус последующего - /// резолвера граф не назначает. Дифференциальный тест закрепляет - /// эквивалентность миграционного подключения и результатов на проверяемом - /// домене, но не является независимым эталоном математики самого резолвера. + /// существует. Приватный appearance-граф материализует tint Paint, применяет + /// его к локальной поверхности ровно в одном fill occurrence и отдаёт + /// `surfaceFrom` как фактический фон. Последующий label solve находится вне + /// графа: этот recipe не создаёт итоговые label Paint/occurrence или их + /// доказательный статус. Дифференциальный тест доказывает только точность + /// границы подложки, а не математику label-resolver-а. /// Тон клампится (флаг `compressed`) при недостижимости на кривой семьи. PairLabel { /// Пер-темный кодированный тинт-якорь семьи (как у лестницы). @@ -2486,16 +2487,9 @@ fn resolve_hued_anchor( resolve_hued_anchor_from_srgb8(bg, anchor, hue_tint.srgb8_for_vc(vc), vc, ctx) } -/// Тот же резолв, но источник идентичности уже выбран по теме как -/// кодированный стимул, а не [`LadderTint`]-пейлоад. -/// -/// Отдельный вход нужен appearance-графу (#307): foreground occurrence несёт -/// identity-ребро «что наблюдается», и потребитель обязан решать foreground из -/// ВОЗВРАЩЁННОГО occurrence-источника (байты → byte/255 точно), а не повторно -/// читать исходный пейлоад — иначе ребро идентичности было бы декоративным. -/// Для квантованного источника оба пути дают один hex по построению -/// ([`crate::spaces::srgb::hex_from_srgb_encoded`] округляет так же, как -/// квантизация эмиссии), что закреплено differential-тестами миграции. +/// Тот же резолв, но выбранный по теме источник уже представлен точными +/// sRGB8-байтами. Для квантованного источника этот путь даёт тот же hex, что и +/// [`resolve_hued_anchor`], потому emission использует тот же byte-grid. fn resolve_hued_anchor_from_srgb8( bg: &BgInput, anchor: TextAnchor, @@ -2609,68 +2603,6 @@ fn 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 -/// ``` -/// -/// Компилируется один раз ([`OnceLock`](std::sync::OnceLock)); спека статична, -/// поэтому ошибка компиляции недостижима по построению, но путь остаётся -/// типизированным (RoleSpec публичен, паника на публичном входе запрещена). -fn nested_foreground_component() -> Result< - &'static crate::appearance::CompiledAppearanceGraph, - &'static crate::appearance::GraphError, -> { - use crate::appearance::{ - AppearanceGraphSpec, CompiledAppearanceGraph, CompositionProfileV1, - 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, - }], - ) - .compile() - }) - .as_ref() -} - /// Public PairLabel opacity rejected by the graph's SSOT validator. fn pair_label_opacity_input_error(error: &str) -> PendingResolution { Err(SolveFailure::InvalidInput(format!( @@ -2682,21 +2614,15 @@ fn pair_label_opacity_input_error(error: &str) -> PendingResolution { /// ([`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 именно против неё. +/// Sealed [`PointOpacityOverSurfaceV1`] материализует tint Paint, применяет его +/// к локальному фону в единственном exact source-over occurrence и проецирует +/// видимый результат в derived Surface. /// Поверхность НЕ является эмитированным [`RoleSpec::PairFill`] — у того своя, /// отдельно сдвинутая солид-эмиссия; никакого ребра `PairFill → PairLabel` нет. /// -/// Оттеночный foreground решается текущим -/// [`resolve_hued_anchor_from_srgb8`] НА ЭТОЙ ПОВЕРХНОСТИ. Appearance- -/// граф не присваивает этому последующему решению доказательный статус: он -/// возвращает только source/against/backdrop. Дифференциальный тест закрепляет -/// подключение и результаты миграции на проверяемом домене, но оба пути -/// используют один резолвер и потому не образуют независимый эталон его -/// математики. Собственный [`ResolveContext`] поверхности задаёт +/// Затем [`resolve_hued_anchor_from_srgb8`] решает label НА ЭТОЙ ПОВЕРХНОСТИ, +/// вне point-графа. Этот контракт не создаёт label Paint/occurrence или +/// constraint evidence. Собственный [`ResolveContext`] поверхности задаёт /// полярность/макс-контраст, поэтому /// WCAG-пол лейбла /// гарантирован против той подложки, на которой foreground реально стоит @@ -2722,10 +2648,8 @@ pub(crate) fn resolve_pair_label( surface_alpha_light }; // Тинт квантуется ДО композита: подложка обязана считаться из отдаваемого - // значения в едином encoded-sRGB8 reference-домене (контракт не изменён - // миграцией). Байты источника и локального фона готовятся ТЕМ ЖЕ - // квантизационным контрактом alpha-SSOT, что и внутри старого пути, — - // порядок доменных проверок (tint → bg → α) сохранён дословно. + // значения в едином 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, @@ -2743,43 +2667,14 @@ pub(crate) fn resolve_pair_label( ))); } }; - let graph = match nested_foreground_component() { - Ok(graph) => graph, - // Статическая спека не компилируется только при внутреннем дефекте — - // типизированный отказ честнее паники (RoleSpec публичен). - Err(defect) => { - return Err(SolveFailure::InternalInvariant(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_opacity_input_error(&message); - } - // Прочие ошибки исполнения статического компонента структурно - // недостижимы (bindings собраны из объявленных handles); отказ - // остаётся типизированным вместо паники. - Err(defect) => { - return Err(SolveFailure::InternalInvariant(format!( - "внутренний дефект исполнения компонента тинт-поверхности: {defect:?}" - ))); + let surface_rgb = match PointOpacityOverSurfaceV1::evaluate(source_rgb, alpha, context_rgb) { + Ok(surface) => surface, + Err(error) => { + return pair_label_opacity_input_error(error.message()); } }; - let Some(occurrence) = evaluation.occurrence(NESTED_FOREGROUND) else { - return Err(SolveFailure::InternalInvariant( - "внутренний дефект компонента тинт-поверхности: occurrence отсутствует".into(), - )); - }; - // Финальные байты РЕАЛЬНО собранной поверхности → прежний контекст резолва. - let surface_hex = crate::alpha::hex_from_srgb8(occurrence.backdrop); + // Финальные байты реально собранной surface становятся контекстом solve. + let surface_hex = crate::alpha::hex_from_srgb8(surface_rgb); let Ok(surface_bg) = BgInput::solid(&surface_hex) else { // Композит 8-битных каналов всегда в кубе — недостижимо, но честнее // отказ, чем правдоподобный мусор (RoleSpec публичен). @@ -2791,27 +2686,24 @@ pub(crate) fn resolve_pair_label( // тинт-подложки, не от фона страницы — потому пол энфорсится против неё. let surface_ctx = ResolveContext::new(&surface_bg, vc); let anchor = TextAnchor::new(fraction, floor)?; - // Identity-ребро occurrence: foreground решается из ВОЗВРАЩЁННОГО - // источника (byte → byte/255 точно), а не повторного чтения `tint` — - // иначе объявленное ребро идентичности было бы декоративным. + // Hue anchor остаётся отдельным входом label-solver. Fill occurrence + // вычисляет только surface и не притворяется зависимостью финального label. resolve_hued_anchor_from_srgb8( &surface_bg, anchor, - Srgb8::new(occurrence.source), + Srgb8::new(source_rgb), vc, &surface_ctx, ) } -/// Замороженная ручная реализация `resolve_pair_label` ДО миграции #307 — -/// независимый differential-oracle графового пути, НЕ production-дубликат. -/// Композиция здесь идёт прежним `composite_hex_from_encoded`-маршрутом, а -/// foreground — через [`resolve_hued_anchor`] по исходному пейлоаду тинта. -/// Любой байтовый/статусный дрейф production-пути от этого оракула — дефект -/// миграции (см. differential-матрицу в тестах PairLabel). +/// Ручной 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_legacy_oracle( +pub(crate) fn resolve_pair_label_manual_composite_oracle( bg: &BgInput, tint: crate::ladder::LadderTint, fraction: f64, diff --git a/crates/labcolors-wasm/src/config_dto.rs b/crates/labcolors-wasm/src/config_dto.rs index 9ff5306e..0e6abaa7 100644 --- a/crates/labcolors-wasm/src/config_dto.rs +++ b/crates/labcolors-wasm/src/config_dto.rs @@ -122,7 +122,7 @@ pub enum FloorDto { /// Рецепт роли из закрытого физического меню текущего resolver-а. /// -/// Это переходная compatibility surface, не target IR и не extension point. +/// Это граница сериализации совместимого API, не доменный IR и не extension point. /// Новая физика не должна добавляться новым recipe variant. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] @@ -663,9 +663,10 @@ mod tests { .expect("восстановленный конфиг компилируется"); } - /// Рецепт `pair-label` (лейбл тинт-бейджа, task #29) гоняется через JSON без + /// Recipe-адаптер `pair-label` гоняется через JSON без /// потерь: kebab-тег `pair-label`, источник/доля/пол целы туда-обратно. - /// Закрывает класс «DTO-ветка компилируется, но круг-трип врёт». + /// Это только доказательство DTO round-trip, а не наличия pair-label + /// в целевом graph API. #[test] fn pair_label_recipe_round_trips_through_json() { use labcolors_core::solve::Floor; diff --git a/docs/whitepaper.md b/docs/whitepaper.md index cbefa289..0f846eed 100644 --- a/docs/whitepaper.md +++ b/docs/whitepaper.md @@ -22,10 +22,10 @@ client-owned program цвета вычисляется для текущего локального контекста. Имена токенов не участвуют в математике. -Текущая реализация ещё компилирует закрытый `ThemeConfig` в -`NamedRoleTable`. Это переходная граница, а не модель продукта: целевой IR +Публичный `ThemeConfig` компилируется в закрытый `NamedRoleTable`; это boundary- +представление совместимого API, а не физическая модель Core. Доменный IR разделяет `Color`, `Paint`, `Surface`, `Occurrence` и типизированные constraints. -Новую возможность нельзя добавлять как очередной вариант recipe enum. +Новый физический закон нельзя кодировать как очередной UI-рецепт. ## Владение @@ -70,7 +70,7 @@ fingerprint считается по распарсенной поддержив `Family(id)` проверяет только существование ссылки. Значение `id` не выбирает формулу, оттенок или поведение. -## Переходное меню рецептов +## Публичные recipe-адаптеры | Рецепт | Физический контракт | |---|---| @@ -85,27 +85,36 @@ fingerprint считается по распарсенной поддержив | `Glow` | точечные screen-слои с явным numerical profile | | `Zero` | явное отсутствие цветового значения | -Эти варианты описывают только существующую совместимую поверхность. Их полезные -законы принадлежат общей физике, но сама упаковка по UI-рецептам — архитектурный -долг. `PairFill`, `PairLabel`, `Material`, `Glow` и `Ladder` должны исчезнуть из -Core после переноса source-over, screen, ordering и occurrence-отношений в общий -граф. Клиент может назвать token как угодно; Core не выводит физику из имени. +Эти варианты принадлежат публичной boundary-поверхности, но их имена не +определяют физику и не являются extension point Core. Клиент может назвать +token как угодно; Core не выводит физику из имени. ## Resolve и зависимости -Переходный `ThemeConfig::compile_named_role_table` строит `NamedRoleTable`. +`ThemeConfig::compile_named_role_table` строит `NamedRoleTable`. `resolve_named_set` принимает локальный `BgInput` и `ViewingConditions`, затем возвращает значения ролей в порядке объявления. -Зависимые поверхности собирает приватный appearance-граф. Он содержит только -typed handles, физические входы и рёбра. Например, `PairLabel` решается против -точного source-over композита своего tint-источника над локальным фоном. -Эмитированный `PairFill` не подменяет эту поверхность и не образует скрытую -зависимость по имени. - -Произвольный client-authored graph и совместный solver пока не являются -публичным API. Поэтому `NamedRoleTable` нельзя расширять как будто это будущий -IR: он удаляется по мере переноса сохранённых законов в общую topology. +Реализованный point-домен приватного appearance-графа содержит путь +`Paint::Solid → Paint::Opacity → Occurrence(fill over input surface) → +Surface::FromOccurrence`. Occurrence моделирует композицию в объявленном +encoded-sRGB8 профиле; это не наблюдение фактических пикселей host. Видимый +результат становится производной поверхностью без повторной композиции. + +Code-owned adapter хранит sealed static compiler-verified IR; production +исполняет его общим runtime evaluator-ом без startup-компиляции. На валидном +point-пути scratch принадлежит caller-у и размещён на стеке; evaluator не +выделяет heap-память. Структурное равенство artifact результату декларативного +compiler-а проверяется тестом. Compiler принадлежит proof-поверхности и +отсутствует в production-артефакте. + +`PairLabel` использует эту поверхность как фон; role-resolver возвращает цвет +метки как `Resolved::Color`. Этот контракт не содержит label Paint/occurrence, +client-authored topology или скрытых зависимостей по имени токена. + +Публичный API не принимает произвольный client-authored graph. `NamedRoleTable` +остаётся boundary-представлением и не служит extension point для новых доменных +законов. ## Якоря и производные цвета diff --git a/packages/colors/bench/wasm-size-budget-v18.json b/packages/colors/bench/wasm-size-budget-v18.json new file mode 100644 index 00000000..2ddcf4e8 --- /dev/null +++ b/packages/colors/bench/wasm-size-budget-v18.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 8, + "budgetId": "labcolors-wasm-runtime-f0a-point-render-spine-v18", + "predecessor": { + "path": "packages/colors/bench/wasm-size-budget-v17.json", + "fileSha256": "8da5e810394ee8fea8491bcd8aff7f3023c267bf31bb5ea4c4b843ef459e7906" + }, + "toolchainSource": { + "path": "packages/colors/bench/wasm-size-budget-v1.json", + "fileSha256": "4f7340fc8cfd0ccb97377c385f2f8d8e7a9ef2c5ba96177f518c5d07de2825e1" + }, + "buildRecipes": { + "runtime": { + "command": "CARGO_ENCODED_RUSTFLAGS= wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked", + "recipeSha256": "0ea74cb070e0a5facb7280f6124930a0bb673ee4dcee9c99fff110db6c9389d4" + } + }, + "roles": { + "runtime": { + "artifact": "packages/colors/pkg/labcolors_bg.wasm", + "measurement": { + "source": "github-actions-run-29675926449", + "measurementPlatform": "linux-x64", + "rawBytes": 385610 + }, + "policy": { + "maxRawBytes": 385610, + "basis": "accepted-f0a-point-render-spine-snapshot", + "gzip": "diagnostic-only" + } + } + } +} diff --git a/packages/colors/test/release-contract.test.mjs b/packages/colors/test/release-contract.test.mjs index d03e8ef8..0d75cc5f 100644 --- a/packages/colors/test/release-contract.test.mjs +++ b/packages/colors/test/release-contract.test.mjs @@ -1187,7 +1187,7 @@ test("release evidence carries no trace of the excised offline line", () => { test("WASM role size budgets are exact, append-only, and acyclic", async () => { const bench = join(root, "packages", "colors", "bench"); const paths = Object.fromEntries( - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17].map((version) => [ + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].map((version) => [ `v${version}`, join(bench, `wasm-size-budget-v${version}.json`), ]), @@ -1213,6 +1213,7 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { v15: "979dc7990742cc2daf163c64051ed6a22c939745a4b1767bc010d6faaaba5161", v16: "9a9ab7050a3f6b103905a817b9d675650d04ddab3524a3fc5aa3d0f5d6cab9c3", v17: "8da5e810394ee8fea8491bcd8aff7f3023c267bf31bb5ea4c4b843ef459e7906", + v18: "2ee1e5b4472aa393eb399e3839ab9ac3cfb832a69fa8b704c3fa4854bc1e11f8", }; const documents = {}; for (const version of Object.keys(paths)) { @@ -1224,7 +1225,7 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { } const { - v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14, v15, v16, v17, + v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14, v15, v16, v17, v18, } = documents; assert.equal(v1.budgetId, "labcolors-wasm-raw-issue-284-v1"); assert.equal(v2.budgetId, "labcolors-wasm-raw-issue-295-v2"); @@ -1596,17 +1597,62 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { "Q0 review hardening must lower the exact runtime ratchet", ); + // V18 (F0a): private point spine исполняет compiler-verified static IR без + // runtime compiler/dynamic bindings; run 29675926449 измерил весь артефакт + // на 46497B меньше v17. Это наблюдаемая дельта снапшота, не стоимость узла. + assert.equal(v18.schemaVersion, 8); + assert.equal(v18.budgetId, "labcolors-wasm-runtime-f0a-point-render-spine-v18"); + assert.deepEqual(v18.predecessor, { + path: "packages/colors/bench/wasm-size-budget-v17.json", + fileSha256: expectedHashes.v17, + }); + assert.deepEqual(v18.toolchainSource, v17.toolchainSource); + assert.deepEqual(v18.buildRecipes, v17.buildRecipes); + assert.deepEqual(v18.roles.runtime.measurement, { + source: "github-actions-run-29675926449", + measurementPlatform: "linux-x64", + rawBytes: 385610, + }); + assert.deepEqual(v18.roles.runtime.policy, { + maxRawBytes: 385610, + basis: "accepted-f0a-point-render-spine-snapshot", + gzip: "diagnostic-only", + }); + assert.equal( + v17.roles.runtime.policy.maxRawBytes - v18.roles.runtime.policy.maxRawBytes, + 46497, + "F0a must lower the exact runtime ratchet by the canonical Linux delta", + ); + const checker = await import( new URL("../../../scripts/check-wasm-size-budget.mjs", import.meta.url) ); - assert.equal(checker.DEFAULT_BUDGET, paths.v17); - for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]) { + assert.equal(checker.DEFAULT_BUDGET, paths.v18); + for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]) { assert.equal(checker[`V${version}_FILE_SHA256`], expectedHashes[`v${version}`]); } assert.equal(checker.V1_RECIPE_SHA256, v5.buildRecipes.runtime.recipeSha256); const ci = read(".github", "workflows", "ci.yml"); assert.match(ci, /name: enforce measured WASM runtime budget/u); - assert.match(ci, /run: node scripts\/check-wasm-size-budget\.mjs/u); + const exactBudgetCommand = " run: node scripts/check-wasm-size-budget.mjs"; + const assertExactBudgetCommand = (workflow) => { + assert.deepEqual( + workflow + .split("\n") + .filter((line) => line.includes("run: node scripts/check-wasm-size-budget.mjs")), + [exactBudgetCommand], + "CI must execute the default budget and built artifact without CLI overrides", + ); + }; + assertExactBudgetCommand(ci); + for (const bypass of [ + `${exactBudgetCommand} --budget attacker.json`, + `${exactBudgetCommand} --runtime-wasm attacker.wasm`, + ]) { + const mutated = ci.replace(exactBudgetCommand, bypass); + assert.notEqual(mutated, ci, "budget CLI mutation must bite the live workflow"); + assert.throws(() => assertExactBudgetCommand(mutated)); + } const wasmJob = ci.match( /\n wasm:\n(?[\s\S]*?)(?=\n [a-z][a-z0-9_-]*:\n|\s*$)/u, )?.groups?.body; @@ -1626,7 +1672,7 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { return `"--remap-path-prefix=\$${mapping.slice(0, separator)}=${mapping.slice(separator + 1)}"`; }) .join("$'\\x1f'")}`; - const runtimeCommand = v17.buildRecipes.runtime.command; + const runtimeCommand = v18.buildRecipes.runtime.command; assert.ok(runtimeCommand.startsWith(recipePrefix)); const expectedBuild = runtimeCommand.slice(recipePrefix.length); const expectedDiffBlock = [ @@ -1703,13 +1749,13 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { assert.notEqual(pathBypass, repetition, "path mutation must bite the live guard"); assert.throws(() => assertRepeatabilityContract(pathBypass)); - const temporary = mkdtempSync(join(tmpdir(), "labcolors-wasm-runtime-budget-v17-")); + const temporary = mkdtempSync(join(tmpdir(), "labcolors-wasm-runtime-budget-v18-")); try { const runtimePath = join(temporary, "runtime.wasm"); const fixtureBudgetPath = join(temporary, "budget.json"); const runtimeBytes = Buffer.alloc(16); runtimeBytes.set([0x00, 0x61, 0x73, 0x6d]); - const fixture = structuredClone(v17); + const fixture = structuredClone(v18); fixture.roles.runtime.measurement.rawBytes = runtimeBytes.length; fixture.roles.runtime.policy.maxRawBytes = runtimeBytes.length; writeFileSync(runtimePath, runtimeBytes); @@ -1782,7 +1828,7 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { // acceptedCeiling-закона. Чекер обязан отклонить его всё равно: // рост сверх принятого снапшота требует НОВОЙ версии бюджета, // а не правки текущей. - const ceiling = v17.roles.runtime.policy.maxRawBytes; + const ceiling = v18.roles.runtime.policy.maxRawBytes; value.roles.runtime.measurement.rawBytes = ceiling + 1; value.roles.runtime.policy.maxRawBytes = ceiling + 1; }], @@ -1796,7 +1842,7 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { roles: value.roles, })], ]; - assert.equal(schemaMutations.length, 24, "v17 schema mutation set changed"); + assert.equal(schemaMutations.length, 24, "v18 schema mutation set changed"); for (const [name, mutate] of schemaMutations) { const invalid = structuredClone(fixture); const result = mutate(invalid) ?? invalid; @@ -1815,7 +1861,7 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { writeFileSync(fixtureBudgetPath, canonicalJson(schemaFirst)); assert.throws( () => runWith(fixtureBudgetPath, join(temporary, "missing-runtime.wasm")), - /budgetId must be labcolors-wasm-runtime-q0-review-hardening-v17/u, + /budgetId must be labcolors-wasm-runtime-f0a-point-render-spine-v18/u, "CLI must reject the budget schema before reading a missing artifact", ); @@ -1871,8 +1917,8 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { coordinatedMutation.roles.runtime.measurement.rawBytes -= 1; coordinatedMutation.roles.runtime.policy.maxRawBytes -= 1; assert.throws( - () => checker.parseBudgetDocument(Buffer.from(canonicalJson(coordinatedMutation)), paths.v17), - /current v17 file SHA-256 mismatch/u, + () => checker.parseBudgetDocument(Buffer.from(canonicalJson(coordinatedMutation)), paths.v18), + /current v18 file SHA-256 mismatch/u, "coordinated artifact and document drift must still fail the default identity", ); } finally { diff --git a/scripts/check-wasm-size-budget.mjs b/scripts/check-wasm-size-budget.mjs index bb38184c..60e55eda 100644 --- a/scripts/check-wasm-size-budget.mjs +++ b/scripts/check-wasm-size-budget.mjs @@ -24,10 +24,11 @@ const V13_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v13. const V14_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v14.json"); const V15_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v15.json"); const V16_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v16.json"); +const V17_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v17.json"); export const DEFAULT_BUDGET = resolve( REPO_ROOT, - "packages/colors/bench/wasm-size-budget-v17.json", + "packages/colors/bench/wasm-size-budget-v18.json", ); export const V1_FILE_SHA256 = "4f7340fc8cfd0ccb97377c385f2f8d8e7a9ef2c5ba96177f518c5d07de2825e1"; @@ -57,7 +58,7 @@ export const V12_FILE_SHA256 = "925452113b18b63137b9dae4786e3a8f7ba098eb47a2631a97107fbd52aa9a95"; // V*_FILE_SHA256 — SHA-256 канонических байтов версионных budget-файлов в // packages/colors/bench/. Версии v1..v(N−1) неизменяемы навсегда (история -// ратчета); константа текущей версии (сейчас v17) ротируется ТОЛЬКО вместе с +// ратчета); константа текущей версии (сейчас v18) ротируется ТОЛЬКО вместе с // принятием нового снапшота: следующий bump добавляет v(N+1)-пару, а v(N) // уходит в неизменяемый список. Дрейф любого файла к его пину = отказ чекера. export const V13_FILE_SHA256 = @@ -70,9 +71,11 @@ export const V16_FILE_SHA256 = "9a9ab7050a3f6b103905a817b9d675650d04ddab3524a3fc5aa3d0f5d6cab9c3"; export const V17_FILE_SHA256 = "8da5e810394ee8fea8491bcd8aff7f3023c267bf31bb5ea4c4b843ef459e7906"; +export const V18_FILE_SHA256 = + "2ee1e5b4472aa393eb399e3839ab9ac3cfb832a69fa8b704c3fa4854bc1e11f8"; const V1_REPOSITORY_PATH = "packages/colors/bench/wasm-size-budget-v1.json"; -const V16_REPOSITORY_PATH = "packages/colors/bench/wasm-size-budget-v16.json"; +const V17_REPOSITORY_PATH = "packages/colors/bench/wasm-size-budget-v17.json"; const V5_BUDGET_ID = "labcolors-wasm-roles-issue-296-c1-v5"; const V6_BUDGET_ID = "labcolors-wasm-roles-issue-296-c3-v6"; const V7_BUDGET_ID = "labcolors-wasm-roles-issue-307-c7a-v7"; @@ -86,6 +89,7 @@ const V14_BUDGET_ID = "labcolors-wasm-runtime-c6-legacy-excision-v14"; const V15_BUDGET_ID = "labcolors-wasm-runtime-c6-validation-srgb-ssot-v15"; const V16_BUDGET_ID = "labcolors-wasm-runtime-q0-output-conflict-v16"; const V17_BUDGET_ID = "labcolors-wasm-runtime-q0-review-hardening-v17"; +const V18_BUDGET_ID = "labcolors-wasm-runtime-f0a-point-render-spine-v18"; const ROLE_ORDER = ["runtime"]; const ROLE_SPECS = { runtime: { @@ -93,11 +97,11 @@ const ROLE_SPECS = { command: "CARGO_ENCODED_RUSTFLAGS= wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked", recipeSha256: V1_RECIPE_SHA256, - // Pinned Linux run 29663300158 измерил review-hardened Q0 на 5B меньше v16. - // Это дельта целого артефакта, не причинная оценка; ратчет следует факту. - basis: "accepted-q0-review-hardening-snapshot", - measurementSource: "github-actions-run-29663300158", - acceptedCeiling: 432107, + // Pinned Linux run 29675926449 измерил весь F0a snapshot на 46497B меньше + // v17. Это дельта целого артефакта, не причинная оценка отдельных узлов. + basis: "accepted-f0a-point-render-spine-snapshot", + measurementSource: "github-actions-run-29675926449", + acceptedCeiling: 385610, }, }; @@ -242,7 +246,11 @@ function verifyImmutableHistory() { if (v16?.schemaVersion !== 8 || v16?.budgetId !== V16_BUDGET_ID) { fail("immutable v16 budget identity drifted"); } - return { v1, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14, v15, v16 }; + const v17 = readImmutableJson(V17_PATH, V17_FILE_SHA256, "v17"); + if (v17?.schemaVersion !== 8 || v17?.budgetId !== V17_BUDGET_ID) { + fail("immutable v17 budget identity drifted"); + } + return { v1, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14, v15, v16, v17 }; } function validateBudgetValue(budget) { @@ -259,14 +267,14 @@ function validateBudgetValue(budget) { "budget", ); if (budget.schemaVersion !== 8) fail("supported schemaVersion is exactly 8"); - if (budget.budgetId !== V17_BUDGET_ID) fail(`budgetId must be ${V17_BUDGET_ID}`); + if (budget.budgetId !== V18_BUDGET_ID) fail(`budgetId must be ${V18_BUDGET_ID}`); exactKeys(budget.predecessor, ["path", "fileSha256"], "predecessor"); if ( - budget.predecessor.path !== V16_REPOSITORY_PATH || - budget.predecessor.fileSha256 !== V16_FILE_SHA256 + budget.predecessor.path !== V17_REPOSITORY_PATH || + budget.predecessor.fileSha256 !== V17_FILE_SHA256 ) { - fail("predecessor must bind the immutable v16 document"); + fail("predecessor must bind the immutable v17 document"); } exactKeys(budget.toolchainSource, ["path", "fileSha256"], "toolchainSource"); @@ -349,10 +357,10 @@ export function parseBudgetDocument(bytes, budgetPath) { validateBudgetValue(budget); if (resolve(budgetPath) === DEFAULT_BUDGET) { const actualFileSha256 = sha256(document); - if (actualFileSha256 !== V17_FILE_SHA256) { + if (actualFileSha256 !== V18_FILE_SHA256) { fail( - `current v17 file SHA-256 mismatch: ` + - `expected=${V17_FILE_SHA256} actual=${actualFileSha256}`, + `current v18 file SHA-256 mismatch: ` + + `expected=${V18_FILE_SHA256} actual=${actualFileSha256}`, ); } }