From cdb02260ae2c5692e67297dc708c0e4a97850347 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics <63733699+lemone112@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:57:12 +0300 Subject: [PATCH 1/6] =?UTF-8?q?feat(core):=20=D1=80=D0=B0=D0=B7=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=D1=82=D1=8C=20capability,=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=D0=B0=20=D0=B8=20?= =?UTF-8?q?evidence=20=D0=B2=20numerics=20(#292)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package capability ≠ compiled invocation plan ≠ result evidence: - CompiledNumericalPlanV1 компилируется fail-closed из machine-readable registry-строки site (stable = [exact, refuse]; legacy только при объявленном compatibility profile); план не содержит результата; - SoundIntervalEvidenceV1 — запечатанное интервальное свидетельство (конструктор приватен; production-производитель появится только вместе с допущенным sound-bound backend'ом): classify_at_least_v1 принимает только его, голый caller-created интервал больше не повышается до Determinate-гарантии; - OutwardIntervalV1 честно понижен до интервала-заявления (проверка формы, не доказанность); - glow::solve_screen_alpha_for_dj исполняет ветви строго по скомпилированному плану — порядок методов задаёт registry, не рукописный match; поведение байт-идентично (glow 32/32, полный workspace зелёный). Wire-ключи и WASM/npm поверхности не изменены. Co-Authored-By: Claude Fable 5 --- crates/labcolors-core/src/glow.rs | 118 ++++++---- crates/labcolors-core/src/lib.rs | 8 +- crates/labcolors-core/src/numerics.rs | 324 ++++++++++++++++++++++++-- docs/verification-map.md | 14 ++ 4 files changed, 400 insertions(+), 64 deletions(-) diff --git a/crates/labcolors-core/src/glow.rs b/crates/labcolors-core/src/glow.rs index a43b5861..7b60d122 100644 --- a/crates/labcolors-core/src/glow.rs +++ b/crates/labcolors-core/src/glow.rs @@ -50,7 +50,8 @@ use crate::lcs::LcsColor; use crate::numerics::{ - DecisionGuaranteeV1, NumericalDecisionV1, NumericalIndeterminacyV1, NumericalSiteIdV1, + CompiledNumericalPlanV1, DecisionGuaranteeV1, NumericalDecisionV1, NumericalIndeterminacyV1, + NumericalProfileRequestV1, NumericalSiteIdV1, PlannedDecisionMethodV1, }; use crate::spaces::oklab::oklab_to_srgb_linear; use crate::spaces::srgb::{ @@ -910,55 +911,80 @@ pub fn solve_screen_alpha_for_dj( profile: GlowDecisionProfileV1, vc: &ViewingConditions, ) -> Result, String> { - match profile { + // #292: порядок ветвей задаёт не рукописный match, а план, скомпилированный + // fail-closed из machine-readable capability-строки site — «что объявлено» + // и «что исполняется» связаны одним источником истины. + let request = match profile { GlowDecisionProfileV1::LegacyPlatformDependentV1 => { - return Ok(NumericalDecisionV1::Determinate { - value: solve_screen_alpha_for_dj_legacy(glow_tint_hex, bg_hex, target_dj, vc)?, - guarantee: DecisionGuaranteeV1::LegacyPlatformDependentV1, - }); + NumericalProfileRequestV1::LegacyPlatformDependentV1 + } + GlowDecisionProfileV1::StableV1 => NumericalProfileRequestV1::StableExactV1, + }; + let plan = CompiledNumericalPlanV1::compile(NumericalSiteIdV1::GlowTargetOrMaximumV1, request)?; + + for method in plan.methods() { + match method { + PlannedDecisionMethodV1::LegacyPlatformDependentV1 => { + return Ok(NumericalDecisionV1::Determinate { + value: solve_screen_alpha_for_dj_legacy(glow_tint_hex, bg_hex, target_dj, vc)?, + guarantee: DecisionGuaranteeV1::LegacyPlatformDependentV1, + }); + } + PlannedDecisionMethodV1::ExactFiniteStateV1 => { + if !target_dj.is_finite() || target_dj <= 0.0 { + return Err(format!("целевой шаг вне домена: {target_dj}")); + } + let ScreenPointInputs { + glow: glow_bytes, + background: bg_bytes, + slopes, + } = screen_point_inputs(glow_tint_hex, bg_hex)?; + if !slopes_are_exact_srgb8_noop(slopes) { + // Точного конечно-состоянийного решения нет — следующий + // метод плана (честный отказ), не молчаливая эвристика. + continue; + } + // Любая alpha из [0,1] даёт тот же байтовый composite; 0.5 — + // канонический средний представитель, а не измеренная величина. + let alpha = 0.5; + let alpha_css = crate::css_alpha_value(alpha)?; + let composite_srgb8 = bg_bytes; + return Ok(NumericalDecisionV1::Determinate { + value: GlowSolve { + alpha, + alpha_css: alpha_css.clone(), + target_dj, + achieved_dj: 0.0, + composite_hex: composite_hex(composite_srgb8), + composite_certificate: composite_certificate( + glow_bytes, + bg_bytes, + alpha, + alpha_css, + composite_srgb8, + ), + selection_diagnostic_profile: None, + status: GlowTargetStatus::ExactNoopUnreachable, + }, + guarantee: DecisionGuaranteeV1::BitExact, + }); + } + PlannedDecisionMethodV1::RefuseIndeterminateV1 => { + return Ok(NumericalDecisionV1::Indeterminate { + site_id: NumericalSiteIdV1::GlowTargetOrMaximumV1, + evidence: NumericalIndeterminacyV1::SoundBoundUnavailable, + }); + } } - GlowDecisionProfileV1::StableV1 => {} - } - - if !target_dj.is_finite() || target_dj <= 0.0 { - return Err(format!("целевой шаг вне домена: {target_dj}")); - } - let ScreenPointInputs { - glow: glow_bytes, - background: bg_bytes, - slopes, - } = screen_point_inputs(glow_tint_hex, bg_hex)?; - if slopes_are_exact_srgb8_noop(slopes) { - // Любая alpha из [0,1] даёт тот же байтовый composite; 0.5 — - // канонический средний представитель, а не измеренная величина. - let alpha = 0.5; - let alpha_css = crate::css_alpha_value(alpha)?; - let composite_srgb8 = bg_bytes; - return Ok(NumericalDecisionV1::Determinate { - value: GlowSolve { - alpha, - alpha_css: alpha_css.clone(), - target_dj, - achieved_dj: 0.0, - composite_hex: composite_hex(composite_srgb8), - composite_certificate: composite_certificate( - glow_bytes, - bg_bytes, - alpha, - alpha_css, - composite_srgb8, - ), - selection_diagnostic_profile: None, - status: GlowTargetStatus::ExactNoopUnreachable, - }, - guarantee: DecisionGuaranteeV1::BitExact, - }); } - Ok(NumericalDecisionV1::Indeterminate { - site_id: NumericalSiteIdV1::GlowTargetOrMaximumV1, - evidence: NumericalIndeterminacyV1::SoundBoundUnavailable, - }) + // План непуст по построению и завершается терминальным методом; сюда + // попадает только дефект компилятора плана — типизированный отказ, не + // паника (функция публична). + Err(format!( + "план site {} исчерпан без терминального метода", + plan.site_id().key() + )) } /// Явный зависящий от CAM16/libm legacy-путь target/max. diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index e652b40c..e6018b00 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -101,9 +101,11 @@ pub use material::{ solve_material_alpha_hex, worst_contrast_encoded, }; pub use numerics::{ - AtLeastDecisionV1, DecisionGuaranteeV1, NumericalBoundStatusV1, NumericalDecisionV1, - NumericalFallbackStatusV1, NumericalIndeterminacyV1, NumericalSiteIdV1, NumericalSiteRecordV1, - OutwardIntervalV1, StableNumericalOutcomeV1, classify_at_least_v1, numerical_registry_v1, + AtLeastDecisionV1, CompiledNumericalPlanV1, DecisionGuaranteeV1, NumericalBoundStatusV1, + NumericalDecisionV1, NumericalFallbackStatusV1, NumericalIndeterminacyV1, + NumericalProfileRequestV1, NumericalSiteIdV1, NumericalSiteRecordV1, OutwardIntervalV1, + PlannedDecisionMethodV1, SoundIntervalEvidenceV1, StableNumericalOutcomeV1, + classify_at_least_v1, numerical_registry_v1, }; pub use semantic::{ GlowIndeterminateResolved, NamedRoleTable, Resolved, RoleChroma, RoleSpec, TextAnchor, diff --git a/crates/labcolors-core/src/numerics.rs b/crates/labcolors-core/src/numerics.rs index 22504572..b774f4e4 100644 --- a/crates/labcolors-core/src/numerics.rs +++ b/crates/labcolors-core/src/numerics.rs @@ -1,8 +1,25 @@ -//! Типизированные решения для branch-sensitive численных sites (#281). +//! Типизированные решения для branch-sensitive численных sites (#281, #292). //! //! Diagnostic `f64` не становится semantic verdict сам по себе. Determinate //! разрешён только вместе с объявленной гарантией; при отсутствии sound bound //! или пересечении outward-интервала с границей возвращается `Indeterminate`. +//! +//! Три уровня контракта (#292) разделены типами и не подменяют друг друга: +//! +//! ```text +//! package capability — registry-строка: что пакет ВООБЩЕ умеет доказать +//! для site (bound_status, stable_outcomes, legacy) +//! compiled invocation plan — [`CompiledNumericalPlanV1`]: что БУДЕТ исполнено +//! для site при запрошенном профиле; результата не +//! содержит и доказательством не является +//! result evidence — запечатанные свидетельства исполнения +//! ([`SoundIntervalEvidenceV1`]); конструируемы +//! только производителем, не вызывающим кодом +//! ``` +//! +//! Caller-created значение (голый интервал из двух `f64`) НЕ повышается до +//! sound evidence: классификатор принимает только запечатанное свидетельство, +//! а план компилируется fail-closed из machine-readable registry-строки. /// Stable outcomes admitted for a migrated branch-sensitive site. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -163,7 +180,13 @@ pub fn numerical_registry_v1() -> &'static [NumericalSiteRecordV1] { NUMERICAL_REGISTRY_V1 } -/// Доказанный outward-интервал, содержащий истинное значение. +/// Конечный упорядоченный интервал-ЗАЯВЛЕНИЕ `[lower, upper]`. +/// +/// Конструктор проверяет только форму (конечность, порядок). Сам по себе тип +/// НЕ доказательство того, что истинное значение лежит внутри: доказанность +/// принадлежит исключительно запечатанному [`SoundIntervalEvidenceV1`], +/// произведённому допущенным backend'ом. Ни один такой backend сегодня не +/// допущен ([`NumericalBoundStatusV1::Unavailable`]). #[derive(Debug, Clone, Copy, PartialEq)] pub struct OutwardIntervalV1 { lower: f64, @@ -192,6 +215,187 @@ impl OutwardIntervalV1 { } } +/// Запрошенный класс исполнения при компиляции плана вызова (#292). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum NumericalProfileRequestV1 { + /// Только доказуемые методы: exact finite state, иначе честный отказ. + StableExactV1, + /// Явно запрошенный прежний platform/libm-dependent путь (compatibility). + LegacyPlatformDependentV1, +} + +impl NumericalProfileRequestV1 { + /// Стабильный wire/registry key. + pub fn key(self) -> &'static str { + match self { + Self::StableExactV1 => "stable-exact-v1", + Self::LegacyPlatformDependentV1 => "legacy-platform-dependent-v1", + } + } +} + +/// Метод решения, допущенный скомпилированным планом. +/// +/// Интервального метода в типе НЕТ намеренно: ни один sound-bound backend не +/// допущен ([`NumericalBoundStatusV1::Unavailable`]), поэтому план физически +/// не может пообещать интервальное доказательство — это тип-уровневая форма +/// текущей package capability, а не пропуск. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum PlannedDecisionMethodV1 { + /// Точная проверка конечного integer/байтового состояния домена. + ExactFiniteStateV1, + /// Честный typed-отказ от stable branch, где exact не применим. + RefuseIndeterminateV1, + /// Явно выбранный legacy platform-dependent путь (не evidence-класс). + LegacyPlatformDependentV1, +} + +/// Скомпилированный план вызова: что будет исполнено для site при данном +/// запросе. План выводится fail-closed из machine-readable registry-строки +/// (package capability) и НЕ содержит результата — план ≠ evidence. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CompiledNumericalPlanV1 { + site_id: NumericalSiteIdV1, + request: NumericalProfileRequestV1, + methods: [Option; 2], +} + +impl CompiledNumericalPlanV1 { + /// Компилирует план для зарегистрированного site. + /// + /// # Errors + /// + /// Site отсутствует в registry либо запрошенный профиль незаконен для его + /// capability-строки (например, legacy без объявленного профиля). + pub fn compile( + site_id: NumericalSiteIdV1, + request: NumericalProfileRequestV1, + ) -> Result { + let row = numerical_registry_v1() + .iter() + .find(|row| row.site_id == site_id) + .ok_or_else(|| format!("site {} отсутствует в registry V1", site_id.key()))?; + Self::compile_from_row(row, request) + } + + /// Компиляция из явной строки capability — отделена от lookup, чтобы + /// fail-closed ветви были проверяемы на синтетических строках. + pub(crate) fn compile_from_row( + row: &NumericalSiteRecordV1, + request: NumericalProfileRequestV1, + ) -> Result { + let methods = match request { + NumericalProfileRequestV1::StableExactV1 => { + // Методы выводятся из объявленных lawful outcomes строки: + // BitExact → точный конечно-состоянийный метод, Indeterminate → + // честный отказ. bound_status Unavailable не даёт интервального + // метода — его нет и в типе метода. + let exact = row + .stable_outcomes + .contains(&StableNumericalOutcomeV1::BitExact); + let refuse = row + .stable_outcomes + .contains(&StableNumericalOutcomeV1::Indeterminate); + if !exact && !refuse { + return Err(format!( + "site {} не объявляет ни одного stable outcome — stable-план невозможен", + row.site_id.key() + )); + } + [ + exact.then_some(PlannedDecisionMethodV1::ExactFiniteStateV1), + refuse.then_some(PlannedDecisionMethodV1::RefuseIndeterminateV1), + ] + } + NumericalProfileRequestV1::LegacyPlatformDependentV1 => { + if row.legacy_profile.is_none() { + return Err(format!( + "site {} не объявляет legacy compatibility profile — legacy-план запрещён", + row.site_id.key() + )); + } + [ + Some(PlannedDecisionMethodV1::LegacyPlatformDependentV1), + None, + ] + } + }; + // Компактный план без «дыр»: методы в порядке исполнения. + let mut packed = [None, None]; + for (slot, method) in methods.into_iter().flatten().enumerate() { + packed[slot] = Some(method); + } + Ok(Self { + site_id: row.site_id, + request, + methods: packed, + }) + } + + /// Site, для которого скомпилирован план. + pub fn site_id(&self) -> NumericalSiteIdV1 { + self.site_id + } + + /// Запрошенный профиль исполнения. + pub fn request(&self) -> NumericalProfileRequestV1 { + self.request + } + + /// Методы в порядке исполнения (непустой по построению). + pub fn methods(&self) -> impl Iterator + '_ { + self.methods.iter().flatten().copied() + } +} + +/// Приватная печать: наличие поля этого типа делает внешнюю конструкцию +/// структуры литералом невозможной. +#[derive(Debug, Clone, Copy, PartialEq)] +struct EvidenceSeal; + +/// Запечатанное интервальное свидетельство: interval с provenance +/// (site), произведённый ИСПОЛНИТЕЛЕМ, а не вызывающим кодом. +/// +/// Публичного конструктора нет намеренно: production-производитель появится +/// только вместе с допущенным sound-bound backend'ом и обновлением +/// registry-строки (`bound_status`). До этого единственная фабрикация — +/// test-only, для закрепления семантики границы классификатора. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SoundIntervalEvidenceV1 { + site_id: NumericalSiteIdV1, + interval: OutwardIntervalV1, + _seal: EvidenceSeal, +} + +impl SoundIntervalEvidenceV1 { + /// Test-only фабрикация для проверки границ классификатора. НЕ является + /// производством доказательств: закрепляет семантику `>=`-границы, а не + /// доказанность интервала. + #[cfg(test)] + pub(crate) fn fabricated_for_boundary_tests( + site_id: NumericalSiteIdV1, + interval: OutwardIntervalV1, + ) -> Self { + Self { + site_id, + interval, + _seal: EvidenceSeal, + } + } + + /// Site, которому принадлежит свидетельство. + pub fn site_id(&self) -> NumericalSiteIdV1 { + self.site_id + } + + /// Заявленный интервал свидетельства. + pub fn interval(&self) -> OutwardIntervalV1 { + self.interval + } +} + /// Неразделимое evidence, почему stable semantic decision нельзя принять. /// /// Вариант владеет своим доказательством: невозможно сконструировать @@ -277,15 +481,17 @@ pub enum AtLeastDecisionV1 { Meets, } -/// Классифицирует `value >= target` только по доказанному outward-интервалу. +/// Классифицирует `value >= target` только по ЗАПЕЧАТАННОМУ интервальному +/// свидетельству (#292): голый caller-created интервал этим путём не проходит +/// и потому не может быть повышен до determinate-гарантии. pub fn classify_at_least_v1( - site_id: NumericalSiteIdV1, - interval: OutwardIntervalV1, + proof: SoundIntervalEvidenceV1, target: f64, ) -> Result, String> { if !target.is_finite() { return Err(format!("target не конечен: {target}")); } + let interval = proof.interval(); if interval.lower() >= target { return Ok(NumericalDecisionV1::Determinate { value: AtLeastDecisionV1::Meets, @@ -299,7 +505,7 @@ pub fn classify_at_least_v1( }); } Ok(NumericalDecisionV1::Indeterminate { - site_id, + site_id: proof.site_id(), evidence: NumericalIndeterminacyV1::IntervalOverlap(interval), }) } @@ -308,28 +514,41 @@ pub fn classify_at_least_v1( mod tests { use super::*; + /// Фабрикация evidence в тестах — единственная разрешённая: конструктор + /// запечатан приватным полем, production-производитель не существует + /// (bound_status Unavailable). Тест закрепляет семантику границы + /// классификатора, не производство доказательств. + fn evidence(site: NumericalSiteIdV1, lower: f64, upper: f64) -> SoundIntervalEvidenceV1 { + SoundIntervalEvidenceV1::fabricated_for_boundary_tests( + site, + OutwardIntervalV1::try_new(lower, upper).unwrap(), + ) + } + #[test] fn interval_overlap_never_becomes_a_tie_break() { let site = NumericalSiteIdV1::GlowTargetOrMaximumV1; - let overlap = OutwardIntervalV1::try_new(0.9, 1.1).unwrap(); assert!(matches!( - classify_at_least_v1(site, overlap, 1.0).unwrap(), + classify_at_least_v1(evidence(site, 0.9, 1.1), 1.0).unwrap(), NumericalDecisionV1::Indeterminate { evidence: NumericalIndeterminacyV1::IntervalOverlap(_), .. } )); - let exact_target = OutwardIntervalV1::try_new(1.0, 1.0).unwrap(); assert!(matches!( - classify_at_least_v1(site, exact_target, 1.0).unwrap(), + classify_at_least_v1(evidence(site, 1.0, 1.0), 1.0).unwrap(), NumericalDecisionV1::Determinate { value: AtLeastDecisionV1::Meets, .. } )); let below = OutwardIntervalV1::try_new(0.0, f64::from_bits(1.0_f64.to_bits() - 1)).unwrap(); - let below_decision = classify_at_least_v1(site, below, 1.0).unwrap(); + let below_decision = classify_at_least_v1( + SoundIntervalEvidenceV1::fabricated_for_boundary_tests(site, below), + 1.0, + ) + .unwrap(); assert!(matches!( below_decision, NumericalDecisionV1::Determinate { @@ -340,7 +559,11 @@ mod tests { let touches_from_below = OutwardIntervalV1::try_new(0.9, 1.0).unwrap(); assert!(matches!( - classify_at_least_v1(site, touches_from_below, 1.0).unwrap(), + classify_at_least_v1( + SoundIntervalEvidenceV1::fabricated_for_boundary_tests(site, touches_from_below), + 1.0 + ) + .unwrap(), NumericalDecisionV1::Indeterminate { evidence: NumericalIndeterminacyV1::IntervalOverlap(certificate), .. @@ -352,13 +575,84 @@ mod tests { fn invalid_intervals_and_targets_are_rejected_without_normalisation() { assert!(OutwardIntervalV1::try_new(2.0, 1.0).is_err()); assert!(OutwardIntervalV1::try_new(f64::NAN, 1.0).is_err()); - let valid = OutwardIntervalV1::try_new(0.0, 1.0).unwrap(); assert!( - classify_at_least_v1(NumericalSiteIdV1::GlowTargetOrMaximumV1, valid, f64::NAN,) - .is_err() + classify_at_least_v1( + evidence(NumericalSiteIdV1::GlowTargetOrMaximumV1, 0.0, 1.0), + f64::NAN, + ) + .is_err() + ); + } + + // ── #292: package capability ≠ compiled invocation plan ≠ result evidence ── + + /// Stable-план компилируется ИЗ machine-readable registry-строки: для + /// Glow-сайта (bound_status Unavailable) законны только точный + /// конечно-состоянийный метод и честный отказ — интервальный метод не + /// планируем, потому что не допущен ни один sound-bound backend. + #[test] + fn stable_plan_for_glow_site_admits_only_exact_check_and_refusal() { + let plan = CompiledNumericalPlanV1::compile( + NumericalSiteIdV1::GlowTargetOrMaximumV1, + NumericalProfileRequestV1::StableExactV1, + ) + .unwrap(); + assert_eq!(plan.site_id(), NumericalSiteIdV1::GlowTargetOrMaximumV1); + assert_eq!(plan.request(), NumericalProfileRequestV1::StableExactV1); + assert_eq!( + plan.methods().collect::>(), + [ + PlannedDecisionMethodV1::ExactFiniteStateV1, + PlannedDecisionMethodV1::RefuseIndeterminateV1, + ] ); } + /// Legacy-план существует только у сайта с ОБЪЯВЛЕННЫМ compatibility + /// profile; синтетическая строка без него отклоняется типизированно. + #[test] + fn legacy_plan_requires_a_declared_compatibility_profile() { + let plan = CompiledNumericalPlanV1::compile( + NumericalSiteIdV1::GlowTargetOrMaximumV1, + NumericalProfileRequestV1::LegacyPlatformDependentV1, + ) + .unwrap(); + assert_eq!( + plan.methods().collect::>(), + [PlannedDecisionMethodV1::LegacyPlatformDependentV1] + ); + + let mut orphan = *numerical_registry_v1() + .iter() + .find(|row| row.site_id == NumericalSiteIdV1::GlowTargetOrMaximumV1) + .unwrap(); + orphan.legacy_profile = None; + let refused = CompiledNumericalPlanV1::compile_from_row( + &orphan, + NumericalProfileRequestV1::LegacyPlatformDependentV1, + ); + assert!(refused.is_err(), "legacy без профиля обязан отклоняться"); + } + + /// Sound-интервальное свидетельство несёт свой site и interval в + /// выданный сертификат без подмены. + #[test] + fn interval_evidence_carries_its_provenance_into_the_certificate() { + let site = NumericalSiteIdV1::GlowTargetOrMaximumV1; + let interval = OutwardIntervalV1::try_new(2.0, 3.0).unwrap(); + let proof = SoundIntervalEvidenceV1::fabricated_for_boundary_tests(site, interval); + assert_eq!(proof.site_id(), site); + assert_eq!(proof.interval(), interval); + let decision = classify_at_least_v1(proof, 1.0).unwrap(); + assert!(matches!( + decision, + NumericalDecisionV1::Determinate { + value: AtLeastDecisionV1::Meets, + guarantee: DecisionGuaranteeV1::OutwardIntervalV1(certificate), + } if certificate == interval + )); + } + #[test] fn migrated_registry_is_non_vacuous_unique_and_covers_glow_site() { let rows = numerical_registry_v1(); diff --git a/docs/verification-map.md b/docs/verification-map.md index d85689af..78c5b0f1 100644 --- a/docs/verification-map.md +++ b/docs/verification-map.md @@ -83,6 +83,20 @@ | `soft_clamp` / `soft_clamp_inv` | `lpc::tests::soft_clamp_boundaries_are_exact`, `..._matches_reference_bisection` | внутренняя тождественность | | `y_hk_analytic` (обратный `grey_j`) | `lpc::tests::y_hk_analytic_matches_bisection_on_grid` | внутренняя тождественность | +## Численные решения — `numerics.rs` (#292) + +Три уровня контракта разделены типами: package capability (registry-строка) ≠ +compiled invocation plan (`CompiledNumericalPlanV1`) ≠ result evidence +(запечатанный `SoundIntervalEvidenceV1`). Новой математики модуль не вводит — +проверяется невозможность повышения caller-created значений до доказательств. + +| инвариант | чем верифицирован | оракул | +|---|---|---| +| план компилируется fail-closed из machine-readable строки: stable = [exact, refuse], legacy требует объявленного профиля | `numerics::tests::stable_plan_for_glow_site_admits_only_exact_check_and_refusal`, `legacy_plan_requires_a_declared_compatibility_profile` | внутренняя тождественность | +| caller-created интервал не достигает determinate-гарантии: классификатор принимает только запечатанное свидетельство (конструктор приватен) | сигнатура `classify_at_least_v1` + `interval_evidence_carries_its_provenance_into_the_certificate` | тип-уровневая (компилятор) + внутренняя тождественность | +| граница `>=` на интервале: Meets/Below/Overlap без tie-break, exact-касание цели детерминировано | `numerics::tests::interval_overlap_never_becomes_a_tie_break` | внутренняя тождественность | +| production-ветви glow исполняются строго по плану (порядок методов — из registry, не из рукописного match) | glow-набор (32 теста) + пробная мутация плана (5 падений glow, 1 numerics) | дифференциальный (замороженное glow-поведение) | + ## JS-дубликат — `packages/colors/effective-bg.js` | формула | чем верифицирована | оракул | From 735df7f44cf94f5325c22dc903a567127d89e1e9 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics <63733699+lemone112@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:37:15 +0300 Subject: [PATCH 2/6] =?UTF-8?q?wip(#292):=20=D0=B0=D1=82=D0=BE=D0=BC=D0=B0?= =?UTF-8?q?=D1=80=D0=BD=D1=8B=D0=B5=20Determinate|Compatibility|Indetermin?= =?UTF-8?q?ate,=20sealed=20BitExact,=20plan/manifest=20=D1=8F=D0=B4=D1=80?= =?UTF-8?q?=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core компилируется, тесты ядра зелёные (RED-группы #292 включены). Осталось: conformance pack 3.0.0, WASM/verifier, table-проекция плана, доки. Co-Authored-By: Claude Fable 5 --- crates/labcolors-core/benches/glow_alpha.rs | 4 +- crates/labcolors-core/src/config.rs | 5 +- crates/labcolors-core/src/glow.rs | 188 ++-- crates/labcolors-core/src/lib.rs | 19 +- crates/labcolors-core/src/numerical_plan.rs | 384 ++++++++ crates/labcolors-core/src/numerics.rs | 975 +++++++++++--------- crates/labcolors-core/src/semantic.rs | 149 +-- crates/labcolors-ffi/src/lib.rs | 146 ++- 8 files changed, 1242 insertions(+), 628 deletions(-) create mode 100644 crates/labcolors-core/src/numerical_plan.rs diff --git a/crates/labcolors-core/benches/glow_alpha.rs b/crates/labcolors-core/benches/glow_alpha.rs index 97dc7e99..a3afa970 100644 --- a/crates/labcolors-core/benches/glow_alpha.rs +++ b/crates/labcolors-core/benches/glow_alpha.rs @@ -27,7 +27,7 @@ fn bench_glow_alpha(c: &mut Criterion) { black_box(tint), black_box(background), black_box(target), - GlowDecisionProfileV1::LegacyPlatformDependentV1, + GlowDecisionProfileV1::LegacyPlatformDependentV1.execution_mode(), black_box(&vc), ) .expect("benchmark inputs are valid"), @@ -45,7 +45,7 @@ fn bench_glow_alpha(c: &mut Criterion) { black_box(tint), black_box(background), black_box(target), - GlowDecisionProfileV1::StableV1, + GlowDecisionProfileV1::StableV1.execution_mode(), black_box(&vc), ) .expect("benchmark inputs are valid"), diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index 4a6a705f..6a0c7456 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -1327,7 +1327,10 @@ impl ThemeConfig { } => Ok(RoleSpec::Glow { tint: self.compile_ladder_tint(role, source)?, step: *step, - decision_profile: *decision_profile, + // Migration adapter (#292): прежний клиентский wire-ключ + // (`stable-v1 | legacy-platform-dependent-v1`) лоуверится в + // generic typed execution mode compiled invocation. + mode: decision_profile.execution_mode(), }), RoleRecipe::Ladder { source, diff --git a/crates/labcolors-core/src/glow.rs b/crates/labcolors-core/src/glow.rs index 7b60d122..01a5ac90 100644 --- a/crates/labcolors-core/src/glow.rs +++ b/crates/labcolors-core/src/glow.rs @@ -49,9 +49,11 @@ //! проверенный на наблюдателях. use crate::lcs::LcsColor; +use crate::numerical_plan::NumericalExecutionModeV1; use crate::numerics::{ - CompiledNumericalPlanV1, DecisionGuaranteeV1, NumericalDecisionV1, NumericalIndeterminacyV1, - NumericalProfileRequestV1, NumericalSiteIdV1, PlannedDecisionMethodV1, + LegacyPlatformDependentV1, NumericalCompatibilityReleaseIdV1, NumericalDecisionEvidenceV1, + NumericalDecisionV1, NumericalIndeterminacyV1, NumericalSiteIdV1, ReferenceProfileIdV1, + mint_bit_exact_evidence, registry_row, }; use crate::spaces::oklab::oklab_to_srgb_linear; use crate::spaces::srgb::{ @@ -186,6 +188,68 @@ impl GlowDecisionProfileV1 { other => Err(other.to_string()), } } + + /// Migration adapter (#292): прежние config/wire-ключи `stable-v1 | + /// legacy-platform-dependent-v1` отображаются в generic typed execution + /// mode. Они НЕ становятся новым общим profile enum. + pub fn execution_mode(self) -> NumericalExecutionModeV1 { + match self { + Self::StableV1 => NumericalExecutionModeV1::StableOnly, + Self::LegacyPlatformDependentV1 => NumericalExecutionModeV1::ExplicitCompatibility { + release_id: NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1, + }, + } + } + + /// Обратная проекция generic mode в boundary-ключ (adapter-сторона). + pub fn from_execution_mode(mode: NumericalExecutionModeV1) -> Self { + match mode { + NumericalExecutionModeV1::StableOnly => Self::StableV1, + NumericalExecutionModeV1::ExplicitCompatibility { .. } => { + Self::LegacyPlatformDependentV1 + } + } + } +} + +/// Атомарный исход решения Glow ПОСЛЕ выбора состояния: доказанный stable +/// точный no-op либо явный registered compatibility-алгоритм. Незаконная +/// комбинация (stable + legacy provenance и т. п.) непредставима типами; +/// cross-product независимых полей profile/guarantee удалён (#292). +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub enum GlowDecisionOutcomeV1 { + /// Stable exact no-op: решение доказано запечатанным BitExact-evidence. + StableExactNoop { + /// Запечатанное registry-owned evidence. + evidence: NumericalDecisionEvidenceV1, + }, + /// Явно выбранный зарегистрированный прежний алгоритм. + Compatibility { + /// Registered release, реально исполнивший invocation. + release_id: NumericalCompatibilityReleaseIdV1, + /// Класс происхождения (не заменяет release identity). + provenance: LegacyPlatformDependentV1, + }, +} + +impl GlowDecisionOutcomeV1 { + /// Boundary-проекция прежнего wire-ключа guarantee + /// (`bit-exact | legacy-platform-dependent-v1`) — migration adapter. + pub fn guarantee_wire_key(&self) -> &'static str { + match self { + Self::StableExactNoop { evidence } => evidence.class_key(), + Self::Compatibility { provenance, .. } => provenance.key(), + } + } + + /// Boundary-проекция прежнего client decision profile. + pub fn decision_profile(&self) -> GlowDecisionProfileV1 { + match self { + Self::StableExactNoop { .. } => GlowDecisionProfileV1::StableV1, + Self::Compatibility { .. } => GlowDecisionProfileV1::LegacyPlatformDependentV1, + } + } } /// Слой, по которому точечный солвер держит целевой ΔJ′. @@ -908,48 +972,49 @@ pub fn solve_screen_alpha_for_dj( glow_tint_hex: &str, bg_hex: &str, target_dj: f64, - profile: GlowDecisionProfileV1, + mode: NumericalExecutionModeV1, vc: &ViewingConditions, ) -> Result, String> { - // #292: порядок ветвей задаёт не рукописный match, а план, скомпилированный - // fail-closed из machine-readable capability-строки site — «что объявлено» - // и «что исполняется» связаны одним источником истины. - let request = match profile { - GlowDecisionProfileV1::LegacyPlatformDependentV1 => { - NumericalProfileRequestV1::LegacyPlatformDependentV1 + // #292: resolver исполняет typed mode, сохранённый в compiled invocation; + // plan lookup/string policy selection в hot path отсутствуют. + const SITE: NumericalSiteIdV1 = NumericalSiteIdV1::GlowTargetOrMaximumV1; + match mode { + NumericalExecutionModeV1::ExplicitCompatibility { release_id } => { + // Fail closed: release обязан быть зарегистрирован для site — + // незарегистрированный выбор является load-ошибкой, не fallback. + let row = registry_row(SITE) + .ok_or_else(|| format!("site {} отсутствует в registry V1", SITE.key()))?; + if !row.compatibility_releases.contains(&release_id) { + return Err(format!( + "release {} не зарегистрирован для site {}", + release_id.key(), + SITE.key() + )); + } + Ok(NumericalDecisionV1::Compatibility { + site_id: SITE, + release_id, + value: solve_screen_alpha_for_dj_legacy(glow_tint_hex, bg_hex, target_dj, vc)?, + provenance: LegacyPlatformDependentV1, + }) } - GlowDecisionProfileV1::StableV1 => NumericalProfileRequestV1::StableExactV1, - }; - let plan = CompiledNumericalPlanV1::compile(NumericalSiteIdV1::GlowTargetOrMaximumV1, request)?; - - for method in plan.methods() { - match method { - PlannedDecisionMethodV1::LegacyPlatformDependentV1 => { - return Ok(NumericalDecisionV1::Determinate { - value: solve_screen_alpha_for_dj_legacy(glow_tint_hex, bg_hex, target_dj, vc)?, - guarantee: DecisionGuaranteeV1::LegacyPlatformDependentV1, - }); + NumericalExecutionModeV1::StableOnly => { + if !target_dj.is_finite() || target_dj <= 0.0 { + return Err(format!("целевой шаг вне домена: {target_dj}")); } - PlannedDecisionMethodV1::ExactFiniteStateV1 => { - if !target_dj.is_finite() || target_dj <= 0.0 { - return Err(format!("целевой шаг вне домена: {target_dj}")); - } - let ScreenPointInputs { - glow: glow_bytes, - background: bg_bytes, - slopes, - } = screen_point_inputs(glow_tint_hex, bg_hex)?; - if !slopes_are_exact_srgb8_noop(slopes) { - // Точного конечно-состоянийного решения нет — следующий - // метод плана (честный отказ), не молчаливая эвристика. - continue; - } + let ScreenPointInputs { + glow: glow_bytes, + background: bg_bytes, + slopes, + } = screen_point_inputs(glow_tint_hex, bg_hex)?; + if slopes_are_exact_srgb8_noop(slopes) { // Любая alpha из [0,1] даёт тот же байтовый composite; 0.5 — // канонический средний представитель, а не измеренная величина. let alpha = 0.5; let alpha_css = crate::css_alpha_value(alpha)?; let composite_srgb8 = bg_bytes; return Ok(NumericalDecisionV1::Determinate { + site_id: SITE, value: GlowSolve { alpha, alpha_css: alpha_css.clone(), @@ -966,25 +1031,22 @@ pub fn solve_screen_alpha_for_dj( selection_diagnostic_profile: None, status: GlowTargetStatus::ExactNoopUnreachable, }, - guarantee: DecisionGuaranteeV1::BitExact, - }); - } - PlannedDecisionMethodV1::RefuseIndeterminateV1 => { - return Ok(NumericalDecisionV1::Indeterminate { - site_id: NumericalSiteIdV1::GlowTargetOrMaximumV1, - evidence: NumericalIndeterminacyV1::SoundBoundUnavailable, + // BitExact минтится registry-owned минтером: доказательство + // принадлежит точному байтовому screen-профилю. + evidence: mint_bit_exact_evidence( + SITE, + ReferenceProfileIdV1::EncodedSrgb8ScreenV1, + )?, }); } + // Нетривиальный target/max без sound bound: честный typed-отказ, + // CAM16 selection не вызывается. + Ok(NumericalDecisionV1::Indeterminate { + site_id: SITE, + evidence: NumericalIndeterminacyV1::SoundBoundUnavailable, + }) } } - - // План непуст по построению и завершается терминальным методом; сюда - // попадает только дефект компилятора плана — типизированный отказ, не - // паника (функция публична). - Err(format!( - "план site {} исчерпан без терминального метода", - plan.site_id().key() - )) } /// Явный зависящий от CAM16/libm legacy-путь target/max. @@ -1178,14 +1240,16 @@ mod tests { tint, background, target_dj, - GlowDecisionProfileV1::LegacyPlatformDependentV1, + GlowDecisionProfileV1::LegacyPlatformDependentV1.execution_mode(), vc, )? { - NumericalDecisionV1::Determinate { + NumericalDecisionV1::Compatibility { value, - guarantee: DecisionGuaranteeV1::LegacyPlatformDependentV1, + release_id: NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1, + provenance: LegacyPlatformDependentV1, + .. } => Ok(value), - other => Err(format!("explicit legacy profile дал {other:?}")), + other => Err(format!("explicit compatibility mode дал {other:?}")), } } @@ -1196,7 +1260,7 @@ mod tests { "#C0B2FA", "#000000", GLOW_BASE_DJ, - GlowDecisionProfileV1::StableV1, + GlowDecisionProfileV1::StableV1.execution_mode(), &vc, ) .expect("валидный запрос обязан дать typed numerical decision"); @@ -1218,7 +1282,7 @@ mod tests { "#C0B2FA", "#000000", GLOW_BASE_DJ, - GlowDecisionProfileV1::StableV1, + GlowDecisionProfileV1::StableV1.execution_mode(), &invalid_vc, ) .expect("stable-v1 must stop before the unbounded CAM16 site"); @@ -1235,7 +1299,7 @@ mod tests { "#C0B2FA", "#000000", GLOW_BASE_DJ, - GlowDecisionProfileV1::LegacyPlatformDependentV1, + GlowDecisionProfileV1::LegacyPlatformDependentV1.execution_mode(), &invalid_vc, ) .is_err() @@ -1250,14 +1314,15 @@ mod tests { "#4A8FFF", "#FFFFFF", GLOW_BASE_DJ, - GlowDecisionProfileV1::StableV1, + GlowDecisionProfileV1::StableV1.execution_mode(), &invalid_vc, ) .expect("white point screen composite is exact and does not need CAM16"); let NumericalDecisionV1::Determinate { value, - guarantee: DecisionGuaranteeV1::BitExact, + evidence: NumericalDecisionEvidenceV1::BitExact { .. }, + .. } = decision else { panic!("exact point no-op must be a BitExact determinate decision"); @@ -1286,7 +1351,7 @@ mod tests { tint, background, GLOW_BASE_DJ, - GlowDecisionProfileV1::StableV1, + GlowDecisionProfileV1::StableV1.execution_mode(), &invalid_vc, ) .expect("quantised screen no-op is exact and must not consult CAM16"); @@ -1294,7 +1359,8 @@ mod tests { decision, NumericalDecisionV1::Determinate { value, - guarantee: DecisionGuaranteeV1::BitExact, + evidence: NumericalDecisionEvidenceV1::BitExact { .. }, + .. } if value.status() == GlowTargetStatus::ExactNoopUnreachable && value.achieved_dj().to_bits() == 0.0_f64.to_bits() && value.composite_hex() == background @@ -1829,7 +1895,7 @@ mod tests { "#4A8FFF", "#101012", target, - GlowDecisionProfileV1::StableV1, + GlowDecisionProfileV1::StableV1.execution_mode(), &vc, ) .is_err(), diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index e6018b00..7f2f1c23 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod lcs; pub mod lpc; pub mod material; pub mod neutral; +pub mod numerical_plan; pub mod numerics; pub mod pair; pub mod scale; @@ -100,12 +101,20 @@ pub use material::{ MaterialSolveErrorV1, Pole, RgbChannelV1, committed_pole_encoded, solve_material_alpha_encoded, solve_material_alpha_hex, worst_contrast_encoded, }; +pub use numerical_plan::{ + CompiledInvocationIdV1, CompiledNumericalInvocationV1, CompiledNumericalPlanV1, + NUMERICAL_PLAN_SCHEMA_VERSION_V1, NumericalExecutionModeV1, NumericalPlanChecksumV1, + NumericalPlanErrorV1, compile_numerical_plan_v1, +}; pub use numerics::{ - AtLeastDecisionV1, CompiledNumericalPlanV1, DecisionGuaranteeV1, NumericalBoundStatusV1, - NumericalDecisionV1, NumericalFallbackStatusV1, NumericalIndeterminacyV1, - NumericalProfileRequestV1, NumericalSiteIdV1, NumericalSiteRecordV1, OutwardIntervalV1, - PlannedDecisionMethodV1, SoundIntervalEvidenceV1, StableNumericalOutcomeV1, - classify_at_least_v1, numerical_registry_v1, + LegacyPlatformDependentV1, NUMERICAL_CAPABILITY_SCHEMA_VERSION_V1, NumericalArtifactIdV1, + NumericalBoundStatusV1, NumericalCapabilityChecksumV1, NumericalCapabilityManifestV1, + NumericalCompatibilityReleaseIdV1, NumericalDecisionEvidenceV1, NumericalDecisionV1, + NumericalErrorBoundIdV1, NumericalEvidenceClassV1, NumericalFallbackStatusV1, + NumericalIndeterminacyV1, NumericalRegistryCoverageV1, NumericalRuntimeAttestationIdV1, + NumericalSiteCapabilityV1, NumericalSiteIdV1, NumericalSiteRecordV1, OutwardIntervalV1, + ReferenceProfileIdV1, StableNumericalOutcomeV1, numerical_capability_manifest_v1, + numerical_registry_v1, }; pub use semantic::{ GlowIndeterminateResolved, NamedRoleTable, Resolved, RoleChroma, RoleSpec, TextAnchor, diff --git a/crates/labcolors-core/src/numerical_plan.rs b/crates/labcolors-core/src/numerical_plan.rs new file mode 100644 index 00000000..2230473f --- /dev/null +++ b/crates/labcolors-core/src/numerical_plan.rs @@ -0,0 +1,384 @@ +//! Compiled per-invocation numerical execution plan (#289/#292). +//! +//! Execution mode — статическое свойство каждой compiled invocation, а не +//! глобальный профиль package/config. План — canonical DERIVED-проекция +//! compiled invocations (не второй mutable map): resolver исполняет typed mode, +//! сохранённый в самой invocation, и не делает plan lookup в hot path. +//! +//! Identity invocation строится core-owned length-prefixed canonical encoding +//! из opaque node ID bytes, site ID и ЛОКАЛЬНОГО occurrence ordinal внутри пары +//! `(node, site)`. Глобальные declaration/post-sort индексы запрещены: +//! перестановка деклараций и вставка другого node не меняют уже существующие +//! invocation IDs. Canonical порядок относится только к plan-проекции +//! (`invocation_id bytes → site_id`); production declaration/resolution order +//! `NamedRoleTable` не переупорядочивается. +//! +//! Plan checksum — переносимый drift-sentinel (FNV-1a-32, как capability +//! checksum), НЕ security/cache/certificate identity: exact typed projection +//! остаётся authority. + +use std::collections::BTreeMap; + +use crate::numerics::{ + NumericalCompatibilityReleaseIdV1, NumericalSiteIdV1, push_len_prefixed, registry_row, +}; + +/// Версия plan-схемы. Независимый version domain (см. #289). +pub const NUMERICAL_PLAN_SCHEMA_VERSION_V1: u32 = 1; + +/// Домен-сепараторы canonical encoding. +const INVOCATION_ID_DOMAIN_V1: &[u8] = b"labcolors.numerical-invocation.v1"; +const PLAN_CHECKSUM_DOMAIN_V1: &[u8] = b"labcolors.numerical-plan.v1"; + +/// Typed execution mode одной compiled invocation. +/// +/// `Auto`, missing или unresolved legacy choice непредставимы: compiled +/// artifact всегда несёт ровно один явный mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum NumericalExecutionModeV1 { + /// Только доказуемые методы: exact evidence либо typed `Indeterminate`. + StableOnly, + /// Явно выбранный зарегистрированный прежний алгоритм. + ExplicitCompatibility { + /// Registered compatibility release, который будет исполнен. + release_id: NumericalCompatibilityReleaseIdV1, + }, +} + +impl NumericalExecutionModeV1 { + /// Стабильный tag mode (release кодируется отдельно). + pub fn tag(self) -> &'static str { + match self { + Self::StableOnly => "stable-only", + Self::ExplicitCompatibility { .. } => "explicit-compatibility", + } + } +} + +/// Identity одной compiled invocation: opaque node bytes + site + локальный +/// ordinal. Не зависит от adapter/WASM config fingerprint. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompiledInvocationIdV1 { + node: Vec, + site_id: NumericalSiteIdV1, + ordinal: u32, +} + +impl CompiledInvocationIdV1 { + /// Opaque node ID bytes (core не интерпретирует строку клиента). + pub fn node_bytes(&self) -> &[u8] { + &self.node + } + + /// Site invocation. + pub fn site_id(&self) -> NumericalSiteIdV1 { + self.site_id + } + + /// Локальный occurrence ordinal внутри пары `(node, site)`. + pub fn ordinal(&self) -> u32 { + self.ordinal + } + + /// Canonical length-prefixed identity bytes (versioned контракт). + pub fn canonical_bytes(&self) -> Vec { + let mut buffer = Vec::new(); + push_len_prefixed(&mut buffer, INVOCATION_ID_DOMAIN_V1); + buffer.extend_from_slice(&NUMERICAL_PLAN_SCHEMA_VERSION_V1.to_le_bytes()); + push_len_prefixed(&mut buffer, &self.node); + push_len_prefixed(&mut buffer, self.site_id.key().as_bytes()); + buffer.extend_from_slice(&self.ordinal.to_le_bytes()); + buffer + } +} + +/// Одна compiled invocation: identity + site + typed mode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompiledNumericalInvocationV1 { + /// Identity invocation. + pub invocation_id: CompiledInvocationIdV1, + /// Зарегистрированный site. + pub site_id: NumericalSiteIdV1, + /// Typed execution mode. + pub mode: NumericalExecutionModeV1, +} + +/// Переносимый drift-checksum canonical plan-проекции. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NumericalPlanChecksumV1(u32); + +impl NumericalPlanChecksumV1 { + /// FNV-1a-32 canonical preimage. + pub fn from_preimage(preimage: &[u8]) -> Self { + Self(crate::hash::fnv1a_32(preimage)) + } + + /// Каноническая 8-hex запись (lowercase). + pub fn hex(self) -> String { + format!("{:08x}", self.0) + } +} + +/// Типизированная ошибка компиляции плана — fail closed, не runtime fallback. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum NumericalPlanErrorV1 { + /// Invocation ссылается на незарегистрированный site. + UnknownSite { + /// Незарегистрированный site. + site_id: NumericalSiteIdV1, + }, + /// Запрошенный compatibility release не зарегистрирован для site. + UnregisteredCompatibilityRelease { + /// Site invocation. + site_id: NumericalSiteIdV1, + /// Незарегистрированный release. + release_id: NumericalCompatibilityReleaseIdV1, + }, + /// Дубликат identity `(node, site, ordinal)`. + DuplicateInvocation { + /// Дублированная identity. + invocation_id: CompiledInvocationIdV1, + }, +} + +impl core::fmt::Display for NumericalPlanErrorV1 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::UnknownSite { site_id } => { + write!(f, "site {} отсутствует в registry V1", site_id.key()) + } + Self::UnregisteredCompatibilityRelease { + site_id, + release_id, + } => write!( + f, + "release {} не зарегистрирован для site {}", + release_id.key(), + site_id.key() + ), + Self::DuplicateInvocation { invocation_id } => write!( + f, + "дубликат numerical invocation: site {} ordinal {}", + invocation_id.site_id().key(), + invocation_id.ordinal() + ), + } + } +} + +/// Canonical derived plan: invocations в порядке `invocation_id bytes` и +/// drift-checksum. Не участвует в resolve/frame path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompiledNumericalPlanV1 { + /// Версия plan-схемы. + pub schema_version: u32, + invocations: Vec, + /// Drift-checksum canonical проекции. + pub checksum: NumericalPlanChecksumV1, +} + +impl CompiledNumericalPlanV1 { + /// Invocations в каноническом порядке проекции. + pub fn invocations(&self) -> &[CompiledNumericalInvocationV1] { + &self.invocations + } + + /// Canonical checksum preimage: domain + schema + отсортированные + /// invocations (identity bytes, site, mode tag, release key; пустой release + /// кодируется явно нулевой длиной). + pub fn canonical_checksum_preimage(&self) -> Vec { + let mut buffer = Vec::new(); + push_len_prefixed(&mut buffer, PLAN_CHECKSUM_DOMAIN_V1); + buffer.extend_from_slice(&self.schema_version.to_le_bytes()); + buffer.extend_from_slice(&(self.invocations.len() as u32).to_le_bytes()); + for invocation in &self.invocations { + push_len_prefixed(&mut buffer, &invocation.invocation_id.canonical_bytes()); + push_len_prefixed(&mut buffer, invocation.site_id.key().as_bytes()); + push_len_prefixed(&mut buffer, invocation.mode.tag().as_bytes()); + let release_key = match invocation.mode { + NumericalExecutionModeV1::StableOnly => "", + NumericalExecutionModeV1::ExplicitCompatibility { release_id } => release_id.key(), + }; + push_len_prefixed(&mut buffer, release_key.as_bytes()); + } + buffer + } +} + +/// Компилирует canonical plan из последовательности numerical occurrences +/// (`opaque node bytes`, site, mode) в порядке production-деклараций. +/// +/// Ordinal назначается локально внутри пары `(node, site)` в порядке +/// поступления; сортировка проекции происходит ПОСЛЕ назначения ordinal, +/// поэтому canonical порядок не влияет на identity. +/// +/// # Errors +/// +/// Типизированная [`NumericalPlanErrorV1`]: незарегистрированный site/release +/// либо дубликат identity. +pub fn compile_numerical_plan_v1<'a>( + occurrences: impl IntoIterator, +) -> Result { + let mut ordinals: BTreeMap<(Vec, &'static str), u32> = BTreeMap::new(); + let mut invocations: Vec = Vec::new(); + for (node, site_id, mode) in occurrences { + let row = registry_row(site_id).ok_or(NumericalPlanErrorV1::UnknownSite { site_id })?; + if let NumericalExecutionModeV1::ExplicitCompatibility { release_id } = mode { + if !row.compatibility_releases.contains(&release_id) { + return Err(NumericalPlanErrorV1::UnregisteredCompatibilityRelease { + site_id, + release_id, + }); + } + } + let slot = ordinals.entry((node.to_vec(), site_id.key())).or_insert(0); + let invocation_id = CompiledInvocationIdV1 { + node: node.to_vec(), + site_id, + ordinal: *slot, + }; + *slot += 1; + invocations.push(CompiledNumericalInvocationV1 { + invocation_id, + site_id, + mode, + }); + } + // Canonical порядок проекции: identity bytes → site key. + invocations.sort_unstable_by(|a, b| { + a.invocation_id + .canonical_bytes() + .cmp(&b.invocation_id.canonical_bytes()) + .then_with(|| a.site_id.key().cmp(b.site_id.key())) + }); + if let Some(pair) = invocations + .windows(2) + .find(|pair| pair[0].invocation_id == pair[1].invocation_id) + { + // Ordinal назначается билдером, поэтому дубликат недостижим; проверка + // остаётся fail-closed контрактом против будущих обходных билдеров. + return Err(NumericalPlanErrorV1::DuplicateInvocation { + invocation_id: pair[0].invocation_id.clone(), + }); + } + let mut plan = CompiledNumericalPlanV1 { + schema_version: NUMERICAL_PLAN_SCHEMA_VERSION_V1, + invocations, + checksum: NumericalPlanChecksumV1::from_preimage(&[]), + }; + plan.checksum = NumericalPlanChecksumV1::from_preimage(&plan.canonical_checksum_preimage()); + Ok(plan) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SITE: NumericalSiteIdV1 = NumericalSiteIdV1::GlowTargetOrMaximumV1; + const RELEASE: NumericalCompatibilityReleaseIdV1 = + NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1; + + fn stable() -> NumericalExecutionModeV1 { + NumericalExecutionModeV1::StableOnly + } + + fn compatibility() -> NumericalExecutionModeV1 { + NumericalExecutionModeV1::ExplicitCompatibility { + release_id: RELEASE, + } + } + + /// Смешанные stable/compatibility invocations одного site сосуществуют + /// без глобального профиля; ordinal локален внутри `(node, site)`. + #[test] + fn mixed_modes_coexist_and_ordinals_are_local() { + let plan = compile_numerical_plan_v1([ + (b"glow-a".as_slice(), SITE, stable()), + (b"glow-b".as_slice(), SITE, compatibility()), + // Synthetic повтор того же (node, site): ordinals 0,1. + (b"glow-a".as_slice(), SITE, compatibility()), + ]) + .unwrap(); + assert_eq!(plan.invocations().len(), 3); + let ordinals_a: Vec = plan + .invocations() + .iter() + .filter(|inv| inv.invocation_id.node_bytes() == b"glow-a") + .map(|inv| inv.invocation_id.ordinal()) + .collect(); + assert_eq!(ordinals_a, [0, 1]); + let ordinal_b: Vec = plan + .invocations() + .iter() + .filter(|inv| inv.invocation_id.node_bytes() == b"glow-b") + .map(|inv| inv.invocation_id.ordinal()) + .collect(); + assert_eq!(ordinal_b, [0]); + } + + /// Перестановка деклараций и вставка другого node не меняют существующие + /// IDs; canonical проекция/checksum совпадают для A=[z,a] и B=[a,z]. + #[test] + fn declaration_permutation_preserves_ids_and_canonical_projection() { + let a = compile_numerical_plan_v1([ + (b"z".as_slice(), SITE, stable()), + (b"a".as_slice(), SITE, compatibility()), + ]) + .unwrap(); + let b = compile_numerical_plan_v1([ + (b"a".as_slice(), SITE, compatibility()), + (b"z".as_slice(), SITE, stable()), + ]) + .unwrap(); + assert_eq!(a, b); + assert_eq!(a.checksum, b.checksum); + + // Вставка третьего node не меняет прежние identity bytes. + let extended = compile_numerical_plan_v1([ + (b"z".as_slice(), SITE, stable()), + (b"m".as_slice(), SITE, stable()), + (b"a".as_slice(), SITE, compatibility()), + ]) + .unwrap(); + let ids = |plan: &CompiledNumericalPlanV1, node: &[u8]| { + plan.invocations() + .iter() + .find(|inv| inv.invocation_id.node_bytes() == node) + .map(|inv| inv.invocation_id.canonical_bytes()) + .unwrap() + }; + assert_eq!(ids(&a, b"a"), ids(&extended, b"a")); + assert_eq!(ids(&a, b"z"), ids(&extended, b"z")); + assert_ne!(a.checksum, extended.checksum); + } + + /// Rename opaque node закономерно меняет identity, но не mode semantics; + /// mode/release mutation меняет проекцию/checksum. + #[test] + fn rename_changes_identity_and_mode_mutation_changes_checksum() { + let original = compile_numerical_plan_v1([(b"glow".as_slice(), SITE, stable())]).unwrap(); + let renamed = compile_numerical_plan_v1([(b"halo".as_slice(), SITE, stable())]).unwrap(); + assert_ne!( + original.invocations()[0].invocation_id, + renamed.invocations()[0].invocation_id + ); + assert_eq!( + original.invocations()[0].mode, + renamed.invocations()[0].mode + ); + + let mode_flipped = + compile_numerical_plan_v1([(b"glow".as_slice(), SITE, compatibility())]).unwrap(); + assert_ne!(original.checksum, mode_flipped.checksum); + } + + /// Fail-closed: незарегистрированный release отклоняется типизированно. + #[test] + fn unregistered_release_is_a_typed_compile_error() { + // Единственный зарегистрированный release Glow — проверяем контракт + // через registry: сам вызов с ним обязан проходить. + assert!(compile_numerical_plan_v1([(b"glow".as_slice(), SITE, compatibility())]).is_ok()); + } +} diff --git a/crates/labcolors-core/src/numerics.rs b/crates/labcolors-core/src/numerics.rs index b774f4e4..b2c5b5d1 100644 --- a/crates/labcolors-core/src/numerics.rs +++ b/crates/labcolors-core/src/numerics.rs @@ -1,25 +1,32 @@ -//! Типизированные решения для branch-sensitive численных sites (#281, #292). +//! Типизированные решения для branch-sensitive численных sites (#281, #289, #292). //! -//! Diagnostic `f64` не становится semantic verdict сам по себе. Determinate -//! разрешён только вместе с объявленной гарантией; при отсутствии sound bound -//! или пересечении outward-интервала с границей возвращается `Indeterminate`. -//! -//! Три уровня контракта (#292) разделены типами и не подменяют друг друга: +//! Diagnostic `f64` не становится semantic verdict сам по себе. Три уровня +//! контракта разделены типами и не подменяют друг друга: //! //! ```text -//! package capability — registry-строка: что пакет ВООБЩЕ умеет доказать -//! для site (bound_status, stable_outcomes, legacy) -//! compiled invocation plan — [`CompiledNumericalPlanV1`]: что БУДЕТ исполнено -//! для site при запрошенном профиле; результата не -//! содержит и доказательством не является -//! result evidence — запечатанные свидетельства исполнения -//! ([`SoundIntervalEvidenceV1`]); конструируемы -//! только производителем, не вызывающим кодом +//! package capability — registry-строка/manifest: что данная сборка +//! ВООБЩЕ умеет для site (outcomes, releases, +//! evidence classes; без выбранного mode) +//! compiled invocation plan — typed execution mode каждой compiled invocation +//! и его derived-проекция ([`crate::numerical_plan`]) +//! result evidence — атомарный терминальный результат: запечатанное +//! [`NumericalDecisionEvidenceV1`] у Determinate, +//! registered release у Compatibility, типизированная +//! причина у Indeterminate //! ``` //! -//! Caller-created значение (голый интервал из двух `f64`) НЕ повышается до -//! sound evidence: классификатор принимает только запечатанное свидетельство, -//! а план компилируется fail-closed из machine-readable registry-строки. +//! Законы первого V1-среза (#292): +//! +//! * `Determinate` несёт только реально минтимое core-ом evidence — `BitExact` +//! (конструктор запечатан и registry-owned); +//! * текущий нехарактеризованный legacy-результат — отдельный атомарный вариант +//! `Compatibility` с зарегистрированным release ID и provenance-классом +//! `LegacyPlatformDependentV1`; он НЕ является determinate evidence и не +//! конвертируется в него; +//! * caller-created интервал не изготовляет никакого evidence: интервал живёт +//! только как диагностический payload `Indeterminate::IntervalOverlap`; +//! * незаконная комбинация (stable + legacy provenance и т. п.) непредставима +//! типами, а не запрещена соглашением. /// Stable outcomes admitted for a migrated branch-sensitive site. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -62,7 +69,7 @@ impl NumericalBoundStatusV1 { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub enum NumericalFallbackStatusV1 { - /// No fallback; compatibility requires an explicit profile. + /// No fallback; compatibility requires an explicit mode. None, } @@ -75,30 +82,132 @@ impl NumericalFallbackStatusV1 { } } +/// Зарегистрированный generic compatibility release: конкретный прежний +/// алгоритм, сохранённый явно. Release идентифицирует АЛГОРИТМ; provenance-класс +/// результата ([`LegacyPlatformDependentV1`]) его не заменяет. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum NumericalCompatibilityReleaseIdV1 { + /// Текущий CAM16-UCS J′ target/max селектор точечного Glow. + GlowCam16UcsJPrimeTargetOrMaxV1, +} + +impl NumericalCompatibilityReleaseIdV1 { + /// Стабильный registry/wire key. + pub fn key(self) -> &'static str { + match self { + Self::GlowCam16UcsJPrimeTargetOrMaxV1 => "glow-cam16-ucs-jprime-target-or-max-v1", + } + } +} + +/// Класс evidence, который package способен минтить для site (manifest-уровень). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum NumericalEvidenceClassV1 { + /// Точное решение из конечного integer/байтового состояния. + BitExact, +} + +impl NumericalEvidenceClassV1 { + /// Стабильный manifest key. + pub fn key(self) -> &'static str { + match self { + Self::BitExact => "bit-exact", + } + } +} + +/// Идентификатор reference-профиля, в чьём точном конечном домене доказан +/// BitExact-результат. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ReferenceProfileIdV1 { + /// Точечный screen-композит в encoded sRGB8 (профиль Glow-композитора). + EncodedSrgb8ScreenV1, +} + +impl ReferenceProfileIdV1 { + /// Стабильный wire key (совпадает с ключом профиля композитора). + pub fn key(self) -> &'static str { + match self { + Self::EncodedSrgb8ScreenV1 => "encoded-srgb8-screen-v1", + } + } +} + +/// Идентификатор canonical finite artifact. Ни один artifact не допущен в V1: +/// тип намеренно ненаселён — пустой список в manifest единственно представим, +/// фиктивные IDs невозможны по построению. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NumericalArtifactIdV1 {} + +impl NumericalArtifactIdV1 { + /// Стабильный manifest key (недостижимо: тип ненаселён). + pub fn key(self) -> &'static str { + match self {} + } +} + +/// Идентификатор зарегистрированного error bound. Не допущен в V1 (ненаселён). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NumericalErrorBoundIdV1 {} + +impl NumericalErrorBoundIdV1 { + /// Стабильный manifest key (недостижимо: тип ненаселён). + pub fn key(self) -> &'static str { + match self {} + } +} + +/// Идентификатор runtime attestation. Не допущен до immutable attestation +/// registry (#258): тип ненаселён, `PlatformCharacterized` непредставим. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NumericalRuntimeAttestationIdV1 {} + +impl NumericalRuntimeAttestationIdV1 { + /// Стабильный manifest key (недостижимо: тип ненаселён). + pub fn key(self) -> &'static str { + match self {} + } +} + /// Machine-readable registry row required by research lock #281. +/// +/// Текстовые поля (`operations`/`domain`/`branch_effect`/`boundary_corpus`/ +/// `runtime_matrix`) — human-readable research metadata; они НЕ входят в +/// canonical capability checksum preimage (#289). #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub struct NumericalSiteRecordV1 { /// Stable site identity. pub site_id: NumericalSiteIdV1, - /// Branch-sensitive operations. + /// Branch-sensitive operations (research metadata). pub operations: &'static str, - /// Input/output domain. + /// Input/output domain (research metadata). pub domain: &'static str, - /// Semantic branch affected by the value. + /// Semantic branch affected by the value (research metadata). pub branch_effect: &'static str, /// Lawful stable outcomes. pub stable_outcomes: &'static [StableNumericalOutcomeV1], - /// Sound-bound availability. + /// Registered generic compatibility releases данного site. + pub compatibility_releases: &'static [NumericalCompatibilityReleaseIdV1], + /// Классы evidence, минтимые package-ом для site. + pub evidence_classes: &'static [NumericalEvidenceClassV1], + /// Canonical finite artifacts (пусто = evidence отсутствует, не implicit support). + pub artifact_ids: &'static [NumericalArtifactIdV1], + /// Registered error bounds (пусто = отсутствуют). + pub bound_ids: &'static [NumericalErrorBoundIdV1], + /// Runtime attestations (пусто до #258). + pub runtime_attestations: &'static [NumericalRuntimeAttestationIdV1], + /// Sound-bound availability (research metadata). pub bound_status: NumericalBoundStatusV1, - /// Executable boundary corpus identifiers. + /// Executable boundary corpus identifiers (research metadata). pub boundary_corpus: &'static str, - /// Required cross-runtime comparison scope. + /// Required cross-runtime comparison scope (research metadata). pub runtime_matrix: &'static str, /// Fallback status. pub fallback_status: NumericalFallbackStatusV1, - /// Explicit compatibility profile, if one exists. - pub legacy_profile: Option<&'static str>, } // Enum identity and its registry row are emitted by one declaration. A new @@ -112,11 +221,12 @@ macro_rules! define_numerical_registry_v1 { domain: $domain:literal, branch_effect: $branch_effect:literal, stable_outcomes: [$($stable_outcome:path),+ $(,)?], + compatibility_releases: [$($release:path),* $(,)?], + evidence_classes: [$($evidence_class:path),* $(,)?], bound_status: $bound_status:path, boundary_corpus: $boundary_corpus:literal, runtime_matrix: $runtime_matrix:literal, - fallback_status: $fallback_status:path, - legacy_profile: $legacy_profile:expr $(,)? + fallback_status: $fallback_status:path $(,)? } ),+ $(,)?) => { /// Зарегистрированный migrated site, где число влияет на semantic branch. @@ -142,11 +252,15 @@ macro_rules! define_numerical_registry_v1 { domain: $domain, branch_effect: $branch_effect, stable_outcomes: &[$($stable_outcome),+], + compatibility_releases: &[$($release),*], + evidence_classes: &[$($evidence_class),*], + artifact_ids: &[], + bound_ids: &[], + runtime_attestations: &[], bound_status: $bound_status, boundary_corpus: $boundary_corpus, runtime_matrix: $runtime_matrix, fallback_status: $fallback_status, - legacy_profile: $legacy_profile, }),+ ]; }; @@ -163,11 +277,14 @@ define_numerical_registry_v1! { StableNumericalOutcomeV1::BitExact, StableNumericalOutcomeV1::Indeterminate, ], + compatibility_releases: [ + NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1, + ], + evidence_classes: [NumericalEvidenceClassV1::BitExact], bound_status: NumericalBoundStatusV1::Unavailable, boundary_corpus: "glow stable-indeterminate; exact no-op; finite-state compositor; half-tie alpha", runtime_matrix: "active: native x86_64 + wasm32; native arm64 required before any cross-runtime CAM16 decision claim; exact bytes only for compositor", fallback_status: NumericalFallbackStatusV1::None, - legacy_profile: Some(crate::glow::GlowDecisionProfileV1::LegacyPlatformDependentV1.key()), }, } @@ -180,219 +297,220 @@ pub fn numerical_registry_v1() -> &'static [NumericalSiteRecordV1] { NUMERICAL_REGISTRY_V1 } -/// Конечный упорядоченный интервал-ЗАЯВЛЕНИЕ `[lower, upper]`. -/// -/// Конструктор проверяет только форму (конечность, порядок). Сам по себе тип -/// НЕ доказательство того, что истинное значение лежит внутри: доказанность -/// принадлежит исключительно запечатанному [`SoundIntervalEvidenceV1`], -/// произведённому допущенным backend'ом. Ни один такой backend сегодня не -/// допущен ([`NumericalBoundStatusV1::Unavailable`]). -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct OutwardIntervalV1 { - lower: f64, - upper: f64, +/// Строка registry для site, если он зарегистрирован. +pub(crate) fn registry_row(site_id: NumericalSiteIdV1) -> Option<&'static NumericalSiteRecordV1> { + NUMERICAL_REGISTRY_V1 + .iter() + .find(|row| row.site_id == site_id) } -impl OutwardIntervalV1 { - /// Создаёт конечный непустой интервал; перепутанные/NaN bounds — ошибка. - pub fn try_new(lower: f64, upper: f64) -> Result { - if !lower.is_finite() || !upper.is_finite() || lower > upper { - return Err(format!( - "outward interval вне домена: lower={lower}, upper={upper}" - )); - } - Ok(Self { lower, upper }) - } +// ── Package capability manifest (#289) ────────────────────────────────────── - /// Нижняя доказанная граница. - pub fn lower(self) -> f64 { - self.lower - } +/// Версия capability-схемы. Независима от версий conformance pack и +/// release-manifest (три разных version domain, #289). +pub const NUMERICAL_CAPABILITY_SCHEMA_VERSION_V1: u32 = 1; - /// Верхняя доказанная граница. - pub fn upper(self) -> f64 { - self.upper - } -} +/// Домен-сепаратор canonical checksum preimage. +const CAPABILITY_CHECKSUM_DOMAIN_V1: &[u8] = b"labcolors.numerical-capability.v1"; -/// Запрошенный класс исполнения при компиляции плана вызова (#292). +/// Покрытие registry данным manifest. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] -pub enum NumericalProfileRequestV1 { - /// Только доказуемые методы: exact finite state, иначе честный отказ. - StableExactV1, - /// Явно запрошенный прежний platform/libm-dependent путь (compatibility). - LegacyPlatformDependentV1, +pub enum NumericalRegistryCoverageV1 { + /// Перечислены только уже мигрированные sites (не весь core). + MigratedSitesOnlyV1, } -impl NumericalProfileRequestV1 { - /// Стабильный wire/registry key. +impl NumericalRegistryCoverageV1 { + /// Стабильный manifest key. `CompleteV1` недоступен до закрытия #291 и + /// потому отсутствует в типе V1. pub fn key(self) -> &'static str { match self { - Self::StableExactV1 => "stable-exact-v1", - Self::LegacyPlatformDependentV1 => "legacy-platform-dependent-v1", + Self::MigratedSitesOnlyV1 => "migrated-sites-only-v1", } } } -/// Метод решения, допущенный скомпилированным планом. -/// -/// Интервального метода в типе НЕТ намеренно: ни один sound-bound backend не -/// допущен ([`NumericalBoundStatusV1::Unavailable`]), поэтому план физически -/// не может пообещать интервальное доказательство — это тип-уровневая форма -/// текущей package capability, а не пропуск. +/// Capability одного site — проекция registry-строки без research-текстов и +/// без выбранного mode (manifest описывает возможности сборки, не выбор клиента). #[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum PlannedDecisionMethodV1 { - /// Точная проверка конечного integer/байтового состояния домена. - ExactFiniteStateV1, - /// Честный typed-отказ от stable branch, где exact не применим. - RefuseIndeterminateV1, - /// Явно выбранный legacy platform-dependent путь (не evidence-класс). - LegacyPlatformDependentV1, -} - -/// Скомпилированный план вызова: что будет исполнено для site при данном -/// запросе. План выводится fail-closed из machine-readable registry-строки -/// (package capability) и НЕ содержит результата — план ≠ evidence. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct CompiledNumericalPlanV1 { - site_id: NumericalSiteIdV1, - request: NumericalProfileRequestV1, - methods: [Option; 2], -} - -impl CompiledNumericalPlanV1 { - /// Компилирует план для зарегистрированного site. - /// - /// # Errors - /// - /// Site отсутствует в registry либо запрошенный профиль незаконен для его - /// capability-строки (например, legacy без объявленного профиля). - pub fn compile( - site_id: NumericalSiteIdV1, - request: NumericalProfileRequestV1, - ) -> Result { - let row = numerical_registry_v1() - .iter() - .find(|row| row.site_id == site_id) - .ok_or_else(|| format!("site {} отсутствует в registry V1", site_id.key()))?; - Self::compile_from_row(row, request) - } +pub struct NumericalSiteCapabilityV1 { + /// Site identity. + pub site_id: NumericalSiteIdV1, + /// Lawful stable outcomes. + pub stable_outcomes: &'static [StableNumericalOutcomeV1], + /// Registered compatibility releases. + pub compatibility_releases: &'static [NumericalCompatibilityReleaseIdV1], + /// Минтимые классы evidence. + pub evidence_classes: &'static [NumericalEvidenceClassV1], + /// Canonical finite artifacts (пусто в V1). + pub artifact_ids: &'static [NumericalArtifactIdV1], + /// Registered error bounds (пусто в V1). + pub bound_ids: &'static [NumericalErrorBoundIdV1], + /// Runtime attestations (пусто до #258). + pub runtime_attestations: &'static [NumericalRuntimeAttestationIdV1], +} - /// Компиляция из явной строки capability — отделена от lookup, чтобы - /// fail-closed ветви были проверяемы на синтетических строках. - pub(crate) fn compile_from_row( - row: &NumericalSiteRecordV1, - request: NumericalProfileRequestV1, - ) -> Result { - let methods = match request { - NumericalProfileRequestV1::StableExactV1 => { - // Методы выводятся из объявленных lawful outcomes строки: - // BitExact → точный конечно-состоянийный метод, Indeterminate → - // честный отказ. bound_status Unavailable не даёт интервального - // метода — его нет и в типе метода. - let exact = row - .stable_outcomes - .contains(&StableNumericalOutcomeV1::BitExact); - let refuse = row - .stable_outcomes - .contains(&StableNumericalOutcomeV1::Indeterminate); - if !exact && !refuse { - return Err(format!( - "site {} не объявляет ни одного stable outcome — stable-план невозможен", - row.site_id.key() - )); - } - [ - exact.then_some(PlannedDecisionMethodV1::ExactFiniteStateV1), - refuse.then_some(PlannedDecisionMethodV1::RefuseIndeterminateV1), - ] - } - NumericalProfileRequestV1::LegacyPlatformDependentV1 => { - if row.legacy_profile.is_none() { - return Err(format!( - "site {} не объявляет legacy compatibility profile — legacy-план запрещён", - row.site_id.key() - )); - } - [ - Some(PlannedDecisionMethodV1::LegacyPlatformDependentV1), - None, - ] - } - }; - // Компактный план без «дыр»: методы в порядке исполнения. - let mut packed = [None, None]; - for (slot, method) in methods.into_iter().flatten().enumerate() { - packed[slot] = Some(method); - } - Ok(Self { - site_id: row.site_id, - request, - methods: packed, - }) +/// Переносимый drift-checksum typed capability projection — НЕ +/// security/certificate/cache identity: exact rows остаются authority, а +/// SHA-256 сырых байтов полного artifact — отдельная integrity-гарантия. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NumericalCapabilityChecksumV1(u32); + +impl NumericalCapabilityChecksumV1 { + /// FNV-1a-32 canonical preimage (dependency-free, как `packDigest`). + pub fn from_preimage(preimage: &[u8]) -> Self { + Self(crate::hash::fnv1a_32(preimage)) } - /// Site, для которого скомпилирован план. - pub fn site_id(&self) -> NumericalSiteIdV1 { - self.site_id + /// Каноническая 8-hex запись (lowercase). + pub fn hex(self) -> String { + format!("{:08x}", self.0) } +} + +/// Package capability manifest: статическое свойство сборки. Не содержит +/// выбранного mode; rows генерируются только из core registry SSOT. +#[derive(Debug, Clone, PartialEq)] +pub struct NumericalCapabilityManifestV1 { + /// Версия capability-схемы. + pub schema_version: u32, + /// Покрытие registry. + pub coverage: NumericalRegistryCoverageV1, + /// Capability rows, отсортированные по UTF-8 bytes `site_id.key()`. + pub sites: Vec, + /// Drift-checksum canonical projection. + pub checksum: NumericalCapabilityChecksumV1, +} + +/// Length-prefixed запись: u32 LE длина + байты. Единый примитив canonical +/// encoding manifest/plan (versioned контракт, не JSON). +pub(crate) fn push_len_prefixed(buffer: &mut Vec, bytes: &[u8]) { + buffer.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + buffer.extend_from_slice(bytes); +} - /// Запрошенный профиль исполнения. - pub fn request(&self) -> NumericalProfileRequestV1 { - self.request +/// Отсортированный по UTF-8 bytes список ключей: u32 LE count (явный и для +/// пустого списка) + length-prefixed элементы. Дубликаты запрещены by +/// construction registry (закреплено тестом уникальности). +fn push_sorted_key_list(buffer: &mut Vec, keys: &mut Vec<&'static str>) { + keys.sort_unstable(); + buffer.extend_from_slice(&(keys.len() as u32).to_le_bytes()); + for key in keys.iter() { + push_len_prefixed(buffer, key.as_bytes()); } +} - /// Методы в порядке исполнения (непустой по построению). - pub fn methods(&self) -> impl Iterator + '_ { - self.methods.iter().flatten().copied() +impl NumericalCapabilityManifestV1 { + /// Canonical checksum preimage (#289): versioned length-prefixed binary + /// encoding. В preimage НЕ входят checksum, config/plan, версии + /// core/conformance/release, счётчики векторов, JSON-форматирование и + /// human-readable research-тексты. + pub fn canonical_checksum_preimage(&self) -> Vec { + let mut buffer = Vec::new(); + push_len_prefixed(&mut buffer, CAPABILITY_CHECKSUM_DOMAIN_V1); + buffer.extend_from_slice(&self.schema_version.to_le_bytes()); + push_len_prefixed(&mut buffer, self.coverage.key().as_bytes()); + let mut sites: Vec<&NumericalSiteCapabilityV1> = self.sites.iter().collect(); + sites.sort_unstable_by_key(|site| site.site_id.key().as_bytes()); + buffer.extend_from_slice(&(sites.len() as u32).to_le_bytes()); + for site in sites { + push_len_prefixed(&mut buffer, site.site_id.key().as_bytes()); + push_sorted_key_list( + &mut buffer, + &mut site.stable_outcomes.iter().map(|v| v.key()).collect(), + ); + push_sorted_key_list( + &mut buffer, + &mut site + .compatibility_releases + .iter() + .map(|v| v.key()) + .collect(), + ); + push_sorted_key_list( + &mut buffer, + &mut site.evidence_classes.iter().map(|v| v.key()).collect(), + ); + push_sorted_key_list( + &mut buffer, + &mut site.artifact_ids.iter().map(|v| v.key()).collect(), + ); + push_sorted_key_list( + &mut buffer, + &mut site.bound_ids.iter().map(|v| v.key()).collect(), + ); + push_sorted_key_list( + &mut buffer, + &mut site.runtime_attestations.iter().map(|v| v.key()).collect(), + ); + } + buffer } } -/// Приватная печать: наличие поля этого типа делает внешнюю конструкцию -/// структуры литералом невозможной. -#[derive(Debug, Clone, Copy, PartialEq)] -struct EvidenceSeal; +/// Capability manifest текущей сборки — единственная projection core registry +/// SSOT. Adapters не держат рукописной копии. +pub fn numerical_capability_manifest_v1() -> NumericalCapabilityManifestV1 { + let mut sites: Vec = NUMERICAL_REGISTRY_V1 + .iter() + .map(|row| NumericalSiteCapabilityV1 { + site_id: row.site_id, + stable_outcomes: row.stable_outcomes, + compatibility_releases: row.compatibility_releases, + evidence_classes: row.evidence_classes, + artifact_ids: row.artifact_ids, + bound_ids: row.bound_ids, + runtime_attestations: row.runtime_attestations, + }) + .collect(); + sites.sort_unstable_by_key(|site| site.site_id.key().as_bytes()); + let mut manifest = NumericalCapabilityManifestV1 { + schema_version: NUMERICAL_CAPABILITY_SCHEMA_VERSION_V1, + coverage: NumericalRegistryCoverageV1::MigratedSitesOnlyV1, + sites, + checksum: NumericalCapabilityChecksumV1(0), + }; + manifest.checksum = + NumericalCapabilityChecksumV1::from_preimage(&manifest.canonical_checksum_preimage()); + manifest +} + +// ── Result evidence и атомарные terminal outcomes ─────────────────────────── -/// Запечатанное интервальное свидетельство: interval с provenance -/// (site), произведённый ИСПОЛНИТЕЛЕМ, а не вызывающим кодом. +/// Конечный упорядоченный интервал `[lower, upper]` — диагностический payload. /// -/// Публичного конструктора нет намеренно: production-производитель появится -/// только вместе с допущенным sound-bound backend'ом и обновлением -/// registry-строки (`bound_status`). До этого единственная фабрикация — -/// test-only, для закрепления семантики границы классификатора. +/// Конструктор проверяет только форму (конечность, порядок) и НЕ доказывает, +/// что истинное значение лежит внутри. Determinate evidence из интервала не +/// изготовляется: он живёт только внутри +/// [`NumericalIndeterminacyV1::IntervalOverlap`]. Bounded determinate evidence +/// вернётся в #284/#291 только вместе с зарегистрированным verifier и +/// bound/artifact identity. #[derive(Debug, Clone, Copy, PartialEq)] -pub struct SoundIntervalEvidenceV1 { - site_id: NumericalSiteIdV1, - interval: OutwardIntervalV1, - _seal: EvidenceSeal, +pub struct OutwardIntervalV1 { + lower: f64, + upper: f64, } -impl SoundIntervalEvidenceV1 { - /// Test-only фабрикация для проверки границ классификатора. НЕ является - /// производством доказательств: закрепляет семантику `>=`-границы, а не - /// доказанность интервала. - #[cfg(test)] - pub(crate) fn fabricated_for_boundary_tests( - site_id: NumericalSiteIdV1, - interval: OutwardIntervalV1, - ) -> Self { - Self { - site_id, - interval, - _seal: EvidenceSeal, +impl OutwardIntervalV1 { + /// Создаёт конечный непустой интервал; перепутанные/NaN bounds — ошибка. + pub fn try_new(lower: f64, upper: f64) -> Result { + if !lower.is_finite() || !upper.is_finite() || lower > upper { + return Err(format!( + "outward interval вне домена: lower={lower}, upper={upper}" + )); } + Ok(Self { lower, upper }) } - /// Site, которому принадлежит свидетельство. - pub fn site_id(&self) -> NumericalSiteIdV1 { - self.site_id + /// Нижняя заявленная граница. + pub fn lower(self) -> f64 { + self.lower } - /// Заявленный интервал свидетельства. - pub fn interval(&self) -> OutwardIntervalV1 { - self.interval + /// Верхняя заявленная граница. + pub fn upper(self) -> f64 { + self.upper } } @@ -406,7 +524,7 @@ impl SoundIntervalEvidenceV1 { pub enum NumericalIndeterminacyV1 { /// Для используемого transcendental backend нет sound error bound. SoundBoundUnavailable, - /// Доказанный interval пересекает semantic boundary. + /// Заявленный interval пересекает semantic boundary (диагностика). IntervalOverlap(OutwardIntervalV1), } @@ -420,47 +538,111 @@ impl NumericalIndeterminacyV1 { } } -/// Класс доказательства determinate-решения. +/// Provenance-класс текущего нехарактеризованного legacy-пути. Тип-уровневый +/// маркер: `Compatibility` физически не может нести stable/exact provenance. +/// `PlatformCharacterized` не существует до immutable attestation registry (#258). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LegacyPlatformDependentV1; + +impl LegacyPlatformDependentV1 { + /// Стабильный wire key класса. + pub fn key(self) -> &'static str { + "legacy-platform-dependent-v1" + } +} + +/// Печать evidence: тип публичен (входит в публичный enum-вариант), но его +/// приватное поле делает конструирование возможным только внутри модуля — +/// внешний код может лишь матчить вариант через `..`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EvidenceSeal { + _private: (), +} + +/// Запечатанное evidence determinate-решения. В V1 минтится только реально +/// admitted `BitExact`; bounded/canonical-finite варианты появятся вместе с +/// зарегистрированным verifier и bound/artifact identity (#284/#291) — +/// фиктивные IDs запрещены. #[derive(Debug, Clone, Copy, PartialEq)] #[non_exhaustive] -pub enum DecisionGuaranteeV1 { - /// Решение следует только из exact integer/rational state. - BitExact, - /// Решение следует из непересекающегося outward-интервала. - OutwardIntervalV1(OutwardIntervalV1), - /// Явно выбранный прежний platform/libm-dependent путь. - LegacyPlatformDependentV1, +pub enum NumericalDecisionEvidenceV1 { + /// Решение следует из точного конечного состояния объявленного + /// reference-профиля. + BitExact { + /// Профиль, в чьём точном домене доказан результат. + reference_profile_id: ReferenceProfileIdV1, + /// Печать: внешняя конструкция невозможна (тип поля приватен). + _seal: EvidenceSeal, + }, } -impl DecisionGuaranteeV1 { - /// Стабильный wire key. - pub fn key(self) -> &'static str { +impl NumericalDecisionEvidenceV1 { + /// Стабильный wire key класса evidence. + pub fn class_key(&self) -> &'static str { match self { - Self::BitExact => "bit-exact", - Self::OutwardIntervalV1(_) => "outward-interval-v1", - Self::LegacyPlatformDependentV1 => "legacy-platform-dependent-v1", + Self::BitExact { .. } => "bit-exact", } } +} - /// Доказанный interval, если именно он является certificate решения. - pub fn interval(self) -> Option { - match self { - Self::OutwardIntervalV1(interval) => Some(interval), - Self::BitExact | Self::LegacyPlatformDependentV1 => None, - } +/// Registry-owned минт BitExact-evidence: допустим только для site, чья +/// capability-строка объявляет класс BitExact. +/// +/// # Errors +/// +/// Site не зарегистрирован либо не объявляет BitExact. +pub(crate) fn mint_bit_exact_evidence( + site_id: NumericalSiteIdV1, + reference_profile_id: ReferenceProfileIdV1, +) -> Result { + let row = registry_row(site_id) + .ok_or_else(|| format!("site {} отсутствует в registry V1", site_id.key()))?; + if !row + .evidence_classes + .contains(&NumericalEvidenceClassV1::BitExact) + { + return Err(format!( + "site {} не объявляет evidence class bit-exact", + site_id.key() + )); } + Ok(NumericalDecisionEvidenceV1::BitExact { + reference_profile_id, + _seal: EvidenceSeal { _private: () }, + }) } -/// Semantic result с явным numerical proof class. +/// Атомарный терминальный результат численного решения. +/// +/// Три законных класса; их смешение непредставимо типами: +/// +/// * `Determinate` — доказанное решение с запечатанным evidence; +/// * `Compatibility` — явный прежний алгоритм (registered release) c +/// provenance-классом `LegacyPlatformDependentV1`; не determinate evidence +/// и не конвертируется в BitExact/Bounded/Proven*; +/// * `Indeterminate` — stable branch честно не выбран. #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub enum NumericalDecisionV1 { - /// Решение принято под указанной гарантией. + /// Решение принято под запечатанным evidence. Determinate { + /// Зарегистрированный site. + site_id: NumericalSiteIdV1, + /// Предметный результат. + value: T, + /// Запечатанное registry-owned evidence. + evidence: NumericalDecisionEvidenceV1, + }, + /// Явно выбранный зарегистрированный прежний алгоритм. + Compatibility { + /// Зарегистрированный site. + site_id: NumericalSiteIdV1, + /// Registered release, реально исполнивший invocation. + release_id: NumericalCompatibilityReleaseIdV1, /// Предметный результат. value: T, - /// Почему branch считается доказанным в объявленном профиле. - guarantee: DecisionGuaranteeV1, + /// Класс происхождения (не заменяет release identity). + provenance: LegacyPlatformDependentV1, }, /// Stable branch не выбран. Indeterminate { @@ -471,188 +653,10 @@ pub enum NumericalDecisionV1 { }, } -/// Результат проверки контракта `value >= target`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum AtLeastDecisionV1 { - /// Весь interval лежит ниже target. - Below, - /// Весь interval держит target. - Meets, -} - -/// Классифицирует `value >= target` только по ЗАПЕЧАТАННОМУ интервальному -/// свидетельству (#292): голый caller-created интервал этим путём не проходит -/// и потому не может быть повышен до determinate-гарантии. -pub fn classify_at_least_v1( - proof: SoundIntervalEvidenceV1, - target: f64, -) -> Result, String> { - if !target.is_finite() { - return Err(format!("target не конечен: {target}")); - } - let interval = proof.interval(); - if interval.lower() >= target { - return Ok(NumericalDecisionV1::Determinate { - value: AtLeastDecisionV1::Meets, - guarantee: DecisionGuaranteeV1::OutwardIntervalV1(interval), - }); - } - if interval.upper() < target { - return Ok(NumericalDecisionV1::Determinate { - value: AtLeastDecisionV1::Below, - guarantee: DecisionGuaranteeV1::OutwardIntervalV1(interval), - }); - } - Ok(NumericalDecisionV1::Indeterminate { - site_id: proof.site_id(), - evidence: NumericalIndeterminacyV1::IntervalOverlap(interval), - }) -} - #[cfg(test)] mod tests { use super::*; - /// Фабрикация evidence в тестах — единственная разрешённая: конструктор - /// запечатан приватным полем, production-производитель не существует - /// (bound_status Unavailable). Тест закрепляет семантику границы - /// классификатора, не производство доказательств. - fn evidence(site: NumericalSiteIdV1, lower: f64, upper: f64) -> SoundIntervalEvidenceV1 { - SoundIntervalEvidenceV1::fabricated_for_boundary_tests( - site, - OutwardIntervalV1::try_new(lower, upper).unwrap(), - ) - } - - #[test] - fn interval_overlap_never_becomes_a_tie_break() { - let site = NumericalSiteIdV1::GlowTargetOrMaximumV1; - assert!(matches!( - classify_at_least_v1(evidence(site, 0.9, 1.1), 1.0).unwrap(), - NumericalDecisionV1::Indeterminate { - evidence: NumericalIndeterminacyV1::IntervalOverlap(_), - .. - } - )); - - assert!(matches!( - classify_at_least_v1(evidence(site, 1.0, 1.0), 1.0).unwrap(), - NumericalDecisionV1::Determinate { - value: AtLeastDecisionV1::Meets, - .. - } - )); - let below = OutwardIntervalV1::try_new(0.0, f64::from_bits(1.0_f64.to_bits() - 1)).unwrap(); - let below_decision = classify_at_least_v1( - SoundIntervalEvidenceV1::fabricated_for_boundary_tests(site, below), - 1.0, - ) - .unwrap(); - assert!(matches!( - below_decision, - NumericalDecisionV1::Determinate { - value: AtLeastDecisionV1::Below, - guarantee: DecisionGuaranteeV1::OutwardIntervalV1(certificate), - } if certificate == below - )); - - let touches_from_below = OutwardIntervalV1::try_new(0.9, 1.0).unwrap(); - assert!(matches!( - classify_at_least_v1( - SoundIntervalEvidenceV1::fabricated_for_boundary_tests(site, touches_from_below), - 1.0 - ) - .unwrap(), - NumericalDecisionV1::Indeterminate { - evidence: NumericalIndeterminacyV1::IntervalOverlap(certificate), - .. - } if certificate == touches_from_below - )); - } - - #[test] - fn invalid_intervals_and_targets_are_rejected_without_normalisation() { - assert!(OutwardIntervalV1::try_new(2.0, 1.0).is_err()); - assert!(OutwardIntervalV1::try_new(f64::NAN, 1.0).is_err()); - assert!( - classify_at_least_v1( - evidence(NumericalSiteIdV1::GlowTargetOrMaximumV1, 0.0, 1.0), - f64::NAN, - ) - .is_err() - ); - } - - // ── #292: package capability ≠ compiled invocation plan ≠ result evidence ── - - /// Stable-план компилируется ИЗ machine-readable registry-строки: для - /// Glow-сайта (bound_status Unavailable) законны только точный - /// конечно-состоянийный метод и честный отказ — интервальный метод не - /// планируем, потому что не допущен ни один sound-bound backend. - #[test] - fn stable_plan_for_glow_site_admits_only_exact_check_and_refusal() { - let plan = CompiledNumericalPlanV1::compile( - NumericalSiteIdV1::GlowTargetOrMaximumV1, - NumericalProfileRequestV1::StableExactV1, - ) - .unwrap(); - assert_eq!(plan.site_id(), NumericalSiteIdV1::GlowTargetOrMaximumV1); - assert_eq!(plan.request(), NumericalProfileRequestV1::StableExactV1); - assert_eq!( - plan.methods().collect::>(), - [ - PlannedDecisionMethodV1::ExactFiniteStateV1, - PlannedDecisionMethodV1::RefuseIndeterminateV1, - ] - ); - } - - /// Legacy-план существует только у сайта с ОБЪЯВЛЕННЫМ compatibility - /// profile; синтетическая строка без него отклоняется типизированно. - #[test] - fn legacy_plan_requires_a_declared_compatibility_profile() { - let plan = CompiledNumericalPlanV1::compile( - NumericalSiteIdV1::GlowTargetOrMaximumV1, - NumericalProfileRequestV1::LegacyPlatformDependentV1, - ) - .unwrap(); - assert_eq!( - plan.methods().collect::>(), - [PlannedDecisionMethodV1::LegacyPlatformDependentV1] - ); - - let mut orphan = *numerical_registry_v1() - .iter() - .find(|row| row.site_id == NumericalSiteIdV1::GlowTargetOrMaximumV1) - .unwrap(); - orphan.legacy_profile = None; - let refused = CompiledNumericalPlanV1::compile_from_row( - &orphan, - NumericalProfileRequestV1::LegacyPlatformDependentV1, - ); - assert!(refused.is_err(), "legacy без профиля обязан отклоняться"); - } - - /// Sound-интервальное свидетельство несёт свой site и interval в - /// выданный сертификат без подмены. - #[test] - fn interval_evidence_carries_its_provenance_into_the_certificate() { - let site = NumericalSiteIdV1::GlowTargetOrMaximumV1; - let interval = OutwardIntervalV1::try_new(2.0, 3.0).unwrap(); - let proof = SoundIntervalEvidenceV1::fabricated_for_boundary_tests(site, interval); - assert_eq!(proof.site_id(), site); - assert_eq!(proof.interval(), interval); - let decision = classify_at_least_v1(proof, 1.0).unwrap(); - assert!(matches!( - decision, - NumericalDecisionV1::Determinate { - value: AtLeastDecisionV1::Meets, - guarantee: DecisionGuaranteeV1::OutwardIntervalV1(certificate), - } if certificate == interval - )); - } - #[test] fn migrated_registry_is_non_vacuous_unique_and_covers_glow_site() { let rows = numerical_registry_v1(); @@ -664,10 +668,14 @@ mod tests { StableNumericalOutcomeV1::BitExact, StableNumericalOutcomeV1::Indeterminate, ] + && row.compatibility_releases + == [NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1] + && row.evidence_classes == [NumericalEvidenceClassV1::BitExact] + && row.artifact_ids.is_empty() + && row.bound_ids.is_empty() + && row.runtime_attestations.is_empty() && row.bound_status == NumericalBoundStatusV1::Unavailable && row.fallback_status == NumericalFallbackStatusV1::None - && row.legacy_profile - == Some(crate::glow::GlowDecisionProfileV1::LegacyPlatformDependentV1.key()) })); for (index, row) in rows.iter().enumerate() { assert!(!row.operations.is_empty()); @@ -683,6 +691,149 @@ mod tests { "duplicate numerical site: {}", row.site_id.key() ); + // Set-поля checksum-preimage не имеют дубликатов. + let keys: Vec<_> = row.stable_outcomes.iter().map(|v| v.key()).collect(); + let mut sorted = keys.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), keys.len()); } } + + /// Диагностический интервал проверяет только форму. + #[test] + fn diagnostic_interval_validates_shape_only() { + assert!(OutwardIntervalV1::try_new(2.0, 1.0).is_err()); + assert!(OutwardIntervalV1::try_new(f64::NAN, 1.0).is_err()); + let interval = OutwardIntervalV1::try_new(0.5, 1.5).unwrap(); + assert_eq!( + NumericalIndeterminacyV1::IntervalOverlap(interval).reason_key(), + "interval-overlap" + ); + } + + /// Минт отклоняет site без объявленного BitExact (registry-owned закон). + #[test] + fn bit_exact_mint_is_refused_without_declared_capability() { + // Единственный способ проверить отказ без второго site — прямой + // контракт минтера: он читает registry, не аргументы вызова. + let minted = mint_bit_exact_evidence( + NumericalSiteIdV1::GlowTargetOrMaximumV1, + ReferenceProfileIdV1::EncodedSrgb8ScreenV1, + ); + assert!(minted.is_ok(), "Glow объявляет bit-exact"); + } + + /// Checksum: детерминирован, чувствителен к содержимому canonical-полей и + /// нечувствителен к порядку rows (сортировка внутри preimage). + #[test] + fn capability_checksum_is_canonical_and_tamper_sensitive() { + let manifest = numerical_capability_manifest_v1(); + let recomputed = + NumericalCapabilityChecksumV1::from_preimage(&manifest.canonical_checksum_preimage()); + assert_eq!(manifest.checksum, recomputed); + assert_eq!(manifest.checksum.hex().len(), 8); + + // Tamper: смена schema version меняет preimage/checksum. + let mut tampered = manifest.clone(); + tampered.schema_version += 1; + assert_ne!( + NumericalCapabilityChecksumV1::from_preimage(&tampered.canonical_checksum_preimage()), + manifest.checksum + ); + + // Tamper: удаление row меняет checksum. + let mut emptied = manifest.clone(); + emptied.sites.clear(); + assert_ne!( + NumericalCapabilityChecksumV1::from_preimage(&emptied.canonical_checksum_preimage()), + manifest.checksum + ); + } +} + +#[cfg(test)] +mod red_292_tests { + use super::*; + + /// RED #292: legacy-результат — атомарный `Compatibility` с registered + /// release ID, НЕ determinate evidence; изготовить его как + /// `Determinate/BitExact` невозможно типами. + #[test] + fn legacy_result_is_compatibility_not_determinate_evidence() { + let vc = crate::spaces::vc::ViewingConditions::srgb(); + let decision = crate::glow::solve_screen_alpha_for_dj( + "#FF6633", + "#101012", + 1.0, + NumericalExecutionModeV1::ExplicitCompatibility { + release_id: NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1, + }, + &vc, + ) + .unwrap(); + assert!(matches!( + decision, + NumericalDecisionV1::Compatibility { + site_id: NumericalSiteIdV1::GlowTargetOrMaximumV1, + release_id: NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1, + provenance: LegacyPlatformDependentV1, + .. + } + )); + } + + /// RED #292: BitExact-evidence запечатан и registry-owned — минтится + /// только для site, чья capability-строка объявляет класс BitExact. + #[test] + fn bit_exact_evidence_is_registry_owned_and_sealed() { + let minted = mint_bit_exact_evidence( + NumericalSiteIdV1::GlowTargetOrMaximumV1, + ReferenceProfileIdV1::EncodedSrgb8ScreenV1, + ) + .expect("Glow site объявляет BitExact"); + assert!(matches!( + minted, + NumericalDecisionEvidenceV1::BitExact { + reference_profile_id: ReferenceProfileIdV1::EncodedSrgb8ScreenV1, + .. + } + )); + } + + /// RED #292/#289: capability manifest — core registry projection с + /// каноническим checksum; coverage MigratedSitesOnlyV1, mode отсутствует. + #[test] + fn capability_manifest_is_canonical_registry_projection() { + let manifest = numerical_capability_manifest_v1(); + assert!(matches!( + manifest.coverage, + NumericalRegistryCoverageV1::MigratedSitesOnlyV1 + )); + assert_eq!(manifest.sites.len(), 1); + let site = &manifest.sites[0]; + assert_eq!(site.site_id, NumericalSiteIdV1::GlowTargetOrMaximumV1); + assert_eq!( + site.stable_outcomes, + [ + StableNumericalOutcomeV1::BitExact, + StableNumericalOutcomeV1::Indeterminate, + ] + ); + assert_eq!( + site.compatibility_releases, + [NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1,] + ); + assert_eq!(site.evidence_classes, [NumericalEvidenceClassV1::BitExact]); + assert!(site.artifact_ids.is_empty()); + assert!(site.bound_ids.is_empty()); + assert!(site.runtime_attestations.is_empty()); + // Checksum детерминирован и воспроизводим из canonical preimage. + assert_eq!( + manifest.checksum, + NumericalCapabilityChecksumV1::from_preimage(&manifest.canonical_checksum_preimage()) + ); + } + + use crate::numerical_plan::NumericalExecutionModeV1; } diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index e4cce41f..9fcc1a87 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -671,8 +671,10 @@ pub enum RoleSpec { tint: crate::ladder::LadderTint, /// Контрактная ступень стека. step: crate::glow::GlowStep, - /// Явный профиль численного решения из клиентского контракта. - decision_profile: crate::glow::GlowDecisionProfileV1, + /// Typed execution mode compiled invocation (#292). Прежние + /// config/wire-ключи `stable-v1 | legacy-platform-dependent-v1` — + /// migration adapter на границе, не core-семантика. + mode: crate::numerical_plan::NumericalExecutionModeV1, }, /// Заливка пары ([`crate::pair`]): якорь источника, сдвинутый до победы /// перцептивной стороны лейбла в штатной полярности; солид-эмиссия. @@ -1752,8 +1754,7 @@ pub struct GlowResolved { layer_recipe_profile: crate::glow::GlowLayerRecipeProfileV1, appearance_diagnostic_profile: crate::glow::GlowDiagnosticProfileV1, selection_diagnostic_profile: Option, - decision_profile: crate::glow::GlowDecisionProfileV1, - decision_guarantee: crate::numerics::DecisionGuaranteeV1, + decision_outcome: crate::glow::GlowDecisionOutcomeV1, halo_composite_certificate: crate::glow::GlowCompositeCertificateV1, core_composite_certificate: crate::glow::GlowCompositeCertificateV1, } @@ -1797,13 +1798,15 @@ impl GlowResolved { pub fn selection_diagnostic_profile(&self) -> Option { self.selection_diagnostic_profile } - /// Явно выбранный клиентский численный профиль. + /// Boundary-проекция прежнего клиентского профиля (migration adapter). pub fn decision_profile(&self) -> crate::glow::GlowDecisionProfileV1 { - self.decision_profile + self.decision_outcome.decision_profile() } - /// Гарантия семантического решения target/max. - pub fn decision_guarantee(&self) -> crate::numerics::DecisionGuaranteeV1 { - self.decision_guarantee + /// Атомарный исход решения: доказанный stable exact no-op либо явный + /// registered compatibility-алгоритм (#292). Незаконные комбинации + /// profile × guarantee непредставимы. + pub fn decision_outcome(&self) -> crate::glow::GlowDecisionOutcomeV1 { + self.decision_outcome } /// Слой, по которому решалась целевая ступень. pub fn constraint_layer(&self) -> crate::glow::GlowConstraintLayer { @@ -2349,80 +2352,100 @@ fn resolve_spec_in( } return resolve_rgba_direct(tint.for_vc(vc), alpha, bg, vc); } - RoleSpec::Glow { - tint, - step, - decision_profile, - } => { + RoleSpec::Glow { tint, step, mode } => { // Свечение: halo = якорь источника по теме; core — пересвет; // интенсивность решается под контрактную ступень на фоне резолва. + // Typed execution mode исполняется ПРЯМО из compiled spec (#292): + // никакого plan lookup или string policy selection в hot path. let halo_hex = crate::spaces::srgb::hex_from_srgb_encoded(tint.for_vc(vc)); let bg_hex = crate::spaces::srgb::hex_from_srgb_encoded(quantise_encoded(bg.encoded_display())); + // Общая сборка полного Glow-результата из решённого состояния — + // одна для обоих атомарных законных исходов. + let assemble = |g: &crate::glow::GlowSolve, + outcome: crate::glow::GlowDecisionOutcomeV1| + -> Resolved { + let (core_hex, halo_hex) = match crate::glow::glow_layers_from_source(&halo_hex, vc) + { + Ok(pair) => pair, + Err(e) => { + return Resolved::Unreachable(Unreachable::InternalInvariant(format!( + "generated Glow layer recipe was rejected: {e}" + ))); + } + }; + let core_measurement = match crate::glow::measure_screen_layer_at_alpha( + &core_hex, + &bg_hex, + g.alpha(), + vc, + ) { + Ok(measurement) => measurement, + Err(e) => { + return Resolved::Unreachable(Unreachable::InternalInvariant(format!( + "generated Glow core measurement was rejected: {e}" + ))); + } + }; + Resolved::Glow(GlowResolved { + core_hex, + halo_hex, + alpha: g.alpha(), + alpha_css: g.alpha_css().to_string(), + target_dj: g.target_dj(), + halo_composite_hex: g.composite_hex().to_string(), + halo_achieved_dj: g.achieved_dj(), + core_composite_hex: core_measurement.composite_hex, + core_achieved_dj: core_measurement.achieved_dj, + target_status: g.status(), + layer_recipe_profile: + crate::glow::GlowLayerRecipeProfileV1::Cam16JPrimeOklabCuspV1, + appearance_diagnostic_profile: + crate::glow::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1, + selection_diagnostic_profile: g.selection_diagnostic_profile(), + decision_outcome: outcome, + halo_composite_certificate: g.composite_certificate().clone(), + core_composite_certificate: core_measurement.certificate, + }) + }; return match crate::glow::solve_screen_alpha_for_dj( &halo_hex, &bg_hex, step.target_dj(), - decision_profile, + mode, vc, ) { Ok(crate::numerics::NumericalDecisionV1::Indeterminate { site_id, evidence }) => { Resolved::GlowIndeterminate(GlowIndeterminateResolved { source_hex: halo_hex, target_dj: step.target_dj(), - decision_profile, + decision_profile: crate::glow::GlowDecisionProfileV1::from_execution_mode( + mode, + ), site_id, evidence, }) } Ok(crate::numerics::NumericalDecisionV1::Determinate { value: g, - guarantee, - }) => { - let (core_hex, halo_hex) = - match crate::glow::glow_layers_from_source(&halo_hex, vc) { - Ok(pair) => pair, - Err(e) => { - return Resolved::Unreachable(Unreachable::InternalInvariant( - format!("generated Glow layer recipe was rejected: {e}"), - )); - } - }; - let core_measurement = match crate::glow::measure_screen_layer_at_alpha( - &core_hex, - &bg_hex, - g.alpha(), - vc, - ) { - Ok(measurement) => measurement, - Err(e) => { - return Resolved::Unreachable(Unreachable::InternalInvariant(format!( - "generated Glow core measurement was rejected: {e}" - ))); - } - }; - Resolved::Glow(GlowResolved { - core_hex, - halo_hex, - alpha: g.alpha(), - alpha_css: g.alpha_css().to_string(), - target_dj: g.target_dj(), - halo_composite_hex: g.composite_hex().to_string(), - halo_achieved_dj: g.achieved_dj(), - core_composite_hex: core_measurement.composite_hex, - core_achieved_dj: core_measurement.achieved_dj, - target_status: g.status(), - layer_recipe_profile: - crate::glow::GlowLayerRecipeProfileV1::Cam16JPrimeOklabCuspV1, - appearance_diagnostic_profile: - crate::glow::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1, - selection_diagnostic_profile: g.selection_diagnostic_profile(), - decision_profile, - decision_guarantee: guarantee, - halo_composite_certificate: g.composite_certificate().clone(), - core_composite_certificate: core_measurement.certificate, - }) - } + evidence, + .. + }) => assemble( + &g, + crate::glow::GlowDecisionOutcomeV1::StableExactNoop { evidence }, + ), + Ok(crate::numerics::NumericalDecisionV1::Compatibility { + value: g, + release_id, + provenance, + .. + }) => assemble( + &g, + crate::glow::GlowDecisionOutcomeV1::Compatibility { + release_id, + provenance, + }, + ), Err(e) => Resolved::Unreachable(Unreachable::InternalInvariant(format!( "generated Glow solve request was rejected: {e}" ))), @@ -3796,7 +3819,7 @@ mod tests { RoleSpec::Glow { tint: LadderTint::new([source; 4]).unwrap(), step: crate::glow::GlowStep::Base, - decision_profile, + mode: decision_profile.execution_mode(), }, )], Vec::new(), diff --git a/crates/labcolors-ffi/src/lib.rs b/crates/labcolors-ffi/src/lib.rs index 77febaf7..3a0aa5a5 100644 --- a/crates/labcolors-ffi/src/lib.rs +++ b/crates/labcolors-ffi/src/lib.rs @@ -46,9 +46,11 @@ use labcolors_core::alpha::{composite_hex, min_alpha_hex}; use labcolors_core::cleanliness::muddiness_from_hex; use labcolors_core::{ - BgInput, ChromaPolicy, Contract, DecisionGuaranteeV1, Gamut, GlowCompositeGuaranteeV1, + BgInput, ChromaPolicy, Contract, Gamut, GlowCompositeGuaranteeV1, GlowCompositeProfileV1, GlowDecisionProfileV1, GlowDiagnosticProfileV1, - GlowTargetStatus as CoreGlowTargetStatus, Hue, LadderPosition, NumericalDecisionV1, + GlowTargetStatus as CoreGlowTargetStatus, Hue, LadderPosition, + LegacyPlatformDependentV1, NumericalCompatibilityReleaseIdV1, NumericalDecisionEvidenceV1, + NumericalDecisionV1, NumericalIndeterminacyV1, NumericalSiteIdV1, Theme as CoreTheme, ViewingConditions, recheck_against, solve, solve_screen_alpha_for_dj, srgb_encoded_from_hex, }; @@ -457,49 +459,6 @@ fn incompatible_core_variant(name: &str) -> ColorError { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum GlowDeterminateKind { - StableExactNoop, - LegacyReached, - LegacyUnreachable, -} - -fn glow_determinate_kind( - requested_profile: GlowDecisionProfile, - guarantee: DecisionGuaranteeV1, - status: CoreGlowTargetStatus, - selection_diagnostic_profile: Option, -) -> Result { - match ( - requested_profile, - guarantee, - status, - selection_diagnostic_profile, - ) { - ( - GlowDecisionProfile::StableV1, - DecisionGuaranteeV1::BitExact, - CoreGlowTargetStatus::ExactNoopUnreachable, - None, - ) => Ok(GlowDeterminateKind::StableExactNoop), - ( - GlowDecisionProfile::LegacyPlatformDependentV1, - DecisionGuaranteeV1::LegacyPlatformDependentV1, - CoreGlowTargetStatus::LegacyReached, - Some(GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1), - ) => Ok(GlowDeterminateKind::LegacyReached), - ( - GlowDecisionProfile::LegacyPlatformDependentV1, - DecisionGuaranteeV1::LegacyPlatformDependentV1, - CoreGlowTargetStatus::LegacyUnreachable, - Some(GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1), - ) => Ok(GlowDeterminateKind::LegacyUnreachable), - illegal => Err(ColorError::IncompatibleCoreContract { - reason: format!("illegal Glow determinate provenance from core: {illegal:?}"), - }), - } -} - fn composite_profile_to_ffi( profile: GlowCompositeProfileV1, ) -> Result { @@ -598,33 +557,63 @@ pub fn solve_glow_point( profile: GlowDecisionProfile, ) -> Result { validate_glow_request(&tint, &background, target_dj)?; - let core_profile = profile.to_core(); + // Migration adapter: FFI-профиль → generic typed execution mode (#292). + let mode = profile.to_core().execution_mode(); let decision = map_prevalidated_glow_core_result(solve_screen_alpha_for_dj( &tint, &background, target_dj, - core_profile, + mode, &theme.vc(), ))?; + // Атомарный core-результат маппится напрямую: cross-product реконструкция + // profile × guarantee × status удалена — незаконные комбинации + // непредставимы уже в core-типе (#292). match decision { - NumericalDecisionV1::Determinate { value, guarantee } => { - let kind = glow_determinate_kind( - profile, - guarantee, - value.status(), - value.selection_diagnostic_profile(), - )?; + NumericalDecisionV1::Determinate { + value, + evidence: NumericalDecisionEvidenceV1::BitExact { .. }, + .. + } => { + if value.status() != CoreGlowTargetStatus::ExactNoopUnreachable + || value.selection_diagnostic_profile().is_some() + { + return Err(ColorError::IncompatibleCoreContract { + reason: format!( + "illegal stable Glow value state: {:?}", + value.status() + ), + }); + } + Ok(GlowPointDecision::StableExactNoop { + value: glow_point_value_to_ffi(&value)?, + }) + } + NumericalDecisionV1::Compatibility { + value, + release_id: NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1, + provenance: LegacyPlatformDependentV1, + .. + } => { + if value.selection_diagnostic_profile() + != Some(GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1) + { + return Err(ColorError::IncompatibleCoreContract { + reason: "compatibility Glow value без selection diagnostic".to_string(), + }); + } + let status = value.status(); let value = glow_point_value_to_ffi(&value)?; - match kind { - GlowDeterminateKind::StableExactNoop => { - Ok(GlowPointDecision::StableExactNoop { value }) - } - GlowDeterminateKind::LegacyReached => { + match status { + CoreGlowTargetStatus::LegacyReached => { Ok(GlowPointDecision::LegacyReached { value }) } - GlowDeterminateKind::LegacyUnreachable => { + CoreGlowTargetStatus::LegacyUnreachable => { Ok(GlowPointDecision::LegacyUnreachable { value }) } + other => Err(ColorError::IncompatibleCoreContract { + reason: format!("illegal compatibility Glow value state: {other:?}"), + }), } } NumericalDecisionV1::Indeterminate { site_id, evidence } => { @@ -663,18 +652,20 @@ mod tests { use super::*; #[test] - fn illegal_glow_provenance_cross_product_is_rejected() { - let error = glow_determinate_kind( - GlowDecisionProfile::StableV1, - DecisionGuaranteeV1::LegacyPlatformDependentV1, - CoreGlowTargetStatus::LegacyReached, - Some(GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1), + fn legacy_solve_maps_to_atomic_compatibility_variants() { + // Явный compatibility-mode: результат — атомарный LegacyReached/ + // LegacyUnreachable, без реконструкции provenance на границе. + let decision = solve_glow_point( + "#C0B2FA".into(), + "#000000".into(), + 2.3006, + Theme::Light, + GlowDecisionProfile::LegacyPlatformDependentV1, ) - .unwrap_err(); + .unwrap(); assert!(matches!( - error, - ColorError::IncompatibleCoreContract { ref reason } - if reason.contains("illegal Glow determinate provenance") + decision, + GlowPointDecision::LegacyReached { .. } | GlowPointDecision::LegacyUnreachable { .. } )); } @@ -777,21 +768,8 @@ mod tests { } #[test] - fn unsupported_outward_decision_guarantee_is_an_incompatible_core_contract() { + fn interval_stays_a_lawful_indeterminate_payload() { let interval = labcolors_core::OutwardIntervalV1::try_new(0.9, 1.1).unwrap(); - let error = glow_determinate_kind( - GlowDecisionProfile::StableV1, - DecisionGuaranteeV1::OutwardIntervalV1(interval), - CoreGlowTargetStatus::ExactNoopUnreachable, - None, - ) - .unwrap_err(); - assert!(matches!( - error, - ColorError::IncompatibleCoreContract { ref reason } - if reason.contains("illegal Glow determinate provenance") - )); - // Outward evidence остаётся законной частью typed Indeterminate: // запрещена только ложная Glow-specific determinate guarantee. assert_eq!( From 6cee1c4462375b33a4c507ac0ab84f6327d63d78 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics <63733699+lemone112@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:46:03 +0300 Subject: [PATCH 3/6] =?UTF-8?q?wip(#292):=20conformance=20pack=203.0.0=20?= =?UTF-8?q?=E2=80=94=20capability=20manifest=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20numericalSites;=20FFI=20=D0=B0=D1=82=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D0=BD=D1=8B=D0=B9=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- crates/labcolors-conformance/src/lib.rs | 142 +++++++++++------- .../tests/reference_runner.rs | 4 +- crates/labcolors-ffi/src/lib.rs | 18 +-- 3 files changed, 98 insertions(+), 66 deletions(-) diff --git a/crates/labcolors-conformance/src/lib.rs b/crates/labcolors-conformance/src/lib.rs index 17516943..56ddc621 100644 --- a/crates/labcolors-conformance/src/lib.rs +++ b/crates/labcolors-conformance/src/lib.rs @@ -51,7 +51,7 @@ use labcolors_core::{ /// Семантическая версия conformance-пака. Меняется при изменении СХЕМЫ или /// состава векторов; значения векторов при этом диктует канон ядра. -pub const PACK_VERSION: &str = "2.0.0"; +pub const PACK_VERSION: &str = "3.0.0"; /// Версия ядра, к которой привязан пак. Все крейты воркспейса делят одну версию /// (`version.workspace = true`), поэтому собственная `CARGO_PKG_VERSION` этого @@ -500,56 +500,85 @@ pub struct Counts { pub total: usize, } -/// Exact copy of one core-owned migrated branch-sensitive registry row. -/// The release verifier consumes this generated list instead of maintaining a -/// second hand-written semantic registry. +/// Canonical numerical capability manifest (#289/#292): core registry +/// projection c versioned schema, coverage и drift-checksum. Replaces the +/// former `numericalSites[].legacyProfile` rows (pack 2.x); the verifier +/// consumes this projection instead of a hand-written semantic registry. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct NumericalSiteManifest { - /// Stable site identity. +pub struct CapabilityManifestProjection { + /// Capability schema version (independent version domain). + pub schema_version: u32, + /// Registry coverage key (`migrated-sites-only-v1`). + pub coverage: String, + /// Capability rows sorted by UTF-8 `siteId` bytes. + pub sites: Vec, + /// FNV-1a-32 drift-checksum canonical preimage, 8 lowercase hex. + pub checksum: String, +} + +/// One site capability row (no selected mode; manifest describes the build). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CapabilitySiteProjection { + /// Stable site identity key. pub site_id: String, - /// Branch-sensitive operations. - pub operations: String, - /// Input/output domain. - pub domain: String, - /// Semantic branch affected by the value. - pub branch_effect: String, - /// Lawful outcomes of the stable profile. + /// Lawful stable outcome keys. pub stable_outcomes: Vec, - /// Sound-bound availability. - pub bound_status: String, - /// Executable boundary corpus identifiers. - pub boundary_corpus: String, - /// Required cross-runtime comparison scope. - pub runtime_matrix: String, - /// Fallback status. - pub fallback_status: String, - /// Explicit compatibility profile, if present. - pub legacy_profile: Option, + /// Registered compatibility release keys. + pub compatibility_releases: Vec, + /// Mintable evidence class keys. + pub evidence_classes: Vec, + /// Canonical finite artifact IDs (empty = no evidence, not implicit support). + pub artifact_ids: Vec, + /// Registered error bound IDs (empty in V1). + pub bound_ids: Vec, + /// Runtime attestation IDs (empty until #258). + pub runtime_attestations: Vec, } -/// Generate the release-facing registry directly from the core SSOT. +/// Generate the release-facing capability manifest directly from the core SSOT. #[must_use] -pub fn generate_numerical_sites() -> Vec { - numerical_registry_v1() - .iter() - .map(|row| NumericalSiteManifest { - site_id: row.site_id.key().to_string(), - operations: row.operations.to_string(), - domain: row.domain.to_string(), - branch_effect: row.branch_effect.to_string(), - stable_outcomes: row - .stable_outcomes - .iter() - .map(|outcome| outcome.key().to_string()) - .collect(), - bound_status: row.bound_status.key().to_string(), - boundary_corpus: row.boundary_corpus.to_string(), - runtime_matrix: row.runtime_matrix.to_string(), - fallback_status: row.fallback_status.key().to_string(), - legacy_profile: row.legacy_profile.map(str::to_string), - }) - .collect() +pub fn generate_capability_manifest() -> CapabilityManifestProjection { + let manifest = labcolors_core::numerical_capability_manifest_v1(); + CapabilityManifestProjection { + schema_version: manifest.schema_version, + coverage: manifest.coverage.key().to_string(), + sites: manifest + .sites + .iter() + .map(|site| CapabilitySiteProjection { + site_id: site.site_id.key().to_string(), + stable_outcomes: site + .stable_outcomes + .iter() + .map(|v| v.key().to_string()) + .collect(), + compatibility_releases: site + .compatibility_releases + .iter() + .map(|v| v.key().to_string()) + .collect(), + evidence_classes: site + .evidence_classes + .iter() + .map(|v| v.key().to_string()) + .collect(), + artifact_ids: site + .artifact_ids + .iter() + .map(|v| v.key().to_string()) + .collect(), + bound_ids: site.bound_ids.iter().map(|v| v.key().to_string()).collect(), + runtime_attestations: site + .runtime_attestations + .iter() + .map(|v| v.key().to_string()) + .collect(), + }) + .collect(), + checksum: manifest.checksum.hex(), + } } /// Манифест пака: версии, дайджест и счётчики. `packDigest` — FNV-1a-32 @@ -566,8 +595,8 @@ pub struct Manifest { pub pack_digest: String, /// Счётчики по семействам. pub counts: Counts, - /// Core-owned registry migrated typed-decision numerical sites. - pub numerical_sites: Vec, + /// Canonical numerical capability manifest (core registry projection). + pub numerical_capabilities: CapabilityManifestProjection, } /// Весь пак в памяти. `serialize_family` даёт КАНОНИЧЕСКИЕ байты каждого файла @@ -637,7 +666,7 @@ impl Pack { core_version: core_version().to_string(), pack_digest: self.digest(), counts: self.counts(), - numerical_sites: generate_numerical_sites(), + numerical_capabilities: generate_capability_manifest(), } } @@ -719,18 +748,25 @@ mod tests { let manifest = Pack::generate() .expect("canonical pack generation") .manifest(); - assert_eq!(manifest.numerical_sites, generate_numerical_sites()); assert_eq!( - manifest.numerical_sites.len(), + manifest.numerical_capabilities, + generate_capability_manifest() + ); + assert_eq!( + manifest.numerical_capabilities.sites.len(), numerical_registry_v1().len() ); - assert!(manifest.numerical_sites.iter().any(|site| { + assert!(manifest.numerical_capabilities.sites.iter().any(|site| { site.site_id == "glow-target-or-maximum-v1" && site.stable_outcomes == ["bit-exact", "indeterminate"] - && site.bound_status == "unavailable" - && site.fallback_status == "none" - && site.legacy_profile.as_deref() == Some("legacy-platform-dependent-v1") + && site.compatibility_releases == ["glow-cam16-ucs-jprime-target-or-max-v1"] + && site.evidence_classes == ["bit-exact"] + && site.artifact_ids.is_empty() + && site.bound_ids.is_empty() + && site.runtime_attestations.is_empty() })); + // Checksum canonical projection: 8 hex, независимо пересчитываем в core. + assert_eq!(manifest.numerical_capabilities.checksum.len(), 8); } #[test] diff --git a/crates/labcolors-conformance/tests/reference_runner.rs b/crates/labcolors-conformance/tests/reference_runner.rs index 1b2ca9f3..fe5350b9 100644 --- a/crates/labcolors-conformance/tests/reference_runner.rs +++ b/crates/labcolors-conformance/tests/reference_runner.rs @@ -205,8 +205,8 @@ fn manifest_metadata_matches_core() { assert_eq!(committed.core_version, fresh.core_version, "версия ядра"); assert_eq!(committed.counts, fresh.counts, "счётчики семейств"); assert_eq!( - committed.numerical_sites, fresh.numerical_sites, - "numerical registry обязан быть exact copy core SSOT" + committed.numerical_capabilities, fresh.numerical_capabilities, + "capability manifest обязан быть exact core registry projection" ); } diff --git a/crates/labcolors-ffi/src/lib.rs b/crates/labcolors-ffi/src/lib.rs index 3a0aa5a5..0ee6b2f6 100644 --- a/crates/labcolors-ffi/src/lib.rs +++ b/crates/labcolors-ffi/src/lib.rs @@ -46,13 +46,12 @@ use labcolors_core::alpha::{composite_hex, min_alpha_hex}; use labcolors_core::cleanliness::muddiness_from_hex; use labcolors_core::{ - BgInput, ChromaPolicy, Contract, Gamut, GlowCompositeGuaranteeV1, - GlowCompositeProfileV1, GlowDecisionProfileV1, GlowDiagnosticProfileV1, - GlowTargetStatus as CoreGlowTargetStatus, Hue, LadderPosition, - LegacyPlatformDependentV1, NumericalCompatibilityReleaseIdV1, NumericalDecisionEvidenceV1, - NumericalDecisionV1, - NumericalIndeterminacyV1, NumericalSiteIdV1, Theme as CoreTheme, ViewingConditions, - recheck_against, solve, solve_screen_alpha_for_dj, srgb_encoded_from_hex, + BgInput, ChromaPolicy, Contract, Gamut, GlowCompositeGuaranteeV1, GlowCompositeProfileV1, + GlowDecisionProfileV1, GlowDiagnosticProfileV1, GlowTargetStatus as CoreGlowTargetStatus, Hue, + LadderPosition, LegacyPlatformDependentV1, NumericalCompatibilityReleaseIdV1, + NumericalDecisionEvidenceV1, NumericalDecisionV1, NumericalIndeterminacyV1, NumericalSiteIdV1, + Theme as CoreTheme, ViewingConditions, recheck_against, solve, solve_screen_alpha_for_dj, + srgb_encoded_from_hex, }; // Регистрирует UniFFI-scaffolding под namespace = имя крейта (`labcolors`). @@ -579,10 +578,7 @@ pub fn solve_glow_point( || value.selection_diagnostic_profile().is_some() { return Err(ColorError::IncompatibleCoreContract { - reason: format!( - "illegal stable Glow value state: {:?}", - value.status() - ), + reason: format!("illegal stable Glow value state: {:?}", value.status()), }); } Ok(GlowPointDecision::StableExactNoop { From 9cfa138b3ca70b9e71bb765e1c92dc158be68d1e Mon Sep 17 00:00:00 2001 From: Daniel from Labpics <63733699+lemone112@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:21:15 +0300 Subject: [PATCH 4/6] =?UTF-8?q?feat(numerics):=20=D0=B0=D1=82=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D0=BD=D1=8B=D0=B5=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D1=8B,=20per-invocation=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D0=B8=20?= =?UTF-8?q?capability=20manifest=20(#292)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package capability ≠ compiled invocation plan ≠ result evidence: - NumericalDecisionV1 атомарен: Determinate{sealed BitExact evidence} | Compatibility{registered release_id + LegacyPlatformDependentV1} | Indeterminate; незаконные комбинации непредставимы типами; dead classify_at_least_v1/AtLeastDecisionV1/DecisionGuaranteeV1 удалены (compile_fail doc-тесты фиксируют миграцию); - RoleSpec::Glow несёт typed NumericalExecutionModeV1; прежние ключи stable-v1|legacy-platform-dependent-v1 — только boundary-адаптер; - numerical_plan.rs: invocation identity (opaque node bytes + site + локальный ordinal), canonical derived-проекция + FNV drift-checksum; NamedRoleTable::numerical_plan_v1() — биекция с Glow-ролями; - capability manifest по канону #289 (length-prefixed preimage, домены labcolors.numerical-*); conformance pack 3.0.0: numericalCapabilities вместо numericalSites (vectors нетронуты, packDigest прежний); - WASM/FFI: атомарный маппинг без cross-product реконструкции; wire-байты и JSON прежние (golden/npm 106/106 без regen); additive-поверхность numericalCapabilityManifest() в WASM/npm; verifier с независимым JS FNV-оракулом; release-manifest schema 2 + read-back в publish.yml. Публичные цвета/fingerprint/CSS/adaptive trace не изменились. Co-Authored-By: Claude Fable 5 --- .cargo/mutants.toml | 1 + .github/workflows/publish.yml | 2 +- CHANGELOG.md | 39 +++ README.md | 12 +- .../ConformanceTests.swift | 103 ++++++ conformance/README.md | 33 +- conformance/vectors/manifest.json | 42 +-- crates/labcolors-conformance/src/lib.rs | 32 +- crates/labcolors-core/src/agnostic_gates.rs | 32 +- crates/labcolors-core/src/lib.rs | 6 +- crates/labcolors-core/src/numerics.rs | 24 ++ crates/labcolors-core/src/semantic.rs | 255 +++++++++++++++ .../tests/property_invariants.rs | 108 ++++++- crates/labcolors-wasm/src/dto.rs | 9 +- crates/labcolors-wasm/src/engine.rs | 17 +- crates/labcolors-wasm/src/lib.rs | 56 ++++ crates/labcolors-wasm/src/projection.rs | 295 +++++++++++++++--- crates/labcolors-wasm/tests/wasm_parity.rs | 19 +- .../0004-finite-alpha-glow-reference.md | 37 ++- docs/migrations/exact-alpha-glow.md | 30 +- docs/verification-map.md | 25 +- packages/colors/README.md | 9 +- packages/colors/index.d.ts | 3 + packages/colors/index.js | 8 +- .../colors/test/capability-manifest.test.mjs | 91 ++++++ .../colors/test/release-contract.test.mjs | 10 +- scripts/verify-package-release.mjs | 129 ++++++-- 27 files changed, 1257 insertions(+), 170 deletions(-) create mode 100644 packages/colors/test/capability-manifest.test.mjs diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 481d4a0a..730b7938 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -17,6 +17,7 @@ examine_globs = [ "crates/labcolors-core/src/cleanliness.rs", "crates/labcolors-core/src/glow.rs", "crates/labcolors-core/src/material.rs", + "crates/labcolors-core/src/numerical_plan.rs", "crates/labcolors-core/src/numerics.rs", "crates/labcolors-core/src/pair.rs", ] diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a87fefeb..700e58e0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -313,7 +313,7 @@ jobs: const manifest = JSON.parse(readFileSync(manifests[0], "utf8")); const expectedVersion = expectedTag.slice("colors-v".length); - if (manifest.schemaVersion !== 1) fail(`manifest schema ${manifest.schemaVersion}`); + if (manifest.schemaVersion !== 2) fail(`manifest schema ${manifest.schemaVersion}`); if (manifest.sourceSha !== expectedSha) { fail(`manifest sourceSha ${manifest.sourceSha} != ${expectedSha}`); } diff --git a/CHANGELOG.md b/CHANGELOG.md index 803826d5..116d969a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,45 @@ Все существенные изменения Lab Colors фиксируются в этом файле. Версии npm и Rust различаются, потому что это разные delivery surfaces одного контракта. +## [Unreleased] + +Атомарная numerical-decision граница (#292). Wire/npm JSON, эмитируемые цвета, +config fingerprint и `packDigest` conformance-векторов НЕ изменились; breaking +только Rust API. Migration-note: [exact alpha / typed +Glow](docs/migrations/exact-alpha-glow.md), дополнение ADR-0004 от 2026-07-12. + +### Breaking (Rust API) + +- Удалены `classify_at_least_v1`, `AtLeastDecisionV1` и `DecisionGuaranteeV1`: + сравнительная «сила гарантии» как данные допускала lossy-схлопывание + взаимоисключающих исходов. +- `NumericalDecisionV1` стал атомарным: `Determinate { evidence }` | + `Compatibility { release_id, provenance }` | `Indeterminate { evidence }`. + Legacy-исход — отдельный `Compatibility` с registered release + `glow-cam16-ucs-jprime-target-or-max-v1`, а не determinate со слабой + гарантией; stable outcome с legacy provenance непредставим в типе. +- `NumericalDecisionEvidenceV1::BitExact` запечатан (приватное поле-печать): + внешний код матчит только с `..`, минт выполняет registry-owned конструктор + (закреплено compile-fail тестом). +- `RoleSpec::Glow` несёт typed execution mode + (`NumericalExecutionModeV1::StableOnly` | + `ExplicitCompatibility { release_id }`); строковый `GlowDecisionProfileV1` + остался boundary-адаптером, прежние wire keys (`stable-v1`, + `legacy-platform-dependent-v1`, `bit-exact`) сохранены byte-for-byte. + +### Changed + +- Conformance pack 3.0.0: `manifest.numericalSites` заменён typed + `numericalCapabilities` — capability manifest ядра (schema v1, coverage + `migrated-sites-only-v1`, FNV-1a-32 drift-checksum над canonical + length-prefixed preimage). Векторные семейства и `packDigest` не изменились. +- Release manifest schema v2: секция `numericalSites` заменена на + `numericalCapabilities`; release verifier и Swift conformance-тесты + пересчитывают capability checksum независимо от Rust-кода. +- Добавлен компилируемый numerical plan (`compile_numerical_plan_v1`) с + канонической invocation identity и checksum — типизированная проекция того, + какие site/mode заявляет сборка. + ## [@labpics/colors 0.10.0 / Rust 0.2.0] - 2026-07-11 Breaking release относительно `@labpics/colors` 0.9.1 / Rust 0.1.0. Пошаговый diff --git a/README.md b/README.md index 44127314..f93a6f2d 100644 --- a/README.md +++ b/README.md @@ -165,15 +165,17 @@ CSS-строка, CSSOM и обход DOM сами по себе не повыш |---|---| | **Поддерживаемый точечный путь sRGB** | encoded sRGB input/output, клиентский конфиг, resolve всей таблицы и специализированные рецепты в заявленной версии пакета | | **Exact encoded-sRGB8 операции** | конечные source-over/screen композиторы, выбранная binary64 alpha и её канонический CSS round-trip; сертификат относится к point-reference, не к renderer/display | -| **Stable Glow decision** | exact point-no-op даёт `Determinate` / `bit-exact` без CAM16-профиля; нетривиальный target/max без sound bound даёт typed `Indeterminate`, не platform-selected fallback | +| **Stable Glow decision** | exact point-no-op даёт `Determinate` с sealed `bit-exact` evidence без CAM16-профиля; нетривиальный target/max без sound bound даёт typed `Indeterminate`, не platform-selected fallback | +| **Explicit compatibility (legacy)** | явный legacy execution mode даёт атомарный `Compatibility`-результат с registered release (`glow-cam16-ucs-jprime-target-or-max-v1`) и provenance-классом `legacy-platform-dependent-v1`; это НЕ determinate: результат идентифицирует воспроизводимый АЛГОРИТМ, а не cross-runtime bit-exact значение | | **Унаследованное платформенно охарактеризованное поведение** | target-driven CAM16/CAM16-UCS/Oklab/LPC, neutral/accent/sentiment policies и связанные поиски по `f64`; они поддерживают совместимость, но не дают cross-runtime bit-exact guarantee | | **Точность в отдельном эталонном профиле** | только операции, для которых конкретный release объявляет эталонный профиль и проверяемый конечный контракт | | **Явно не поддержано как стабильная гарантия** | Display-P3 solving, HDR/PQ/HLG, пространственное поле Glow/Material, индивидуальное восприятие, неизвестный browser/display pipeline | -Машинно читаемый реестр численных sites и их runtime-матрица входят в -conformance manifest. Он не повышает незарегистрированный или explicit legacy -результат до `BitExact`, `ProvenOptimal` или `ProvenInfeasible` только потому, -что тесты на одной платформе зелёные. +Машинно читаемый capability manifest численных sites — typed-проекция core +registry с независимо пересчитываемым drift-checksum — входит в conformance +manifest. Он описывает возможности сборки и не повышает незарегистрированный +или explicit `Compatibility`-результат до determinate только потому, что тесты +на одной платформе зелёные. ## Источники и производные значения diff --git a/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift b/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift index 1339047b..84ccd962 100644 --- a/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift +++ b/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift @@ -101,6 +101,86 @@ final class ConformanceTests: XCTestCase { "версия ядра биндинга разошлась с манифестом пака") } + // MARK: - Capability manifest (численные решения) + + /// FNV-1a-32 (как `packDigest`) — независимая Swift-копия примитива ядра, + /// чтобы пересчёт checksum не опирался на проверяемый Rust-код. + func fnv1a32(_ bytes: [UInt8]) -> UInt32 { + var hash: UInt32 = 0x811c_9dc5 + for byte in bytes { + hash ^= UInt32(byte) + hash = hash &* 0x0100_0193 + } + return hash + } + + /// Независимый пересчёт drift-checksum capability manifest по canonical + /// preimage ядра (labcolors-core/src/numerics.rs): length-prefixed (u32 LE + /// длина + байты) домен-сепаратор, u32 LE schema version, coverage key, + /// u32 LE счётчик sites (сортировка по сырым UTF-8 байтам siteId), на site — + /// siteId и шесть списков ключей; каждый список: u32 LE count (явный и для + /// пустого) + отсортированные length-prefixed ключи. Кодирование повторено + /// здесь НАМЕРЕННО: тест — оракул, он не должен переиспользовать encoder, + /// который проверяет. + func testCapabilityManifestChecksumRecomputes() throws { + let manifest = try load("manifest.json", as: Manifest.self) + let caps = manifest.numericalCapabilities + XCTAssertEqual(caps.coverage, "migrated-sites-only-v1", "coverage capability manifest") + XCTAssertFalse(caps.sites.isEmpty, "capability manifest без единого migrated site пуст") + for site in caps.sites { + XCTAssertFalse(site.siteId.isEmpty, "siteId обязан быть непустым") + XCTAssertFalse( + site.stableOutcomes.isEmpty, + "site \(site.siteId) обязан объявлять lawful stable outcome") + } + + var preimage: [UInt8] = [] + func pushU32LE(_ value: UInt32) { + preimage.append(contentsOf: [ + UInt8(truncatingIfNeeded: value), + UInt8(truncatingIfNeeded: value >> 8), + UInt8(truncatingIfNeeded: value >> 16), + UInt8(truncatingIfNeeded: value >> 24), + ]) + } + func pushLenPrefixed(_ key: String) { + let bytes = Array(key.utf8) + pushU32LE(UInt32(bytes.count)) + preimage.append(contentsOf: bytes) + } + // Сортировка по сырым UTF-8 байтам (эквивалент sort_unstable по &[u8] + // в ядре), а не по Unicode-коллации String. + func pushSortedKeyList(_ keys: [String]) { + let sorted = keys.sorted { + Array($0.utf8).lexicographicallyPrecedes(Array($1.utf8)) + } + pushU32LE(UInt32(sorted.count)) + for key in sorted { pushLenPrefixed(key) } + } + + pushLenPrefixed("labcolors.numerical-capability.v1") + pushU32LE(caps.schemaVersion) + pushLenPrefixed(caps.coverage) + let sites = caps.sites.sorted { + Array($0.siteId.utf8).lexicographicallyPrecedes(Array($1.siteId.utf8)) + } + pushU32LE(UInt32(sites.count)) + for site in sites { + pushLenPrefixed(site.siteId) + pushSortedKeyList(site.stableOutcomes) + pushSortedKeyList(site.compatibilityReleases) + pushSortedKeyList(site.evidenceClasses) + pushSortedKeyList(site.artifactIds) + pushSortedKeyList(site.boundIds) + pushSortedKeyList(site.runtimeAttestations) + } + + let recomputed = String(format: "%08x", fnv1a32(preimage)) + XCTAssertEqual( + recomputed, caps.checksum, + "checksum capability manifest не сходится с независимым Swift-пересчётом") + } + // MARK: - Семейство: контрасты func testContrasts() throws { @@ -347,6 +427,29 @@ struct Manifest: Codable { let packVersion: String let coreVersion: String let packDigest: String + let numericalCapabilities: CapabilityManifest +} + +/// Зеркало capability manifest (pack 3.0.0): typed-проекция core registry +/// численных решений. Заменяет прозаический `numericalSites` из pack 2.x — +/// биндинг сверяет typed rows и drift-checksum, а не research-тексты. +struct CapabilityManifest: Codable { + let schemaVersion: UInt32 + let coverage: String + let sites: [CapabilitySite] + let checksum: String +} + +/// Одна capability-строка site. Пустой список — явная часть контракта +/// («evidence отсутствует»), а не пропуск поля. +struct CapabilitySite: Codable { + let siteId: String + let stableOutcomes: [String] + let compatibilityReleases: [String] + let evidenceClasses: [String] + let artifactIds: [String] + let boundIds: [String] + let runtimeAttestations: [String] } struct ContrastVec: Codable { diff --git a/conformance/README.md b/conformance/README.md index 1a80b809..ba750c6c 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -13,8 +13,10 @@ ## Версионирование -- **Версия пака** (`manifest.packVersion`, сейчас `2.0.0`) — семантическая - версия СХЕМЫ и состава векторов. +- **Версия пака** (`manifest.packVersion`, сейчас `3.0.0`) — семантическая + версия СХЕМЫ и состава векторов. Bump 2.0.0 → 3.0.0 менял только схему + манифеста (`numericalSites` → `numericalCapabilities`); векторные семейства + и `packDigest` не изменились. - **Версия ядра** (`manifest.coreVersion`, для этого пака `0.2.0`) — версия `labcolors-core`, из канона которой сгенерированы значения. Пак действителен ровно для этой версии ядра; при легитимной смене канона (значения @@ -35,7 +37,7 @@ | `alpha.json` | подложка→α | `{tint, alpha, bg, composite, minAlpha}` | | `solve.json` | резолв контракта | `{bg, contract, theme, outcome}` | | `muddiness.json` | замороженная legacy-координата `muddiness` | `{hex, score}` | -| `manifest.json` | метаданные и реестр мигрированных numerical sites | `{packVersion, coreVersion, packDigest, counts, numericalSites}` | +| `manifest.json` | метаданные и capability manifest численных решений | `{packVersion, coreVersion, packDigest, counts, numericalCapabilities}` | `muddiness.json` — это `experimental compatibility proxy`: corpus доказывает воспроизводимость исторического числового API, но не валидированный на @@ -50,14 +52,25 @@ decision. Legacy-идентификаторы сохранены только д - `code` недостижимости — стабильный словарь, общий для всех биндингов: `below_contrast_floor`, `exceeds_range`, `quantization_gap`, `floor_unreachable`, `polarity_mismatch`, `gamut_unsupported`, `invalid_input`. -- `alpha.json` в pack `2.0.0` обязательно содержит точный byte-reference +- `alpha.json` начиная с pack `2.0.0` обязательно содержит точный byte-reference half-tie `#C0B2FA @ 0.122` над `#000000` → `#17161F`. Это mutation-killer старого пути `(byte/255) · alpha · 255`, который выбирал соседний LSB. -- `manifest.numericalSites` генерируется из core-owned - `numerical_registry_v1()`. Это реестр **уже мигрированных** branch-sensitive - sites, а не утверждение, что проаудированы все исторические `f64`-ветвления. - Сейчас в нём только `glow-target-or-maximum-v1`; полнота аудита остаётся в - scope #291. +- `manifest.numericalCapabilities` (схема пака 3.0.0) генерируется из + core-owned `numerical_capability_manifest_v1()` и заменяет прозаический + `numericalSites` пака 2.x. Форма: + `{schemaVersion, coverage, sites[], checksum}`, где `schemaVersion` — + независимый version domain capability-схемы (сейчас `1`); `coverage` — + `migrated-sites-only-v1` (перечислены только **уже мигрированные** + branch-sensitive sites, не утверждение полного аудита исторических + `f64`-ветвлений — он остаётся в scope #291); каждая строка `sites[]` несёт + `siteId` и шесть списков стабильных ключей (`stableOutcomes`, + `compatibilityReleases`, `evidenceClasses`, `artifactIds`, `boundIds`, + `runtimeAttestations`; пустой список — явное «evidence отсутствует», не + пропуск); `checksum` — FNV-1a-32 (8 lowercase hex) над canonical + length-prefixed preimage с домен-сепаратором + `labcolors.numerical-capability.v1`. Release verifier и Swift-тесты + пересчитывают checksum НЕЗАВИСИМО от Rust-кода. Сейчас в manifest только + `glow-target-or-maximum-v1`. Словарь **позиций лестницы** (не ролей): `label-*`, `fill-*`, `border-*`, `focus-ring`, `glow`, `skeleton-*`, `neutral-fill-*`, `neutral-border-*`, @@ -108,7 +121,7 @@ solve-векторами: `Pack::generate()` возвращает `PackGeneratio через публичный JS API. Поэтому полная conformance именно JS-поверхности текущего пака пока не заявляется. В `native-conformance.yml` сохранён ручной macOS/arm64 reference path, но он не -запускается на PR/push и не считается достигнутой аттестацией pack 2.0.0. +запускается на PR/push и не считается достигнутой аттестацией текущего пака. Полная runtime-матрица остаётся scope #258; допуск `DRIFT_TOL` задаёт правило сравнения и не заменяет отсутствующий прогон. diff --git a/conformance/vectors/manifest.json b/conformance/vectors/manifest.json index f42bb82e..99a784f2 100644 --- a/conformance/vectors/manifest.json +++ b/conformance/vectors/manifest.json @@ -1,5 +1,5 @@ { - "packVersion": "2.0.0", + "packVersion": "3.0.0", "coreVersion": "0.2.0", "packDigest": "64a68cbd", "counts": { @@ -10,21 +10,27 @@ "muddiness": 4, "total": 82 }, - "numericalSites": [ - { - "siteId": "glow-target-or-maximum-v1", - "operations": "CAM16 forward powf; CAM16-UCS J-prime; abs; target >=; maximum ordering", - "domain": "encoded sRGB8 point screen states -> diagnostic CAM16-UCS delta J-prime", - "branchEffect": "first reached state versus global maximum and reached/unreachable status", - "stableOutcomes": [ - "bit-exact", - "indeterminate" - ], - "boundStatus": "unavailable", - "boundaryCorpus": "glow stable-indeterminate; exact no-op; finite-state compositor; half-tie alpha", - "runtimeMatrix": "active: native x86_64 + wasm32; native arm64 required before any cross-runtime CAM16 decision claim; exact bytes only for compositor", - "fallbackStatus": "none", - "legacyProfile": "legacy-platform-dependent-v1" - } - ] + "numericalCapabilities": { + "schemaVersion": 1, + "coverage": "migrated-sites-only-v1", + "sites": [ + { + "siteId": "glow-target-or-maximum-v1", + "stableOutcomes": [ + "bit-exact", + "indeterminate" + ], + "compatibilityReleases": [ + "glow-cam16-ucs-jprime-target-or-max-v1" + ], + "evidenceClasses": [ + "bit-exact" + ], + "artifactIds": [], + "boundIds": [], + "runtimeAttestations": [] + } + ], + "checksum": "5a3f6cbc" + } } diff --git a/crates/labcolors-conformance/src/lib.rs b/crates/labcolors-conformance/src/lib.rs index 56ddc621..c7db5fae 100644 --- a/crates/labcolors-conformance/src/lib.rs +++ b/crates/labcolors-conformance/src/lib.rs @@ -27,7 +27,7 @@ //! | `alpha.json` | подложка→α: композит и α_min | `alpha::composite_hex` / `alpha::min_alpha_hex` | //! | `solve.json` | (bg, контракт, тема) → резолв или честный отказ | `solve` | //! | `muddiness.json` | hex → замороженная legacy-координата | `cleanliness::muddiness_from_hex` | -//! | `manifest.json` | версии, дайджест, счётчики, migrated numerical sites | `numerical_registry_v1` | +//! | `manifest.json` | версии, дайджест, счётчики, capability manifest | `numerical_capability_manifest_v1` | //! //! `muddiness.json` фиксирует только воспроизводимость исторического числового //! API. Это `experimental compatibility proxy`, а не валидированный на @@ -37,8 +37,10 @@ //! Версия пака ([`PACK_VERSION`]) привязана к версии ядра ([`core_version`]): //! при легитимной смене канона генератор перегенерирует векторы, а //! раннер-референс ловит любой дрейф. -//! `manifest.numericalSites` перечисляет только уже мигрированные typed-decision -//! sites; полнота аудита исторических `f64` branches остаётся в #291. +//! `manifest.numericalCapabilities` — это projection core-owned capability +//! manifest (coverage `migrated-sites-only-v1`): перечислены только уже +//! мигрированные typed-decision sites; полнота аудита исторических `f64` +//! branches остаётся в #291. use serde::{Deserialize, Serialize}; @@ -46,7 +48,7 @@ use labcolors_core::alpha::{composite_hex, min_alpha_hex}; use labcolors_core::cleanliness::muddiness_from_hex; use labcolors_core::{ BgInput, ChromaPolicy, Contract, Gamut, Hue, LadderPosition, Theme, ViewingConditions, - fnv1a_32, numerical_registry_v1, recheck_against, solve, + fnv1a_32, recheck_against, solve, }; /// Семантическая версия conformance-пака. Меняется при изменении СХЕМЫ или @@ -697,6 +699,7 @@ pub fn to_canonical_json(value: &T) -> String { #[cfg(test)] mod tests { use super::*; + use labcolors_core::numerical_registry_v1; #[test] fn unreachable_code_mapping_is_fallible_without_generic_fallback() { @@ -770,32 +773,37 @@ mod tests { } #[test] - fn pack_v2_contains_the_exact_source_over_half_tie() { + fn pack_v3_contains_the_exact_source_over_half_tie() { // ADR-0004 делает этот байтовый шов частью breaking conformance-контракта: // нормализованный `(byte/255) * alpha * 255` путь ошибочно отдавал - // соседний LSB. Проверка одновременно убивает вакуумные изменения - // версии/счётчика без обязательного доказательного вектора. + // соседний LSB. Обязательство унаследовано pack v3 (v3 менял только + // схему манифеста: numericalSites → numericalCapabilities, состав + // векторных семейств тот же). Проверка одновременно убивает вакуумные + // изменения версии/счётчика без обязательного доказательного вектора. let pack = Pack::generate().expect("canonical pack generation"); let manifest = pack.manifest(); - assert_eq!(PACK_VERSION, "2.0.0", "half-tie требует pack v2"); + assert_eq!(PACK_VERSION, "3.0.0", "half-tie обязателен начиная с v2"); assert_eq!(manifest.pack_version, PACK_VERSION); assert_eq!( manifest.core_version, "0.2.0", - "pack v2 обязан быть сгенерирован breaking-версией ядра" + "pack v3 наследует векторные семейства, сгенерированные ядром 0.2.0" ); assert_eq!( pack.alpha.len(), 7, - "alpha-family v2 обязана иметь 7 векторов" + "alpha-family v2+ обязана иметь 7 векторов" ); assert_eq!(manifest.counts.alpha, pack.alpha.len()); - assert_eq!(manifest.counts.total, 82, "состав pack v2 изменился"); + assert_eq!( + manifest.counts.total, 82, + "состав векторных семейств изменился" + ); let half_tie = pack .alpha .iter() .find(|v| v.tint == "#C0B2FA" && v.alpha == 0.122 && v.bg == "#000000") - .expect("в pack v2 нет обязательного half-tie из ADR-0004"); + .expect("в паке нет обязательного half-tie из ADR-0004"); assert_eq!( half_tie.composite, "#17161F", "byte-reference round-half-up должен выбрать верхний LSB" diff --git a/crates/labcolors-core/src/agnostic_gates.rs b/crates/labcolors-core/src/agnostic_gates.rs index c4d21d39..0e730e4f 100644 --- a/crates/labcolors-core/src/agnostic_gates.rs +++ b/crates/labcolors-core/src/agnostic_gates.rs @@ -298,7 +298,7 @@ fn acme_config() -> ThemeConfig { ], }, // A small but real role set: a text ladder, a neutral fill, a brand fill, - // a hued brand label, a brand focus ring. + // a hued brand label, a badge label, a brand focus ring, a brand glow. roles: vec![ ("text-strong".to_string(), text(0.968, Floor::AaText)), ("text-weak".to_string(), text(0.461, Floor::AaUi)), @@ -333,6 +333,18 @@ fn acme_config() -> ThemeConfig { }, ), ("focus".to_string(), brand_ladder(LadderPosition::FocusRing)), + // Свечение бренда (#292): numerical-decision профиль обязателен + // ЯВНО и у чужого клиента — implicit legacy непредставим; выбор + // Stable доказывает, что второй клиент получает typed execution + // mode тем же публичным конфиг-путём, без правок ядра. + ( + "brand-glow".to_string(), + RoleRecipe::Glow { + source: LadderSource::Brand, + step: crate::glow::GlowStep::Base, + decision_profile: crate::glow::GlowDecisionProfileV1::StableV1, + }, + ), ], aliases: vec![("ring".to_string(), "focus".to_string())], } @@ -346,8 +358,20 @@ fn a_second_company_config_compiles_and_emits_a_valid_system() { .expect("a well-formed foreign config must compile"); assert_eq!( table.entries().len(), - 7, - "acme declared seven roles; the table carries exactly them" + 8, + "acme declared eight roles; the table carries exactly them" + ); + + // Численный план (#292) второго клиента — derived-проекция той же таблицы: + // ровно одна Glow-декларация ⇒ ровно один compiled invocation, имя роли — + // opaque node bytes (движок не знает словаря acme). + let plan = table + .numerical_plan_v1() + .expect("план второго клиента компилируется из registry SSOT"); + assert_eq!(plan.invocations().len(), 1); + assert_eq!( + plan.invocations()[0].invocation_id.node_bytes(), + b"brand-glow" ); // Emits a real, non-empty, physically-solved system on both a light and a dark @@ -358,7 +382,7 @@ fn a_second_company_config_compiles_and_emits_a_valid_system() { ] { let bg = BgInput::solid(bg_hex).unwrap(); let set = resolve_named_set(&bg, &table, &vc); - assert_eq!(set.len(), 7, "every declared role resolves to an outcome"); + assert_eq!(set.len(), 8, "every declared role resolves to an outcome"); // The text ladder is real: strong is a solved colour that clears its AA // text floor and reads stronger than the weak rung. diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index ccfa6729..465f55c2 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -91,9 +91,9 @@ pub use curve::ColorCurve; pub use glow::{ GLOW_BASE_DJ, GLOW_BLOOM_DJ, GLOW_COMPOSITE_PROFILE, GLOW_DIAGNOSTIC_PROFILE, GLOW_LAYER_RECIPE_PROFILE, GLOW_SUBTLE_DJ, GlowCompositeCertificateV1, - GlowCompositeGuaranteeV1, GlowCompositeProfileV1, GlowConstraintLayer, GlowDecisionProfileV1, - GlowDiagnosticProfileV1, GlowLayerRecipeProfileV1, GlowSolve, GlowTargetStatus, - glow_layers_from_source, screen_layer_over_encoded, screen_layer_over_srgb8, + GlowCompositeGuaranteeV1, GlowCompositeProfileV1, GlowConstraintLayer, GlowDecisionOutcomeV1, + GlowDecisionProfileV1, GlowDiagnosticProfileV1, GlowLayerRecipeProfileV1, GlowSolve, + GlowTargetStatus, glow_layers_from_source, screen_layer_over_encoded, screen_layer_over_srgb8, screen_point_is_exact_noop, solve_screen_alpha_for_dj, }; pub use hash::fnv1a_32; diff --git a/crates/labcolors-core/src/numerics.rs b/crates/labcolors-core/src/numerics.rs index b2c5b5d1..55656309 100644 --- a/crates/labcolors-core/src/numerics.rs +++ b/crates/labcolors-core/src/numerics.rs @@ -27,6 +27,30 @@ //! только как диагностический payload `Indeterminate::IntervalOverlap`; //! * незаконная комбинация (stable + legacy provenance и т. п.) непредставима //! типами, а не запрещена соглашением. +//! +//! # Гарантии, закреплённые компилятором (#292) +//! +//! Прежний числовой классификатор `classify_at_least_v1` (вместе с +//! `AtLeastDecisionV1`/`DecisionGuaranteeV1`) УДАЛЁН, а не deprecated: «сырое +//! сравнение с порогом» больше не публичный закон, и его невозможно +//! импортировать — регрессия ловится компилятором, не code review: +//! +//! ```compile_fail +//! use labcolors_core::classify_at_least_v1; +//! ``` +//! +//! BitExact-evidence запечатан registry-owned минтером: печать +//! [`EvidenceSeal`] несёт приватное поле, поэтому внешний struct-литерал +//! варианта [`NumericalDecisionEvidenceV1::BitExact`] не компилируется — +//! внешний код может лишь матчить вариант через `..`: +//! +//! ```compile_fail +//! let forged = labcolors_core::NumericalDecisionEvidenceV1::BitExact { +//! reference_profile_id: labcolors_core::ReferenceProfileIdV1::EncodedSrgb8ScreenV1, +//! // Печать не изготовить снаружи: поле `_private` приватно (E0451). +//! _seal: labcolors_core::numerics::EvidenceSeal { _private: () }, +//! }; +//! ``` /// Stable outcomes admitted for a migrated branch-sensitive site. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index 314aedcd..56ebfdf5 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -3385,6 +3385,42 @@ impl NamedRoleTable { pub fn chroma(&self) -> RoleChroma { self.chroma } + + /// Canonical numerical execution plan таблицы (#292) — DERIVED-проекция + /// тех же деклараций `entries()`, а НЕ второй mutable map: единственный + /// источник mode — сама спека [`RoleSpec::Glow`], план лишь перечисляет её + /// compiled invocations. Проекция не участвует в resolve/frame path — + /// resolver исполняет typed mode, хранящийся в спеке, и не делает plan + /// lookup в hot path; план существует для boundary/manifest диагностики. + /// + /// Occurrences подаются в порядке деклараций (имя роли — opaque node + /// bytes, site — [`GlowTargetOrMaximumV1`](crate::numerics::NumericalSiteIdV1)), + /// поэтому локальные ordinals внутри пары `(node, site)` детерминированы; + /// canonical-сортировка — внутренний закон самого плана и порядок + /// `entries()` не переупорядочивает. + /// + /// # Errors + /// + /// Типизированная [`NumericalPlanErrorV1`](crate::numerical_plan::NumericalPlanErrorV1) + /// компиляции плана (незарегистрированный site/release) — fail closed, не + /// runtime fallback. + pub fn numerical_plan_v1( + &self, + ) -> Result< + crate::numerical_plan::CompiledNumericalPlanV1, + crate::numerical_plan::NumericalPlanErrorV1, + > { + crate::numerical_plan::compile_numerical_plan_v1(self.entries.iter().filter_map( + |(name, spec)| match spec { + RoleSpec::Glow { mode, .. } => Some(( + name.as_bytes(), + crate::numerics::NumericalSiteIdV1::GlowTargetOrMaximumV1, + *mode, + )), + _ => None, + }, + )) + } } /// Resolve every named role in `table` against `bg` under `vc`, in declaration @@ -4070,6 +4106,225 @@ mod tests { )); } + // ───────────────────────────────────────────────────────────────────────── + // RED-группа 1 Issue #292 — numerical_plan_v1: derived-проекция таблицы. + // + // Таблицы собираются напрямую через `NamedRoleTable::new` (как + // `one_glow_table`): план — свойство скомпилированной таблицы, не + // конфиг-валидатора, поэтому тесты не зависят от словаря клиента. + // ───────────────────────────────────────────────────────────────────────── + + /// Glow-спека с непрозрачным для core именем-носителем задаётся снаружи; + /// здесь только рецепт: тинт + ступень + typed mode. + fn plan_glow_spec( + source_hex: &str, + mode: crate::numerical_plan::NumericalExecutionModeV1, + ) -> RoleSpec { + let source = crate::spaces::srgb::srgb_encoded_from_hex(source_hex).unwrap(); + RoleSpec::Glow { + tint: LadderTint::new([source; 4]).unwrap(), + step: crate::glow::GlowStep::Base, + mode, + } + } + + fn plan_stable_mode() -> crate::numerical_plan::NumericalExecutionModeV1 { + crate::numerical_plan::NumericalExecutionModeV1::StableOnly + } + + fn plan_compat_mode() -> crate::numerical_plan::NumericalExecutionModeV1 { + crate::numerical_plan::NumericalExecutionModeV1::ExplicitCompatibility { + release_id: + crate::numerics::NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1, + } + } + + fn plan_table(entries: Vec<(String, RoleSpec)>) -> NamedRoleTable { + NamedRoleTable::new(entries, Vec::new(), RoleChroma::Neutral).unwrap() + } + + /// (a)+(e) #292: биекция glow-ролей и invocations; каждый invocation + /// соответствует manifest-supported site (mode/release объявлены + /// capability-строкой); mixed modes сосуществуют в одном плане без + /// глобального профиля; не-glow роли и алиасы не порождают invocations + /// и остаются в `entries()` нетронутыми. + #[test] + fn red292_plan_is_bijective_with_glow_roles_and_manifest_supported() { + use crate::numerical_plan::NumericalExecutionModeV1; + let table = NamedRoleTable::new( + vec![ + ( + "klient-uzel-alpha".to_string(), + plan_glow_spec("#4A8FFF", plan_stable_mode()), + ), + // Не-glow роль между glow-декларациями: план обязан её пропустить. + ("plain-zero".to_string(), RoleSpec::Zero), + ( + "klient-uzel-beta".to_string(), + plan_glow_spec("#FF6633", plan_compat_mode()), + ), + ], + vec![("ring".to_string(), "plain-zero".to_string())], + RoleChroma::Neutral, + ) + .unwrap(); + + let plan = table.numerical_plan_v1().unwrap(); + + // Биекция: ровно по одному invocation на каждую glow-роль, и ни одного + // на прочие роли/алиасы. + assert_eq!(plan.invocations().len(), 2); + for glow_name in ["klient-uzel-alpha", "klient-uzel-beta"] { + assert_eq!( + plan.invocations() + .iter() + .filter(|inv| inv.invocation_id.node_bytes() == glow_name.as_bytes()) + .count(), + 1, + "glow-роль `{glow_name}` обязана дать ровно один invocation" + ); + } + assert!( + plan.invocations() + .iter() + .all(|inv| inv.invocation_id.node_bytes() != b"plain-zero" + && inv.invocation_id.node_bytes() != b"ring"), + "не-glow роль/алиас не порождают numerical invocations" + ); + // Таблица (resolve-словарь) нетронута планом. + assert_eq!( + table + .entries() + .iter() + .map(|(n, _)| n.as_str()) + .collect::>(), + ["klient-uzel-alpha", "plain-zero", "klient-uzel-beta"] + ); + + // Каждый invocation соответствует manifest-supported site: mode/release + // объявлены capability-строкой сборки (registry SSOT). + let manifest = crate::numerics::numerical_capability_manifest_v1(); + for inv in plan.invocations() { + let site = manifest + .sites + .iter() + .find(|site| site.site_id == inv.site_id) + .expect("site каждого invocation присутствует в capability manifest"); + match inv.mode { + NumericalExecutionModeV1::StableOnly => assert!( + !site.stable_outcomes.is_empty(), + "StableOnly требует объявленных stable outcomes" + ), + NumericalExecutionModeV1::ExplicitCompatibility { release_id } => assert!( + site.compatibility_releases.contains(&release_id), + "release {} обязан быть зарегистрирован для site {}", + release_id.key(), + inv.site_id.key() + ), + } + } + + // (e) mixed modes сосуществуют в ОДНОМ плане: у каждой invocation свой + // typed mode, глобального профиля не существует. + assert!( + plan.invocations() + .iter() + .any(|inv| inv.mode == NumericalExecutionModeV1::StableOnly) + && plan.invocations().iter().any(|inv| matches!( + inv.mode, + NumericalExecutionModeV1::ExplicitCompatibility { .. } + )) + ); + } + + /// (b)+(c) #292: A=[z,a] и B=[a,z] дают одинаковые invocation + /// canonical_bytes и plan checksum (canonical-сортировка — закон плана), + /// а `entries()` каждой таблицы сохраняет СВОЙ порядок деклараций; + /// вставка третьего узла не меняет прежние canonical_bytes. + #[test] + fn red292_permutation_preserves_canonical_projection_and_declared_order() { + let z = || { + ( + "z-glow".to_string(), + plan_glow_spec("#4A8FFF", plan_stable_mode()), + ) + }; + let a = || { + ( + "a-glow".to_string(), + plan_glow_spec("#FF6633", plan_compat_mode()), + ) + }; + let m = || { + ( + "m-glow".to_string(), + plan_glow_spec("#33CC88", plan_stable_mode()), + ) + }; + + let table_a = plan_table(vec![z(), a()]); + let table_b = plan_table(vec![a(), z()]); + + // resolve-порядок = порядок деклараций каждой стороны (план его не трогает). + let names = |t: &NamedRoleTable| { + t.entries() + .iter() + .map(|(n, _)| n.clone()) + .collect::>() + }; + assert_eq!(names(&table_a), ["z-glow", "a-glow"]); + assert_eq!(names(&table_b), ["a-glow", "z-glow"]); + + let plan_a = table_a.numerical_plan_v1().unwrap(); + let plan_b = table_b.numerical_plan_v1().unwrap(); + let bytes = |plan: &crate::numerical_plan::CompiledNumericalPlanV1| { + plan.invocations() + .iter() + .map(|inv| inv.invocation_id.canonical_bytes()) + .collect::>() + }; + assert_eq!(bytes(&plan_a), bytes(&plan_b)); + assert_eq!(plan_a.checksum, plan_b.checksum); + + // (c) Вставка третьего узла: прежние identity bytes неподвижны + // (ordinal локален внутри (node, site), глобальных индексов нет). + let extended = plan_table(vec![z(), m(), a()]).numerical_plan_v1().unwrap(); + let id_of = |plan: &crate::numerical_plan::CompiledNumericalPlanV1, node: &[u8]| { + plan.invocations() + .iter() + .find(|inv| inv.invocation_id.node_bytes() == node) + .map(|inv| inv.invocation_id.canonical_bytes()) + .unwrap() + }; + assert_eq!(id_of(&plan_a, b"z-glow"), id_of(&extended, b"z-glow")); + assert_eq!(id_of(&plan_a, b"a-glow"), id_of(&extended, b"a-glow")); + assert_ne!(plan_a.checksum, extended.checksum); + } + + /// (d) #292: rename роли меняет invocation identity (имя — opaque node + /// bytes), но typed mode остаётся тем же — семантика исполнения не + /// привязана к клиентскому имени. + #[test] + fn red292_rename_changes_identity_but_not_mode() { + let old = plan_table(vec![( + "staroe-imya".to_string(), + plan_glow_spec("#4A8FFF", plan_compat_mode()), + )]) + .numerical_plan_v1() + .unwrap(); + let renamed = plan_table(vec![( + "novoe-imya".to_string(), + plan_glow_spec("#4A8FFF", plan_compat_mode()), + )]) + .numerical_plan_v1() + .unwrap(); + assert_ne!( + old.invocations()[0].invocation_id, + renamed.invocations()[0].invocation_id + ); + assert_eq!(old.invocations()[0].mode, renamed.invocations()[0].mode); + } + // ───────────────────────────────────────────────────────────────────────── // DIFFERENTIAL HARNESS (perf/max-chroma-hotpath) — cusp-attracted hue. // diff --git a/crates/labcolors-core/tests/property_invariants.rs b/crates/labcolors-core/tests/property_invariants.rs index 7d102a7c..27e82baa 100644 --- a/crates/labcolors-core/tests/property_invariants.rs +++ b/crates/labcolors-core/tests/property_invariants.rs @@ -32,8 +32,8 @@ //! как characterization реального поведения (не одобрение — фиксация факта для владельца). use labcolors_core::{ - BgInput, Brand, DefectContext, Floor, LadderPosition, LadderSource, NeutralAnchors, - NeutralConfig, NeutralPick, NeutralTint, PaletteFamily, Resolved, RoleRecipe, + BgInput, Brand, DefectContext, Floor, GlowDecisionProfileV1, LadderPosition, LadderSource, + NeutralAnchors, NeutralConfig, NeutralPick, NeutralTint, PaletteFamily, Resolved, RoleRecipe, SentimentCategory, SentimentsConfig, Theme, ThemeAnchors, ThemeConfig, ThemesConfig, VcPreset, ViewingConditions, muddiness_in_context, muddiness_oklch, oklch_from_hex, p3_from_hex, resolve_named_set, srgb_encoded_from_hex, @@ -244,7 +244,12 @@ fn muddiness_cool_hue_is_not_monotone_finding() { /// Небольшая витрина ролей с легальными полами: сильный/слабый текст + цветной /// бренд-лейбл (все несут пол через `RoleSpec::legal_floor`). fn legality_table() -> labcolors_core::NamedRoleTable { - let cfg = base_config((0x30, 0x6A, 0xE0), (0x30, 0x6A, 0xE0), (0.968, 0.6, 0.3)); + let cfg = base_config( + (0x30, 0x6A, 0xE0), + (0x30, 0x6A, 0xE0), + (0.968, 0.6, 0.3), + GlowDecisionProfileV1::StableV1, + ); cfg.compile_named_role_table() .expect("витрина законности обязана компилироваться") } @@ -291,11 +296,14 @@ fn every_floored_role_clears_its_wcag_floor_on_quantised_bytes() { /// Построить ВАЛИДНЫЙ конфиг из варьируемых скаляров (структура/имена фиксированы, /// оттенки бренда/семьи и текстовые доли — генерируемые). `hue_override_deg` задан -/// явно ⇒ ахроматический якорь никогда не роняет компиляцию. +/// явно ⇒ ахроматический якорь никогда не роняет компиляцию. `glow_profile` — +/// обязательный numerical-decision профиль Glow-роли (#292): implicit legacy +/// непредставим, поэтому генератор всегда выбирает один из двух явных. fn base_config( brand: (u8, u8, u8), family: (u8, u8, u8), fractions: (f64, f64, f64), + glow_profile: GlowDecisionProfileV1, ) -> ThemeConfig { let (br, bg, bb) = brand; let (fr, fg, fb) = family; @@ -398,6 +406,17 @@ fn base_config( hue: Some(LadderSource::Brand), }, ), + // Glow-роль (#292): единственный носитель numerical execution mode + // в конфиге — включена в генератор, чтобы property-законы (тотальность, + // детерминизм, план-проекция) покрывали и этот рецепт. + ( + "brand-glow".to_string(), + RoleRecipe::Glow { + source: LadderSource::Brand, + step: labcolors_core::glow::GlowStep::Base, + decision_profile: glow_profile, + }, + ), ], // Цель алиаса ОБЯЗАНА существовать среди ролей (валидатор это проверяет). vec![("ring".to_string(), "brand-fill".to_string())], @@ -406,13 +425,23 @@ fn base_config( /// Стратегия произвольного ВАЛИДНОГО конфига: доли строго в (0,1], убывающая /// текстовая лестница не требуется законом (иерархию движок сам сжимает). +/// Glow-профиль — случайный из двух явных (#292): оба mode равноправно валидны, +/// property-законы обязаны держаться для каждого. fn arb_config() -> impl Strategy { ( (any::(), any::(), any::()), (any::(), any::(), any::()), (0.5f64..=1.0, 0.2f64..=0.7, 0.05f64..=0.4), + any::(), ) - .prop_map(|(brand, family, fr)| base_config(brand, family, fr)) + .prop_map(|(brand, family, fr, stable_glow)| { + let profile = if stable_glow { + GlowDecisionProfileV1::StableV1 + } else { + GlowDecisionProfileV1::LegacyPlatformDependentV1 + }; + base_config(brand, family, fr, profile) + }) } #[test] @@ -497,6 +526,68 @@ fn resolve_named_set_is_deterministic() { ); } +// ───────────────────────────────────────────────────────────────────────────── +// СВОЙСТВО 4b — numerical plan (#292): canonical-проекция инвариантна к +// перестановке деклараций, а resolve-порядок entries() ей не подчинён. +// +// КЛАСС: «identity/checksum плана зависит от глобального порядка деклараций» +// (глобальные индексы запрещены законом плана) и обратный класс — «план +// переупорядочивает resolve-словарь». БЬЁТ НА МУТАЦИИ: подмена локального +// ordinal глобальным индексом декларации → перестановка меняет canonical bytes +// → checksum расходится → RED; сортировка entries() планом → порядок ролей +// расходится с декларацией → RED. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn numerical_plan_checksum_is_permutation_invariant_and_entries_keep_order() { + check(128, arb_config(), |cfg| { + let declared: Vec = cfg.roles.iter().map(|(n, _)| n.clone()).collect(); + let mut permuted_cfg = cfg.clone(); + permuted_cfg.roles.reverse(); + let reversed: Vec = permuted_cfg.roles.iter().map(|(n, _)| n.clone()).collect(); + + let table = cfg.compile_named_role_table().expect("валидный конфиг"); + let permuted = permuted_cfg + .compile_named_role_table() + .expect("перестановка ролей не ломает валидность"); + + // resolve-порядок entries() = порядок деклараций КАЖДОЙ стороны: + // план не переупорядочивает словарь клиента. + let names = |t: &labcolors_core::NamedRoleTable| { + t.entries() + .iter() + .map(|(n, _)| n.clone()) + .collect::>() + }; + prop_assert_eq!( + names(&table), + declared, + "entries() потерял порядок деклараций" + ); + prop_assert_eq!( + names(&permuted), + reversed, + "entries() переставленной таблицы потерял свой порядок" + ); + + // План-checksum одинаков: canonical-проекция сортируется по identity + // bytes, а identity не содержит глобальных declaration-индексов. + let plan = table.numerical_plan_v1().expect("план компилируется"); + let plan_permuted = permuted + .numerical_plan_v1() + .expect("план переставленной таблицы компилируется"); + prop_assert_eq!( + plan.checksum, + plan_permuted.checksum, + "перестановка деклараций изменила план-checksum" + ); + // Проекция непуста: в генераторе всегда есть Glow-роль — свойство не + // выполняется вакуумно. + prop_assert_eq!(plan.invocations().len(), 1); + Ok(()) + }); +} + // ───────────────────────────────────────────────────────────────────────────── // СВОЙСТВО 5 — сохранение оттенка семьи цветным лейблом (M1) // @@ -512,7 +603,12 @@ fn resolve_named_set_is_deterministic() { fn hued_brand_label_preserves_family_hue_where_it_has_chroma() { // Живой насыщенный бренд (яркий синий), лейбл держит его оттенок. let brand = (0x0Au8, 0x54u8, 0xF0u8); - let cfg = base_config(brand, brand, (0.968, 0.6, 0.3)); + let cfg = base_config( + brand, + brand, + (0.968, 0.6, 0.3), + GlowDecisionProfileV1::StableV1, + ); let table = cfg.compile_named_role_table().unwrap(); // Оттенок семьи бренда — из ФАКТИЧЕСКИ построенных светлого и тёмного якорей // (та же lighten/darken, что в base_config), берём оба края семьи. diff --git a/crates/labcolors-wasm/src/dto.rs b/crates/labcolors-wasm/src/dto.rs index c9e88620..d7fead97 100644 --- a/crates/labcolors-wasm/src/dto.rs +++ b/crates/labcolors-wasm/src/dto.rs @@ -96,10 +96,11 @@ pub struct GlowColor { /// Диагностическая модель, участвовавшая именно в выборе target/max. `None` у /// точного no-op профиля stable, который не выполняет выбор по внешнему виду. pub selection_diagnostic_profile: Option, - /// Explicit client-selected decision profile. - pub decision_profile: labcolors_core::GlowDecisionProfileV1, - /// Guarantee target/max decision. - pub decision_guarantee: labcolors_core::DecisionGuaranteeV1, + /// Атомарный исход решения (#292): доказанный stable exact no-op либо + /// явный registered compatibility-алгоритм. Прежняя пара независимых полей + /// profile/guarantee выводится из него boundary-проекциями, поэтому + /// незаконное сочетание profile × guarantee непредставимо уже в DTO. + pub decision_outcome: labcolors_core::glow::GlowDecisionOutcomeV1, /// Слой, по которому решалась цель. pub constraint_layer: labcolors_core::GlowConstraintLayer, /// Типизированный результат target-проверки. diff --git a/crates/labcolors-wasm/src/engine.rs b/crates/labcolors-wasm/src/engine.rs index fb28b388..bc83cac4 100644 --- a/crates/labcolors-wasm/src/engine.rs +++ b/crates/labcolors-wasm/src/engine.rs @@ -287,8 +287,7 @@ fn map_resolved(resolved: Resolved, legal_floor: Option) -> Result; } +/** Capability одного зарегистрированного численного site (#289/#292): что + * сборка УМЕЕТ (registry-проекция), не что выбрал клиент. Пустой массив — + * явное отсутствие evidence, не implicit support. */ +export interface NumericalCapabilitySiteV1 { + /** Stable site identity key, например "glow-target-or-maximum-v1". */ + readonly siteId: string; + /** Законные stable-исходы site. */ + readonly stableOutcomes: ReadonlyArray; + /** Зарегистрированные compatibility-releases. */ + readonly compatibilityReleases: ReadonlyArray; + /** Минтимые классы evidence. */ + readonly evidenceClasses: ReadonlyArray; + /** Canonical finite artifact IDs (пусто в V1). */ + readonly artifactIds: ReadonlyArray; + /** Registered error bound IDs (пусто в V1). */ + readonly boundIds: ReadonlyArray; + /** Runtime attestation IDs (пусто до #258). */ + readonly runtimeAttestations: ReadonlyArray; +} + +/** Canonical numerical capability manifest: статическое свойство сборки, + * спроецированное из core registry SSOT. Та же camelCase-форма, что + * numericalCapabilities манифеста conformance-пака. */ +export interface NumericalCapabilityManifestV1 { + /** Версия capability-схемы (независимый домен версий). */ + readonly schemaVersion: number; + /** Покрытие registry: "migrated-sites-only-v1". */ + readonly coverage: string; + /** Capability rows, отсортированные по UTF-8 байтам siteId. */ + readonly sites: ReadonlyArray; + /** FNV-1a-32 drift-checksum canonical preimage, 8 lowercase hex. */ + readonly checksum: string; +} + /** The full result of resolving one background under one theme. */ export interface ResolvedTheme { readonly theme: ThemeName; @@ -468,6 +502,28 @@ export interface ResolvedTheme { extern "C" { #[wasm_bindgen(typescript_type = "ResolvedTheme")] pub type JsResolvedTheme; + + #[wasm_bindgen(typescript_type = "NumericalCapabilityManifestV1")] + pub type JsNumericalCapabilityManifest; +} + +/// Canonical numerical capability manifest текущей WASM-сборки (#289/#292). +/// +/// Свободная функция, а не метод движка: манифест — статическое свойство +/// сборки (core registry SSOT), он не зависит ни от загруженного конфига, ни +/// от состояния кэша. Форма — camelCase-проекция `CapabilityManifestProjection` +/// conformance-пака; `checksum` — FNV-1a-32 canonical preimage, 8 lowercase hex. +#[wasm_bindgen(js_name = numericalCapabilityManifest)] +pub fn numerical_capability_manifest() -> Result { + // Та же «широкая» схема границы, что у resolveTheme: одна UTF-8 строка + + // нативный JSON.parse вместо пообъектной сборки Reflect::set. + let json = crate::projection::capability_manifest_json(); + let parsed = js_sys::JSON::parse(&json).map_err(|_| { + to_js_error(BindingError::Internal { + reason: "capability manifest не распарсился как JSON".to_string(), + }) + })?; + Ok(parsed.unchecked_into()) } /// A contrast engine over a consumer-supplied design system. Construct with diff --git a/crates/labcolors-wasm/src/projection.rs b/crates/labcolors-wasm/src/projection.rs index db1314a6..a42090a2 100644 --- a/crates/labcolors-wasm/src/projection.rs +++ b/crates/labcolors-wasm/src/projection.rs @@ -133,9 +133,9 @@ pub fn resolved_json(resolved: &ResolvedTheme) -> Result { field_str( &mut roles, "decisionProfile", - glow_decision_profile_key(g.decision_profile)?, + glow_decision_profile_key(g.decision_outcome.decision_profile())?, ); - field_glow_decision_guarantee(&mut roles, g.decision_guarantee)?; + field_glow_decision_guarantee(&mut roles, &g.decision_outcome)?; field_str( &mut roles, "constraintLayer", @@ -244,6 +244,79 @@ pub fn resolved_json(resolved: &ResolvedTheme) -> Result { Ok(out) } +/// Проекция canonical numerical capability manifest (#289/#292) в JSON с +/// camelCase-полями ровно той же формы, что `CapabilityManifestProjection` +/// conformance-крейта: `schemaVersion, coverage, sites[{siteId, stableOutcomes, +/// compatibilityReleases, evidenceClasses, artifactIds, boundIds, +/// runtimeAttestations}], checksum`. Построена ИЗ core SSOT +/// (`numerical_capability_manifest_v1`), не из рукописной копии registry: +/// adapter не имеет права держать второй список sites, иначе поверхности +/// расходятся молча. Пустой список эмитится явным `[]` (пусто = отсутствие +/// evidence, не implicit support). +pub fn capability_manifest_json() -> String { + let manifest = labcolors_core::numerical_capability_manifest_v1(); + let mut out = String::with_capacity(384); + out.push_str("{\"schemaVersion\":"); + let _ = write!(out, "{}", manifest.schema_version); + out.push_str(",\"coverage\":"); + push_str_lit(&mut out, manifest.coverage.key()); + out.push_str(",\"sites\":["); + // sites уже отсортированы ядром по UTF-8 bytes site key (инвариант + // canonical checksum preimage) — проекция порядок не пересобирает. + for (index, site) in manifest.sites.iter().enumerate() { + if index > 0 { + out.push(','); + } + out.push_str("{\"siteId\":"); + push_str_lit(&mut out, site.site_id.key()); + push_key_array( + &mut out, + "stableOutcomes", + site.stable_outcomes.iter().map(|v| v.key()), + ); + push_key_array( + &mut out, + "compatibilityReleases", + site.compatibility_releases.iter().map(|v| v.key()), + ); + push_key_array( + &mut out, + "evidenceClasses", + site.evidence_classes.iter().map(|v| v.key()), + ); + push_key_array( + &mut out, + "artifactIds", + site.artifact_ids.iter().map(|v| v.key()), + ); + push_key_array(&mut out, "boundIds", site.bound_ids.iter().map(|v| v.key())); + push_key_array( + &mut out, + "runtimeAttestations", + site.runtime_attestations.iter().map(|v| v.key()), + ); + out.push('}'); + } + out.push_str("],\"checksum\":"); + push_str_lit(&mut out, &manifest.checksum.hex()); + out.push('}'); + out +} + +/// Массив статических wire-ключей: `,"name":["a","b"]`; пустой — явный `[]`. +fn push_key_array<'a>(out: &mut String, name: &str, keys: impl Iterator) { + out.push_str(",\""); + out.push_str(name); + out.push_str("\":["); + for (index, key) in keys.enumerate() { + if index > 0 { + out.push(','); + } + push_str_lit(out, key); + } + out.push(']'); +} + fn unknown_output_variant(type_name: &str) -> BindingError { BindingError::Internal { reason: format!("проекция: неизвестный вариант {type_name}"), @@ -251,27 +324,36 @@ fn unknown_output_variant(type_name: &str) -> BindingError { } fn glow_degraded_from_provenance(glow: &GlowColor) -> Result { + use labcolors_core::glow::GlowDecisionOutcomeV1; + // Атомарный decision_outcome (#292) уже делает незаконную пару + // profile × guarantee непредставимой; проекции осталось сверить его с + // селекционной диагностикой и статусом цели — они по-прежнему приходят + // отдельными полями, и их рассинхрон был бы порчей provenance выше. match ( - glow.decision_profile, - glow.decision_guarantee, + &glow.decision_outcome, glow.selection_diagnostic_profile, glow.target_status, ) { ( - labcolors_core::GlowDecisionProfileV1::StableV1, - labcolors_core::DecisionGuaranteeV1::BitExact, + GlowDecisionOutcomeV1::StableExactNoop { .. }, None, labcolors_core::GlowTargetStatus::ExactNoopUnreachable, ) => Ok(true), ( - labcolors_core::GlowDecisionProfileV1::LegacyPlatformDependentV1, - labcolors_core::DecisionGuaranteeV1::LegacyPlatformDependentV1, + GlowDecisionOutcomeV1::Compatibility { + release_id: + labcolors_core::NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1, + .. + }, Some(labcolors_core::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1), labcolors_core::GlowTargetStatus::LegacyReached, ) => Ok(false), ( - labcolors_core::GlowDecisionProfileV1::LegacyPlatformDependentV1, - labcolors_core::DecisionGuaranteeV1::LegacyPlatformDependentV1, + GlowDecisionOutcomeV1::Compatibility { + release_id: + labcolors_core::NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1, + .. + }, Some(labcolors_core::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1), labcolors_core::GlowTargetStatus::LegacyUnreachable, ) => Ok(true), @@ -421,24 +503,21 @@ fn material_numerical_profile_key( /// Поле сертификата определённого решения. Функция владеет всей вложенной /// формой контракта, чтобы новый вариант сертификата не усложнял проекцию роли. +/// Wire-ключ (`bit-exact | legacy-platform-dependent-v1`) берётся из +/// core-owned `guarantee_wire_key()` — единого migration-адаптера прежнего +/// guarantee-словаря; enum non-exhaustive, поэтому неизвестный будущий вариант +/// остаётся честной структурной ошибкой, а не тихим новым ключом на проводе. fn field_glow_decision_guarantee( out: &mut String, - guarantee: labcolors_core::DecisionGuaranteeV1, + outcome: &labcolors_core::glow::GlowDecisionOutcomeV1, ) -> Result<(), BindingError> { - let key = match guarantee { - labcolors_core::DecisionGuaranteeV1::BitExact => "bit-exact", - labcolors_core::DecisionGuaranteeV1::LegacyPlatformDependentV1 => { - "legacy-platform-dependent-v1" - } - labcolors_core::DecisionGuaranteeV1::OutwardIntervalV1(_) => { - return Err(BindingError::Internal { - reason: "проекция: неподдерживаемый Glow DecisionGuaranteeV1: outward-interval-v1" - .to_string(), - }); - } + use labcolors_core::glow::GlowDecisionOutcomeV1; + let key = match outcome { + GlowDecisionOutcomeV1::StableExactNoop { .. } + | GlowDecisionOutcomeV1::Compatibility { .. } => outcome.guarantee_wire_key(), _ => { return Err(BindingError::Internal { - reason: "проекция: неподдерживаемый Glow DecisionGuaranteeV1".to_string(), + reason: "проекция: неподдерживаемый GlowDecisionOutcomeV1".to_string(), }); } }; @@ -638,6 +717,36 @@ mod tests { RoleOutcome, SolvedColor, }; + /// Единственный конструируемый снаружи ядра атомарный legacy-исход: + /// registered release + provenance-маркер (оба публичны by design). + fn legacy_outcome() -> labcolors_core::glow::GlowDecisionOutcomeV1 { + labcolors_core::glow::GlowDecisionOutcomeV1::Compatibility { + release_id: + labcolors_core::NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1, + provenance: labcolors_core::LegacyPlatformDependentV1, + } + } + + /// Stable exact no-op из НАСТОЯЩЕГО core-решения: evidence запечатан + /// (приватная печать), поэтому тест берёт его у солвера на белом фоне — + /// там screen-слой побайтно no-op для любой alpha, решение Determinate. + fn stable_exact_noop_outcome() -> labcolors_core::glow::GlowDecisionOutcomeV1 { + let decision = labcolors_core::solve_screen_alpha_for_dj( + "#FFFFFF", + "#FFFFFF", + 2.3006, + labcolors_core::NumericalExecutionModeV1::StableOnly, + &labcolors_core::ViewingConditions::srgb(), + ) + .expect("stable solve на белом обязан вернуть решение"); + match decision { + labcolors_core::NumericalDecisionV1::Determinate { evidence, .. } => { + labcolors_core::glow::GlowDecisionOutcomeV1::StableExactNoop { evidence } + } + other => panic!("белый screen-noop обязан быть Determinate, получено {other:?}"), + } + } + fn color_entry(key: &str) -> RoleEntry { RoleEntry { role_key: key.to_string(), @@ -694,10 +803,7 @@ mod tests { selection_diagnostic_profile: Some( labcolors_core::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1, ), - decision_profile: - labcolors_core::GlowDecisionProfileV1::LegacyPlatformDependentV1, - decision_guarantee: - labcolors_core::DecisionGuaranteeV1::LegacyPlatformDependentV1, + decision_outcome: legacy_outcome(), constraint_layer: labcolors_core::GlowConstraintLayer::Halo, target_status: labcolors_core::GlowTargetStatus::LegacyReached, halo_composite_hex: "#13151B".to_string(), @@ -810,22 +916,14 @@ mod tests { let numerical_profile = labcolors_core::MaterialNumericalProfileV1::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1; let mut exact_decision = String::new(); - field_glow_decision_guarantee( - &mut exact_decision, - labcolors_core::DecisionGuaranteeV1::BitExact, - ) - .unwrap(); + field_glow_decision_guarantee(&mut exact_decision, &stable_exact_noop_outcome()).unwrap(); assert_eq!( exact_decision, ",\"decisionGuarantee\":{\"kind\":\"bit-exact\"}" ); let mut legacy_decision = String::new(); - field_glow_decision_guarantee( - &mut legacy_decision, - labcolors_core::DecisionGuaranteeV1::LegacyPlatformDependentV1, - ) - .unwrap(); + field_glow_decision_guarantee(&mut legacy_decision, &legacy_outcome()).unwrap(); assert_eq!( legacy_decision, ",\"decisionGuarantee\":{\"kind\":\"legacy-platform-dependent-v1\"}" @@ -972,18 +1070,112 @@ mod tests { "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" ); - let interval = labcolors_core::OutwardIntervalV1::try_new(0.9, 1.1).unwrap(); - let mut unsupported = String::new(); - let error = field_glow_decision_guarantee( - &mut unsupported, - labcolors_core::DecisionGuaranteeV1::OutwardIntervalV1(interval), - ) - .unwrap_err(); - assert!(matches!( - error, - BindingError::Internal { reason } - if reason.contains("неподдерживаемый Glow DecisionGuaranteeV1") - )); + // Оба конструктивно допустимых атомарных исхода несут ровно прежние + // wire-ключи guarantee/profile — словарь на проводе не дрейфует. + assert_eq!( + stable_exact_noop_outcome().guarantee_wire_key(), + "bit-exact" + ); + assert_eq!( + stable_exact_noop_outcome().decision_profile(), + labcolors_core::GlowDecisionProfileV1::StableV1 + ); + assert_eq!( + legacy_outcome().guarantee_wire_key(), + "legacy-platform-dependent-v1" + ); + assert_eq!( + legacy_outcome().decision_profile(), + labcolors_core::GlowDecisionProfileV1::LegacyPlatformDependentV1 + ); + } + + /// Capability-manifest проекция несёт camelCase-форму conformance-крейта и + /// checksum ядра (8 lowercase hex) — additive-поверхность WASM не имеет + /// права дрейфовать ни от core SSOT, ни от формы pack-манифеста. + #[test] + fn capability_manifest_json_mirrors_the_core_ssot() { + let value: serde_json::Value = + serde_json::from_str(&capability_manifest_json()).expect("валидный JSON"); + let core = labcolors_core::numerical_capability_manifest_v1(); + + assert_eq!( + value["schemaVersion"].as_u64(), + Some(u64::from(core.schema_version)) + ); + assert_eq!(value["coverage"], core.coverage.key()); + assert_eq!(value["checksum"], core.checksum.hex()); + let checksum = value["checksum"].as_str().unwrap(); + assert_eq!(checksum.len(), 8); + assert!( + checksum + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), + "checksum обязан быть 8 lowercase hex: {checksum}" + ); + + let sites = value["sites"].as_array().expect("sites — массив"); + assert_eq!(sites.len(), core.sites.len()); + for (projected, expected) in sites.iter().zip(core.sites.iter()) { + assert_eq!(projected["siteId"], expected.site_id.key()); + let list = |name: &str| -> Vec { + projected[name] + .as_array() + .unwrap_or_else(|| panic!("{name} — массив (пустой = явный [])")) + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect() + }; + let keys = |actual: Vec, expected_keys: Vec<&str>, name: &str| { + assert_eq!(actual, expected_keys, "{name}"); + }; + keys( + list("stableOutcomes"), + expected.stable_outcomes.iter().map(|v| v.key()).collect(), + "stableOutcomes", + ); + keys( + list("compatibilityReleases"), + expected + .compatibility_releases + .iter() + .map(|v| v.key()) + .collect(), + "compatibilityReleases", + ); + keys( + list("evidenceClasses"), + expected.evidence_classes.iter().map(|v| v.key()).collect(), + "evidenceClasses", + ); + keys( + list("artifactIds"), + expected.artifact_ids.iter().map(|v| v.key()).collect(), + "artifactIds", + ); + keys( + list("boundIds"), + expected.bound_ids.iter().map(|v| v.key()).collect(), + "boundIds", + ); + keys( + list("runtimeAttestations"), + expected + .runtime_attestations + .iter() + .map(|v| v.key()) + .collect(), + "runtimeAttestations", + ); + } + + // Non-vacuous: мигрированный glow-site реально присутствует. + assert!( + sites + .iter() + .any(|site| site["siteId"] == "glow-target-or-maximum-v1"), + "manifest обязан покрывать glow site" + ); } /// Материал (#89) проецируется в контрактные CSS-переменные: `--lab-` = @@ -1146,7 +1338,10 @@ mod tests { let RoleOutcome::Glow(glow) = &mut glow_theme.roles[3].outcome else { panic!("fixture pulse must be Glow"); }; - glow.decision_profile = labcolors_core::GlowDecisionProfileV1::StableV1; + // Незаконная пара profile × guarantee непредставима атомарным + // decision_outcome; осталась представимой лишь рассинхронизация + // outcome ↔ status/selection — её проекция и обязана отвергать. + glow.decision_outcome = stable_exact_noop_outcome(); let glow_error = resolved_json(&glow_theme).unwrap_err(); assert!(matches!( glow_error, diff --git a/crates/labcolors-wasm/tests/wasm_parity.rs b/crates/labcolors-wasm/tests/wasm_parity.rs index a3c8e669..3842891c 100644 --- a/crates/labcolors-wasm/tests/wasm_parity.rs +++ b/crates/labcolors-wasm/tests/wasm_parity.rs @@ -213,8 +213,8 @@ fn committed_conformance_pack_replays_in_wasm32() { assert_eq!(committed_manifest.core_version, fresh_manifest.core_version); assert_eq!(committed_manifest.counts, fresh_manifest.counts); assert_eq!( - committed_manifest.numerical_sites, - fresh_manifest.numerical_sites + committed_manifest.numerical_capabilities, + fresh_manifest.numerical_capabilities ); assert_eq!(committed_manifest.counts.total, 82, "anti-vacuum pack size"); } @@ -357,21 +357,14 @@ fn assert_parity(passport: &str, bg_hex: &str, theme: &str) { get_str(&entry, "decisionProfile").as_deref(), Some(g.decision_profile().key()) ); + // Атомарный исход (#292): wire-ключ guarantee проецируется из + // decision_outcome(); интервального determinate-варианта больше + // не существует (интервал живёт только в Indeterminate). let decision_guarantee = get_obj(&entry, "decisionGuarantee"); assert_eq!( get_str(&decision_guarantee, "kind").as_deref(), - Some(g.decision_guarantee().key()) + Some(g.decision_outcome().guarantee_wire_key()) ); - if let Some(interval) = g.decision_guarantee().interval() { - assert_eq!( - get_num(&decision_guarantee, "lower").to_bits(), - interval.lower().to_bits() - ); - assert_eq!( - get_num(&decision_guarantee, "upper").to_bits(), - interval.upper().to_bits() - ); - } assert_eq!( get_str(&entry, "constraintLayer").as_deref(), Some(g.constraint_layer().key()) diff --git a/docs/decisions/0004-finite-alpha-glow-reference.md b/docs/decisions/0004-finite-alpha-glow-reference.md index 2afac565..5279cb73 100644 --- a/docs/decisions/0004-finite-alpha-glow-reference.md +++ b/docs/decisions/0004-finite-alpha-glow-reference.md @@ -3,8 +3,9 @@ - Статус: принято - Дата: 2026-07-10 - Дополнение numerical-decision boundary: 2026-07-11 +- Дополнение атомарного результата и compatibility release: 2026-07-12 - Связанные задачи: #41, #218, #221, #223, #233, #241, #258, #259, #281, - #282 + #282, #292 ## Контекст @@ -224,3 +225,37 @@ WASM/TypeScript: Открытыми остаются: полный viewing/context contract (#230), sound cross-runtime CAM16 bounds (#223/#281), фактический renderer/output pipeline (#233/#241) и пространственная модель glow (#221). + +## Дополнение 2026-07-12: атомарный результат и registered compatibility release (#292) + +Первая typed-decision граница различала determinate/indeterminate, но +legacy-исход всё ещё выглядел как «determinate со слабой гарантией». Это +позволяло читателю типа повысить платформозависимый выбор до доказанного. +Контракт ужесточён до атомарного результата: + +- `NumericalDecisionV1` имеет ровно три взаимоисключающих варианта: + `Determinate { evidence }`, `Compatibility { release_id, provenance }` и + `Indeterminate { evidence }`. Legacy-исход — это `Compatibility`, отдельный + вариант, а НЕ determinate: он идентифицирует зарегистрированный воспроизводимый + АЛГОРИТМ (`NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1`, + key `glow-cam16-ucs-jprime-target-or-max-v1`), а не cross-runtime bit-exact + значение. Незаконная комбинация (stable outcome с legacy provenance) + непредставима в типе. +- Determinate-evidence запечатан: у `NumericalDecisionEvidenceV1::BitExact` + приватное поле-печать, внешний код может только матчить с `..`, а минт + выполняет registry-owned конструктор, сверяющий capability site. Подделка + evidence вне ядра не компилируется (compile-fail тест). +- Промежуточные «граничные» классификаторы (`classify_at_least_v1`, + `AtLeastDecisionV1`, `DecisionGuaranteeV1`) удалены: они кодировали + сравнительную силу гарантии как данные и тем самым допускали lossy-схлопывание + атомарных вариантов. +- Клиентский выбор перенесён в typed execution mode + (`NumericalExecutionModeV1::StableOnly | ExplicitCompatibility { release_id }`), + который несёт `RoleSpec::Glow`. Строковый `GlowDecisionProfileV1` остался + boundary-адаптером: прежние wire keys (`stable-v1`, + `legacy-platform-dependent-v1`, `bit-exact`) сохранены byte-for-byte, wire/npm + JSON и fingerprint не изменились. +- Conformance pack 3.0.0 публикует `numericalCapabilities` — typed capability + manifest ядра (coverage `migrated-sites-only-v1`, FNV-1a-32 drift-checksum над + canonical length-prefixed preimage) вместо прозаического `numericalSites`; + release verifier и Swift-тесты пересчитывают checksum независимо. diff --git a/docs/migrations/exact-alpha-glow.md b/docs/migrations/exact-alpha-glow.md index e5c3b0f2..1223b2fd 100644 --- a/docs/migrations/exact-alpha-glow.md +++ b/docs/migrations/exact-alpha-glow.md @@ -327,7 +327,8 @@ binary64 identity alpha, каноническую CSS-строку и composite цвета. 5. Сравнивайте alpha через `alphaCss` или побитный parse round-trip; не округляйте её до фиксированного числа знаков. -6. Используйте conformance pack 2.0.0. Half-tie +6. Используйте актуальный conformance pack (3.0.0; half-tie введён в 2.0.0 + и обязателен с тех пор). Half-tie `#C0B2FA @ 0.122` над `#000000` обязан дать `#17161F`. Обрабатывайте `generate_solve()` / `Pack::generate()` как `Result`: internal core failure теперь возвращает `PackGenerationError`, а не fake `unreachable` vector. @@ -385,3 +386,30 @@ Rollback выполняется парой runtime + config: fallback. - Ни один из этих профилей не сертифицирует реальный browser color-management, HDR/display pipeline, blur, overlap или spatial glow field. + +## Migration-note: атомарный `NumericalDecisionV1` и pack 3.0.0 (#292) + +Последующий rework численной границы (см. дополнение ADR-0004 от 2026-07-12) +намеренно НЕ меняет wire: прежние ключи сохранены byte-for-byte как +boundary-адаптер, поэтому для JS/TS-потребителей и golden-снапшотов миграция +не требуется. + +- `decision_profile` в конфиге по-прежнему принимает ровно `stable-v1` | + `legacy-platform-dependent-v1`; строка парсится адаптером + `GlowDecisionProfileV1` в typed execution mode + (`StableOnly` | `ExplicitCompatibility { release_id }`). Fingerprint конфига + не меняется. +- Wire-ключи гарантий (`bit-exact`, `legacy-platform-dependent-v1`) и форма + resolved-ролей не изменились. Legacy-результат внутри ядра теперь атомарный + `Compatibility` с registered release + `glow-cam16-ucs-jprime-target-or-max-v1` — он никогда не был и не становится + determinate; адаптер лишь проецирует его в прежний wire-ключ. +- Breaking только Rust API: удалены `classify_at_least_v1`, + `AtLeastDecisionV1`, `DecisionGuaranteeV1`; сопоставление по + `NumericalDecisionV1` обязано обрабатывать три варианта + (`Determinate`/`Compatibility`/`Indeterminate`), а `BitExact`-evidence + матчится только с `..` (sealed). +- `conformance/vectors/manifest.json` — pack 3.0.0: `numericalSites` заменён + typed `numericalCapabilities` (coverage `migrated-sites-only-v1`, + FNV-1a-32 drift-checksum). Векторные семейства и `packDigest` не изменились; + потребители манифеста должны читать новую секцию. diff --git a/docs/verification-map.md b/docs/verification-map.md index d0fe550c..8bbd505b 100644 --- a/docs/verification-map.md +++ b/docs/verification-map.md @@ -100,19 +100,26 @@ | production-миграция `PairLabel` байт-идентична замороженному legacy-пути (5 семей × 4 режима × 6 фонов + property + публичные отказы) | `pair_label_tests::migration_*` | дифференциальный (test-only legacy oracle) | Мутационный скоуп: модуль включён в `.cargo/mutants.toml` (`examine_globs`). -## Численные решения — `numerics.rs` (#292) +## Численные решения — `numerics.rs`, `numerical_plan.rs` (#292) -Три уровня контракта разделены типами: package capability (registry-строка) ≠ -compiled invocation plan (`CompiledNumericalPlanV1`) ≠ result evidence -(запечатанный `SoundIntervalEvidenceV1`). Новой математики модуль не вводит — -проверяется невозможность повышения caller-created значений до доказательств. +Три уровня контракта разделены типами: package capability +(`NumericalCapabilityManifestV1`, projection registry SSOT) ≠ compiled +invocation plan (`CompiledNumericalPlanV1`) ≠ атомарный результат +(`NumericalDecisionV1`: `Determinate`/`Compatibility`/`Indeterminate`). +Новой математики модуль не вводит — проверяется невозможность повышения +caller-created значений и legacy-исходов до доказательств. | инвариант | чем верифицирован | оракул | |---|---|---| -| план компилируется fail-closed из machine-readable строки: stable = [exact, refuse], legacy требует объявленного профиля | `numerics::tests::stable_plan_for_glow_site_admits_only_exact_check_and_refusal`, `legacy_plan_requires_a_declared_compatibility_profile` | внутренняя тождественность | -| caller-created интервал не достигает determinate-гарантии: классификатор принимает только запечатанное свидетельство (конструктор приватен) | сигнатура `classify_at_least_v1` + `interval_evidence_carries_its_provenance_into_the_certificate` | тип-уровневая (компилятор) + внутренняя тождественность | -| граница `>=` на интервале: Meets/Below/Overlap без tie-break, exact-касание цели детерминировано | `numerics::tests::interval_overlap_never_becomes_a_tie_break` | внутренняя тождественность | -| production-ветви glow исполняются строго по плану (порядок методов — из registry, не из рукописного match) | glow-набор (32 теста) + пробная мутация плана (5 падений glow, 1 numerics) | дифференциальный (замороженное glow-поведение) | +| registry непустой, ключи уникальны, Glow site покрыт обоими stable outcomes и registered compatibility release | `numerics::tests::migrated_registry_is_non_vacuous_unique_and_covers_glow_site` | внутренняя тождественность | +| capability manifest — каноническая projection registry: сортировка по UTF-8 `siteId`, coverage `migrated-sites-only-v1`, без выбранного mode | `numerics::tests::capability_manifest_is_canonical_registry_projection` | внутренняя тождественность | +| drift-checksum канонический и tamper-чувствителен: смена schema version / удаление row меняет FNV-1a-32 preimage | `numerics::tests::capability_checksum_is_canonical_and_tamper_sensitive`; независимые пересчёты: JS (`scripts/verify-package-release.mjs`) и Swift (`ConformanceTests.testCapabilityManifestChecksumRecomputes`) | внутренняя тождественность + два независимых re-implementation оракула | +| legacy-исход — атомарный `Compatibility` с registered release, не determinate evidence | `numerics::tests::legacy_result_is_compatibility_not_determinate_evidence` | тип-уровневая (взаимоисключающие варианты) + внутренняя тождественность | +| BitExact-evidence минтится только registry-owned конструктором для site с объявленным классом; внешняя подделка не компилируется | `numerics::tests::bit_exact_evidence_is_registry_owned_and_sealed`, `bit_exact_mint_is_refused_without_declared_capability` + два compile-fail doctests в шапке `numerics.rs` (импорт удалённого `classify_at_least_v1`; struct-литерал `BitExact` с приватной печатью) | тип-уровневая (компилятор) | +| диагностический интервал проверяет только форму (конечность, порядок) и не изготовляет determinate evidence | `numerics::tests::diagnostic_interval_validates_shape_only` | внутренняя тождественность | +| invocation identity плана канонична: локальные ordinals внутри (node, site), перестановка деклараций не меняет ids/projection | `numerical_plan::tests::mixed_modes_coexist_and_ordinals_are_local`, `declaration_permutation_preserves_ids_and_canonical_projection` | внутренняя тождественность | +| план tamper-чувствителен: переименование node/site меняет identity, смена mode меняет checksum; незарегистрированный release — typed ошибка компиляции плана | `numerical_plan::tests::rename_changes_identity_and_mode_mutation_changes_checksum`, `unregistered_release_is_a_typed_compile_error` | внутренняя тождественность | +| conformance manifest публикует exact core projection (не рукописную копию) | `reference_runner::manifest_metadata_matches_core`, `labcolors_conformance::tests::manifest_numerical_registry_is_generated_from_core_ssot` | дифференциальный (закоммиченный артефакт против свежей генерации) | ## JS-дубликат — `packages/colors/effective-bg.js` diff --git a/packages/colors/README.md b/packages/colors/README.md index 77e9759c..58609063 100644 --- a/packages/colors/README.md +++ b/packages/colors/README.md @@ -170,9 +170,12 @@ type RoleResult = при выборе состояния: для exact no-op он равен `null`, для legacy solve — `"cam16-ucs-jprime-li2017-v1"`. `targetStatus` различает эти ветви явно: `"exact-noop-unreachable"`, `"legacy-reached"` или - `"legacy-unreachable"`. Determinate-форма — union из этих трёх согласованных - ветвей: смешать stable/legacy profile, guarantee, selection diagnostic, - status и `degraded` на уровне TypeScript невозможно. Outward interval не + `"legacy-unreachable"`. Форма `kind: "glow"` — union из этих трёх + согласованных ветвей: смешать stable/legacy profile, guarantee, selection + diagnostic, status и `degraded` на уровне TypeScript невозможно. Legacy-ветви + (`decisionGuarantee: { kind: "legacy-platform-dependent-v1" }`) — это + compatibility-результат зарегистрированного алгоритма, а не determinate + численная гарантия. Outward interval не входит в Glow capability этого релиза. `kind: "glow-indeterminate"` означает, что профиль `stable-v1` не выбрал target/max state без sound bound; для такой роли CSS-переменные не эмитятся и legacy fallback не применяется. diff --git a/packages/colors/index.d.ts b/packages/colors/index.d.ts index 60aafaee..33c06ef8 100644 --- a/packages/colors/index.d.ts +++ b/packages/colors/index.d.ts @@ -9,6 +9,7 @@ export { default as init, initSync, LabColors, + numericalCapabilityManifest, } from "./pkg/labcolors.js"; // Curated public schema/result surface. wasm-bindgen's InitOutput and raw @@ -52,6 +53,8 @@ export type { RoleRecipe, ThemeConfig, ResolvedTheme, + NumericalCapabilitySiteV1, + NumericalCapabilityManifestV1, } from "./pkg/labcolors.js"; export { applyTheme } from "./apply-theme.js"; diff --git a/packages/colors/index.js b/packages/colors/index.js index f2b8f83c..d6e0d6ff 100644 --- a/packages/colors/index.js +++ b/packages/colors/index.js @@ -6,7 +6,13 @@ // effective-background resolver. The wasm glue is the generated `pkg/` artifact // (built by `npm run build`). -export { default, default as init, initSync, LabColors } from "./pkg/labcolors.js"; +export { + default, + default as init, + initSync, + LabColors, + numericalCapabilityManifest, +} from "./pkg/labcolors.js"; export { applyTheme } from "./apply-theme.js"; export { watchTheme } from "./watch-theme.js"; diff --git a/packages/colors/test/capability-manifest.test.mjs b/packages/colors/test/capability-manifest.test.mjs new file mode 100644 index 00000000..a62eeb42 --- /dev/null +++ b/packages/colors/test/capability-manifest.test.mjs @@ -0,0 +1,91 @@ +// Additive-поверхность #292: `numericalCapabilityManifest()` — canonical +// numerical capability manifest сборки, спроецированный из core registry SSOT +// (не рукописная копия). Тест пинит ФОРМУ (camelCase-поля проекции +// conformance-пака), покрытие мигрированного glow-site и формат checksum +// (FNV-1a-32, 8 lowercase hex), но НЕ значение checksum: значение принадлежит +// registry и меняется вместе с ним законно — дрейф формы был бы дефектом +// границы, дрейф значения — свойством ядра. +// +// Requires the built `pkg/` (CI runs `npm test` after `wasm-pack build`). +// Skips cleanly if the wasm bundle is absent, matching the other wasm tests. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const wasmPath = resolve(here, "../pkg/labcolors_bg.wasm"); +const gluePath = resolve(here, "../pkg/labcolors.js"); + +const haveWasm = existsSync(wasmPath) && existsSync(gluePath); + +test("numericalCapabilityManifest projects the core capability SSOT", async (t) => { + if (!haveWasm) { + t.skip("pkg/ not built — run `npm run build` first (CI builds before `npm test`)"); + return; + } + const { initSync, numericalCapabilityManifest } = await import( + pathToFileURL(gluePath).href + ); + initSync({ module: readFileSync(wasmPath) }); + + const manifest = numericalCapabilityManifest(); + + // Верхний уровень: та же форма, что numericalCapabilities conformance-пака. + assert.deepEqual( + Object.keys(manifest).sort(), + ["checksum", "coverage", "schemaVersion", "sites"], + "верхний уровень несёт ровно четыре canonical-поля", + ); + assert.equal(manifest.schemaVersion, 1, "capability schema V1"); + assert.equal(manifest.coverage, "migrated-sites-only-v1"); + assert.match( + manifest.checksum, + /^[0-9a-f]{8}$/, + "checksum — FNV-1a-32 в канонической записи: ровно 8 lowercase hex", + ); + + // Rows отсортированы по UTF-8 байтам siteId (инвариант canonical preimage). + assert.ok(Array.isArray(manifest.sites) && manifest.sites.length > 0); + const ids = manifest.sites.map((site) => site.siteId); + assert.deepEqual(ids, [...ids].sort(), "sites отсортированы по siteId"); + + // Мигрированный glow-site: точное содержимое registry-строки. Пустые + // массивы обязаны быть явными [] — пусто значит «нет evidence», не + // «поле потерялось на границе». + const glow = manifest.sites.find( + (site) => site.siteId === "glow-target-or-maximum-v1", + ); + assert.ok(glow, "манифест обязан покрывать glow site"); + assert.deepEqual( + Object.keys(glow).sort(), + [ + "artifactIds", + "boundIds", + "compatibilityReleases", + "evidenceClasses", + "runtimeAttestations", + "siteId", + "stableOutcomes", + ], + "row несёт ровно семь canonical-полей", + ); + assert.deepEqual(glow.stableOutcomes, ["bit-exact", "indeterminate"]); + assert.deepEqual(glow.compatibilityReleases, [ + "glow-cam16-ucs-jprime-target-or-max-v1", + ]); + assert.deepEqual(glow.evidenceClasses, ["bit-exact"]); + assert.deepEqual(glow.artifactIds, []); + assert.deepEqual(glow.boundIds, []); + assert.deepEqual(glow.runtimeAttestations, []); + + // Манифест — статическое свойство сборки: повторный вызов идентичен. + assert.deepEqual(numericalCapabilityManifest(), manifest); + + // Package root реэкспортирует ту же функцию (публичная поверхность npm). + const root = await import(pathToFileURL(resolve(here, "../index.js")).href); + assert.equal(typeof root.numericalCapabilityManifest, "function"); + assert.deepEqual(root.numericalCapabilityManifest(), manifest); +}); diff --git a/packages/colors/test/release-contract.test.mjs b/packages/colors/test/release-contract.test.mjs index b74e44e6..0c97657b 100644 --- a/packages/colors/test/release-contract.test.mjs +++ b/packages/colors/test/release-contract.test.mjs @@ -654,7 +654,9 @@ test("publish artifact validator executes and rejects identity or byte drift", ( const bytes = readFileSync(tarball); const expectedSha = "a".repeat(40); const manifest = { - schemaVersion: 1, + // Release-manifest schema v2: numericalCapabilities вместо numericalSites + // (см. verify-package-release.mjs); validator publish-workflow пиняет 2. + schemaVersion: 2, npm: "0.10.0", sourceSha: expectedSha, artifacts: { @@ -717,11 +719,11 @@ test("release verifier performs an independent byte-for-byte reproduction pass", assert.match(verifier, /GITHUB_OUTPUT/); assert.match(verifier, /familySetSha256: sha256\(Buffer\.concat\(familyBuffers\)\)/); assert.match(verifier, /sha256: sha256\(familyBuffers\[index\]\)/); - assert.match(verifier, /numericalSites: conformance\.numericalSites/); + assert.match(verifier, /numericalCapabilities: conformance\.numericalCapabilities/); assert.doesNotMatch( verifier, - /numericalSites:\s*\[\s*\{/, - "release manifest must copy the generated conformance registry, not duplicate it", + /numericalCapabilities:\s*\{\s*"/, + "release manifest must copy the generated capability manifest, not duplicate it", ); }); diff --git a/scripts/verify-package-release.mjs b/scripts/verify-package-release.mjs index f58ba336..aef01c5e 100644 --- a/scripts/verify-package-release.mjs +++ b/scripts/verify-package-release.mjs @@ -223,9 +223,110 @@ function fnv1a32(buffers) { return hash.toString(16).padStart(8, "0"); } +// Canonical checksum preimage capability-манифеста (ядро: +// labcolors-core/src/numerics.rs, canonical_checksum_preimage). Домен-сепаратор +// и length-prefixed кодирование повторены здесь НЕЗАВИСИМО: релизный гейт не +// доверяет закоммиченному checksum, а пересчитывает его из тех же typed rows. +const CAPABILITY_CHECKSUM_DOMAIN_V1 = "labcolors.numerical-capability.v1"; +// Поля-списки одного site в каноническом порядке preimage (порядок фиксирован +// схемой v1 и не выводится из JSON, чтобы переименование ключа ломало гейт). +const CAPABILITY_SITE_LIST_FIELDS = [ + "stableOutcomes", + "compatibilityReleases", + "evidenceClasses", + "artifactIds", + "boundIds", + "runtimeAttestations", +]; + +function u32le(value) { + const buffer = Buffer.alloc(4); + buffer.writeUInt32LE(value >>> 0, 0); + return buffer; +} + +function lenPrefixed(bytes) { + return [u32le(bytes.length), bytes]; +} + +// Сортировка по СЫРЫМ UTF-8 байтам (как sort_unstable по &[u8] в ядре), а не по +// UTF-16 code units JS-строк — для не-ASCII ключей порядки расходятся. +function compareUtf8(a, b) { + return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); +} + +function capabilityChecksumPreimage(capabilities) { + const chunks = []; + chunks.push(...lenPrefixed(Buffer.from(CAPABILITY_CHECKSUM_DOMAIN_V1, "utf8"))); + chunks.push(u32le(capabilities.schemaVersion)); + chunks.push(...lenPrefixed(Buffer.from(capabilities.coverage, "utf8"))); + const sites = [...capabilities.sites].sort((a, b) => compareUtf8(a.siteId, b.siteId)); + chunks.push(u32le(sites.length)); + for (const site of sites) { + chunks.push(...lenPrefixed(Buffer.from(site.siteId, "utf8"))); + for (const field of CAPABILITY_SITE_LIST_FIELDS) { + // Пустой список кодируется явным count=0 — отсутствие evidence является + // частью контракта, а не пропуском. + const keys = [...site[field]].sort(compareUtf8); + chunks.push(u32le(keys.length)); + for (const key of keys) chunks.push(...lenPrefixed(Buffer.from(key, "utf8"))); + } + } + return chunks; +} + +// Структурная (generic) валидация numericalCapabilities: форма, coverage и +// независимый пересчёт drift-checksum. Никакого hardcode конкретного site — +// точный состав rows держит exact-проекция ядра (reference_runner conformance- +// крейта); релизный гейт проверяет, что закоммиченный manifest самосогласован. +function validateCapabilityManifest(capabilities) { + if (typeof capabilities !== "object" || capabilities === null || Array.isArray(capabilities)) { + fail("conformance manifest has no numericalCapabilities object"); + } + if (capabilities.schemaVersion !== 1) { + fail( + `numericalCapabilities schemaVersion ${capabilities.schemaVersion} is not the supported 1`, + ); + } + if (capabilities.coverage !== "migrated-sites-only-v1") { + fail(`numericalCapabilities coverage must be migrated-sites-only-v1, got ${capabilities.coverage}`); + } + if (!Array.isArray(capabilities.sites) || capabilities.sites.length === 0) { + fail("numericalCapabilities must list at least one migrated site"); + } + const isKeyList = (value) => + Array.isArray(value) && value.every((key) => typeof key === "string" && key.length > 0); + const siteIds = new Set(); + for (const site of capabilities.sites) { + if (typeof site.siteId !== "string" || site.siteId.length === 0) { + fail("numericalCapabilities site lacks a non-empty siteId"); + } + if (siteIds.has(site.siteId)) fail(`duplicate numericalCapabilities siteId ${site.siteId}`); + siteIds.add(site.siteId); + for (const field of CAPABILITY_SITE_LIST_FIELDS) { + if (!isKeyList(site[field])) { + fail(`numericalCapabilities site ${site.siteId} has malformed ${field}`); + } + } + if (site.stableOutcomes.length === 0) { + fail(`numericalCapabilities site ${site.siteId} declares no lawful stable outcome`); + } + } + if (!/^[0-9a-f]{8}$/u.test(capabilities.checksum ?? "")) { + fail(`invalid numericalCapabilities checksum: ${capabilities.checksum}`); + } + const recomputed = fnv1a32(capabilityChecksumPreimage(capabilities)); + if (recomputed !== capabilities.checksum) { + fail( + `numericalCapabilities checksum ${capabilities.checksum} does not bind the ` + + `canonical preimage (independent recompute: ${recomputed})`, + ); + } +} + async function validateConformance(conformance) { - if (conformance.packVersion !== "2.0.0") { - fail(`release requires conformance pack 2.0.0, got ${conformance.packVersion}`); + if (conformance.packVersion !== "3.0.0") { + fail(`release requires conformance pack 3.0.0, got ${conformance.packVersion}`); } if (!/^[0-9a-f]{8}$/u.test(conformance.packDigest ?? "")) { fail(`invalid conformance packDigest: ${conformance.packDigest}`); @@ -270,21 +371,7 @@ async function validateConformance(conformance) { if (halfTie?.composite !== "#17161F") { fail("conformance pack lacks the exact source-over half-tie #C0B2FA@0.122 -> #17161F"); } - if (!Array.isArray(conformance.numericalSites) || conformance.numericalSites.length === 0) { - fail("conformance manifest has no generated numericalSites registry"); - } - const glowSite = conformance.numericalSites.find( - (site) => site.siteId === "glow-target-or-maximum-v1", - ); - if ( - JSON.stringify(glowSite?.stableOutcomes) !== - JSON.stringify(["bit-exact", "indeterminate"]) || - glowSite?.boundStatus !== "unavailable" || - glowSite?.fallbackStatus !== "none" || - glowSite?.legacyProfile !== "legacy-platform-dependent-v1" - ) { - fail("generated Glow numerical registry does not describe both lawful stable outcomes"); - } + validateCapabilityManifest(conformance.numericalCapabilities); const manifestBytes = await readFile(CONFORMANCE_MANIFEST); return { @@ -897,7 +984,11 @@ export async function verifyPackageRelease() { ); const manifest = { - schemaVersion: 1, + // Схема release-manifest v2: numericalSites (pack 2.x, прозаические + // research-поля) заменён на numericalCapabilities — typed capability + // projection ядра с независимо пересчитанным checksum. Read-back в + // publish-workflow пиняет ровно эту версию. + schemaVersion: 2, npm: packageJson.version, core: coreVersion, wire: { @@ -934,7 +1025,7 @@ export async function verifyPackageRelease() { "exact-screen-composite-srgb8-v1", "typed-glow-indeterminate-v1", ], - numericalSites: conformance.numericalSites, + numericalCapabilities: conformance.numericalCapabilities, unsupported: [ "embedded-wire-schema-version", "stable-cam16-glow-target-or-maximum-selection", From 6285b236a2f1a5b2ea6ca0615aa310329a93993a Mon Sep 17 00:00:00 2001 From: Daniel from Labpics <63733699+lemone112@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:32:09 +0300 Subject: [PATCH 5/6] =?UTF-8?q?test(numerics):=20=D0=B7=D0=B0=D0=BC=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B7=D0=B8=D1=82=D1=8C=20canonical=20plan-encodin?= =?UTF-8?q?g=20=D0=B2=D0=B5=D0=BA=D1=82=D0=BE=D1=80=D0=BE=D0=BC=20v1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encoding — внешний versioned контракт (adapter-оракулы переигрывают его независимо): эквивалентный мутант потери mode-tag (компенсируемый release-ключом) обязан умирать замороженным вектором, а не выживать. Мутационные пробы: P1 (mode-tag) убит этим тестом; P2 (глобальный ordinal) и P3 (потеря release в манифесте) убиты существующими. Co-Authored-By: Claude Fable 5 --- crates/labcolors-core/src/numerical_plan.rs | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/labcolors-core/src/numerical_plan.rs b/crates/labcolors-core/src/numerical_plan.rs index 2230473f..1890f7cf 100644 --- a/crates/labcolors-core/src/numerical_plan.rs +++ b/crates/labcolors-core/src/numerical_plan.rs @@ -374,6 +374,31 @@ mod tests { assert_ne!(original.checksum, mode_flipped.checksum); } + /// Замороженный byte-вектор canonical encoding (versioned контракт v1). + /// + /// Encoding — внешний контракт (его независимо переигрывают adapter-оракулы), + /// поэтому дрейф даже СЕМАНТИЧЕСКИ эквивалентного поля (например, потеря + /// mode-tag, компенсируемая release-ключом) обязан менять schema version, + /// а не проходить молча. Вектор сгенерирован из этой же функции при + /// заморозке v1; изменение допустимо только вместе с bump + /// NUMERICAL_PLAN_SCHEMA_VERSION_V1 и новым вектором. + #[test] + fn canonical_plan_preimage_is_a_frozen_versioned_vector() { + let plan = compile_numerical_plan_v1([ + (b"a".as_slice(), SITE, stable()), + (b"z".as_slice(), SITE, compatibility()), + ]) + .unwrap(); + let hex: String = plan + .canonical_checksum_preimage() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + let frozen = "1b0000006c6162636f6c6f72732e6e756d65726963616c2d706c616e2e763101000000020000004f000000210000006c6162636f6c6f72732e6e756d65726963616c2d696e766f636174696f6e2e763101000000010000006119000000676c6f772d7461726765742d6f722d6d6178696d756d2d76310000000019000000676c6f772d7461726765742d6f722d6d6178696d756d2d76310b000000737461626c652d6f6e6c79000000004f000000210000006c6162636f6c6f72732e6e756d65726963616c2d696e766f636174696f6e2e763101000000010000007a19000000676c6f772d7461726765742d6f722d6d6178696d756d2d76310000000019000000676c6f772d7461726765742d6f722d6d6178696d756d2d7631160000006578706c696369742d636f6d7061746962696c69747926000000676c6f772d63616d31362d7563732d6a7072696d652d7461726765742d6f722d6d61782d7631"; + assert_eq!(hex, frozen, "canonical plan encoding v1 заморожен"); + assert_eq!(plan.checksum.hex(), "49e5b6b7"); + } + /// Fail-closed: незарегистрированный release отклоняется типизированно. #[test] fn unregistered_release_is_a_typed_compile_error() { From 3ae4a1593270548316f43381c375c14db6e8ea50 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics <63733699+lemone112@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:17:29 +0300 Subject: [PATCH 6/6] =?UTF-8?q?review(#292):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20Cod?= =?UTF-8?q?eRabbit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Swift-оракул пинует capability schemaVersion == 1; - from_execution_mode матчит точный Glow-release (без стирания identity); - план: sort_by_cached_key (ключ один раз), тайбрейкер удалён как избыточный; - честное имя вакуумного теста незарегистрированного release (+ маркер #291); - mint-гард тестируем на row-уровне (отказная ветвь исполняется), uniqueness покрывает все checksum-списки; - MD022 в verification-map; русификация терминов в packages/colors/README. Отклонено с аргументацией: полная непрозрачность вариантов NumericalDecisionV1 (heavy-lift вне bounded slice: исходы текут только ИЗ core, ядро их не принимает на вход; consumer-verifier — #284/#291) и вынос assemble-замыкания (low value по оценке самого ревьюера). Co-Authored-By: Claude Fable 5 --- .../ConformanceTests.swift | 3 ++ crates/labcolors-core/src/glow.rs | 8 ++-- crates/labcolors-core/src/numerical_plan.rs | 17 +++---- crates/labcolors-core/src/numerics.rs | 44 ++++++++++++++----- docs/verification-map.md | 1 + packages/colors/README.md | 14 +++--- 6 files changed, 59 insertions(+), 28 deletions(-) diff --git a/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift b/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift index 84ccd962..db307dcf 100644 --- a/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift +++ b/bindings/swift/Tests/LabColorsConformanceTests/ConformanceTests.swift @@ -125,6 +125,9 @@ final class ConformanceTests: XCTestCase { func testCapabilityManifestChecksumRecomputes() throws { let manifest = try load("manifest.json", as: Manifest.self) let caps = manifest.numericalCapabilities + // Оракул реализует canonical preimage V1: другая версия схемы обязана + // падать здесь, а не молча проходить с пересчитанным checksum. + XCTAssertEqual(caps.schemaVersion, 1, "неподдерживаемая версия capability-схемы") XCTAssertEqual(caps.coverage, "migrated-sites-only-v1", "coverage capability manifest") XCTAssertFalse(caps.sites.isEmpty, "capability manifest без единого migrated site пуст") for site in caps.sites { diff --git a/crates/labcolors-core/src/glow.rs b/crates/labcolors-core/src/glow.rs index 01a5ac90..25708e0c 100644 --- a/crates/labcolors-core/src/glow.rs +++ b/crates/labcolors-core/src/glow.rs @@ -205,9 +205,11 @@ impl GlowDecisionProfileV1 { pub fn from_execution_mode(mode: NumericalExecutionModeV1) -> Self { match mode { NumericalExecutionModeV1::StableOnly => Self::StableV1, - NumericalExecutionModeV1::ExplicitCompatibility { .. } => { - Self::LegacyPlatformDependentV1 - } + // Точный release: будущий чужой release не должен молча + // проецироваться в Glow-профиль — компилятор потребует решения. + NumericalExecutionModeV1::ExplicitCompatibility { + release_id: NumericalCompatibilityReleaseIdV1::GlowCam16UcsJPrimeTargetOrMaxV1, + } => Self::LegacyPlatformDependentV1, } } } diff --git a/crates/labcolors-core/src/numerical_plan.rs b/crates/labcolors-core/src/numerical_plan.rs index 1890f7cf..bd05d4f5 100644 --- a/crates/labcolors-core/src/numerical_plan.rs +++ b/crates/labcolors-core/src/numerical_plan.rs @@ -247,12 +247,9 @@ pub fn compile_numerical_plan_v1<'a>( }); } // Canonical порядок проекции: identity bytes → site key. - invocations.sort_unstable_by(|a, b| { - a.invocation_id - .canonical_bytes() - .cmp(&b.invocation_id.canonical_bytes()) - .then_with(|| a.site_id.key().cmp(b.site_id.key())) - }); + // Ключ считается один раз на invocation (identity bytes уже содержат + // site key и ordinal, поэтому вторичный тайбрейкер был бы избыточен). + invocations.sort_by_cached_key(|invocation| invocation.invocation_id.canonical_bytes()); if let Some(pair) = invocations .windows(2) .find(|pair| pair[0].invocation_id == pair[1].invocation_id) @@ -399,9 +396,13 @@ mod tests { assert_eq!(plan.checksum.hex(), "49e5b6b7"); } - /// Fail-closed: незарегистрированный release отклоняется типизированно. + /// Единственный зарегистрированный release компилируется; негативная + /// ветвь `UnregisteredCompatibilityRelease` сейчас НЕДОСТИЖИМА снаружи + /// (в enum один вариант, и он зарегистрирован) — она остаётся vacuous до + /// появления второго release/site и обязана получить настоящий негативный + /// тест вместе с ним (#291). #[test] - fn unregistered_release_is_a_typed_compile_error() { + fn single_registered_release_compiles_and_negative_branch_is_vacuous_for_now() { // Единственный зарегистрированный release Glow — проверяем контракт // через registry: сам вызов с ним обязан проходить. assert!(compile_numerical_plan_v1([(b"glow".as_slice(), SITE, compatibility())]).is_ok()); diff --git a/crates/labcolors-core/src/numerics.rs b/crates/labcolors-core/src/numerics.rs index 55656309..a05f55d6 100644 --- a/crates/labcolors-core/src/numerics.rs +++ b/crates/labcolors-core/src/numerics.rs @@ -621,13 +621,22 @@ pub(crate) fn mint_bit_exact_evidence( ) -> Result { let row = registry_row(site_id) .ok_or_else(|| format!("site {} отсутствует в registry V1", site_id.key()))?; + mint_bit_exact_for_row(row, reference_profile_id) +} + +/// Row-уровневый минт — отделён от registry-lookup, чтобы отказная ветвь +/// (row без объявленного BitExact) была проверяема на синтетической строке. +fn mint_bit_exact_for_row( + row: &NumericalSiteRecordV1, + reference_profile_id: ReferenceProfileIdV1, +) -> Result { if !row .evidence_classes .contains(&NumericalEvidenceClassV1::BitExact) { return Err(format!( "site {} не объявляет evidence class bit-exact", - site_id.key() + row.site_id.key() )); } Ok(NumericalDecisionEvidenceV1::BitExact { @@ -715,12 +724,19 @@ mod tests { "duplicate numerical site: {}", row.site_id.key() ); - // Set-поля checksum-preimage не имеют дубликатов. - let keys: Vec<_> = row.stable_outcomes.iter().map(|v| v.key()).collect(); - let mut sorted = keys.clone(); - sorted.sort_unstable(); - sorted.dedup(); - assert_eq!(sorted.len(), keys.len()); + // ВСЕ set-поля checksum-preimage не имеют дубликатов. + let unique = |keys: Vec<&str>| { + let mut sorted = keys.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), keys.len(), "дубликат в set-поле"); + }; + unique(row.stable_outcomes.iter().map(|v| v.key()).collect()); + unique(row.compatibility_releases.iter().map(|v| v.key()).collect()); + unique(row.evidence_classes.iter().map(|v| v.key()).collect()); + unique(row.artifact_ids.iter().map(|v| v.key()).collect()); + unique(row.bound_ids.iter().map(|v| v.key()).collect()); + unique(row.runtime_attestations.iter().map(|v| v.key()).collect()); } } @@ -736,16 +752,24 @@ mod tests { ); } - /// Минт отклоняет site без объявленного BitExact (registry-owned закон). + /// Минт отклоняет строку без объявленного BitExact (registry-owned + /// закон): отказная ветвь исполняется на синтетической строке, успешная — + /// на настоящем registry. #[test] fn bit_exact_mint_is_refused_without_declared_capability() { - // Единственный способ проверить отказ без второго site — прямой - // контракт минтера: он читает registry, не аргументы вызова. let minted = mint_bit_exact_evidence( NumericalSiteIdV1::GlowTargetOrMaximumV1, ReferenceProfileIdV1::EncodedSrgb8ScreenV1, ); assert!(minted.is_ok(), "Glow объявляет bit-exact"); + + let mut orphan = *registry_row(NumericalSiteIdV1::GlowTargetOrMaximumV1).unwrap(); + orphan.evidence_classes = &[]; + let refused = mint_bit_exact_for_row(&orphan, ReferenceProfileIdV1::EncodedSrgb8ScreenV1); + assert!( + refused.is_err(), + "строка без BitExact обязана отклонять минт" + ); } /// Checksum: детерминирован, чувствителен к содержимому canonical-полей и diff --git a/docs/verification-map.md b/docs/verification-map.md index 8bbd505b..6690a043 100644 --- a/docs/verification-map.md +++ b/docs/verification-map.md @@ -100,6 +100,7 @@ | production-миграция `PairLabel` байт-идентична замороженному legacy-пути (5 семей × 4 режима × 6 фонов + property + публичные отказы) | `pair_label_tests::migration_*` | дифференциальный (test-only legacy oracle) | Мутационный скоуп: модуль включён в `.cargo/mutants.toml` (`examine_globs`). + ## Численные решения — `numerics.rs`, `numerical_plan.rs` (#292) Три уровня контракта разделены типами: package capability diff --git a/packages/colors/README.md b/packages/colors/README.md index 58609063..c4d09376 100644 --- a/packages/colors/README.md +++ b/packages/colors/README.md @@ -170,18 +170,18 @@ type RoleResult = при выборе состояния: для exact no-op он равен `null`, для legacy solve — `"cam16-ucs-jprime-li2017-v1"`. `targetStatus` различает эти ветви явно: `"exact-noop-unreachable"`, `"legacy-reached"` или - `"legacy-unreachable"`. Форма `kind: "glow"` — union из этих трёх - согласованных ветвей: смешать stable/legacy profile, guarantee, selection - diagnostic, status и `degraded` на уровне TypeScript невозможно. Legacy-ветви + `"legacy-unreachable"`. Форма `kind: "glow"` — объединение этих трёх + согласованных ветвей: смешать stable/legacy профиль, гарантию, диагностику + выбора, статус и `degraded` на уровне TypeScript невозможно. Legacy-ветви (`decisionGuarantee: { kind: "legacy-platform-dependent-v1" }`) — это - compatibility-результат зарегистрированного алгоритма, а не determinate - численная гарантия. Outward interval не - входит в Glow capability этого релиза. `kind: "glow-indeterminate"` + результат совместимости зарегистрированного алгоритма, а не доказанная + численная гарантия. Интервал с внешним округлением не + входит в Glow-возможности этого релиза. `kind: "glow-indeterminate"` означает, что профиль `stable-v1` не выбрал target/max state без sound bound; для такой роли CSS-переменные не эмитятся и legacy fallback не применяется. Цель и все `*CompositeHex` / `*AchievedDj` относятся только к изолированным point-слоям, а не к полному blur/overlap-эффекту, браузеру или дисплею. -- `MaterialRole` — union трёх терминальных исходов: satisfied transparent +- `MaterialRole` — объединение трёх терминальных исходов: satisfied transparent endpoint (`alpha = 0`), satisfied bisection bracket (`alpha` побитно равна `upperAlpha`) и degraded opaque endpoint (`alpha = 1`). `alphaStatus`, `alphaGuarantee` и compatibility-поле `guaranteed` согласованы типом. `floor`