From 66e4ab5f5ea68a38acf399a1b79b73f7f5a7741c Mon Sep 17 00:00:00 2001 From: Daniel from Labpics <63733699+lemone112@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:49:46 +0300 Subject: [PATCH 1/2] =?UTF-8?q?G1a:=20=D1=81=D0=B2=D1=8F=D0=B7=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D1=8D=D0=BC=D0=B8=D1=81=D1=81=D0=B8=D1=8E=20=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D1=84=D0=B0-=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B0=20=D1=81=20exact=20occurrence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cargo/mutants.toml | 7 +- crates/labcolors-core/src/alpha.rs | 310 ++--------- crates/labcolors-core/src/analog.rs | 488 ++++++++++++++++ crates/labcolors-core/src/appearance.rs | 107 ++-- .../src/appearance_graph_tests.rs | 34 +- crates/labcolors-core/src/composition.rs | 58 ++ crates/labcolors-core/src/config.rs | 16 +- crates/labcolors-core/src/config/tests.rs | 21 +- crates/labcolors-core/src/constraint_tests.rs | 2 +- .../labcolors-core/src/constraints/exact.rs | 62 +++ crates/labcolors-core/src/constraints/mod.rs | 12 + crates/labcolors-core/src/lib.rs | 3 +- crates/labcolors-core/src/semantic.rs | 527 ++++++++++++++++-- crates/labcolors-core/src/test_support.rs | 76 +++ 14 files changed, 1345 insertions(+), 378 deletions(-) create mode 100644 crates/labcolors-core/src/analog.rs create mode 100644 crates/labcolors-core/src/composition.rs create mode 100644 crates/labcolors-core/src/constraints/exact.rs diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 6e5f0e56..a36b9249 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -13,7 +13,10 @@ # выживший мутант = молчаливая дыра в наборе (coverage=пол, mutation=правда). examine_globs = [ "crates/labcolors-core/src/alpha.rs", + "crates/labcolors-core/src/analog.rs", "crates/labcolors-core/src/appearance.rs", + "crates/labcolors-core/src/composition.rs", + "crates/labcolors-core/src/constraints/exact.rs", "crates/labcolors-core/src/glow.rs", "crates/labcolors-core/src/material.rs", "crates/labcolors-core/src/numerical_plan.rs", @@ -25,7 +28,7 @@ examine_globs = [ "crates/labcolors-core/src/wcag22/kernel.rs", ] -# Запас времени на мутант: legacy proxy coordinate считается быстро, но набор в целом не -# мгновенный; 3× базового прогона + пол в 60с гасит ложные timeout-«убийства». +# Timeout выводится из baseline полного детерминированного core-набора; multiplier +# и минимальный пол отделяют реальные зависания мутантов от штатного времени тестов. timeout_multiplier = 3.0 minimum_test_timeout = 60 diff --git a/crates/labcolors-core/src/alpha.rs b/crates/labcolors-core/src/alpha.rs index 68fd5a05..023a9d23 100644 --- a/crates/labcolors-core/src/alpha.rs +++ b/crates/labcolors-core/src/alpha.rs @@ -61,6 +61,16 @@ use crate::spaces::srgb::{hex_from_srgb_encoded, srgb_encoded_from_hex}; +#[cfg(test)] +pub(crate) fn reset_source_over_evaluation_count() { + crate::composition::reset_source_over_evaluation_count(); +} + +#[cfg(test)] +pub(crate) fn source_over_evaluation_count() -> usize { + crate::composition::source_over_evaluation_count() +} + /// Валидный кодированный канал/цвет: конечный и в `[0,1]` — домен всех /// функций модуля (hex-обёртки гарантируют его по построению, byte/255). fn is_encoded_rgb(v: [f64; 3]) -> bool { @@ -68,18 +78,6 @@ fn is_encoded_rgb(v: [f64; 3]) -> bool { .all(|x| x.is_finite() && (0.0..=1.0).contains(&x)) } -/// Ровно тот же монотонный порядок binary64-операций, что у официального JS- -/// потребителя на непрозрачной подложке: `bg + alpha * (tint - bg)`. -/// Expanded-форма `tint*alpha + bg*(1-alpha)` немонотонна на отдельных ULP- -/// швах из-за двух округлений, поэтому не годится для доказуемого lower-bound. -fn source_over_channel_value(tint: u8, alpha: f64, bg: u8) -> f64 { - f64::from(bg) + alpha * (f64::from(tint) - f64::from(bg)) -} - -fn source_over_channel_srgb8(tint: u8, alpha: f64, bg: u8) -> u8 { - source_over_channel_value(tint, alpha, bg).round() as u8 -} - /// Непрерывная алгебра straight-alpha до квантования. Она нужна инверсии и /// материалам; путь эмитируемого 8-битного пикселя использует /// [`composite_over_srgb8`], чтобы нормализация `byte/255` не сдвигала half-tie. @@ -136,31 +134,7 @@ pub fn composite_over_encoded( /// /// `Err`, если `alpha` не конечна или лежит вне `[0,1]`. pub fn composite_over_srgb8(tint: [u8; 3], alpha: f64, bg: [u8; 3]) -> Result<[u8; 3], String> { - validate_alpha(alpha)?; - 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]`. Единый и для -/// композитора, и для графовых bindings — доменный отказ по альфе обязан -/// звучать одинаково на всех путях (тексты отказов публично наблюдаемы). -pub(crate) fn validate_alpha(alpha: f64) -> Result<(), String> { - if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) { - return Err(format!("alpha вне конечного [0,1]: {alpha}")); - } - Ok(()) + crate::composition::source_over_srgb8(tint, alpha, bg) } /// Квантизация кодированного цвета в эмитируемые sRGB8-байты с доменной @@ -461,190 +435,6 @@ pub fn resolve_alpha_analog( Some(AlphaAnalog { tint, alpha }) } -/// Существование byte-тинта при фиксированной alpha проверяется только по -/// крайнему тинту нужного направления. Поканальный output монотонен по tint, а -/// соседние тинты отличаются до round не больше чем на `alpha ≤ 1`, поэтому -/// целевой байт нельзя «перепрыгнуть». -fn srgb8_target_is_feasible(solid: [u8; 3], alpha: f64, bg: [u8; 3]) -> bool { - (0..3).all(|channel| match solid[channel].cmp(&bg[channel]) { - std::cmp::Ordering::Equal => true, - std::cmp::Ordering::Greater => { - source_over_channel_srgb8(u8::MAX, alpha, bg[channel]) >= solid[channel] - } - std::cmp::Ordering::Less => { - source_over_channel_srgb8(u8::MIN, alpha, bg[channel]) <= solid[channel] - } - }) -} - -/// Первый `binary64`, на котором фактический reference-предикат допускает -/// byte-тинт. Положительные `f64` упорядочены своими битами, поэтому полный -/// lower-bound по `[0, 1]` занимает фиксированные 62 шага и не зависит от -/// эвристического epsilon или аналитического округления стенки. -fn first_srgb8_alpha(solid: [u8; 3], bg: [u8; 3]) -> f64 { - if solid == bg { - return 0.0; - } - let mut failing = 0.0_f64.to_bits(); - let mut passing = 1.0_f64.to_bits(); - // При alpha=0 композит равен bg при любом тинте, а при alpha=1 выбор - // tint=solid всегда достигает цели. После раннего `solid == bg` это строгая - // пара FAIL/PASS, необходимая двоичному lower-bound. - debug_assert!( - !srgb8_target_is_feasible(solid, 0.0, bg), - "разные solid/bg не могут быть достижимы при alpha=0: solid={solid:?}, bg={bg:?}" - ); - debug_assert!( - srgb8_target_is_feasible(solid, 1.0, bg), - "alpha=1 обязана достигать любой sRGB8-цели через tint=solid: solid={solid:?}, bg={bg:?}" - ); - - while passing - failing > 1 { - let middle = failing + (passing - failing) / 2; - if srgb8_target_is_feasible(solid, f64::from_bits(middle), bg) { - passing = middle; - } else { - failing = middle; - } - } - f64::from_bits(passing) -} - -/// Канонический byte-тинт при фиксированной alpha. -/// -/// Каналы независимы. Монотонным двоичным поиском находится весь непрерывный -/// диапазон byte-тинтов, которые production-композитор округляет в цель. Из -/// него выбирается байт с минимальной ошибкой до непрерывной инверсии; при -/// точном равенстве — меньший байт. Затем тот же композитор проверяет ответ. -fn srgb8_tint_at_alpha(solid: [u8; 3], alpha: f64, bg: [u8; 3]) -> Option<[u8; 3]> { - if !srgb8_target_is_feasible(solid, alpha, bg) { - return None; - } - let mut tint = [0_u8; 3]; - - for channel in 0..3 { - let solid_channel = solid[channel]; - let bg_channel = bg[channel]; - if solid_channel == bg_channel { - tint[channel] = bg_channel; - continue; - } - if alpha == 0.0 { - return None; - } - - let output = |candidate: u8| source_over_channel_srgb8(candidate, alpha, bg_channel); - let mut lo = 0_u16; - let mut hi = 256_u16; - while lo < hi { - let mid = lo + (hi - lo) / 2; - if output(mid as u8) < solid_channel { - lo = mid + 1; - } else { - hi = mid; - } - } - if lo == 256 || output(lo as u8) != solid_channel { - return None; - } - let first = lo as u8; - - lo = u16::from(first); - hi = 256; - while lo < hi { - let mid = lo + (hi - lo) / 2; - if output(mid as u8) <= solid_channel { - lo = mid + 1; - } else { - hi = mid; - } - } - let last = (lo - 1) as u8; - - let ideal = - f64::from(bg_channel) + (f64::from(solid_channel) - f64::from(bg_channel)) / alpha; - let floor = ideal.floor().clamp(f64::from(first), f64::from(last)) as u8; - let ceil = ideal.ceil().clamp(f64::from(first), f64::from(last)) as u8; - let error = |candidate: u8| { - (source_over_channel_value(candidate, alpha, bg_channel) - f64::from(solid_channel)) - .abs() - }; - tint[channel] = if error(floor).total_cmp(&error(ceil)).is_le() { - floor - } else { - ceil - }; - } - - let composite = [ - 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]), - ]; - (composite == solid).then_some(tint) -} - -/// Эмиссионный sRGB8-путь альфа-аналога. -/// -/// Входные цвета сначала квантуются до той же byte-сетки, в которой будет -/// эмитирован тинт. Если запрошенная alpha допускает хотя бы один точный -/// byte-тинт, она сохраняется буквально. Иначе alpha поднимается до первого -/// `binary64`, который проходит тот же фиксированный Rust/JS reference- -/// композитор; непосредственный predecessor не проходит. Это отдельный, более -/// сильный контракт, чем continuous-инверсия [`resolve_alpha_analog`]. -/// -/// Недоменная запрошенная α и нарушение точного постусловия возвращаются как -/// `Err`: такой результат нельзя отдавать наружу. Для любого валидного домена -/// результат тотален — в худшем случае α=1 и byte-тинт равен цели. -pub(crate) fn resolve_alpha_analog_srgb8( - solid: [f64; 3], - requested_alpha: f64, - bg: [f64; 3], -) -> Result<([u8; 3], f64), String> { - if !requested_alpha.is_finite() || !(0.0..=1.0).contains(&requested_alpha) { - return Err(format!( - "requested_alpha вне конечного [0,1]: {requested_alpha}" - )); - } - let solid_srgb8 = encoded_to_srgb8(solid, "solid")?; - let bg_srgb8 = encoded_to_srgb8(bg, "bg")?; - if let Some(tint) = srgb8_tint_at_alpha(solid_srgb8, requested_alpha, bg_srgb8) { - verify_srgb8_alpha_analog(solid_srgb8, tint, requested_alpha, bg_srgb8)?; - return Ok((tint, requested_alpha)); - } - - let floor = first_srgb8_alpha(solid_srgb8, bg_srgb8); - // Запрошенная alpha уже не дала byte-тинт; тотальность канального поиска и - // монотонность reference-предиката означают, что первый PASS обязан быть - // строго правее неё. Равенство здесь выявляет рассогласование двух путей. - debug_assert!( - floor > requested_alpha, - "отвергнутая requested alpha обязана лежать ниже первого sRGB8 PASS: solid={solid_srgb8:?}, bg={bg_srgb8:?}, requested={requested_alpha}, floor={floor}" - ); - let tint = srgb8_tint_at_alpha(solid_srgb8, floor, bg_srgb8) - .ok_or_else(|| "первая sRGB8-alpha не дала допустимый byte-тинт".to_string())?; - verify_srgb8_alpha_analog(solid_srgb8, tint, floor, bg_srgb8)?; - Ok((tint, floor)) -} - -/// Глубинная проверка постусловия именно в эмитируемой byte-арифметике. -/// Отдельная функция нужна, чтобы тест мог доказать: неверная пара отвергается, -/// а не только что текущий солвер обычно выдаёт верную. -fn verify_srgb8_alpha_analog( - solid: [u8; 3], - tint: [u8; 3], - alpha: f64, - bg: [u8; 3], -) -> Result<(), String> { - let composite = composite_over_srgb8(tint, alpha, bg)?; - if composite != solid { - return Err(format!( - "alpha-analog не воспроизвёл sRGB8-цель: solid={solid:?}, tint={tint:?}, alpha={alpha}, bg={bg:?}, composite={composite:?}" - )); - } - Ok(()) -} - /// Hex-обёртка эмиссионного sRGB8-резолвера: `(tint_hex, фактическая α)`. /// Возвращённая пара побайтно воспроизводит /// `solid_hex` через [`composite_over_srgb8`]; постусловие проверено до возврата. @@ -658,10 +448,15 @@ pub fn resolve_alpha_analog_hex( requested_alpha: f64, bg_hex: &str, ) -> Result<(String, f64), String> { - let solid = srgb_encoded_from_hex(solid_hex)?; - let bg = srgb_encoded_from_hex(bg_hex)?; - let (tint, alpha) = resolve_alpha_analog_srgb8(solid, requested_alpha, bg)?; - Ok((hex_from_srgb8(tint), alpha)) + let target = crate::Srgb8::new(crate::srgb8::hex_bytes(solid_hex)?); + let backdrop = crate::Srgb8::new(crate::srgb8::hex_bytes(bg_hex)?); + let verified = crate::analog::resolve_verified( + crate::analog::AuthoredAlphaBindingIdV1::Standalone, + target, + requested_alpha, + backdrop, + )?; + Ok((verified.tint().to_hex(), verified.alpha())) } #[cfg(test)] @@ -737,7 +532,8 @@ mod tests { centre, f64::from_bits(centre.to_bits() + 1), ]; - let outputs = alphas.map(|alpha| source_over_channel_srgb8(255, alpha, 1)); + let outputs = + alphas.map(|alpha| crate::composition::source_over_channel_srgb8(255, alpha, 1)); assert!( outputs.windows(2).all(|pair| pair[0] <= pair[1]), "{outputs:?}" @@ -771,17 +567,21 @@ mod tests { for requested_alpha in [0.0, 0.01, 0.122, 0.5, 0.9, near_one, 1.0] { for solid in u8::MIN..=u8::MAX { for bg in u8::MIN..=u8::MAX { - let solid_encoded = [f64::from(solid) / 255.0; 3]; - let bg_encoded = [f64::from(bg) / 255.0; 3]; + let target = crate::Srgb8::new([solid; 3]); + let backdrop = crate::Srgb8::new([bg; 3]); let requested_is_feasible = - srgb8_tint_at_alpha([solid; 3], requested_alpha, [bg; 3]).is_some(); - let (tint, actual_alpha) = - resolve_alpha_analog_srgb8(solid_encoded, requested_alpha, bg_encoded) - .unwrap_or_else(|error| { - panic!( - "solid={solid}, bg={bg}, requested={requested_alpha}: {error}" - ) - }); + crate::analog::tint_at_alpha(target, requested_alpha, backdrop).is_some(); + let verified = crate::analog::resolve_verified( + crate::analog::AuthoredAlphaBindingIdV1::Standalone, + target, + requested_alpha, + backdrop, + ) + .unwrap_or_else(|error| { + panic!("solid={solid}, bg={bg}, requested={requested_alpha}: {error}") + }); + let tint = verified.tint().bytes(); + let actual_alpha = verified.alpha(); let got = composite_over_srgb8(tint, actual_alpha, [bg; 3]) .expect("резолвер возвращает α в [0,1]"); assert_eq!( @@ -798,7 +598,7 @@ mod tests { assert!(actual_alpha > requested_alpha); let predecessor = f64::from_bits(actual_alpha.to_bits() - 1); assert!( - srgb8_tint_at_alpha([solid; 3], predecessor, [bg; 3]).is_none(), + crate::analog::tint_at_alpha(target, predecessor, backdrop).is_none(), "actual alpha не минимальна: solid={solid}, bg={bg}, actual={actual_alpha}" ); } @@ -837,7 +637,14 @@ mod tests { assert!(actual > requested); assert_eq!(composite_hex(&tint, actual, "#000000").unwrap(), "#010000"); let predecessor = f64::from_bits(actual.to_bits() - 1); - assert!(srgb8_tint_at_alpha([1, 0, 0], predecessor, [0; 3]).is_none()); + assert!( + crate::analog::tint_at_alpha( + crate::Srgb8::new([1, 0, 0]), + predecessor, + crate::Srgb8::new([0; 3]), + ) + .is_none() + ); } } @@ -849,9 +656,11 @@ mod tests { for bg in u8::MIN..=u8::MAX { let target = [solid, bg, bg]; let background = [bg; 3]; - let floor = first_srgb8_alpha(target, background); + let target = crate::Srgb8::new(target); + let background = crate::Srgb8::new(background); + let floor = crate::analog::first_alpha(target, background); assert!( - srgb8_tint_at_alpha(target, floor, background).is_some(), + crate::analog::tint_at_alpha(target, floor, background).is_some(), "solid={solid}, bg={bg}, floor={floor} не проходит" ); if solid == bg { @@ -859,7 +668,7 @@ mod tests { } else { let predecessor = f64::from_bits(floor.to_bits() - 1); assert!( - srgb8_tint_at_alpha(target, predecessor, background).is_none(), + crate::analog::tint_at_alpha(target, predecessor, background).is_none(), "solid={solid}, bg={bg}: predecessor={predecessor} тоже проходит" ); } @@ -874,16 +683,19 @@ mod tests { #[test] fn srgb8_resolver_quantises_target_before_inversion_and_guard_rejects_drift() { let off_grid_solid = [0.25 / 255.0; 3]; - let resolved: Result<([u8; 3], f64), String> = - resolve_alpha_analog_srgb8(off_grid_solid, 0.5, [0.0; 3]); - let (tint, alpha) = resolved.expect("валидный домен всегда имеет конечный sRGB8-ответ"); + let target = crate::Srgb8::new(encoded_to_srgb8(off_grid_solid, "solid").unwrap()); + let backdrop = crate::Srgb8::new([0; 3]); + let verified = crate::analog::resolve_verified( + crate::analog::AuthoredAlphaBindingIdV1::Standalone, + target, + 0.5, + backdrop, + ) + .expect("валидный домен всегда имеет конечный sRGB8-ответ"); + let tint = verified.tint().bytes(); + let alpha = verified.alpha(); assert_eq!(tint, [0; 3]); assert_eq!(composite_over_srgb8(tint, alpha, [0; 3]).unwrap(), [0; 3]); - - assert!( - verify_srgb8_alpha_analog([0; 3], [1; 3], 0.5, [0; 3]).is_err(), - "пара из старого off-grid пути обязана быть отвергнута" - ); } /// Обратный ход на живых парах: восстановленный тинт отклоняется от diff --git a/crates/labcolors-core/src/analog.rs b/crates/labcolors-core/src/analog.rs new file mode 100644 index 00000000..fe6a2f1b --- /dev/null +++ b/crates/labcolors-core/src/analog.rs @@ -0,0 +1,488 @@ +//! Приватная exact-программа допуска AlphaAnalog. +//! +//! Proposal выбирает `(tint, alpha)`, но не сертифицирует себя. Этот модуль +//! материализует ровно один финальный occurrence общей point-программой, +//! применяет exact identity constraint и только после PASS создаёт verified +//! value. Никакого результата с частичным evidence при mismatch не существует. + +use crate::Srgb8; +use crate::appearance::{ + PhysicalProgramIdentityV1, PointOpacityOverSurfaceV1, ProgramOccurrenceBindingV1, + ResolvedOccurrence, SourceOverCertificateV1, +}; +use crate::constraints::{ + ExactConstraintIdentityV1, ExactIdentityMismatchV1, ExactSrgb8IdentityV1, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ExactIdentityCapabilityV1 { + FinalOccurrenceSrgb8IdentityV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ExactIdentityReleaseV1 { + V1, +} + +/// Opaque identity authored invocation-а. Standalone helper не притворяется +/// client binding; named compiler назначает ordinal конкретной декларации. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AuthoredAlphaBindingIdV1 { + Standalone, + Named { declaration_ordinal: usize }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ExactIdentityEvidenceV1 { + physical: PhysicalProgramIdentityV1, + authored: AuthoredAlphaBindingIdV1, + constraint: ExactConstraintIdentityV1, + capability: ExactIdentityCapabilityV1, + release: ExactIdentityReleaseV1, + program_occurrence: ProgramOccurrenceBindingV1, + occurrence: SourceOverCertificateV1, + target: Srgb8, + actual: Srgb8, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct VerifiedAlphaAnalogV1 { + occurrence: ResolvedOccurrence, + authored: AuthoredAlphaBindingIdV1, + target: Srgb8, +} + +impl VerifiedAlphaAnalogV1 { + pub(crate) fn tint(&self) -> Srgb8 { + Srgb8::new(self.occurrence.certificate().subject_rgb()) + } + + pub(crate) fn alpha(&self) -> f64 { + f64::from_bits(self.occurrence.certificate().subject_opacity_bits()) + } + + pub(crate) const fn occurrence(&self) -> &ResolvedOccurrence { + &self.occurrence + } + + pub(crate) fn evidence(&self) -> ExactIdentityEvidenceV1 { + ExactIdentityEvidenceV1 { + physical: ExactAlphaProgramV1::physical_identity(), + authored: self.authored, + constraint: ExactAlphaProgramV1::constraint_identity(), + capability: ExactIdentityCapabilityV1::FinalOccurrenceSrgb8IdentityV1, + release: ExactIdentityReleaseV1::V1, + program_occurrence: self.occurrence.program_occurrence_binding(), + occurrence: *self.occurrence.certificate(), + target: self.target, + actual: Srgb8::new(self.occurrence.visible()), + } + } +} + +/// Существование byte-тинта проверяется по крайнему тинту нужного направления: +/// source-over монотонен по tint, а соседние байты при `alpha <= 1` не могут +/// перепрыгнуть целевой байт. +fn target_is_feasible(target: Srgb8, alpha: f64, backdrop: Srgb8) -> bool { + let target = target.bytes(); + let backdrop = backdrop.bytes(); + (0..3).all(|channel| match target[channel].cmp(&backdrop[channel]) { + core::cmp::Ordering::Equal => true, + core::cmp::Ordering::Greater => { + crate::composition::source_over_channel_srgb8(u8::MAX, alpha, backdrop[channel]) + >= target[channel] + } + core::cmp::Ordering::Less => { + crate::composition::source_over_channel_srgb8(u8::MIN, alpha, backdrop[channel]) + <= target[channel] + } + }) +} + +/// Первый `binary64` в `[0,1]`, на котором byte-grid допускает target. +/// Неотрицательные `f64` упорядочены битами, поэтому поиск точен и конечен. +pub(crate) fn first_alpha(target: Srgb8, backdrop: Srgb8) -> f64 { + if target == backdrop { + return 0.0; + } + let mut failing = 0.0_f64.to_bits(); + let mut passing = 1.0_f64.to_bits(); + debug_assert!(!target_is_feasible(target, 0.0, backdrop)); + debug_assert!(target_is_feasible(target, 1.0, backdrop)); + + while passing - failing > 1 { + let middle = failing + (passing - failing) / 2; + if target_is_feasible(target, f64::from_bits(middle), backdrop) { + passing = middle; + } else { + failing = middle; + } + } + f64::from_bits(passing) +} + +/// Канонический byte-тинт при фиксированной alpha. Для каждого канала берётся +/// ближайший к непрерывной инверсии байт из полного проходящего интервала. +pub(crate) fn tint_at_alpha(target: Srgb8, alpha: f64, backdrop: Srgb8) -> Option { + if !target_is_feasible(target, alpha, backdrop) { + return None; + } + let target = target.bytes(); + let backdrop = backdrop.bytes(); + let mut tint = [0_u8; 3]; + + for channel in 0..3 { + let target_channel = target[channel]; + let backdrop_channel = backdrop[channel]; + if target_channel == backdrop_channel { + tint[channel] = backdrop_channel; + continue; + } + if alpha == 0.0 { + return None; + } + + let output = |candidate: u8| { + crate::composition::source_over_channel_srgb8(candidate, alpha, backdrop_channel) + }; + let mut lo = 0_u16; + let mut hi = 255_u16; + while lo < hi { + let mid = lo + (hi - lo) / 2; + if output(mid as u8) < target_channel { + lo = mid + 1; + } else { + hi = mid; + } + } + // Feasibility помещает target между output(0) и output(255), а шаг + // монотонного source-over не превышает один byte, поэтому lower-bound + // не может ни выйти за 255, ни перепрыгнуть target. + let first = lo as u8; + debug_assert_eq!(output(first), target_channel); + + lo = u16::from(first); + hi = 256; + while lo < hi { + let mid = lo + (hi - lo) / 2; + if output(mid as u8) <= target_channel { + lo = mid + 1; + } else { + hi = mid; + } + } + let last = (lo - 1) as u8; + + let ideal = f64::from(backdrop_channel) + + (f64::from(target_channel) - f64::from(backdrop_channel)) / alpha; + let floor = ideal.floor().clamp(f64::from(first), f64::from(last)) as u8; + let ceil = ideal.ceil().clamp(f64::from(first), f64::from(last)) as u8; + let error = |candidate: u8| { + (crate::composition::source_over_channel_value(candidate, alpha, backdrop_channel) + - f64::from(target_channel)) + .abs() + }; + tint[channel] = if error(floor).total_cmp(&error(ceil)).is_le() { + floor + } else { + ceil + }; + } + + let actual = [ + crate::composition::source_over_channel_srgb8(tint[0], alpha, backdrop[0]), + crate::composition::source_over_channel_srgb8(tint[1], alpha, backdrop[1]), + crate::composition::source_over_channel_srgb8(tint[2], alpha, backdrop[2]), + ]; + (actual == target).then_some(Srgb8::new(tint)) +} + +fn propose(target: Srgb8, requested_alpha: f64, backdrop: Srgb8) -> Result<(Srgb8, f64), String> { + if !requested_alpha.is_finite() || !(0.0..=1.0).contains(&requested_alpha) { + return Err(format!( + "requested_alpha вне конечного [0,1]: {requested_alpha}" + )); + } + if let Some(tint) = tint_at_alpha(target, requested_alpha, backdrop) { + return Ok((tint, requested_alpha)); + } + + let alpha = first_alpha(target, backdrop); + debug_assert!(alpha > requested_alpha); + let tint = tint_at_alpha(target, alpha, backdrop) + .ok_or_else(|| "первая sRGB8-alpha не дала допустимый byte-тинт".to_owned())?; + Ok((tint, alpha)) +} + +/// Единственный coordinator byte-grid proposal, point occurrence и exact gate. +pub(crate) fn resolve_verified( + authored: AuthoredAlphaBindingIdV1, + target: Srgb8, + requested_alpha: f64, + backdrop: Srgb8, +) -> Result { + let (tint, alpha) = propose(target, requested_alpha, backdrop)?; + ExactAlphaProgramV1::evaluate(authored, target, tint, alpha, backdrop) + .map_err(|error| error.message()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ExactAlphaProgramErrorV1 { + InvalidOpacity(String), + IdentityMismatch(ExactIdentityMismatchV1), +} + +impl ExactAlphaProgramErrorV1 { + pub(crate) fn message(&self) -> String { + match self { + Self::InvalidOpacity(message) => message.clone(), + Self::IdentityMismatch(mismatch) => format!( + "alpha-analog не воспроизвёл sRGB8-цель: target={:?}, actual={:?}", + mismatch.target().bytes(), + mismatch.actual().bytes() + ), + } + } +} + +/// Code-owned compiled invocation: одна физическая topology и один exact +/// evaluator. Runtime bindings — только admitted bytes и alpha. +pub(crate) struct ExactAlphaProgramV1; + +impl ExactAlphaProgramV1 { + pub(crate) const fn physical_identity() -> PhysicalProgramIdentityV1 { + PointOpacityOverSurfaceV1::physical_identity() + } + + pub(crate) const fn constraint_identity() -> ExactConstraintIdentityV1 { + ExactSrgb8IdentityV1::IDENTITY + } + + pub(crate) fn evaluate( + authored: AuthoredAlphaBindingIdV1, + target: Srgb8, + tint: Srgb8, + alpha: f64, + backdrop: Srgb8, + ) -> Result { + let occurrence = PointOpacityOverSurfaceV1::evaluate(tint.bytes(), alpha, backdrop.bytes()) + .map_err(|error| { + ExactAlphaProgramErrorV1::InvalidOpacity(error.message().to_owned()) + })?; + let assessment = ExactSrgb8IdentityV1::evaluate(&occurrence, target) + .map_err(ExactAlphaProgramErrorV1::IdentityMismatch)?; + debug_assert_eq!(assessment.target(), assessment.actual()); + let verified = VerifiedAlphaAnalogV1 { + occurrence, + authored, + target: assessment.target(), + }; + debug_assert_eq!(verified.evidence().actual, assessment.actual()); + Ok(verified) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_alpha_facade_owns_no_byte_grid_proposal_or_verified_coordinator() { + let facade = include_str!("alpha.rs"); + let (production, _) = facade + .split_once("\n#[cfg(test)]\nmod tests {") + .expect("alpha facade must retain one explicit test-module boundary"); + for forbidden in [ + "fn target_is_feasible", + "fn first_alpha(", + "fn tint_at_alpha", + "fn propose(", + "fn resolve_verified(", + "fn srgb8_target_is_feasible", + "fn first_srgb8_alpha", + "fn srgb8_tint_at_alpha", + "fn propose_alpha_analog_srgb8", + "fn resolve_alpha_analog_srgb8_verified", + ] { + assert!( + !production.contains(forbidden), + "alpha facade still owns private exact machinery: {forbidden}" + ); + } + assert_eq!( + production + .matches("crate::analog::resolve_verified") + .count(), + 1, + "the public hex facade must delegate exactly once to the private coordinator" + ); + } + + #[test] + fn canonical_tint_matches_independent_exhaustive_byte_oracle() { + for alpha in [0.125, 0.5, 0.875, 1.0] { + for backdrop in u8::MIN..=u8::MAX { + for target in u8::MIN..=u8::MAX { + let expected = (u8::MIN..=u8::MAX) + .filter(|&candidate| { + crate::composition::source_over_channel_srgb8( + candidate, alpha, backdrop, + ) == target + }) + .min_by(|left, right| { + let error = |candidate| { + (crate::composition::source_over_channel_value( + candidate, alpha, backdrop, + ) - f64::from(target)) + .abs() + }; + error(*left) + .total_cmp(&error(*right)) + .then_with(|| left.cmp(right)) + }); + let actual = tint_at_alpha( + Srgb8::new([target, backdrop, backdrop]), + alpha, + Srgb8::new([backdrop; 3]), + ) + .map(|tint| tint.bytes()[0]); + assert_eq!( + actual, expected, + "target={target}, backdrop={backdrop}, alpha={alpha}" + ); + } + } + } + } + + #[test] + fn finite_invalid_alpha_is_rejected_before_any_proposal() { + let target = Srgb8::new([1, 0, 0]); + let backdrop = Srgb8::new([0; 3]); + for requested_alpha in [-0.25, 1.25] { + let error = resolve_verified( + AuthoredAlphaBindingIdV1::Standalone, + target, + requested_alpha, + backdrop, + ) + .expect_err("invalid alpha must not be silently moved onto the exact frontier"); + assert!( + error.contains("requested_alpha вне конечного [0,1]"), + "unexpected rejection boundary: {error}" + ); + } + } + + #[test] + fn corrupted_candidate_cannot_create_verified_output() { + let target = Srgb8::new([0, 0, 0]); + crate::composition::reset_source_over_evaluation_count(); + let error = ExactAlphaProgramV1::evaluate( + AuthoredAlphaBindingIdV1::Standalone, + target, + Srgb8::new([255, 255, 255]), + 0.5, + Srgb8::new([0, 0, 0]), + ) + .expect_err("wrong final bytes must not mint VerifiedAlphaAnalogV1"); + let message = error.message(); + assert!(message.contains("target=[0, 0, 0]"), "{message}"); + assert!(message.contains("actual=[128, 128, 128]"), "{message}"); + + let ExactAlphaProgramErrorV1::IdentityMismatch(mismatch) = error else { + panic!("unexpected exact-program error: {error:?}"); + }; + assert_eq!(mismatch.target(), target); + assert_eq!(mismatch.actual(), Srgb8::new([128, 128, 128])); + assert_eq!(crate::composition::source_over_evaluation_count(), 1); + } + + #[test] + fn exact_evidence_keeps_physics_and_authored_routing_separate() { + let verified = ExactAlphaProgramV1::evaluate( + AuthoredAlphaBindingIdV1::Named { + declaration_ordinal: 7, + }, + Srgb8::new([128, 128, 128]), + Srgb8::new([0, 0, 0]), + 0.5, + Srgb8::new([255, 255, 255]), + ) + .unwrap(); + let evidence = verified.evidence(); + + assert_eq!( + evidence.physical, + PhysicalProgramIdentityV1::SolidOpacityOverSurfaceEncodedSrgb8V1 + ); + assert_eq!( + evidence.constraint, + ExactConstraintIdentityV1::FinalSrgb8IdentityV1 + ); + assert_eq!(evidence.target, evidence.actual); + assert_eq!( + evidence.program_occurrence, + verified.occurrence().program_occurrence_binding() + ); + assert_eq!(evidence.occurrence.output_rgb(), evidence.actual.bytes()); + assert_eq!( + evidence.authored, + AuthoredAlphaBindingIdV1::Named { + declaration_ordinal: 7 + } + ); + } + + #[test] + fn equal_physics_under_distinct_named_bindings_keeps_distinct_evidence() { + let evaluate = |declaration_ordinal| { + ExactAlphaProgramV1::evaluate( + AuthoredAlphaBindingIdV1::Named { + declaration_ordinal, + }, + Srgb8::new([128, 128, 128]), + Srgb8::new([0, 0, 0]), + 0.5, + Srgb8::new([255, 255, 255]), + ) + .unwrap() + }; + let first = evaluate(2).evidence(); + let second = evaluate(9).evidence(); + + assert_eq!(first.physical, second.physical); + assert_eq!(first.constraint, second.constraint); + assert_eq!(first.capability, second.capability); + assert_eq!(first.release, second.release); + assert_eq!(first.program_occurrence, second.program_occurrence); + assert_eq!(first.occurrence, second.occurrence); + assert_eq!(first.target, second.target); + assert_eq!(first.actual, second.actual); + assert_ne!(first.authored, second.authored); + assert_ne!(first, second); + } + + #[test] + fn exact_point_program_is_heap_allocation_free() { + let target = Srgb8::new([0x80, 0x80, 0x80]); + let tint = Srgb8::new([0x00, 0x00, 0x00]); + let backdrop = Srgb8::new([0xFF, 0xFF, 0xFF]); + + let (result, allocations) = crate::test_support::measured_allocations(|| { + ExactAlphaProgramV1::evaluate( + AuthoredAlphaBindingIdV1::Standalone, + target, + tint, + 0.5, + backdrop, + ) + }); + + assert!(result.is_ok()); + assert_eq!( + allocations, 0, + "exact point execution must stay on the stack" + ); + } +} diff --git a/crates/labcolors-core/src/appearance.rs b/crates/labcolors-core/src/appearance.rs index 20c2b905..1d91bc70 100644 --- a/crates/labcolors-core/src/appearance.rs +++ b/crates/labcolors-core/src/appearance.rs @@ -7,7 +7,7 @@ //! не входят. //! //! Единственная операция композиции — версионированный exact-композитор -//! [`crate::alpha::composite_over_srgb8`]. `Opacity` только умножает straight +//! [`crate::composition`]. `Opacity` только умножает straight //! alpha уже материализованного Paint и никогда не композитит промежуточный //! результат. //! @@ -93,6 +93,39 @@ pub(crate) enum CompositionProfileV1 { EncodedSrgb8SourceOverV1, } +/// Структурная identity статической физической программы. Она описывает +/// topology/opcode/profile, а не числовые handles декларации или client ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PhysicalProgramIdentityV1 { + SolidOpacityOverSurfaceEncodedSrgb8V1, +} + +/// Routing внутри одной compiled point-программы отделён от физического +/// source-over proof. Эти code-owned handles не являются client-authored ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ProgramOccurrenceBindingV1 { + occurrence: OccurrenceId, + subject: PaintId, + backdrop_surface: SurfaceId, +} + +impl ProgramOccurrenceBindingV1 { + #[cfg(test)] + pub(crate) const fn occurrence(self) -> OccurrenceId { + self.occurrence + } + + #[cfg(test)] + pub(crate) const fn subject(self) -> PaintId { + self.subject + } + + #[cfg(test)] + pub(crate) const fn backdrop_surface(self) -> SurfaceId { + self.backdrop_surface + } +} + /// Paint-конструкторы point-домена. Ни один вариант не знает Surface. #[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -258,7 +291,7 @@ pub(crate) enum BindingError { /// Единственный отказ sealed point-adapter-а: невалидная authored alpha. /// Topology/bindings не представлены во входном типе и потому не могут дать /// runtime-ошибку. -#[derive(Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct PointOpacityError { message: String, } @@ -853,12 +886,17 @@ const POINT_OPACITY_OVER_SURFACE_V1: CompiledAppearanceProgram<'static> = pub(crate) struct PointOpacityOverSurfaceV1; impl PointOpacityOverSurfaceV1 { + pub(crate) const fn physical_identity() -> PhysicalProgramIdentityV1 { + PhysicalProgramIdentityV1::SolidOpacityOverSurfaceEncodedSrgb8V1 + } + pub(crate) fn evaluate( source: [u8; 3], opacity: f64, backdrop: [u8; 3], ) -> Result { - crate::alpha::validate_alpha(opacity).map_err(|message| PointOpacityError { message })?; + crate::composition::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]; @@ -980,11 +1018,8 @@ impl ResolvedPaint { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct SourceOverCertificateV1 { 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], } @@ -993,11 +1028,13 @@ impl SourceOverCertificateV1 { #[cfg(test)] pub(crate) fn replay(&self) -> Result<[u8; 3], String> { match self.profile { - CompositionProfileV1::EncodedSrgb8SourceOverV1 => crate::alpha::composite_over_srgb8( - self.subject_rgb, - f64::from_bits(self.subject_opacity_bits), - self.backdrop_rgb, - ), + CompositionProfileV1::EncodedSrgb8SourceOverV1 => { + crate::composition::source_over_srgb8( + self.subject_rgb, + f64::from_bits(self.subject_opacity_bits), + self.backdrop_rgb, + ) + } } } @@ -1006,31 +1043,14 @@ impl SourceOverCertificateV1 { 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 @@ -1070,7 +1090,6 @@ impl ResolvedOccurrence { self.against } - #[cfg(test)] pub(crate) fn backdrop(&self) -> [u8; 3] { self.backdrop } @@ -1089,10 +1108,20 @@ impl ResolvedOccurrence { #[cfg(test)] pub(crate) fn visible_point_binding(&self) -> VisiblePointBindingV1 { - VisiblePointBindingV1(self.certificate) + VisiblePointBindingV1 { + program_occurrence: self.program_occurrence_binding(), + occurrence: self.certificate, + } + } + + pub(crate) const fn program_occurrence_binding(&self) -> ProgramOccurrenceBindingV1 { + ProgramOccurrenceBindingV1 { + occurrence: self.id, + subject: self.subject, + backdrop_surface: self.against, + } } - #[cfg(test)] pub(crate) fn certificate(&self) -> &SourceOverCertificateV1 { &self.certificate } @@ -1124,12 +1153,15 @@ impl ModeledSrgb8PointOccurrence { /// что финальные байты случайно совпали. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg(test)] -pub(crate) struct VisiblePointBindingV1(SourceOverCertificateV1); +pub(crate) struct VisiblePointBindingV1 { + program_occurrence: ProgramOccurrenceBindingV1, + occurrence: SourceOverCertificateV1, +} #[cfg(test)] impl VisiblePointBindingV1 { - pub(crate) fn certificate(self) -> SourceOverCertificateV1 { - self.0 + pub(crate) fn program_occurrence(self) -> ProgramOccurrenceBindingV1 { + self.program_occurrence } } @@ -1262,7 +1294,7 @@ impl CompiledAppearanceProgram<'_> { } } for (input, alpha) in opacities { - if let Err(message) = crate::alpha::validate_alpha(*alpha) { + if let Err(message) = crate::composition::validate_alpha(*alpha) { return Err(BindingError::OpacityOutOfDomain { input: *input, message, @@ -1404,7 +1436,7 @@ impl CompiledAppearanceProgram<'_> { }); let visible = match spec.profile { CompositionProfileV1::EncodedSrgb8SourceOverV1 => { - crate::alpha::composite_over_srgb8_validated( + crate::composition::source_over_srgb8_validated( subject.rgb.bytes(), f64::from_bits(subject.opacity_bits), backdrop.bytes(), @@ -1413,11 +1445,8 @@ impl CompiledAppearanceProgram<'_> { }; let certificate = SourceOverCertificateV1 { profile: spec.profile, - occurrence: spec.id, - subject: spec.subject_id, subject_rgb: subject.rgb.bytes(), subject_opacity_bits: subject.opacity_bits, - backdrop_surface: spec.against_id, backdrop_rgb: backdrop.bytes(), output_rgb: visible, }; diff --git a/crates/labcolors-core/src/appearance_graph_tests.rs b/crates/labcolors-core/src/appearance_graph_tests.rs index 0713af47..a714aab2 100644 --- a/crates/labcolors-core/src/appearance_graph_tests.rs +++ b/crates/labcolors-core/src/appearance_graph_tests.rs @@ -80,11 +80,19 @@ fn bindings(source: [u8; 3], opacity: f64, context: [u8; 3]) -> AppearanceBindin } #[test] -fn static_runtime_program_is_exactly_the_compiler_output() { +fn static_exact_program_is_declarative_topology_plus_typed_constraint() { let compiled = crate::appearance::point_opacity_over_surface_declarative_spec() .compile() .unwrap(); assert!(crate::appearance::point_program_matches(&compiled)); + assert_eq!( + crate::analog::ExactAlphaProgramV1::physical_identity(), + crate::appearance::PhysicalProgramIdentityV1::SolidOpacityOverSurfaceEncodedSrgb8V1 + ); + assert_eq!( + crate::analog::ExactAlphaProgramV1::constraint_identity(), + crate::constraints::ExactConstraintIdentityV1::FinalSrgb8IdentityV1 + ); } #[test] @@ -211,6 +219,18 @@ fn complete_typed_id_renaming_does_not_change_physics() { first.occurrence(FILL_OCCURRENCE).unwrap().visible(), second.occurrence(occurrence).unwrap().visible() ); + let first_occurrence = first.occurrence(FILL_OCCURRENCE).unwrap(); + let second_occurrence = second.occurrence(occurrence).unwrap(); + assert_eq!( + first_occurrence.certificate(), + second_occurrence.certificate(), + "typed-ID rename must not change the ID-free physical proof" + ); + assert_ne!( + first_occurrence.program_occurrence_binding(), + second_occurrence.program_occurrence_binding(), + "program routing remains distinct provenance outside the physical proof" + ); assert_eq!( first.surface_rgb(DERIVED_SURFACE), second.surface_rgb(derived_surface) @@ -681,10 +701,12 @@ proptest! { .unwrap() .evaluate(&bindings(source, opacity, context)) .unwrap(); - let certificate = rendered.occurrence(FILL_OCCURRENCE).unwrap().certificate(); + let occurrence = rendered.occurrence(FILL_OCCURRENCE).unwrap(); + let certificate = occurrence.certificate(); + let program_occurrence = occurrence.program_occurrence_binding(); prop_assert_eq!(certificate.profile(), CompositionProfileV1::EncodedSrgb8SourceOverV1); - prop_assert_eq!(certificate.occurrence(), FILL_OCCURRENCE); - prop_assert_eq!(certificate.subject(), FILL_PAINT); + prop_assert_eq!(program_occurrence.occurrence(), FILL_OCCURRENCE); + prop_assert_eq!(program_occurrence.subject(), FILL_PAINT); prop_assert_eq!(certificate.subject_rgb(), source); let canonical_opacity_bits = if opacity == 0.0 { 0.0f64.to_bits() @@ -692,7 +714,7 @@ proptest! { opacity.to_bits() }; prop_assert_eq!(certificate.subject_opacity_bits(), canonical_opacity_bits); - prop_assert_eq!(certificate.backdrop_surface(), CONTEXT_SURFACE); + prop_assert_eq!(program_occurrence.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())); @@ -804,7 +826,7 @@ proptest! { } else { (invalid, 0.5, OPACITY) }; - let expected_message = crate::alpha::validate_alpha(invalid).unwrap_err(); + let expected_message = crate::composition::validate_alpha(invalid).unwrap_err(); prop_assert_eq!( graph.evaluate(&AppearanceBindings::new( vec![(SOURCE, Srgb8::new([1, 2, 3]))], diff --git a/crates/labcolors-core/src/composition.rs b/crates/labcolors-core/src/composition.rs new file mode 100644 index 00000000..a258e3ee --- /dev/null +++ b/crates/labcolors-core/src/composition.rs @@ -0,0 +1,58 @@ +//! Нижний exact-слой point-композиции. +//! +//! Модуль не знает solver, recipe, constraint или client ID. Он фиксирует +//! единственную физическую операцию encoded-sRGB8 source-over, чтобы proposal, +//! appearance runtime и final-emission gate не могли разойтись по арифметике. + +/// Порядок binary64-операций совпадает с официальным JS-потребителем на +/// непрозрачной подложке. Expanded-форма запрещена: два округления нарушают +/// монотонность на отдельных ULP-швах. +pub(crate) fn source_over_channel_value(tint: u8, alpha: f64, backdrop: u8) -> f64 { + f64::from(backdrop) + alpha * (f64::from(tint) - f64::from(backdrop)) +} + +pub(crate) fn source_over_channel_srgb8(tint: u8, alpha: f64, backdrop: u8) -> u8 { + source_over_channel_value(tint, alpha, backdrop).round() as u8 +} + +pub(crate) fn validate_alpha(alpha: f64) -> Result<(), String> { + if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) { + return Err(format!("alpha вне конечного [0,1]: {alpha}")); + } + Ok(()) +} + +pub(crate) fn source_over_srgb8( + tint: [u8; 3], + alpha: f64, + backdrop: [u8; 3], +) -> Result<[u8; 3], String> { + validate_alpha(alpha)?; + Ok(source_over_srgb8_validated(tint, alpha, backdrop)) +} + +pub(crate) fn source_over_srgb8_validated(tint: [u8; 3], alpha: f64, backdrop: [u8; 3]) -> [u8; 3] { + debug_assert!(validate_alpha(alpha).is_ok()); + #[cfg(test)] + SOURCE_OVER_EVALUATIONS.with(|count| count.set(count.get() + 1)); + [ + source_over_channel_srgb8(tint[0], alpha, backdrop[0]), + source_over_channel_srgb8(tint[1], alpha, backdrop[1]), + source_over_channel_srgb8(tint[2], alpha, backdrop[2]), + ] +} + +#[cfg(test)] +std::thread_local! { + static SOURCE_OVER_EVALUATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn reset_source_over_evaluation_count() { + SOURCE_OVER_EVALUATIONS.with(|count| count.set(0)); +} + +#[cfg(test)] +pub(crate) fn source_over_evaluation_count() -> usize { + SOURCE_OVER_EVALUATIONS.with(std::cell::Cell::get) +} diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index d9b5be80..eb308e68 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -27,7 +27,7 @@ //! компилируется в [`RoleSpec::Ladder`]: источник раскладывается в пер-темный //! тинт-якорь ([`crate::ladder::LadderTint`]), позиция несёт альфу Figma-рампы. //! [`RoleRecipe::AlphaAnalog`] компилируется в [`RoleSpec::AlphaAnalog`] (солид- -//! цель источника + запрошенная альфа, композит-инверсия — [`crate::alpha`], #119). +//! цель источника + запрошенная альфа, exact encoded-sRGB8 identity — [`crate::alpha`]). //! Резолв обоих — [`crate::semantic::Resolved::Translucent`] (тинт×альфа напрямую + солид- //! композит на фоне резолва для замера контраста). Исполняемый канон позиций и //! альф — [`LadderPosition::ALL`] и [`LadderPosition::alpha_pair`]. @@ -962,7 +962,7 @@ impl ThemeConfig { *alpha, ALPHA_MIN_EXCLUSIVE, ALPHA_MAX_INCLUSIVE, - "0 < alpha ≤ 1 (запрошенная альфа альфа-аналога)", + "0 < alpha ≤ 1 (запрошенная непрозрачность альфа-аналога)", ) } RoleRecipe::Material { @@ -1092,11 +1092,13 @@ impl ThemeConfig { } } - Ok(NamedRoleTable::from_validated_parts( - entries, - self.aliases.clone(), - chroma, - )) + NamedRoleTable::from_validated_parts(entries, self.aliases.clone(), chroma).map_err( + |error| ConfigError::OutOfBounds { + handle: format!("roles.{}.alpha", self.roles[error.declaration_ordinal()].0), + value: error.value(), + bound: "0 < alpha ≤ 1 (запрошенная непрозрачность альфа-аналога)", + }, + ) } /// Скомпилировать один рецепт в [`RoleSpec`]. Ladder/AlphaAnalog раскладывают diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index 6bde84de..5c752af9 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -1038,9 +1038,9 @@ fn ladder_bites_on_family_source_mutation() { ); } -/// AlphaAnalog-рецепт (#119): солид-цель фиксирована, тинт выводится +/// AlphaAnalog-рецепт: солид-цель фиксирована, тинт выводится /// композит-инверсией. RED-proof: разные α (обе ≥ α_min) дают разный тинт; -/// композит фактической пары ТОЧНО равен солид-цели (теорема тождества #119). +/// exact gate допускает только побайтное равенство финального occurrence цели. /// /// Фон подобран так, чтобы солид был разрешим при α < 1 (иначе солид над белым /// вырождается в α_min≈1 — это физика, не баг: полностью насыщенный солид над @@ -1092,21 +1092,10 @@ fn alpha_analog_recipe_inverts_and_bites_on_alpha() { tint_low != tint_high || (a_low - a_high).abs() > 1e-6, "RED-proof альфы провален: α=0.5 и α=0.9 дали одно ({tint_low}@{a_low} vs {tint_high}@{a_high})" ); - // Теорема тождества #119: композит фактической пары равен солид-цели - // `#787880` в пределах границы квантования 8-бит (при α<1 точное побайтное - // восстановление тинта не гарантируется, но композит держится в ±несколько - // LSB — гарантия из документации `crate::alpha`). - let target = crate::spaces::srgb::srgb_encoded_from_hex("#787880").unwrap(); + // Эмиссионный контракт byte-grid точен: фактический occurrence обязан + // воспроизвести солид-цель побайтно, а не попасть в эвристический LSB-допуск. for comp in [&comp_low, &comp_high] { - let got = crate::spaces::srgb::srgb_encoded_from_hex(comp).unwrap(); - for c in 0..3 { - let lsb = (got[c] - target[c]).abs() * 255.0; - assert!( - lsb <= 3.0, - "композит альфа-аналога {comp} канал {c} отклонился на {lsb:.2} LSB \ - от солид-цели #787880 (> 3 LSB — инверсия сломана)" - ); - } + assert_eq!(comp, "#787880"); } } diff --git a/crates/labcolors-core/src/constraint_tests.rs b/crates/labcolors-core/src/constraint_tests.rs index 73c3d6da..5ff6df80 100644 --- a/crates/labcolors-core/src/constraint_tests.rs +++ b/crates/labcolors-core/src/constraint_tests.rs @@ -53,7 +53,7 @@ fn wcag_reads_final_visible_occurrence_in_measurement_order() { assert_eq!(measurement.background, [255, 255, 255]); assert_eq!(*decision, Wcag22ApplicableDecisionV1::Fail); assert_eq!( - report.binding().certificate().occurrence(), + report.binding().program_occurrence().occurrence(), OccurrenceId::new(0) ); diff --git a/crates/labcolors-core/src/constraints/exact.rs b/crates/labcolors-core/src/constraints/exact.rs new file mode 100644 index 00000000..eac9120c --- /dev/null +++ b/crates/labcolors-core/src/constraints/exact.rs @@ -0,0 +1,62 @@ +use crate::Srgb8; +use crate::appearance::ResolvedOccurrence; + +/// Структурная identity единственного exact-ограничения AlphaAnalog v1. +/// Она не содержит client ID, target bytes или выбранную alpha: эти значения +/// принадлежат конкретной invocation, а не закону проверки. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ExactConstraintIdentityV1 { + FinalSrgb8IdentityV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ExactIdentityAssessmentV1 { + target: Srgb8, + actual: Srgb8, +} + +impl ExactIdentityAssessmentV1 { + pub(crate) const fn target(self) -> Srgb8 { + self.target + } + + pub(crate) const fn actual(self) -> Srgb8 { + self.actual + } +} + +/// Типизированный отказ exact-гейта. Он несёт только диагностическую пару и +/// никогда не выдаёт частично «проверенный» occurrence/evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ExactIdentityMismatchV1 { + target: Srgb8, + actual: Srgb8, +} + +impl ExactIdentityMismatchV1 { + pub(crate) const fn target(self) -> Srgb8 { + self.target + } + + pub(crate) const fn actual(self) -> Srgb8 { + self.actual + } +} + +pub(crate) struct ExactSrgb8IdentityV1; + +impl ExactSrgb8IdentityV1 { + pub(crate) const IDENTITY: ExactConstraintIdentityV1 = + ExactConstraintIdentityV1::FinalSrgb8IdentityV1; + + pub(crate) fn evaluate( + occurrence: &ResolvedOccurrence, + target: Srgb8, + ) -> Result { + let actual = Srgb8::new(occurrence.visible()); + if actual != target { + return Err(ExactIdentityMismatchV1 { target, actual }); + } + Ok(ExactIdentityAssessmentV1 { target, actual }) + } +} diff --git a/crates/labcolors-core/src/constraints/mod.rs b/crates/labcolors-core/src/constraints/mod.rs index 05052106..cc63c60b 100644 --- a/crates/labcolors-core/src/constraints/mod.rs +++ b/crates/labcolors-core/src/constraints/mod.rs @@ -4,18 +4,26 @@ //! сохраняет identity физического evidence и release реально вызванного //! evaluator-а. +#[cfg(test)] use crate::appearance::{ModeledSrgb8PointOccurrence, ResolvedOccurrence, VisiblePointBindingV1}; +mod exact; +pub(crate) use exact::{ExactConstraintIdentityV1, ExactIdentityMismatchV1, ExactSrgb8IdentityV1}; + +#[cfg(test)] mod wcag22; +#[cfg(test)] pub(crate) use wcag22::Wcag22Srgb8V1; /// Marker-ы недоступны внешним crate-ам: новые target/evaluator families /// добавляются только вместе с code-owned physical adapter-ом. +#[cfg(test)] mod private { pub trait EvaluatorSealed {} } +#[cfg(test)] pub(crate) trait Evaluator: private::EvaluatorSealed { type Invocation; type Release; @@ -34,12 +42,14 @@ pub(crate) trait Evaluator: private::EvaluatorSealed { /// Assessment вместе с exact physical binding и evaluator release. /// Поля закрыты, чтобы genuine result нельзя было пересвязать вручную. #[derive(Debug, Clone, PartialEq)] +#[cfg(test)] pub(crate) struct BoundAssessment { binding: Binding, release: Release, assessment: Assessment, } +#[cfg(test)] impl BoundAssessment { pub(crate) fn binding(&self) -> &Binding { &self.binding @@ -54,6 +64,7 @@ impl BoundAssessment } } +#[cfg(test)] pub(crate) type AssessmentResult = Result< BoundAssessment< VisiblePointBindingV1, @@ -63,6 +74,7 @@ pub(crate) type AssessmentResult = Result< >::Error, >; +#[cfg(test)] pub(crate) fn assess( source: &ResolvedOccurrence, evaluator: &Evaluation, diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index 5b620b4f..74287468 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -7,15 +7,16 @@ pub mod wcag22; pub mod wcag22_evidence; // END WCAG22_SOURCE_ROUTES_V1 +pub(crate) mod composition; pub(crate) mod spaces; pub use srgb8::Srgb8; pub(crate) mod accent_balance; pub mod alpha; +pub(crate) mod analog; pub(crate) mod appearance; pub mod config; -#[cfg(test)] pub(crate) mod constraints; pub mod glow; pub mod hash; diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index ea4bb9de..848384c6 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -542,9 +542,8 @@ pub enum RoleSpec { tint: crate::ladder::LadderTint, /// Контрактная ступень стека. step: crate::glow::GlowStep, - /// Typed execution mode compiled invocation (#292). Прежние - /// config/wire-ключи `stable-v1 | legacy-platform-dependent-v1` — - /// migration adapter на границе, не core-семантика. + /// Typed execution mode реально выбирает зарегистрированный numerical + /// path. Wire-ключ — его boundary-проекция, а не декоративный metadata. mode: crate::numerical_plan::NumericalExecutionModeV1, }, /// Заливка пары (внутренний модуль `pair`): якорь источника, сдвинутый до победы @@ -594,8 +593,8 @@ pub enum RoleSpec { /// поле должно отсутствовать; `Some(Floor::None)` тоже невалиден. floor: Option, }, - /// Альфа-аналог солида источника через композит-инверсию ([`crate::alpha`], - /// #119): для солид-цвета `of` (по теме) на фоне резолва подбирается + /// Альфа-аналог солида источника через композит-инверсию ([`crate::alpha`]): + /// для солид-цвета `of` (по теме) на фоне резолва подбирается /// `(tint, α)`, чей композит равен солиду. Отличается от [`Ladder`](Self::Ladder) /// тем, что здесь солид-цель ФИКСИРОВАНА (тинт выводится инверсией), а не /// тинт-якорь эмитится напрямую. Даёт `-tinted`-роли labui (fill-*-tinted): @@ -2218,7 +2217,7 @@ fn resolve_spec_in( RoleSpec::Glow { tint, step, mode } => { // Свечение: halo = якорь источника по теме; core — пересвет; // интенсивность решается под контрактную ступень на фоне резолва. - // Typed execution mode исполняется ПРЯМО из compiled spec (#292): + // Typed execution mode исполняется ПРЯМО из compiled spec: // никакого plan lookup или string policy selection в hot path. let halo_hex = crate::spaces::srgb::hex_from_srgb_encoded(tint.for_vc(vc)); let bg_hex = @@ -2315,10 +2314,13 @@ fn resolve_spec_in( }; } RoleSpec::AlphaAnalog { of, alpha } => { - // Альфа-аналог: солид-цель фиксирована (тинт источника по теме), - // тинт выводится композит-инверсией (`crate::alpha`, #119). Фактическая - // α поднимается до α_min, если запрошенная неразрешима в гамуте. - return resolve_rgba_inverted(of.for_vc(vc), alpha, bg, vc); + let _ = (of, alpha); + // Named runtime обязан перехватить этот ordinal скомпилированной + // invocation до recipe-dispatch. Исполнение raw variant здесь + // создало бы второй источник физики. + return Err(SolveFailure::InternalInvariant( + "alpha-analog recipe bypassed its compiled invocation".into(), + )); } RoleSpec::Material { hue, tone, floor } => { // Материал (whitepaper, «Точечные композиции»): тон-база — опаковая @@ -2742,7 +2744,8 @@ pub(crate) fn resolve_pair_label_manual_composite_oracle( /// Альфа-аналог: солид-цель `solid` (кодированный, по теме) на фоне резолва /// инвертируется в `(tint, фактическая α)`. Перед инверсией цель квантуется до /// эмитируемой sRGB8-сетки; production-композитор обязан побайтно вернуть её. -fn resolve_rgba_inverted( +fn resolve_rgba_inverted_with_binding( + authored: crate::analog::AuthoredAlphaBindingIdV1, solid_encoded: [f64; 3], requested_alpha: f64, bg: &BgInput, @@ -2761,22 +2764,58 @@ fn resolve_rgba_inverted( "alpha-analog alpha must be finite and inside (0, 1]".into(), )); } - let bg_encoded = bg.encoded_display(); - let solid_q = quantise_encoded(solid_encoded); - let analog = - match crate::alpha::resolve_alpha_analog_srgb8(solid_q, requested_alpha, bg_encoded) { - Ok(analog) => analog, + let target = match crate::alpha::encoded_to_srgb8(solid_encoded, "solid") { + Ok(target) => Srgb8::new(target), + Err(error) => { + return Err(SolveFailure::InternalInvariant(format!( + "validated alpha-analog target left encoded sRGB domain: {error}" + ))); + } + }; + let backdrop = match crate::alpha::encoded_to_srgb8(bg.encoded_display(), "bg") { + Ok(backdrop) => Srgb8::new(backdrop), + Err(error) => { + return Err(SolveFailure::InternalInvariant(format!( + "validated alpha-analog backdrop left encoded sRGB domain: {error}" + ))); + } + }; + let verified = + match crate::analog::resolve_verified(authored, target, requested_alpha, backdrop) { + Ok(verified) => verified, Err(error) => { return Err(SolveFailure::InternalInvariant(format!( "validated alpha-analog resolver violated its total-domain contract: {error}" ))); } }; - let (tint_srgb8, actual_alpha) = analog; - let tint_q = Srgb8::new(tint_srgb8).encoded(); + let actual_alpha = verified.alpha(); // Резолвер возвращает тот же binary64 либо строго больший точный пол. let alpha_coerced = actual_alpha > requested_alpha; - finish_rgba(tint_q, actual_alpha, bg_encoded, vc, alpha_coerced, false) + finish_rgba_from_occurrence( + verified.tint(), + actual_alpha, + verified.occurrence(), + vc, + alpha_coerced, + false, + ) +} + +#[cfg(test)] +fn resolve_rgba_inverted( + solid_encoded: [f64; 3], + requested_alpha: f64, + bg: &BgInput, + vc: &ViewingConditions, +) -> PendingResolution { + resolve_rgba_inverted_with_binding( + crate::analog::AuthoredAlphaBindingIdV1::Standalone, + solid_encoded, + requested_alpha, + bg, + vc, + ) } /// Собрать [`Resolved::Translucent`] из эмитируемых тинта и альфы: вывести их @@ -2793,27 +2832,46 @@ fn finish_rgba( alpha_coerced: bool, floor_coerced: bool, ) -> PendingResolution { - use crate::spaces::srgb::{hex_from_srgb_encoded, srgb_encoded_from_hex, srgb_gamma_inv}; - // Единый байтовый домен SSOT нужен и для hex, и для обеих метрик: - // нормализация `(byte/255)·255` способна изменить граничное значение - // половинного округления на один LSB. - let composite_hex = - match crate::alpha::composite_hex_from_encoded(tint_encoded, alpha, bg_encoded) { - Ok(hex) => hex, - Err(error) => { - return Err(SolveFailure::InternalInvariant(format!( - "rgba-композит вне encoded-sRGB8 reference-домена: {error}" - ))); - } - }; - let composite_q = match srgb_encoded_from_hex(&composite_hex) { - Ok(value) => value, - Err(reason) => { - return Err(SolveFailure::InternalInvariant(format!( - "rgba formatter emitted an invalid sRGB hex: {reason}" - ))); - } - }; + let tint = crate::alpha::encoded_to_srgb8(tint_encoded, "tint") + .map(Srgb8::new) + .map_err(|error| { + SolveFailure::InternalInvariant(format!( + "rgba tint вне encoded-sRGB8 reference-домена: {error}" + )) + })?; + let backdrop = crate::alpha::encoded_to_srgb8(bg_encoded, "bg") + .map(Srgb8::new) + .map_err(|error| { + SolveFailure::InternalInvariant(format!( + "rgba backdrop вне encoded-sRGB8 reference-домена: {error}" + )) + })?; + let occurrence = PointOpacityOverSurfaceV1::evaluate(tint.bytes(), alpha, backdrop.bytes()) + .map_err(|error| { + SolveFailure::InternalInvariant(format!( + "rgba-композит вне encoded-sRGB8 reference-домена: {}", + error.message() + )) + })?; + finish_rgba_from_occurrence(tint, alpha, &occurrence, vc, alpha_coerced, floor_coerced) +} + +/// Финальная эмиссия читает байты уже исполненного occurrence. Ветка не умеет +/// композитить повторно, поэтому exact gate и публичный compositeHex физически +/// ссылаются на один результат. +fn finish_rgba_from_occurrence( + tint: Srgb8, + alpha: f64, + occurrence: &crate::appearance::ResolvedOccurrence, + vc: &ViewingConditions, + alpha_coerced: bool, + floor_coerced: bool, +) -> PendingResolution { + use crate::spaces::srgb::srgb_gamma_inv; + let composite = Srgb8::new(occurrence.visible()); + let backdrop = Srgb8::new(occurrence.backdrop()); + let composite_q = composite.encoded(); + let bg_encoded = backdrop.encoded(); // Линейный свет из кодированного (per-channel gamma-декод) для Ys candidate score. let decode = |e: [f64; 3]| { [ @@ -2828,12 +2886,12 @@ fn finish_rgba( let composite_wcag = crate::wcag::contrast_ratio(composite_q, bg_encoded); // Отличимость в encoded-sRGB8 reference: сравнение по тем же // 8-битным hex, из которых строится сертификат. Фон квантуется тем же - // форматтером; применимость к рендереру проверяется отдельно (#241). - let composite_distinct = composite_hex != hex_from_srgb_encoded(bg_encoded); + // форматтером; этот вердикт не распространяется на иной renderer pipeline. + let composite_distinct = composite != backdrop; Ok(Resolved::Translucent(TranslucentResolved { - tint_hex: hex_from_srgb_encoded(tint_encoded), + tint_hex: tint.to_hex(), alpha, - composite_hex, + composite_hex: composite.to_hex(), composite_lc, composite_wcag, composite_distinct, @@ -3081,11 +3139,98 @@ pub(crate) fn resolve_set_live( /// /// Entries are independent opaque nodes. Declaration order never implies a /// hierarchy or dependency; relations must arrive as explicit typed graph edges. -#[derive(Debug, Clone, PartialEq)] +#[derive(Clone)] pub struct NamedRoleTable { entries: Vec<(String, RoleSpec)>, aliases: Vec<(String, String)>, chroma: RoleChroma, + alpha_analog_invocations: Box<[CompiledAlphaAnalogInvocationV1]>, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct AdmittedRequestedAlphaV1(f64); + +impl AdmittedRequestedAlphaV1 { + fn parse(value: f64) -> Option { + role_alpha_valid(value).then_some(Self(value)) + } + + const fn get(self) -> f64 { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct CompiledAlphaAnalogInvocationV1 { + declaration_ordinal: usize, + target: LadderTint, + requested_alpha: AdmittedRequestedAlphaV1, +} + +#[cfg(test)] +thread_local! { + static ALPHA_BINDING_PLAN_COMPILATIONS: std::cell::Cell = const { + std::cell::Cell::new(0) + }; +} + +#[cfg(test)] +fn reset_alpha_binding_plan_compilation_count() { + ALPHA_BINDING_PLAN_COMPILATIONS.with(|count| count.set(0)); +} + +#[cfg(test)] +fn alpha_binding_plan_compilation_count() -> usize { + ALPHA_BINDING_PLAN_COMPILATIONS.with(std::cell::Cell::get) +} + +impl CompiledAlphaAnalogInvocationV1 { + fn resolve(self, bg: &BgInput, vc: &ViewingConditions) -> PendingResolution { + resolve_rgba_inverted_with_binding( + crate::analog::AuthoredAlphaBindingIdV1::Named { + declaration_ordinal: self.declaration_ordinal, + }, + self.target.for_vc(vc), + self.requested_alpha.get(), + bg, + vc, + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct AlphaAnalogCompileErrorV1 { + declaration_ordinal: usize, + value: f64, +} + +impl AlphaAnalogCompileErrorV1 { + pub(crate) const fn declaration_ordinal(self) -> usize { + self.declaration_ordinal + } + + pub(crate) const fn value(self) -> f64 { + self.value + } +} + +impl core::fmt::Debug for NamedRoleTable { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter + .debug_struct("NamedRoleTable") + .field("entries", &self.entries) + .field("aliases", &self.aliases) + .field("chroma", &self.chroma) + .finish() + } +} + +impl PartialEq for NamedRoleTable { + fn eq(&self, other: &Self) -> bool { + self.entries == other.entries + && self.aliases == other.aliases + && self.chroma == other.chroma + } } impl RoleSpec { @@ -3243,7 +3388,19 @@ impl NamedRoleTable { spec.validate_with_chroma(chroma) .map_err(|message| SolveFailure::InvalidInput(format!("role {name}: {message}")))?; } - Ok(Self::from_validated_parts(entries, aliases, chroma)) + let alpha_analog_invocations = + Self::compile_alpha_analog_invocations(&entries).map_err(|error| { + SolveFailure::InvalidInput(format!( + "role {}: alpha-analog alpha must be finite and inside (0, 1], got {}", + entries[error.declaration_ordinal].0, error.value + )) + })?; + Ok(Self::from_compiled_parts( + entries, + aliases, + chroma, + alpha_analog_invocations, + )) } /// Собирает таблицу из частей, уже проверенных [`ThemeConfig::validate`]. @@ -3254,12 +3411,62 @@ impl NamedRoleTable { entries: Vec<(String, RoleSpec)>, aliases: Vec<(String, String)>, chroma: RoleChroma, + ) -> Result { + let alpha_analog_invocations = Self::compile_alpha_analog_invocations(&entries)?; + Ok(Self::from_compiled_parts( + entries, + aliases, + chroma, + alpha_analog_invocations, + )) + } + + fn from_compiled_parts( + entries: Vec<(String, RoleSpec)>, + aliases: Vec<(String, String)>, + chroma: RoleChroma, + alpha_analog_invocations: Box<[CompiledAlphaAnalogInvocationV1]>, ) -> Self { Self { entries, aliases, chroma, + alpha_analog_invocations, + } + } + + fn compile_alpha_analog_invocations( + entries: &[(String, RoleSpec)], + ) -> Result, AlphaAnalogCompileErrorV1> { + #[cfg(test)] + ALPHA_BINDING_PLAN_COMPILATIONS.with(|count| count.set(count.get() + 1)); + let mut invocations = Vec::new(); + for (declaration_ordinal, (_, spec)) in entries.iter().enumerate() { + let RoleSpec::AlphaAnalog { of, alpha } = *spec else { + continue; + }; + let requested_alpha = + AdmittedRequestedAlphaV1::parse(alpha).ok_or(AlphaAnalogCompileErrorV1 { + declaration_ordinal, + value: alpha, + })?; + invocations.push(CompiledAlphaAnalogInvocationV1 { + declaration_ordinal, + target: of, + requested_alpha, + }); } + debug_assert!( + invocations + .windows(2) + .all(|pair| pair[0].declaration_ordinal < pair[1].declaration_ordinal) + ); + debug_assert!( + invocations + .iter() + .all(|invocation| invocation.declaration_ordinal < entries.len()) + ); + Ok(invocations.into_boxed_slice()) } /// Алиасы `(имя, цель)` сохраняются в скомпилированном контракте, чтобы @@ -3335,10 +3542,32 @@ pub fn resolve_named_set( let _forward_cache = crate::spaces::cam16::ForwardCacheGuard::activate(); let ctx = ResolveContext::new(bg, vc); let mut set = Vec::with_capacity(table.entries.len()); - for (name, spec) in &table.entries { - let resolved = admit_resolution(resolve_spec_in(bg, spec, table.chroma, vc, &ctx))?; + let mut alpha_invocations = table.alpha_analog_invocations.iter().copied().peekable(); + for (declaration_ordinal, (name, spec)) in table.entries.iter().enumerate() { + let pending = match alpha_invocations.peek().copied() { + Some(invocation) if invocation.declaration_ordinal < declaration_ordinal => { + return Err(ResolveSetError { + state: ResolveSetErrorState::Internal(SolveFailure::InternalInvariant( + "compiled alpha-analog invocation order drifted behind declarations".into(), + )), + }); + } + Some(invocation) if invocation.declaration_ordinal == declaration_ordinal => { + alpha_invocations.next(); + invocation.resolve(bg, vc) + } + _ => resolve_spec_in(bg, spec, table.chroma, vc, &ctx), + }; + let resolved = admit_resolution(pending)?; set.push((name.clone(), resolved)); } + if alpha_invocations.next().is_some() { + return Err(ResolveSetError { + state: ResolveSetErrorState::Internal(SolveFailure::InternalInvariant( + "compiled alpha-analog invocation points outside declarations".into(), + )), + }); + } Ok(set) } @@ -3793,6 +4022,7 @@ fn bg_display(bg: &BgInput) -> [f64; 3] { #[cfg(test)] mod tests { use super::*; + use proptest::prelude::*; #[test] fn admission_preserves_every_failure_category_code_and_reason() { @@ -3870,9 +4100,9 @@ mod tests { } #[test] - fn last_role_rejection_closes_named_set_without_partial_output() { + fn invalid_alpha_cannot_enter_a_compiled_named_table() { let source = crate::spaces::srgb::srgb_encoded_from_hex("#3478F6").unwrap(); - let table = NamedRoleTable::from_validated_parts( + let error = NamedRoleTable::from_validated_parts( vec![ ("first".into(), RoleSpec::Zero), ( @@ -3885,21 +4115,186 @@ mod tests { ], Vec::new(), RoleChroma::Neutral, + ) + .expect_err("invalid requested alpha must fail before a table exists"); + assert_eq!(error.declaration_ordinal(), 1); + assert!(error.value().is_nan()); + } + + #[test] + fn named_alpha_analog_uses_only_its_compiled_invocation_and_guards_plan_drift() { + let target = crate::spaces::srgb::srgb_encoded_from_hex("#787880").unwrap(); + let table = NamedRoleTable::new( + vec![ + ("plain".into(), RoleSpec::Zero), + ( + "analog".into(), + RoleSpec::AlphaAnalog { + of: LadderTint::new([target; 4]).unwrap(), + alpha: 0.5, + }, + ), + ], + Vec::new(), + RoleChroma::Neutral, + ) + .unwrap(); + assert_eq!(table.alpha_analog_invocations.len(), 1); + assert_eq!(table.alpha_analog_invocations[0].declaration_ordinal, 1); + + crate::composition::reset_source_over_evaluation_count(); + let set = resolve_named_set( + &BgInput::solid("#FFFFFF").unwrap(), + &table, + &ViewingConditions::srgb(), + ) + .expect("compiled invocation must intercept the raw recipe arm"); + assert_eq!(set[1].1.translucent().unwrap().composite_hex(), "#787880"); + assert_eq!(crate::composition::source_over_evaluation_count(), 1); + + let mut missing = table.clone(); + missing.alpha_analog_invocations = Box::default(); + assert_eq!( + missing, table, + "derived execution state is outside public equality" ); + assert_eq!(format!("{missing:?}"), format!("{table:?}")); let error = resolve_named_set( + &BgInput::solid("#FFFFFF").unwrap(), + &missing, + &ViewingConditions::srgb(), + ) + .expect_err("missing compiled invocation must not fall back to recipe execution"); + assert_eq!(error.kind(), ResolveSetErrorKind::Internal); + assert!(matches!(error.reason(), SolveFailure::InternalInvariant(_))); + } + + #[test] + fn named_alpha_dispatch_does_not_read_recipe_kind_after_lowering() { + let target = crate::spaces::srgb::srgb_encoded_from_hex("#787880").unwrap(); + let mut table = NamedRoleTable::new( + vec![( + "opaque-client-id".into(), + RoleSpec::AlphaAnalog { + of: LadderTint::new([target; 4]).unwrap(), + alpha: 0.5, + }, + )], + Vec::new(), + RoleChroma::Neutral, + ) + .unwrap(); + table.entries[0].1 = RoleSpec::Zero; + + let set = resolve_named_set( &BgInput::solid("#FFFFFF").unwrap(), &table, &ViewingConditions::srgb(), ) - .expect_err("late rejected input must not expose a partial set"); - assert_eq!(error.kind(), ResolveSetErrorKind::Rejected); - assert_eq!(error.code(), Some("invalid_input")); + .expect("compiled ordinal dispatch must not reopen the authored recipe"); + assert_eq!(set[0].1.translucent().unwrap().composite_hex(), "#787880"); + } + + #[test] + fn alpha_binding_plan_is_sparse_compiled_once_and_reused() { + let first_target = crate::spaces::srgb::srgb_encoded_from_hex("#787880").unwrap(); + let second_target = crate::spaces::srgb::srgb_encoded_from_hex("#406080").unwrap(); + reset_alpha_binding_plan_compilation_count(); + let table = NamedRoleTable::new( + vec![ + ( + "first".into(), + RoleSpec::AlphaAnalog { + of: LadderTint::new([first_target; 4]).unwrap(), + alpha: 0.5, + }, + ), + ("unrelated".into(), RoleSpec::Zero), + ( + "second".into(), + RoleSpec::AlphaAnalog { + of: LadderTint::new([second_target; 4]).unwrap(), + alpha: 0.75, + }, + ), + ], + Vec::new(), + RoleChroma::Neutral, + ) + .unwrap(); + assert_eq!(alpha_binding_plan_compilation_count(), 1); assert_eq!( - error.reason(), - &SolveFailure::InvalidInput( - "alpha-analog alpha must be finite and inside (0, 1]".into() - ) + table + .alpha_analog_invocations + .iter() + .map(|invocation| invocation.declaration_ordinal) + .collect::>(), + vec![0, 2] ); + + let cloned = table.clone(); + let backdrop = BgInput::solid("#FFFFFF").unwrap(); + for current in [&table, &table, &cloned] { + let set = resolve_named_set(&backdrop, current, &ViewingConditions::srgb()).unwrap(); + assert_eq!(set.len(), 3); + } + assert_eq!(alpha_binding_plan_compilation_count(), 1); + + let without_analogs = NamedRoleTable::new( + vec![("unrelated".into(), RoleSpec::Zero)], + Vec::new(), + RoleChroma::Neutral, + ) + .unwrap(); + assert!(without_analogs.alpha_analog_invocations.is_empty()); + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + #[test] + fn public_helper_and_named_compiled_invocation_share_exact_bytes( + target_bytes in any::<[u8; 3]>(), + backdrop_bytes in any::<[u8; 3]>(), + alpha_step in 1_u16..=1024, + ) { + let target = Srgb8::new(target_bytes); + let backdrop = Srgb8::new(backdrop_bytes); + let requested_alpha = f64::from(alpha_step) / 1024.0; + let table = NamedRoleTable::new( + vec![( + "opaque-client-id".into(), + RoleSpec::AlphaAnalog { + of: LadderTint::new([target.encoded(); 4]).unwrap(), + alpha: requested_alpha, + }, + )], + Vec::new(), + RoleChroma::Neutral, + ) + .unwrap(); + let named = resolve_named_set( + &BgInput::solid(&backdrop.to_hex()).unwrap(), + &table, + &ViewingConditions::srgb(), + ) + .unwrap(); + let named = named[0].1.translucent().unwrap(); + let (public_tint, public_alpha) = crate::alpha::resolve_alpha_analog_hex( + &target.to_hex(), + requested_alpha, + &backdrop.to_hex(), + ) + .unwrap(); + + prop_assert_eq!(named.tint_hex(), public_tint.as_str()); + prop_assert_eq!(named.alpha().to_bits(), public_alpha.to_bits()); + prop_assert_eq!(named.composite_hex(), target.to_hex()); + prop_assert_eq!( + crate::alpha::composite_hex(&public_tint, public_alpha, &backdrop.to_hex()).unwrap(), + target.to_hex() + ); + } } #[test] @@ -6545,6 +6940,24 @@ mod tests { ); } + #[test] + fn alpha_analog_semantic_path_evaluates_final_source_over_once() { + let vc = ViewingConditions::srgb(); + let background = BgInput::solid("#FFFFFF").unwrap(); + let target = crate::spaces::srgb::srgb_encoded_from_hex("#787880").unwrap(); + + crate::alpha::reset_source_over_evaluation_count(); + let resolved = resolve_rgba_inverted(target, 0.5, &background, &vc) + .expect("valid alpha analog must resolve"); + + assert_eq!(resolved.translucent().unwrap().composite_hex(), "#787880"); + assert_eq!( + crate::alpha::source_over_evaluation_count(), + 1, + "proposal, exact gate and semantic emission must share one final occurrence" + ); + } + #[test] fn fill_constant_anchors_are_strictly_descending() { assert!( diff --git a/crates/labcolors-core/src/test_support.rs b/crates/labcolors-core/src/test_support.rs index 0716a9bc..cdc83a2b 100644 --- a/crates/labcolors-core/src/test_support.rs +++ b/crates/labcolors-core/src/test_support.rs @@ -6,6 +6,82 @@ use crate::RoleFailure; +use std::{ + alloc::{GlobalAlloc, Layout, System}, + cell::Cell, +}; + +thread_local! { + static COUNT_ALLOCATIONS: Cell = const { Cell::new(false) }; + static ALLOCATION_COUNT: Cell = const { Cell::new(0) }; +} + +struct TestAllocator; + +#[global_allocator] +static TEST_ALLOCATOR: TestAllocator = TestAllocator; + +impl TestAllocator { + fn record_allocation() { + let active = COUNT_ALLOCATIONS.try_with(Cell::get).unwrap_or(false); + if active { + let _ = ALLOCATION_COUNT.try_with(|count| count.set(count.get() + 1)); + } + } +} + +// SAFETY: каждая операция без изменений делегирует `System` контракты layout и +// указателя; thread-local наблюдатель считает вызовы, но не владеет памятью. +unsafe impl GlobalAlloc for TestAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + Self::record_allocation(); + // SAFETY: `layout` без изменений передаётся от вызывающего allocator-а. + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + Self::record_allocation(); + // SAFETY: `layout` без изменений передаётся от вызывающего allocator-а. + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + // SAFETY: `ptr` и `layout` получены от делегированного allocator-а `System`. + unsafe { System.dealloc(ptr, layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + Self::record_allocation(); + // SAFETY: аргументы без изменений передаются от вызывающего allocator-а. + unsafe { System.realloc(ptr, layout, new_size) } + } +} + +/// Считает heap-аллокации `operation` в текущем тестовом потоке. Параллельные +/// тесты не могут исказить измерение. +pub(crate) fn measured_allocations(operation: impl FnOnce() -> T) -> (T, usize) { + struct DisableMeasurement; + + impl Drop for DisableMeasurement { + fn drop(&mut self) { + let _ = COUNT_ALLOCATIONS.try_with(|active| active.set(false)); + } + } + + COUNT_ALLOCATIONS.with(|active| { + assert!( + !active.replace(true), + "измерения аллокаций нельзя вкладывать" + ); + }); + ALLOCATION_COUNT.with(|count| count.set(0)); + let disable = DisableMeasurement; + let result = operation(); + let count = ALLOCATION_COUNT.with(Cell::get); + drop(disable); + (result, count) +} + /// Stable representation of an already-admitted role failure. Admission lives /// in production; tests only format the typed category and core-owned code. pub(crate) fn role_failure_repr(failure: &RoleFailure) -> String { From 87c97318703e2d167dd3fe0cff3a61030ce49904 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics <63733699+lemone112@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:56:13 +0300 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20=D0=B7=D0=B0=D0=BA=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D0=B8=D1=82=D1=8C=20exact=20WASM=20snapshot=20G1a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/colors/bench/wasm.json | 8 ++++---- scripts/check-wasm-size-budget.mjs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/colors/bench/wasm.json b/packages/colors/bench/wasm.json index 4790be07..6a6eecb4 100644 --- a/packages/colors/bench/wasm.json +++ b/packages/colors/bench/wasm.json @@ -19,13 +19,13 @@ "command": "CARGO_ENCODED_RUSTFLAGS= wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked" }, "measurement": { - "source": "github-actions-run-29684632555", + "source": "github-actions-run-29691640790", "platform": "linux-x64", - "rawBytes": 385346 + "rawBytes": 387276 }, "policy": { - "maxRawBytes": 385346, - "basis": "accepted-f2a-observation-admission-snapshot", + "maxRawBytes": 387276, + "basis": "accepted-g1a-exact-alpha-occurrence-snapshot", "gzip": "diagnostic-only" } } diff --git a/scripts/check-wasm-size-budget.mjs b/scripts/check-wasm-size-budget.mjs index 838ce855..e917a6b1 100644 --- a/scripts/check-wasm-size-budget.mjs +++ b/scripts/check-wasm-size-budget.mjs @@ -14,7 +14,7 @@ export const DEFAULT_BUDGET = resolve( "packages/colors/bench/wasm.json", ); export const WASM_BUDGET_FILE_SHA256 = - "7ea4a2d848eb61473aa2d6a653938fb91ea04c2f91da56f6f75b32ea0c8521af"; + "d85b480ca408e3966ea5b99cd1484ca27f06e378a02b74e2419ce12666888e2b"; const SCHEMA_VERSION = 1; const CANONICAL_ARTIFACT = "packages/colors/pkg/labcolors_bg.wasm";