diff --git a/crates/labcolors-core/src/appearance.rs b/crates/labcolors-core/src/appearance.rs index d5a013fe..20c2b905 100644 --- a/crates/labcolors-core/src/appearance.rs +++ b/crates/labcolors-core/src/appearance.rs @@ -19,6 +19,8 @@ #[cfg(test)] use std::collections::BTreeSet; +use crate::Srgb8; + /// Непрозрачный handle цветового входа. Число — только идентичность. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct ColorInputId(u32); @@ -29,6 +31,19 @@ impl ColorInputId { } } +/// Непрозрачный handle наблюдаемого point-входа поверхности. +/// +/// Он намеренно не взаимозаменяем с [`ColorInputId`]: authored Paint source и +/// runtime backdrop имеют разные lifecycle и admission contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct SurfaceInputPortId(u32); + +impl SurfaceInputPortId { + pub(crate) const fn new(raw: u32) -> Self { + Self(raw) + } +} + /// Непрозрачный handle входа straight alpha. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct OpacityInputId(u32); @@ -113,7 +128,7 @@ impl PaintSpec { pub(crate) enum SurfaceSpec { Input { id: SurfaceId, - color: ColorInputId, + port: SurfaceInputPortId, }, FromOccurrence { id: SurfaceId, @@ -151,6 +166,9 @@ pub(crate) enum CompileError { DuplicateOpacityInput { input: OpacityInputId, }, + DuplicateSurfaceInputPort { + input: SurfaceInputPortId, + }, DuplicatePaint { paint: PaintId, }, @@ -172,9 +190,9 @@ pub(crate) enum CompileError { paint: PaintId, input: OpacityInputId, }, - MissingSurfaceColorInput { + MissingSurfaceInputPort { surface: SurfaceId, - input: ColorInputId, + input: SurfaceInputPortId, }, MissingSurfaceOccurrence { surface: SurfaceId, @@ -210,18 +228,27 @@ pub(crate) enum BindingError { DuplicateOpacityBinding { input: OpacityInputId, }, + DuplicateSurfaceInputBinding { + input: SurfaceInputPortId, + }, MissingColorBinding { input: ColorInputId, }, MissingOpacityBinding { input: OpacityInputId, }, + MissingSurfaceInputBinding { + input: SurfaceInputPortId, + }, UnexpectedColorBinding { input: ColorInputId, }, UnexpectedOpacityBinding { input: OpacityInputId, }, + UnexpectedSurfaceInputBinding { + input: SurfaceInputPortId, + }, OpacityOutOfDomain { input: OpacityInputId, message: String, @@ -247,6 +274,7 @@ impl PointOpacityError { #[derive(Debug, Clone, PartialEq)] pub(crate) struct AppearanceGraphSpec { color_inputs: Vec, + surface_input_ports: Vec, opacity_inputs: Vec, paints: Vec, surfaces: Vec, @@ -257,6 +285,7 @@ pub(crate) struct AppearanceGraphSpec { impl AppearanceGraphSpec { pub(crate) fn new( color_inputs: Vec, + surface_input_ports: Vec, opacity_inputs: Vec, paints: Vec, surfaces: Vec, @@ -264,6 +293,7 @@ impl AppearanceGraphSpec { ) -> Self { Self { color_inputs, + surface_input_ports, opacity_inputs, paints, surfaces, @@ -276,6 +306,7 @@ impl AppearanceGraphSpec { pub(crate) fn compile(self) -> Result { let Self { mut color_inputs, + mut surface_input_ports, mut opacity_inputs, mut paints, mut surfaces, @@ -287,6 +318,11 @@ impl AppearanceGraphSpec { return Err(CompileError::DuplicateColorInput { input: duplicate }); } + surface_input_ports.sort_unstable(); + if let Some(duplicate) = adjacent_duplicate(&surface_input_ports) { + return Err(CompileError::DuplicateSurfaceInputPort { input: duplicate }); + } + opacity_inputs.sort_unstable(); if let Some(duplicate) = adjacent_duplicate(&opacity_inputs) { return Err(CompileError::DuplicateOpacityInput { input: duplicate }); @@ -323,6 +359,8 @@ impl AppearanceGraphSpec { } let has_color = |id: ColorInputId| color_inputs.binary_search(&id).is_ok(); + let has_surface_input = + |id: SurfaceInputPortId| surface_input_ports.binary_search(&id).is_ok(); let has_opacity = |id: OpacityInputId| opacity_inputs.binary_search(&id).is_ok(); let paint_index = |id: PaintId| paints.binary_search_by_key(&id, PaintSpec::id).ok(); let surface_index = @@ -363,11 +401,11 @@ impl AppearanceGraphSpec { for surface in &surfaces { match *surface { - SurfaceSpec::Input { id, color } => { - if !has_color(color) { - return Err(CompileError::MissingSurfaceColorInput { + SurfaceSpec::Input { id, port } => { + if !has_surface_input(port) { + return Err(CompileError::MissingSurfaceInputPort { surface: id, - input: color, + input: port, }); } } @@ -484,7 +522,7 @@ impl AppearanceGraphSpec { let compiled_surfaces = surfaces .iter() .map(|surface| match *surface { - SurfaceSpec::Input { id, color } => CompiledSurfaceSpec::Input { id, color }, + SurfaceSpec::Input { id, port } => CompiledSurfaceSpec::Input { id, port }, SurfaceSpec::FromOccurrence { id, occurrence } => { CompiledSurfaceSpec::FromOccurrence { id, @@ -510,6 +548,7 @@ impl AppearanceGraphSpec { Ok(CompiledAppearanceGraph { color_inputs, + surface_input_ports, opacity_inputs, paints: compiled_paints, surfaces: compiled_surfaces, @@ -643,8 +682,14 @@ enum CompiledPaintSpec { #[derive(Debug, Clone, PartialEq, Eq)] enum CompiledSurfaceSpec { - Input { id: SurfaceId, color: ColorInputId }, - FromOccurrence { id: SurfaceId, occurrence: usize }, + Input { + id: SurfaceId, + port: SurfaceInputPortId, + }, + FromOccurrence { + id: SurfaceId, + occurrence: usize, + }, } #[cfg(test)] @@ -672,6 +717,7 @@ struct CompiledOccurrenceSpec { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CompiledAppearanceGraph { color_inputs: Vec, + surface_input_ports: Vec, opacity_inputs: Vec, paints: Vec, surfaces: Vec, @@ -683,9 +729,31 @@ pub(crate) struct CompiledAppearanceGraph { /// Borrowed runtime-представление уже проверенного compiled IR. Оно отделяет /// исполнение от compiler-а и позволяет статическим внутренним adapter-ам не /// тащить admission/topology machinery в конечный binary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CompiledInputSchema<'a> { + color_inputs: &'a [ColorInputId], + surface_input_ports: &'a [SurfaceInputPortId], + opacity_inputs: &'a [OpacityInputId], +} + +impl<'a> CompiledInputSchema<'a> { + const fn new( + color_inputs: &'a [ColorInputId], + surface_input_ports: &'a [SurfaceInputPortId], + opacity_inputs: &'a [OpacityInputId], + ) -> Self { + Self { + color_inputs, + surface_input_ports, + opacity_inputs, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct CompiledAppearanceProgram<'a> { color_inputs: &'a [ColorInputId], + surface_input_ports: &'a [SurfaceInputPortId], opacity_inputs: &'a [OpacityInputId], paints: &'a [CompiledPaintSpec], surfaces: &'a [CompiledSurfaceSpec], @@ -699,8 +767,7 @@ impl<'a> CompiledAppearanceProgram<'a> { /// `AppearanceGraphSpec::compile`. Static adapter обязан защищать это /// равенство characterization-тестом. const fn from_validated_parts( - color_inputs: &'a [ColorInputId], - opacity_inputs: &'a [OpacityInputId], + inputs: CompiledInputSchema<'a>, paints: &'a [CompiledPaintSpec], surfaces: &'a [CompiledSurfaceSpec], occurrences: &'a [CompiledOccurrenceSpec], @@ -708,8 +775,9 @@ impl<'a> CompiledAppearanceProgram<'a> { render_topo: &'a [RenderNode], ) -> Self { Self { - color_inputs, - opacity_inputs, + color_inputs: inputs.color_inputs, + surface_input_ports: inputs.surface_input_ports, + opacity_inputs: inputs.opacity_inputs, paints, surfaces, occurrences, @@ -720,7 +788,7 @@ impl<'a> CompiledAppearanceProgram<'a> { } const POINT_SOURCE: ColorInputId = ColorInputId::new(0); -const POINT_CONTEXT: ColorInputId = ColorInputId::new(1); +const POINT_CONTEXT: SurfaceInputPortId = SurfaceInputPortId::new(0); const POINT_OPACITY: OpacityInputId = OpacityInputId::new(0); const POINT_SOLID_PAINT: PaintId = PaintId::new(0); const POINT_OPACITY_PAINT: PaintId = PaintId::new(1); @@ -728,7 +796,8 @@ const POINT_CONTEXT_SURFACE: SurfaceId = SurfaceId::new(0); const POINT_DERIVED_SURFACE: SurfaceId = SurfaceId::new(1); const POINT_OCCURRENCE: OccurrenceId = OccurrenceId::new(0); -const POINT_COLOR_INPUTS: [ColorInputId; 2] = [POINT_SOURCE, POINT_CONTEXT]; +const POINT_COLOR_INPUTS: [ColorInputId; 1] = [POINT_SOURCE]; +const POINT_SURFACE_INPUT_PORTS: [SurfaceInputPortId; 1] = [POINT_CONTEXT]; const POINT_OPACITY_INPUTS: [OpacityInputId; 1] = [POINT_OPACITY]; const POINT_PAINTS: [CompiledPaintSpec; 2] = [ CompiledPaintSpec::Solid { @@ -744,7 +813,7 @@ const POINT_PAINTS: [CompiledPaintSpec; 2] = [ const POINT_SURFACES: [CompiledSurfaceSpec; 2] = [ CompiledSurfaceSpec::Input { id: POINT_CONTEXT_SURFACE, - color: POINT_CONTEXT, + port: POINT_CONTEXT, }, CompiledSurfaceSpec::FromOccurrence { id: POINT_DERIVED_SURFACE, @@ -767,8 +836,11 @@ const POINT_RENDER_TOPO: [RenderNode; 3] = [ ]; const POINT_OPACITY_OVER_SURFACE_V1: CompiledAppearanceProgram<'static> = CompiledAppearanceProgram::from_validated_parts( - &POINT_COLOR_INPUTS, - &POINT_OPACITY_INPUTS, + CompiledInputSchema::new( + &POINT_COLOR_INPUTS, + &POINT_SURFACE_INPUT_PORTS, + &POINT_OPACITY_INPUTS, + ), &POINT_PAINTS, &POINT_SURFACES, &POINT_OCCURRENCES, @@ -793,9 +865,12 @@ impl PointOpacityOverSurfaceV1 { let mut occurrences = [None; 1]; POINT_OPACITY_OVER_SURFACE_V1.execute_into( |id| match id { - POINT_SOURCE => source, - POINT_CONTEXT => backdrop, - _ => unreachable!("sealed point program has two ColorInput ports"), + POINT_SOURCE => Srgb8::new(source), + _ => unreachable!("sealed point program has one ColorInput port"), + }, + |id| match id { + POINT_CONTEXT => Srgb8::new(backdrop), + _ => unreachable!("sealed point program has one SurfaceInput port"), }, |id| match id { POINT_OPACITY => opacity, @@ -815,6 +890,7 @@ impl PointOpacityOverSurfaceV1 { pub(crate) fn point_opacity_over_surface_declarative_spec() -> AppearanceGraphSpec { AppearanceGraphSpec::new( POINT_COLOR_INPUTS.to_vec(), + POINT_SURFACE_INPUT_PORTS.to_vec(), POINT_OPACITY_INPUTS.to_vec(), vec![ PaintSpec::Solid { @@ -830,7 +906,7 @@ pub(crate) fn point_opacity_over_surface_declarative_spec() -> AppearanceGraphSp vec![ SurfaceSpec::Input { id: POINT_CONTEXT_SURFACE, - color: POINT_CONTEXT, + port: POINT_CONTEXT, }, SurfaceSpec::FromOccurrence { id: POINT_DERIVED_SURFACE, @@ -855,19 +931,26 @@ pub(crate) fn point_program_matches(compiled: &CompiledAppearanceGraph) -> bool #[cfg(test)] #[derive(Debug, Clone, PartialEq)] pub(crate) struct AppearanceBindings { - colors: Vec<(ColorInputId, [u8; 3])>, + colors: Vec<(ColorInputId, Srgb8)>, + surfaces: Vec<(SurfaceInputPortId, Srgb8)>, opacities: Vec<(OpacityInputId, f64)>, } #[cfg(test)] impl AppearanceBindings { pub(crate) fn new( - mut colors: Vec<(ColorInputId, [u8; 3])>, + mut colors: Vec<(ColorInputId, Srgb8)>, + mut surfaces: Vec<(SurfaceInputPortId, Srgb8)>, mut opacities: Vec<(OpacityInputId, f64)>, ) -> Self { colors.sort_unstable_by_key(|(id, _)| *id); + surfaces.sort_unstable_by_key(|(id, _)| *id); opacities.sort_unstable_by_key(|(id, _)| *id); - Self { colors, opacities } + Self { + colors, + surfaces, + opacities, + } } } @@ -876,14 +959,14 @@ impl AppearanceBindings { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct ResolvedPaint { id: PaintId, - rgb: [u8; 3], + rgb: Srgb8, opacity_bits: u64, } impl ResolvedPaint { #[cfg(test)] pub(crate) fn rgb(&self) -> [u8; 3] { - self.rgb + self.rgb.bytes() } #[cfg(test)] @@ -1055,7 +1138,7 @@ impl VisiblePointBindingV1 { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AppearanceEvaluation { paints: Vec, - surfaces: Vec<(SurfaceId, [u8; 3])>, + surfaces: Vec<(SurfaceId, Srgb8)>, occurrences: Vec, } @@ -1072,7 +1155,7 @@ impl AppearanceEvaluation { self.surfaces .binary_search_by_key(&id, |(surface, _)| *surface) .ok() - .map(|index| self.surfaces[index].1) + .map(|index| self.surfaces[index].1.bytes()) } pub(crate) fn occurrence(&self, id: OccurrenceId) -> Option<&ResolvedOccurrence> { @@ -1087,8 +1170,11 @@ impl AppearanceEvaluation { impl CompiledAppearanceGraph { fn program(&self) -> CompiledAppearanceProgram<'_> { CompiledAppearanceProgram::from_validated_parts( - &self.color_inputs, - &self.opacity_inputs, + CompiledInputSchema::new( + &self.color_inputs, + &self.surface_input_ports, + &self.opacity_inputs, + ), &self.paints, &self.surfaces, &self.occurrences, @@ -1128,6 +1214,13 @@ impl CompiledAppearanceProgram<'_> { { return Err(BindingError::DuplicateOpacityBinding { input: window[0].0 }); } + let surfaces = &bindings.surfaces; + if let Some(window) = surfaces + .windows(2) + .find(|window| window[0].0 == window[1].0) + { + return Err(BindingError::DuplicateSurfaceInputBinding { input: window[0].0 }); + } for declared in self.color_inputs { if colors @@ -1145,6 +1238,14 @@ impl CompiledAppearanceProgram<'_> { return Err(BindingError::MissingOpacityBinding { input: *declared }); } } + for declared in self.surface_input_ports { + if surfaces + .binary_search_by_key(declared, |(id, _)| *id) + .is_err() + { + return Err(BindingError::MissingSurfaceInputBinding { input: *declared }); + } + } for (bound, _) in colors { if self.color_inputs.binary_search(bound).is_err() { return Err(BindingError::UnexpectedColorBinding { input: *bound }); @@ -1155,6 +1256,11 @@ impl CompiledAppearanceProgram<'_> { return Err(BindingError::UnexpectedOpacityBinding { input: *bound }); } } + for (bound, _) in surfaces { + if self.surface_input_ports.binary_search(bound).is_err() { + return Err(BindingError::UnexpectedSurfaceInputBinding { input: *bound }); + } + } for (input, alpha) in opacities { if let Err(message) = crate::alpha::validate_alpha(*alpha) { return Err(BindingError::OpacityOutOfDomain { @@ -1164,12 +1270,18 @@ impl CompiledAppearanceProgram<'_> { } } - let color_value = |id: ColorInputId| -> [u8; 3] { + let color_value = |id: ColorInputId| -> Srgb8 { let index = colors .binary_search_by_key(&id, |(bound, _)| *bound) .unwrap_or_else(|_| unreachable!("bindings were matched before evaluation")); colors[index].1 }; + let surface_value = |id: SurfaceInputPortId| -> Srgb8 { + let index = surfaces + .binary_search_by_key(&id, |(bound, _)| *bound) + .unwrap_or_else(|_| unreachable!("bindings were matched before evaluation")); + surfaces[index].1 + }; let opacity_value = |id: OpacityInputId| -> f64 { let index = opacities .binary_search_by_key(&id, |(bound, _)| *bound) @@ -1181,11 +1293,12 @@ impl CompiledAppearanceProgram<'_> { }; let mut resolved_paints: Vec> = vec![None; self.paints.len()]; - let mut resolved_surfaces: Vec> = vec![None; self.surfaces.len()]; + let mut resolved_surfaces: Vec> = vec![None; self.surfaces.len()]; let mut resolved_occurrences: Vec> = vec![None; self.occurrences.len()]; self.execute_into( color_value, + surface_value, opacity_value, &mut resolved_paints, &mut resolved_surfaces, @@ -1224,15 +1337,17 @@ impl CompiledAppearanceProgram<'_> { /// Единственное исполнение compiled IR. Scratch принадлежит caller-у: /// static adapter использует stack arrays, test-only generic admission — /// динамические buffers. Алгоритм и сертификат при этом общие. - fn execute_into( + fn execute_into( &self, color_value: C, + surface_value: S, opacity_value: O, resolved_paints: &mut [Option], - resolved_surfaces: &mut [Option<[u8; 3]>], + resolved_surfaces: &mut [Option], resolved_occurrences: &mut [Option], ) where - C: Fn(ColorInputId) -> [u8; 3], + C: Fn(ColorInputId) -> Srgb8, + S: Fn(SurfaceInputPortId) -> Srgb8, O: Fn(OpacityInputId) -> f64, { debug_assert_eq!(resolved_paints.len(), self.paints.len()); @@ -1269,14 +1384,14 @@ impl CompiledAppearanceProgram<'_> { match *node { RenderNode::Surface(index) => { let value = match self.surfaces[index] { - CompiledSurfaceSpec::Input { color, .. } => color_value(color), - CompiledSurfaceSpec::FromOccurrence { occurrence, .. } => { + CompiledSurfaceSpec::Input { port, .. } => surface_value(port), + CompiledSurfaceSpec::FromOccurrence { occurrence, .. } => Srgb8::new( resolved_occurrences[occurrence] .unwrap_or_else(|| { unreachable!("occurrence precedes surfaceFrom in render topo") }) - .visible() - } + .visible(), + ), }; resolved_surfaces[index] = Some(value); } @@ -1290,9 +1405,9 @@ impl CompiledAppearanceProgram<'_> { let visible = match spec.profile { CompositionProfileV1::EncodedSrgb8SourceOverV1 => { crate::alpha::composite_over_srgb8_validated( - subject.rgb, + subject.rgb.bytes(), f64::from_bits(subject.opacity_bits), - backdrop, + backdrop.bytes(), ) } }; @@ -1300,17 +1415,17 @@ impl CompiledAppearanceProgram<'_> { profile: spec.profile, occurrence: spec.id, subject: spec.subject_id, - subject_rgb: subject.rgb, + subject_rgb: subject.rgb.bytes(), subject_opacity_bits: subject.opacity_bits, backdrop_surface: spec.against_id, - backdrop_rgb: backdrop, + backdrop_rgb: backdrop.bytes(), output_rgb: visible, }; resolved_occurrences[index] = Some(ResolvedOccurrence { id: spec.id, subject: spec.subject_id, against: spec.against_id, - backdrop, + backdrop: backdrop.bytes(), visible, certificate, }); diff --git a/crates/labcolors-core/src/appearance_graph_tests.rs b/crates/labcolors-core/src/appearance_graph_tests.rs index 360c3adb..0713af47 100644 --- a/crates/labcolors-core/src/appearance_graph_tests.rs +++ b/crates/labcolors-core/src/appearance_graph_tests.rs @@ -7,15 +7,17 @@ use proptest::prelude::*; +use crate::Srgb8; use crate::appearance::{ AppearanceBindings, AppearanceGraphSpec, BindingError, ColorInputId, CompileError, CompositionProfileV1, OccurrenceId, OccurrenceSpec, OpacityInputId, PaintId, PaintSpec, - SurfaceId, SurfaceSpec, + SurfaceId, SurfaceInputPortId, SurfaceSpec, }; const SOURCE: ColorInputId = ColorInputId::new(0); -const CONTEXT: ColorInputId = ColorInputId::new(1); const OTHER_SOURCE: ColorInputId = ColorInputId::new(2); +const CONTEXT: SurfaceInputPortId = SurfaceInputPortId::new(1); +const OTHER_CONTEXT: SurfaceInputPortId = SurfaceInputPortId::new(2); const OPACITY: OpacityInputId = OpacityInputId::new(0); const OTHER_OPACITY: OpacityInputId = OpacityInputId::new(1); const SOLID_PAINT: PaintId = PaintId::new(70); @@ -40,7 +42,7 @@ fn point_component(reverse_paints: bool, reverse_surfaces: bool) -> AppearanceGr let mut surfaces = vec![ SurfaceSpec::Input { id: CONTEXT_SURFACE, - color: CONTEXT, + port: CONTEXT, }, SurfaceSpec::FromOccurrence { id: DERIVED_SURFACE, @@ -55,7 +57,8 @@ fn point_component(reverse_paints: bool, reverse_surfaces: bool) -> AppearanceGr } AppearanceGraphSpec::new( - vec![SOURCE, CONTEXT], + vec![SOURCE], + vec![CONTEXT], vec![OPACITY], paints, surfaces, @@ -70,7 +73,8 @@ fn point_component(reverse_paints: bool, reverse_surfaces: bool) -> AppearanceGr fn bindings(source: [u8; 3], opacity: f64, context: [u8; 3]) -> AppearanceBindings { AppearanceBindings::new( - vec![(SOURCE, source), (CONTEXT, context)], + vec![(SOURCE, Srgb8::new(source))], + vec![(CONTEXT, Srgb8::new(context))], vec![(OPACITY, opacity)], ) } @@ -137,7 +141,7 @@ fn compile_and_evaluate_ignore_declaration_order() { #[test] fn complete_typed_id_renaming_does_not_change_physics() { let source = ColorInputId::new(700); - let context = ColorInputId::new(42); + let context = SurfaceInputPortId::new(42); let opacity = OpacityInputId::new(91); let solid = PaintId::new(901); let fill = PaintId::new(11); @@ -145,7 +149,8 @@ fn complete_typed_id_renaming_does_not_change_physics() { let derived_surface = SurfaceId::new(12); let occurrence = OccurrenceId::new(501); let renamed = AppearanceGraphSpec::new( - vec![source, context], + vec![source], + vec![context], vec![opacity], vec![ PaintSpec::Opacity { @@ -165,7 +170,7 @@ fn complete_typed_id_renaming_does_not_change_physics() { }, SurfaceSpec::Input { id: context_surface, - color: context, + port: context, }, ], vec![OccurrenceSpec { @@ -188,7 +193,8 @@ fn complete_typed_id_renaming_does_not_change_physics() { .unwrap(); let second = renamed .evaluate(&AppearanceBindings::new( - vec![(source, source_rgb), (context, context_rgb)], + vec![(source, Srgb8::new(source_rgb))], + vec![(context, Srgb8::new(context_rgb))], vec![(opacity, alpha)], )) .unwrap(); @@ -214,7 +220,8 @@ fn complete_typed_id_renaming_does_not_change_physics() { #[test] fn occurrence_uses_the_declared_paint_not_an_unrelated_color_input() { let graph = AppearanceGraphSpec::new( - vec![SOURCE, CONTEXT, OTHER_SOURCE], + vec![SOURCE, OTHER_SOURCE], + vec![CONTEXT], vec![OPACITY], vec![ PaintSpec::Solid { @@ -233,7 +240,7 @@ fn occurrence_uses_the_declared_paint_not_an_unrelated_color_input() { ], vec![SurfaceSpec::Input { id: CONTEXT_SURFACE, - color: CONTEXT, + port: CONTEXT, }], vec![OccurrenceSpec { id: FILL_OCCURRENCE, @@ -247,10 +254,10 @@ fn occurrence_uses_the_declared_paint_not_an_unrelated_color_input() { let rendered = graph .evaluate(&AppearanceBindings::new( vec![ - (SOURCE, [10, 20, 30]), - (CONTEXT, [200, 200, 200]), - (OTHER_SOURCE, [111, 112, 113]), + (SOURCE, Srgb8::new([10, 20, 30])), + (OTHER_SOURCE, Srgb8::new([111, 112, 113])), ], + vec![(CONTEXT, Srgb8::new([200, 200, 200]))], vec![(OPACITY, 0.25)], )) .unwrap(); @@ -267,7 +274,8 @@ fn occurrence_uses_the_declared_paint_not_an_unrelated_color_input() { fn nested_opacity_materializes_once_by_multiplying_opacity() { let nested = PaintId::new(99); let graph = AppearanceGraphSpec::new( - vec![SOURCE, CONTEXT], + vec![SOURCE], + vec![CONTEXT], vec![OTHER_OPACITY, OPACITY], vec![ PaintSpec::Opacity { @@ -287,7 +295,7 @@ fn nested_opacity_materializes_once_by_multiplying_opacity() { ], vec![SurfaceSpec::Input { id: CONTEXT_SURFACE, - color: CONTEXT, + port: CONTEXT, }], vec![OccurrenceSpec { id: FILL_OCCURRENCE, @@ -300,7 +308,8 @@ fn nested_opacity_materializes_once_by_multiplying_opacity() { .unwrap(); let rendered = graph .evaluate(&AppearanceBindings::new( - vec![(SOURCE, [0; 3]), (CONTEXT, [255; 3])], + vec![(SOURCE, Srgb8::new([0; 3]))], + vec![(CONTEXT, Srgb8::new([255; 3]))], vec![(OTHER_OPACITY, 0.5), (OPACITY, 0.5)], )) .unwrap(); @@ -326,7 +335,8 @@ fn nested_opacity_materializes_once_by_multiplying_opacity() { fn nested_opacity_preserves_subnormal_and_rounds_underflow_to_positive_zero() { let nested = PaintId::new(99); let graph = AppearanceGraphSpec::new( - vec![SOURCE, CONTEXT], + vec![SOURCE], + vec![CONTEXT], vec![OPACITY, OTHER_OPACITY], vec![ PaintSpec::Solid { @@ -346,7 +356,7 @@ fn nested_opacity_preserves_subnormal_and_rounds_underflow_to_positive_zero() { ], vec![SurfaceSpec::Input { id: CONTEXT_SURFACE, - color: CONTEXT, + port: CONTEXT, }], vec![OccurrenceSpec { id: FILL_OCCURRENCE, @@ -368,7 +378,8 @@ fn nested_opacity_preserves_subnormal_and_rounds_underflow_to_positive_zero() { for (inner, expected_bits) in cases { let rendered = graph .evaluate(&AppearanceBindings::new( - vec![(SOURCE, [255, 0, 0]), (CONTEXT, [0; 3])], + vec![(SOURCE, Srgb8::new([255, 0, 0]))], + vec![(CONTEXT, Srgb8::new([0; 3]))], vec![(OPACITY, inner), (OTHER_OPACITY, 0.5)], )) .unwrap(); @@ -406,7 +417,8 @@ fn opacity_constructor_edges_define_binary64_operation_order() { let second = PaintId::new(102); let third = PaintId::new(103); AppearanceGraphSpec::new( - vec![SOURCE, CONTEXT], + vec![SOURCE], + vec![CONTEXT], vec![alpha_a, alpha_b, alpha_c], vec![ PaintSpec::Solid { @@ -431,7 +443,7 @@ fn opacity_constructor_edges_define_binary64_operation_order() { ], vec![SurfaceSpec::Input { id: CONTEXT_SURFACE, - color: CONTEXT, + port: CONTEXT, }], vec![OccurrenceSpec { id: FILL_OCCURRENCE, @@ -443,7 +455,8 @@ fn opacity_constructor_edges_define_binary64_operation_order() { .compile() .unwrap() .evaluate(&AppearanceBindings::new( - vec![(SOURCE, [255, 0, 0]), (CONTEXT, [0; 3])], + vec![(SOURCE, Srgb8::new([255, 0, 0]))], + vec![(CONTEXT, Srgb8::new([0; 3]))], values.to_vec(), )) .unwrap() @@ -466,7 +479,8 @@ fn one_paint_is_surface_agnostic_across_two_occurrences() { let on_black = OccurrenceId::new(10); let on_white = OccurrenceId::new(11); let graph = AppearanceGraphSpec::new( - vec![SOURCE, CONTEXT, OTHER_SOURCE], + vec![SOURCE], + vec![CONTEXT, OTHER_CONTEXT], vec![OPACITY], vec![ PaintSpec::Solid { @@ -482,11 +496,11 @@ fn one_paint_is_surface_agnostic_across_two_occurrences() { vec![ SurfaceSpec::Input { id: black, - color: CONTEXT, + port: CONTEXT, }, SurfaceSpec::Input { id: white, - color: OTHER_SOURCE, + port: OTHER_CONTEXT, }, ], vec![ @@ -508,10 +522,10 @@ fn one_paint_is_surface_agnostic_across_two_occurrences() { .unwrap(); let rendered = graph .evaluate(&AppearanceBindings::new( + vec![(SOURCE, Srgb8::new([240, 60, 20]))], vec![ - (SOURCE, [240, 60, 20]), - (CONTEXT, [0; 3]), - (OTHER_SOURCE, [255; 3]), + (CONTEXT, Srgb8::new([0; 3])), + (OTHER_CONTEXT, Srgb8::new([255; 3])), ], vec![(OPACITY, 0.5)], )) @@ -528,8 +542,8 @@ fn one_paint_is_surface_agnostic_across_two_occurrences() { #[test] fn surface_from_reuses_visible_result_without_recompositing() { let black_color = ColorInputId::new(10); - let white_color = ColorInputId::new(11); let red_color = ColorInputId::new(12); + let page_port = SurfaceInputPortId::new(11); let black_solid = PaintId::new(10); let black_tint = PaintId::new(11); let red_solid = PaintId::new(12); @@ -540,7 +554,8 @@ fn surface_from_reuses_visible_result_without_recompositing() { let first_surface = SurfaceId::new(11); let first = OccurrenceId::new(10); let second = OccurrenceId::new(11); - let colors = vec![black_color, white_color, red_color]; + let colors = vec![black_color, red_color]; + let surface_inputs = vec![page_port]; let opacities = vec![black_alpha, red_alpha]; let paints = vec![ PaintSpec::Solid { @@ -565,7 +580,7 @@ fn surface_from_reuses_visible_result_without_recompositing() { let surfaces = vec![ SurfaceSpec::Input { id: page, - color: white_color, + port: page_port, }, SurfaceSpec::FromOccurrence { id: first_surface, @@ -588,6 +603,7 @@ fn surface_from_reuses_visible_result_without_recompositing() { ]; let graph = AppearanceGraphSpec::new( colors.clone(), + surface_inputs.clone(), opacities.clone(), paints.clone(), surfaces.clone(), @@ -597,16 +613,22 @@ fn surface_from_reuses_visible_result_without_recompositing() { .unwrap(); let mut reversed_occurrences = occurrences; reversed_occurrences.reverse(); - let reordered = - AppearanceGraphSpec::new(colors, opacities, paints, surfaces, reversed_occurrences) - .compile() - .unwrap(); + let reordered = AppearanceGraphSpec::new( + colors, + surface_inputs, + opacities, + paints, + surfaces, + reversed_occurrences, + ) + .compile() + .unwrap(); let bindings = AppearanceBindings::new( vec![ - (black_color, [0; 3]), - (white_color, [255; 3]), - (red_color, [255, 0, 0]), + (black_color, Srgb8::new([0; 3])), + (red_color, Srgb8::new([255, 0, 0])), ], + vec![(page_port, Srgb8::new([255; 3]))], vec![(black_alpha, 0.25), (red_alpha, 0.5)], ); let rendered = graph.evaluate(&bindings).unwrap(); @@ -700,12 +722,13 @@ proptest! { subject = next; } let graph = AppearanceGraphSpec::new( - vec![SOURCE, CONTEXT], + vec![SOURCE], + vec![CONTEXT], opacity_inputs.clone(), paints, vec![SurfaceSpec::Input { id: CONTEXT_SURFACE, - color: CONTEXT, + port: CONTEXT, }], vec![OccurrenceSpec { id: FILL_OCCURRENCE, @@ -719,7 +742,8 @@ proptest! { let effective = alphas.iter().copied().fold(1.0, |product, alpha| product * alpha); let rendered = graph .evaluate(&AppearanceBindings::new( - vec![(SOURCE, source), (CONTEXT, context)], + vec![(SOURCE, Srgb8::new(source))], + vec![(CONTEXT, Srgb8::new(context))], opacity_inputs .iter() .copied() @@ -743,7 +767,8 @@ proptest! { prop_assume!(!invalid.is_finite() || !(0.0..=1.0).contains(&invalid)); let outer_paint = PaintId::new(99); let graph = AppearanceGraphSpec::new( - vec![SOURCE, CONTEXT], + vec![SOURCE], + vec![CONTEXT], vec![OPACITY, OTHER_OPACITY], vec![ PaintSpec::Solid { @@ -763,7 +788,7 @@ proptest! { ], vec![SurfaceSpec::Input { id: CONTEXT_SURFACE, - color: CONTEXT, + port: CONTEXT, }], vec![OccurrenceSpec { id: FILL_OCCURRENCE, @@ -782,7 +807,8 @@ proptest! { let expected_message = crate::alpha::validate_alpha(invalid).unwrap_err(); prop_assert_eq!( graph.evaluate(&AppearanceBindings::new( - vec![(SOURCE, [1, 2, 3]), (CONTEXT, [4, 5, 6])], + vec![(SOURCE, Srgb8::new([1, 2, 3]))], + vec![(CONTEXT, Srgb8::new([4, 5, 6]))], vec![(OPACITY, inner), (OTHER_OPACITY, outer)], )), Err(BindingError::OpacityOutOfDomain { @@ -796,16 +822,38 @@ proptest! { #[test] fn compile_rejects_duplicate_declarations_with_typed_errors() { assert_eq!( - AppearanceGraphSpec::new(vec![SOURCE, SOURCE], vec![], vec![], vec![], vec![]).compile(), + AppearanceGraphSpec::new(vec![SOURCE, SOURCE], vec![], vec![], vec![], vec![], vec![]) + .compile(), Err(CompileError::DuplicateColorInput { input: SOURCE }) ); assert_eq!( - AppearanceGraphSpec::new(vec![], vec![OPACITY, OPACITY], vec![], vec![], vec![]).compile(), + AppearanceGraphSpec::new( + vec![], + vec![], + vec![OPACITY, OPACITY], + vec![], + vec![], + vec![] + ) + .compile(), Err(CompileError::DuplicateOpacityInput { input: OPACITY }) ); assert_eq!( AppearanceGraphSpec::new( - vec![SOURCE, CONTEXT], + vec![], + vec![CONTEXT, CONTEXT], + vec![], + vec![], + vec![], + vec![] + ) + .compile(), + Err(CompileError::DuplicateSurfaceInputPort { input: CONTEXT }) + ); + assert_eq!( + AppearanceGraphSpec::new( + vec![SOURCE, OTHER_SOURCE], + vec![], vec![], vec![ PaintSpec::Solid { @@ -814,7 +862,7 @@ fn compile_rejects_duplicate_declarations_with_typed_errors() { }, PaintSpec::Solid { id: SOLID_PAINT, - color: CONTEXT, + color: OTHER_SOURCE, }, ], vec![], @@ -825,17 +873,18 @@ fn compile_rejects_duplicate_declarations_with_typed_errors() { ); assert_eq!( AppearanceGraphSpec::new( - vec![SOURCE, CONTEXT], + vec![], + vec![CONTEXT, OTHER_CONTEXT], vec![], vec![], vec![ SurfaceSpec::Input { id: CONTEXT_SURFACE, - color: SOURCE, + port: CONTEXT, }, SurfaceSpec::Input { id: CONTEXT_SURFACE, - color: CONTEXT, + port: OTHER_CONTEXT, }, ], vec![] @@ -851,6 +900,7 @@ fn compile_rejects_duplicate_declarations_with_typed_errors() { vec![], vec![], vec![], + vec![], vec![ OccurrenceSpec { id: FILL_OCCURRENCE, @@ -876,6 +926,7 @@ fn compile_rejects_duplicate_declarations_with_typed_errors() { #[test] fn compile_rejects_every_dangling_canonical_edge() { let missing_color = AppearanceGraphSpec::new( + vec![], vec![], vec![], vec![PaintSpec::Solid { @@ -895,6 +946,7 @@ fn compile_rejects_every_dangling_canonical_edge() { ); let missing_paint = AppearanceGraphSpec::new( + vec![], vec![], vec![OPACITY], vec![PaintSpec::Opacity { @@ -917,6 +969,7 @@ fn compile_rejects_every_dangling_canonical_edge() { let missing_opacity = AppearanceGraphSpec::new( vec![SOURCE], vec![], + vec![], vec![ PaintSpec::Solid { id: SOLID_PAINT, @@ -940,20 +993,21 @@ fn compile_rejects_every_dangling_canonical_edge() { }) ); - let missing_surface_color = AppearanceGraphSpec::new( + let missing_surface_input = AppearanceGraphSpec::new( + vec![], vec![], vec![], vec![], vec![SurfaceSpec::Input { id: CONTEXT_SURFACE, - color: CONTEXT, + port: CONTEXT, }], vec![], ) .compile(); assert_eq!( - missing_surface_color, - Err(CompileError::MissingSurfaceColorInput { + missing_surface_input, + Err(CompileError::MissingSurfaceInputPort { surface: CONTEXT_SURFACE, input: CONTEXT, }) @@ -963,6 +1017,7 @@ fn compile_rejects_every_dangling_canonical_edge() { vec![], vec![], vec![], + vec![], vec![SurfaceSpec::FromOccurrence { id: DERIVED_SURFACE, occurrence: FILL_OCCURRENCE, @@ -979,12 +1034,13 @@ fn compile_rejects_every_dangling_canonical_edge() { ); let missing_occurrence_paint = AppearanceGraphSpec::new( + vec![], vec![CONTEXT], vec![], vec![], vec![SurfaceSpec::Input { id: CONTEXT_SURFACE, - color: CONTEXT, + port: CONTEXT, }], vec![OccurrenceSpec { id: FILL_OCCURRENCE, @@ -1005,6 +1061,7 @@ fn compile_rejects_every_dangling_canonical_edge() { let missing_occurrence_backdrop = AppearanceGraphSpec::new( vec![SOURCE], vec![], + vec![], vec![PaintSpec::Solid { id: SOLID_PAINT, color: SOURCE, @@ -1033,6 +1090,7 @@ fn cycle_errors_contain_only_actual_cycle_members() { let cycle_b = PaintId::new(20); let dependent = PaintId::new(1); let paint_cycle = AppearanceGraphSpec::new( + vec![], vec![], vec![OPACITY], vec![ @@ -1070,6 +1128,7 @@ fn cycle_errors_contain_only_actual_cycle_members() { let render_cycle = AppearanceGraphSpec::new( vec![SOURCE], vec![], + vec![], vec![PaintSpec::Solid { id: SOLID_PAINT, color: SOURCE, @@ -1115,31 +1174,42 @@ fn evaluate_rejects_duplicate_missing_and_unexpected_bindings() { assert_eq!( graph.evaluate(&AppearanceBindings::new( vec![ - (SOURCE, [1, 2, 3]), - (CONTEXT, [4, 5, 6]), - (SOURCE, [7, 8, 9]), + (SOURCE, Srgb8::new([1, 2, 3])), + (SOURCE, Srgb8::new([7, 8, 9])), ], + vec![(CONTEXT, Srgb8::new([4, 5, 6]))], vec![(OPACITY, 0.5)], )), Err(BindingError::DuplicateColorBinding { input: SOURCE }) ); assert_eq!( graph.evaluate(&AppearanceBindings::new( - vec![(SOURCE, [1, 2, 3]), (CONTEXT, [4, 5, 6])], + vec![(SOURCE, Srgb8::new([1, 2, 3]))], + vec![(CONTEXT, Srgb8::new([4, 5, 6]))], vec![(OPACITY, 0.5), (OPACITY, 0.6)], )), Err(BindingError::DuplicateOpacityBinding { input: OPACITY }) ); assert_eq!( graph.evaluate(&AppearanceBindings::new( - vec![(SOURCE, [1, 2, 3])], + vec![], + vec![(CONTEXT, Srgb8::new([4, 5, 6]))], + vec![(OPACITY, 0.5)], + )), + Err(BindingError::MissingColorBinding { input: SOURCE }) + ); + assert_eq!( + graph.evaluate(&AppearanceBindings::new( + vec![(SOURCE, Srgb8::new([1, 2, 3]))], + vec![], vec![(OPACITY, 0.5)], )), - Err(BindingError::MissingColorBinding { input: CONTEXT }) + Err(BindingError::MissingSurfaceInputBinding { input: CONTEXT }) ); assert_eq!( graph.evaluate(&AppearanceBindings::new( - vec![(SOURCE, [1, 2, 3]), (CONTEXT, [4, 5, 6])], + vec![(SOURCE, Srgb8::new([1, 2, 3]))], + vec![(CONTEXT, Srgb8::new([4, 5, 6]))], vec![], )), Err(BindingError::MissingOpacityBinding { input: OPACITY }) @@ -1147,10 +1217,10 @@ fn evaluate_rejects_duplicate_missing_and_unexpected_bindings() { assert_eq!( graph.evaluate(&AppearanceBindings::new( vec![ - (SOURCE, [1, 2, 3]), - (CONTEXT, [4, 5, 6]), - (ColorInputId::new(9), [7, 8, 9]), + (SOURCE, Srgb8::new([1, 2, 3])), + (ColorInputId::new(9), Srgb8::new([7, 8, 9])), ], + vec![(CONTEXT, Srgb8::new([4, 5, 6]))], vec![(OPACITY, 0.5)], )), Err(BindingError::UnexpectedColorBinding { @@ -1159,13 +1229,27 @@ fn evaluate_rejects_duplicate_missing_and_unexpected_bindings() { ); assert_eq!( graph.evaluate(&AppearanceBindings::new( - vec![(SOURCE, [1, 2, 3]), (CONTEXT, [4, 5, 6])], + vec![(SOURCE, Srgb8::new([1, 2, 3]))], + vec![(CONTEXT, Srgb8::new([4, 5, 6]))], vec![(OPACITY, 0.5), (OpacityInputId::new(9), 0.5)], )), Err(BindingError::UnexpectedOpacityBinding { input: OpacityInputId::new(9), }) ); + assert_eq!( + graph.evaluate(&AppearanceBindings::new( + vec![(SOURCE, Srgb8::new([1, 2, 3]))], + vec![ + (CONTEXT, Srgb8::new([4, 5, 6])), + (SurfaceInputPortId::new(9), Srgb8::new([7, 8, 9])), + ], + vec![(OPACITY, 0.5)], + )), + Err(BindingError::UnexpectedSurfaceInputBinding { + input: SurfaceInputPortId::new(9), + }) + ); } #[test] diff --git a/crates/labcolors-core/src/constraint_tests.rs b/crates/labcolors-core/src/constraint_tests.rs index 838e83f2..73c3d6da 100644 --- a/crates/labcolors-core/src/constraint_tests.rs +++ b/crates/labcolors-core/src/constraint_tests.rs @@ -3,7 +3,7 @@ use proptest::prelude::*; use crate::appearance::{ AppearanceBindings, AppearanceGraphSpec, ColorInputId, CompositionProfileV1, OccurrenceId, OccurrenceSpec, OpacityInputId, PaintId, PaintSpec, PointOpacityOverSurfaceV1, SurfaceId, - SurfaceSpec, + SurfaceInputPortId, SurfaceSpec, }; use crate::constraints::{BoundAssessment, Wcag22Srgb8V1, assess}; use crate::wcag22::{ @@ -107,7 +107,7 @@ fn modeled_target_has_no_binding_or_source_capability() { fn two_equal_physical_occurrences() -> [crate::appearance::ResolvedOccurrence; 2] { let source = ColorInputId::new(0); - let backdrop = ColorInputId::new(1); + let backdrop = SurfaceInputPortId::new(1); let opacity = OpacityInputId::new(0); let solid = PaintId::new(0); let translucent = PaintId::new(1); @@ -117,7 +117,8 @@ fn two_equal_physical_occurrences() -> [crate::appearance::ResolvedOccurrence; 2 let first = OccurrenceId::new(0); let second = OccurrenceId::new(1); let graph = AppearanceGraphSpec::new( - vec![source, backdrop], + vec![source], + vec![backdrop], vec![opacity], vec![ PaintSpec::Solid { @@ -133,7 +134,7 @@ fn two_equal_physical_occurrences() -> [crate::appearance::ResolvedOccurrence; 2 vec![ SurfaceSpec::Input { id: backdrop_surface, - color: backdrop, + port: backdrop, }, SurfaceSpec::FromOccurrence { id: first_surface, @@ -163,7 +164,8 @@ fn two_equal_physical_occurrences() -> [crate::appearance::ResolvedOccurrence; 2 .expect("acyclic typed graph must compile"); let evaluation = graph .evaluate(&AppearanceBindings::new( - vec![(source, [0, 64, 255]), (backdrop, [255, 255, 255])], + vec![(source, crate::Srgb8::new([0, 64, 255]))], + vec![(backdrop, crate::Srgb8::new([255, 255, 255]))], vec![(opacity, 0.5)], )) .expect("complete bindings must evaluate"); diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index 16c9c3f2..5b620b4f 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -48,6 +48,12 @@ mod agnostic_gates; #[cfg(test)] mod appearance_graph_tests; +#[cfg(test)] +pub(crate) mod observation; + +#[cfg(test)] +mod observation_tests; + #[cfg(test)] mod constraint_tests; diff --git a/crates/labcolors-core/src/observation.rs b/crates/labcolors-core/src/observation.rs new file mode 100644 index 00000000..fd46f3b8 --- /dev/null +++ b/crates/labcolors-core/src/observation.rs @@ -0,0 +1,442 @@ +//! Admission коррелированных point sRGB8 observations. +//! +//! Authored color inputs не могут удовлетворить runtime surface ports. Один +//! stream атомарно принимает полный коррелированный ScenarioSet и хранит один +//! revision-ordered head: отклонённый update сохраняет прежнее состояние, а +//! Unknown никогда не изобретает поверхность. + +use std::collections::BTreeMap; + +use crate::Srgb8; +use crate::appearance::SurfaceInputPortId; + +/// Runtime instance/epoch одного атомарного потока наблюдений. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct ObservationStreamId(u32); + +impl ObservationStreamId { + pub(crate) const fn new(raw: u32) -> Self { + Self(raw) + } +} + +/// Монотонная revision внутри одного [`ObservationStreamId`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct Revision(u64); + +impl Revision { + pub(crate) const fn new(raw: u64) -> Self { + Self(raw) + } +} + +/// Opaque provenance одной одновременно наблюдённой tuple. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct ScenarioId(u32); + +impl ScenarioId { + pub(crate) const fn new(raw: u32) -> Self { + Self(raw) + } +} + +/// Opaque причина утраты текущего observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct UnknownReasonId(u32); + +impl UnknownReasonId { + pub(crate) const fn new(raw: u32) -> Self { + Self(raw) + } +} + +/// Raw binding одного surface-input внутри коррелированного scenario. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SurfaceInputBinding { + pub(crate) port: SurfaceInputPortId, + pub(crate) value: Srgb8, +} + +/// Raw tuple: все bindings были наблюдены одновременно. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ScenarioInput { + pub(crate) id: ScenarioId, + pub(crate) bindings: Vec, +} + +/// Raw collection до проверки schema и канонизации. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ObservedScenarioSetInput { + pub(crate) scenarios: Vec, +} + +/// Raw payload одной revision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ObservationPayloadInput { + Scenarios(ObservedScenarioSetInput), + Unknown(UnknownReasonId), +} + +/// Атомарное обновление одного stream. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ObservationUpdateInput { + pub(crate) stream: ObservationStreamId, + pub(crate) revision: Revision, + pub(crate) payload: ObservationPayloadInput, +} + +/// Одна уникальная физическая tuple в порядке compiled schema. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PhysicalScenario { + bindings: Box<[Srgb8]>, + provenance: Box<[ScenarioId]>, +} + +impl PhysicalScenario { + pub(crate) fn bindings(&self) -> &[Srgb8] { + &self.bindings + } + + pub(crate) fn provenance(&self) -> &[ScenarioId] { + &self.provenance + } +} + +/// Sealed canonical value: nonempty unique physical cases без скрытой редукции. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ObservedScenarioSet { + cases: Box<[PhysicalScenario]>, +} + +impl ObservedScenarioSet { + pub(crate) fn cases(&self) -> &[PhysicalScenario] { + &self.cases + } + + pub(crate) fn physical_bindings(&self) -> Vec> { + self.cases + .iter() + .map(|case| case.bindings.to_vec()) + .collect() + } +} + +/// Последний admitted observation до явного `Unknown`. +/// +/// Значение является только evidence для presentation hold; current set из +/// него не восстанавливается. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PriorObservation { + revision: Revision, + set: ObservedScenarioSet, +} + +impl PriorObservation { + pub(crate) fn revision(&self) -> Revision { + self.revision + } + + pub(crate) fn set(&self) -> &ObservedScenarioSet { + &self.set + } +} + +/// Единственный SSOT watermark и последнего payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ObservationHead { + Empty, + Unknown { + revision: Revision, + reason: UnknownReasonId, + previous: Option, + }, + Observed { + revision: Revision, + set: ObservedScenarioSet, + }, +} + +impl ObservationHead { + fn revision(&self) -> Option { + match self { + Self::Empty => None, + Self::Unknown { revision, .. } | Self::Observed { revision, .. } => Some(*revision), + } + } + + fn matches(&self, payload: &CanonicalPayload) -> bool { + match (self, payload) { + (Self::Unknown { reason, .. }, CanonicalPayload::Unknown(candidate)) => { + reason == candidate + } + (Self::Observed { set, .. }, CanonicalPayload::Observed(candidate)) => set == candidate, + _ => false, + } + } + + fn prior_observation(&self) -> Option { + match self { + Self::Empty => None, + Self::Unknown { previous, .. } => previous.clone(), + Self::Observed { revision, set } => Some(PriorObservation { + revision: *revision, + set: set.clone(), + }), + } + } +} + +/// Availability полностью выводится из [`ObservationHead`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Availability { + Waiting, + Ready, + Stale, +} + +/// Успешное обновление либо exact-idempotent replay. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UpdateDisposition { + Applied, + Idempotent, +} + +/// Typed admission failures; при любом варианте state побайтно сохраняется. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ObservationError { + EmptyCompiledSurfaceInputSchema, + DuplicateCompiledSurfaceInputPort { + input: SurfaceInputPortId, + }, + StreamMismatch { + expected: ObservationStreamId, + actual: ObservationStreamId, + }, + EmptyScenarioSet, + DuplicateScenarioId { + scenario: ScenarioId, + }, + DuplicateSurfaceInputBinding { + scenario: ScenarioId, + input: SurfaceInputPortId, + }, + MissingSurfaceInputBinding { + scenario: ScenarioId, + input: SurfaceInputPortId, + }, + UnexpectedSurfaceInputBinding { + scenario: ScenarioId, + input: SurfaceInputPortId, + }, + RevisionOutOfOrder { + current: Revision, + incoming: Revision, + }, + RevisionConflict { + revision: Revision, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CanonicalPayload { + Observed(ObservedScenarioSet), + Unknown(UnknownReasonId), +} + +/// Stream-affine admission-state с immutable canonical schema. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ObservationState { + stream: ObservationStreamId, + compiled_surface_input_schema: Box<[SurfaceInputPortId]>, + head: ObservationHead, +} + +impl ObservationState { + pub(crate) fn new( + stream: ObservationStreamId, + mut compiled_surface_input_schema: Vec, + ) -> Result { + if compiled_surface_input_schema.is_empty() { + return Err(ObservationError::EmptyCompiledSurfaceInputSchema); + } + compiled_surface_input_schema.sort_unstable(); + if let Some(duplicate) = compiled_surface_input_schema + .windows(2) + .find(|window| window[0] == window[1]) + { + return Err(ObservationError::DuplicateCompiledSurfaceInputPort { + input: duplicate[0], + }); + } + Ok(Self { + stream, + compiled_surface_input_schema: compiled_surface_input_schema.into_boxed_slice(), + head: ObservationHead::Empty, + }) + } + + pub(crate) fn stream(&self) -> ObservationStreamId { + self.stream + } + + pub(crate) fn compiled_surface_input_schema(&self) -> &[SurfaceInputPortId] { + &self.compiled_surface_input_schema + } + + pub(crate) fn head(&self) -> &ObservationHead { + &self.head + } + + pub(crate) fn availability(&self) -> Availability { + match &self.head { + ObservationHead::Empty | ObservationHead::Unknown { previous: None, .. } => { + Availability::Waiting + } + ObservationHead::Observed { .. } => Availability::Ready, + ObservationHead::Unknown { + previous: Some(_), .. + } => Availability::Stale, + } + } + + /// Только текущий `Observed` предоставляет set. Prior evidence не является + /// неявным fallback-ом. + pub(crate) fn current_set(&self) -> Option<&ObservedScenarioSet> { + match &self.head { + ObservationHead::Observed { set, .. } => Some(set), + ObservationHead::Empty | ObservationHead::Unknown { .. } => None, + } + } + + /// Полностью канонизирует raw payload до сравнения revision, затем меняет + /// один `head`. Поэтому malformed update не потребляет watermark. + pub(crate) fn apply( + &mut self, + update: ObservationUpdateInput, + ) -> Result { + if update.stream != self.stream { + return Err(ObservationError::StreamMismatch { + expected: self.stream, + actual: update.stream, + }); + } + + let payload = match update.payload { + ObservationPayloadInput::Scenarios(raw) => CanonicalPayload::Observed(admit_scenarios( + &self.compiled_surface_input_schema, + raw, + )?), + ObservationPayloadInput::Unknown(reason) => CanonicalPayload::Unknown(reason), + }; + + if let Some(current) = self.head.revision() { + if update.revision < current { + return Err(ObservationError::RevisionOutOfOrder { + current, + incoming: update.revision, + }); + } + if update.revision == current { + return if self.head.matches(&payload) { + Ok(UpdateDisposition::Idempotent) + } else { + Err(ObservationError::RevisionConflict { revision: current }) + }; + } + } + + self.head = match payload { + CanonicalPayload::Observed(set) => ObservationHead::Observed { + revision: update.revision, + set, + }, + CanonicalPayload::Unknown(reason) => ObservationHead::Unknown { + revision: update.revision, + reason, + previous: self.head.prior_observation(), + }, + }; + Ok(UpdateDisposition::Applied) + } +} + +fn admit_scenarios( + schema: &[SurfaceInputPortId], + raw: ObservedScenarioSetInput, +) -> Result { + if raw.scenarios.is_empty() { + return Err(ObservationError::EmptyScenarioSet); + } + + let mut scenarios = raw.scenarios; + scenarios.sort_unstable_by_key(|scenario| scenario.id); + if let Some(duplicate) = scenarios + .windows(2) + .find(|window| window[0].id == window[1].id) + { + return Err(ObservationError::DuplicateScenarioId { + scenario: duplicate[0].id, + }); + } + + let mut grouped: BTreeMap, Vec> = BTreeMap::new(); + for mut scenario in scenarios { + scenario + .bindings + .sort_unstable_by_key(|binding| binding.port); + if let Some(duplicate) = scenario + .bindings + .windows(2) + .find(|window| window[0].port == window[1].port) + { + return Err(ObservationError::DuplicateSurfaceInputBinding { + scenario: scenario.id, + input: duplicate[0].port, + }); + } + + if let Some(missing) = schema.iter().find(|required| { + scenario + .bindings + .binary_search_by_key(*required, |binding| binding.port) + .is_err() + }) { + return Err(ObservationError::MissingSurfaceInputBinding { + scenario: scenario.id, + input: *missing, + }); + } + if let Some(unexpected) = scenario + .bindings + .iter() + .find(|binding| schema.binary_search(&binding.port).is_err()) + { + return Err(ObservationError::UnexpectedSurfaceInputBinding { + scenario: scenario.id, + input: unexpected.port, + }); + } + + let tuple: Vec = schema + .iter() + .map(|required| { + let index = scenario + .bindings + .binary_search_by_key(required, |binding| binding.port) + .unwrap_or_else(|_| unreachable!("required bindings were checked")); + scenario.bindings[index].value + }) + .collect(); + grouped.entry(tuple).or_default().push(scenario.id); + } + + let cases = grouped + .into_iter() + .map(|(bindings, provenance)| PhysicalScenario { + bindings: bindings.into_boxed_slice(), + provenance: provenance.into_boxed_slice(), + }) + .collect::>() + .into_boxed_slice(); + Ok(ObservedScenarioSet { cases }) +} diff --git a/crates/labcolors-core/src/observation_tests.rs b/crates/labcolors-core/src/observation_tests.rs new file mode 100644 index 00000000..b4243365 --- /dev/null +++ b/crates/labcolors-core/src/observation_tests.rs @@ -0,0 +1,532 @@ +use crate::Srgb8; +use crate::appearance::{ + AppearanceBindings, AppearanceGraphSpec, BindingError, ColorInputId, CompositionProfileV1, + OccurrenceId, OccurrenceSpec, PaintId, PaintSpec, PointOpacityError, PointOpacityOverSurfaceV1, + ResolvedOccurrence, SurfaceId, SurfaceInputPortId, SurfaceSpec, +}; +use crate::observation::{ + Availability, ObservationError, ObservationHead, ObservationPayloadInput, ObservationState, + ObservationStreamId, ObservationUpdateInput, ObservedScenarioSet, ObservedScenarioSetInput, + Revision, ScenarioId, ScenarioInput, SurfaceInputBinding, UnknownReasonId, UpdateDisposition, +}; + +const PORT_A: SurfaceInputPortId = SurfaceInputPortId::new(10); +const PORT_B: SurfaceInputPortId = SurfaceInputPortId::new(20); +const STREAM: ObservationStreamId = ObservationStreamId::new(7); + +fn binding(port: SurfaceInputPortId, bytes: [u8; 3]) -> SurfaceInputBinding { + SurfaceInputBinding { + port, + value: Srgb8::new(bytes), + } +} + +fn scenario(id: u32, bindings: impl IntoIterator) -> ScenarioInput { + ScenarioInput { + id: ScenarioId::new(id), + bindings: bindings.into_iter().collect(), + } +} + +fn scenarios(items: impl IntoIterator) -> ObservedScenarioSetInput { + ObservedScenarioSetInput { + scenarios: items.into_iter().collect(), + } +} + +fn observed_update( + stream: ObservationStreamId, + revision: u64, + set: ObservedScenarioSetInput, +) -> ObservationUpdateInput { + ObservationUpdateInput { + stream, + revision: Revision::new(revision), + payload: ObservationPayloadInput::Scenarios(set), + } +} + +fn unknown_update( + stream: ObservationStreamId, + revision: u64, + reason: u32, +) -> ObservationUpdateInput { + ObservationUpdateInput { + stream, + revision: Revision::new(revision), + payload: ObservationPayloadInput::Unknown(UnknownReasonId::new(reason)), + } +} + +fn paired_set(first: ([u8; 3], [u8; 3]), second: ([u8; 3], [u8; 3])) -> ObservedScenarioSetInput { + scenarios([ + scenario(1, [binding(PORT_A, first.0), binding(PORT_B, first.1)]), + scenario(2, [binding(PORT_A, second.0), binding(PORT_B, second.1)]), + ]) +} + +fn observed_set(state: &ObservationState) -> &ObservedScenarioSet { + match state.head() { + ObservationHead::Observed { set, .. } => set, + head => panic!("expected Observed head, got {head:?}"), + } +} + +#[test] +fn authored_color_and_observed_surface_ports_are_distinct_through_execution() { + let _sealed_adapter_contract: fn( + [u8; 3], + f64, + [u8; 3], + ) -> Result = PointOpacityOverSurfaceV1::evaluate; + let color = ColorInputId::new(7); + let surface_port = SurfaceInputPortId::new(7); + let paint = PaintId::new(1); + let surface = SurfaceId::new(1); + let occurrence = OccurrenceId::new(1); + let graph = AppearanceGraphSpec::new( + vec![color], + vec![surface_port], + vec![], + vec![PaintSpec::Solid { id: paint, color }], + vec![SurfaceSpec::Input { + id: surface, + port: surface_port, + }], + vec![OccurrenceSpec { + id: occurrence, + subject: paint, + against: surface, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }], + ) + .compile() + .expect("separate typed inputs must compile"); + + let evaluation = graph + .evaluate(&AppearanceBindings::new( + vec![(color, Srgb8::new([1, 2, 3]))], + vec![(surface_port, Srgb8::new([240, 241, 242]))], + vec![], + )) + .expect("complete typed bindings must evaluate"); + assert_eq!( + evaluation.occurrence(occurrence).unwrap().visible(), + [1, 2, 3] + ); + + assert_eq!( + graph.evaluate(&AppearanceBindings::new( + vec![(color, Srgb8::new([1, 2, 3]))], + vec![], + vec![], + )), + Err(BindingError::MissingSurfaceInputBinding { + input: surface_port, + }) + ); + assert_eq!( + graph.evaluate(&AppearanceBindings::new( + vec![(color, Srgb8::new([1, 2, 3]))], + vec![ + (surface_port, Srgb8::new([240, 241, 242])), + (SurfaceInputPortId::new(8), Srgb8::new([0, 0, 0])), + ], + vec![], + )), + Err(BindingError::UnexpectedSurfaceInputBinding { + input: SurfaceInputPortId::new(8), + }) + ); + assert_eq!( + graph.evaluate(&AppearanceBindings::new( + vec![(color, Srgb8::new([1, 2, 3]))], + vec![ + (surface_port, Srgb8::new([240, 241, 242])), + (surface_port, Srgb8::new([0, 0, 0])), + ], + vec![], + )), + Err(BindingError::DuplicateSurfaceInputBinding { + input: surface_port, + }) + ); +} + +#[test] +fn admission_preserves_correlated_tuples_without_cartesian_product() { + let mut state = ObservationState::new(STREAM, vec![PORT_A, PORT_B]).unwrap(); + state + .apply(observed_update( + STREAM, + 1, + paired_set(([1, 2, 3], [4, 5, 6]), ([7, 8, 9], [10, 11, 12])), + )) + .unwrap(); + + let cases: Vec> = observed_set(&state) + .cases() + .iter() + .map(|case| case.bindings().iter().copied().map(Srgb8::bytes).collect()) + .collect(); + assert_eq!(cases.len(), 2); + assert_eq!( + cases, + vec![vec![[1, 2, 3], [4, 5, 6]], vec![[7, 8, 9], [10, 11, 12]],] + ); + assert!(!cases.contains(&vec![[1, 2, 3], [10, 11, 12]])); + assert!(!cases.contains(&vec![[7, 8, 9], [4, 5, 6]])); +} + +#[test] +fn canonicalization_ignores_declaration_order_and_groups_duplicate_physics() { + let first = scenarios([ + scenario(9, [binding(PORT_B, [4, 5, 6]), binding(PORT_A, [1, 2, 3])]), + scenario(4, [binding(PORT_A, [9, 8, 7]), binding(PORT_B, [6, 5, 4])]), + scenario(3, [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])]), + ]); + let second = scenarios([ + scenario(3, [binding(PORT_B, [4, 5, 6]), binding(PORT_A, [1, 2, 3])]), + scenario(9, [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])]), + scenario(4, [binding(PORT_B, [6, 5, 4]), binding(PORT_A, [9, 8, 7])]), + ]); + let mut left = ObservationState::new(STREAM, vec![PORT_B, PORT_A]).unwrap(); + let mut right = ObservationState::new(STREAM, vec![PORT_A, PORT_B]).unwrap(); + left.apply(observed_update(STREAM, 1, first)).unwrap(); + right.apply(observed_update(STREAM, 1, second)).unwrap(); + + assert_eq!(left, right); + let set = observed_set(&left); + assert_eq!(set.cases().len(), 2); + assert_eq!( + set.cases()[0].provenance(), + &[ScenarioId::new(3), ScenarioId::new(9)] + ); + assert_eq!( + set.cases()[0] + .bindings() + .iter() + .copied() + .map(Srgb8::bytes) + .collect::>(), + vec![[1, 2, 3], [4, 5, 6]] + ); +} + +#[test] +fn invalid_permutations_return_the_same_deterministic_error() { + let invalid_a = scenarios([ + scenario(2, [binding(PORT_A, [1, 1, 1])]), + scenario(1, [binding(PORT_A, [2, 2, 2]), binding(PORT_A, [3, 3, 3])]), + ]); + let invalid_b = scenarios([ + scenario(1, [binding(PORT_A, [3, 3, 3]), binding(PORT_A, [2, 2, 2])]), + scenario(2, [binding(PORT_A, [1, 1, 1])]), + ]); + for invalid in [invalid_a, invalid_b] { + let mut state = ObservationState::new(STREAM, vec![PORT_B, PORT_A]).unwrap(); + assert_eq!( + state.apply(observed_update(STREAM, 1, invalid)), + Err(ObservationError::DuplicateSurfaceInputBinding { + scenario: ScenarioId::new(1), + input: PORT_A, + }) + ); + assert_eq!(state.head(), &ObservationHead::Empty); + } +} + +#[test] +fn initial_unknown_advances_watermark_without_inventing_a_surface() { + let mut state = ObservationState::new(STREAM, vec![PORT_A]).unwrap(); + assert_eq!(state.availability(), Availability::Waiting); + assert_eq!( + state.apply(unknown_update(STREAM, 3, 11)), + Ok(UpdateDisposition::Applied) + ); + assert_eq!(state.availability(), Availability::Waiting); + assert!(matches!( + state.head(), + ObservationHead::Unknown { + revision, + reason, + previous: None, + } if *revision == Revision::new(3) && *reason == UnknownReasonId::new(11) + )); + + let explicit_white = scenarios([scenario(1, [binding(PORT_A, [255; 3])])]); + assert_eq!( + state.apply(observed_update(STREAM, 2, explicit_white.clone())), + Err(ObservationError::RevisionOutOfOrder { + current: Revision::new(3), + incoming: Revision::new(2), + }) + ); + assert_eq!( + state.apply(unknown_update(STREAM, 3, 11)), + Ok(UpdateDisposition::Idempotent) + ); + assert_eq!( + state.apply(unknown_update(STREAM, 3, 12)), + Err(ObservationError::RevisionConflict { + revision: Revision::new(3), + }) + ); + assert_eq!( + state.apply(observed_update(STREAM, 4, explicit_white)), + Ok(UpdateDisposition::Applied) + ); + assert_eq!(state.availability(), Availability::Ready); + assert_eq!( + observed_set(&state).cases()[0].bindings(), + &[Srgb8::new([255; 3])] + ); +} + +#[test] +fn unknown_chain_preserves_prior_evidence_without_ready_fallback() { + let ready = scenarios([scenario(1, [binding(PORT_A, [3, 4, 5])])]); + let restored = scenarios([scenario(2, [binding(PORT_A, [6, 7, 8])])]); + let mut state = ObservationState::new(STREAM, vec![PORT_A]).unwrap(); + state + .apply(observed_update(STREAM, 2, ready.clone())) + .unwrap(); + let prior_set = observed_set(&state).clone(); + state.apply(unknown_update(STREAM, 3, 1)).unwrap(); + assert_eq!(state.availability(), Availability::Stale); + let ObservationHead::Unknown { + previous: Some(previous), + .. + } = state.head() + else { + panic!("Ready must become stale evidence"); + }; + assert_eq!(previous.revision(), Revision::new(2)); + assert_eq!(previous.set(), &prior_set); + assert!(state.current_set().is_none()); + + assert!(matches!( + state.apply(observed_update(STREAM, 2, ready)), + Err(ObservationError::RevisionOutOfOrder { .. }) + )); + state.apply(unknown_update(STREAM, 4, 2)).unwrap(); + let ObservationHead::Unknown { + previous: Some(previous), + .. + } = state.head() + else { + panic!("Unknown chain lost prior evidence"); + }; + assert_eq!(previous.revision(), Revision::new(2)); + assert_eq!(previous.set(), &prior_set); + + state.apply(observed_update(STREAM, 5, restored)).unwrap(); + assert_eq!(state.availability(), Availability::Ready); +} + +#[test] +fn malformed_update_does_not_consume_its_revision() { + let malformed = scenarios([scenario(1, [binding(PORT_A, [1, 2, 3])])]); + let corrected = scenarios([scenario( + 1, + [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])], + )]); + let mut state = ObservationState::new(STREAM, vec![PORT_A, PORT_B]).unwrap(); + assert_eq!( + state.apply(observed_update(STREAM, 9, malformed)), + Err(ObservationError::MissingSurfaceInputBinding { + scenario: ScenarioId::new(1), + input: PORT_B, + }) + ); + assert_eq!(state.head(), &ObservationHead::Empty); + assert_eq!( + state.apply(observed_update(STREAM, 9, corrected)), + Ok(UpdateDisposition::Applied) + ); +} + +#[test] +fn raw_validation_rejects_empty_duplicate_and_unexpected_members() { + let cases = [ + (scenarios([]), ObservationError::EmptyScenarioSet), + ( + scenarios([ + scenario(1, [binding(PORT_A, [1, 2, 3])]), + scenario(1, [binding(PORT_A, [4, 5, 6])]), + ]), + ObservationError::DuplicateScenarioId { + scenario: ScenarioId::new(1), + }, + ), + ( + scenarios([scenario( + 1, + [ + binding(PORT_A, [1, 2, 3]), + binding(SurfaceInputPortId::new(99), [4, 5, 6]), + ], + )]), + ObservationError::UnexpectedSurfaceInputBinding { + scenario: ScenarioId::new(1), + input: SurfaceInputPortId::new(99), + }, + ), + ]; + + for (raw, expected) in cases { + let mut state = ObservationState::new(STREAM, vec![PORT_A]).unwrap(); + assert_eq!(state.apply(observed_update(STREAM, 1, raw)), Err(expected)); + assert_eq!(state.head(), &ObservationHead::Empty); + } +} + +#[test] +fn same_revision_uses_full_canonical_identity() { + let first = scenarios([scenario( + 9, + [binding(PORT_B, [4, 5, 6]), binding(PORT_A, [1, 2, 3])], + )]); + let reordered = scenarios([scenario( + 9, + [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])], + )]); + let renamed = scenarios([scenario( + 10, + [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])], + )]); + let mut state = ObservationState::new(STREAM, vec![PORT_A, PORT_B]).unwrap(); + state.apply(observed_update(STREAM, 1, first)).unwrap(); + assert_eq!( + state.apply(observed_update(STREAM, 1, reordered)), + Ok(UpdateDisposition::Idempotent) + ); + let retained = state.clone(); + assert_eq!( + state.apply(observed_update(STREAM, 1, renamed)), + Err(ObservationError::RevisionConflict { + revision: Revision::new(1), + }) + ); + assert_eq!(state, retained); +} + +#[test] +fn tuple_correlation_is_identity_even_when_marginals_match() { + let correlated = paired_set(([1, 0, 0], [10, 0, 0]), ([2, 0, 0], [20, 0, 0])); + let crossed = paired_set(([1, 0, 0], [20, 0, 0]), ([2, 0, 0], [10, 0, 0])); + let mut left = ObservationState::new(STREAM, vec![PORT_A, PORT_B]).unwrap(); + let mut right = ObservationState::new(STREAM, vec![PORT_A, PORT_B]).unwrap(); + left.apply(observed_update(STREAM, 1, correlated)).unwrap(); + right.apply(observed_update(STREAM, 1, crossed)).unwrap(); + + assert_ne!(observed_set(&left), observed_set(&right)); +} + +#[test] +fn ids_only_route_and_provenance_while_physical_evidence_stays_invariant() { + let renamed_a = SurfaceInputPortId::new(110); + let renamed_b = SurfaceInputPortId::new(120); + let original = scenarios([scenario( + 1, + [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])], + )]); + let renamed = scenarios([ScenarioInput { + id: ScenarioId::new(99), + bindings: vec![binding(renamed_b, [4, 5, 6]), binding(renamed_a, [1, 2, 3])], + }]); + let mut left = ObservationState::new(STREAM, vec![PORT_A, PORT_B]).unwrap(); + let mut right = + ObservationState::new(ObservationStreamId::new(70), vec![renamed_b, renamed_a]).unwrap(); + left.apply(observed_update(STREAM, 1, original)).unwrap(); + right + .apply(observed_update(ObservationStreamId::new(70), 1, renamed)) + .unwrap(); + + assert_eq!( + observed_set(&left).physical_bindings(), + observed_set(&right).physical_bindings() + ); + assert_ne!(observed_set(&left), observed_set(&right)); +} + +#[test] +fn stream_and_compiled_schema_are_immutable_and_watermarks_are_independent() { + let mut first = ObservationState::new(STREAM, vec![PORT_B, PORT_A]).unwrap(); + let other_stream = ObservationStreamId::new(8); + let mut second = ObservationState::new(other_stream, vec![PORT_A, PORT_B]).unwrap(); + let payload = paired_set(([1, 2, 3], [4, 5, 6]), ([7, 8, 9], [10, 11, 12])); + + assert_eq!( + first.apply(observed_update(other_stream, 1, payload.clone())), + Err(ObservationError::StreamMismatch { + expected: STREAM, + actual: other_stream, + }) + ); + first + .apply(observed_update(STREAM, 7, payload.clone())) + .unwrap(); + second + .apply(observed_update(other_stream, 1, payload)) + .unwrap(); + + assert_eq!(first.stream(), STREAM); + assert_eq!(second.stream(), other_stream); + assert_eq!(first.compiled_surface_input_schema(), &[PORT_A, PORT_B]); + assert_eq!(second.compiled_surface_input_schema(), &[PORT_A, PORT_B]); + assert_eq!( + observed_set(&first).physical_bindings(), + observed_set(&second).physical_bindings() + ); + assert!(matches!( + first.head(), + ObservationHead::Observed { revision, .. } if *revision == Revision::new(7) + )); + assert!(matches!( + second.head(), + ObservationHead::Observed { revision, .. } if *revision == Revision::new(1) + )); +} + +#[test] +fn admitted_state_owns_canonical_values_and_rejects_invalid_schemas() { + assert_eq!( + ObservationState::new(STREAM, vec![]), + Err(ObservationError::EmptyCompiledSurfaceInputSchema) + ); + assert_eq!( + ObservationState::new(STREAM, vec![PORT_A, PORT_A]), + Err(ObservationError::DuplicateCompiledSurfaceInputPort { input: PORT_A }) + ); + + let mut caller = scenarios([scenario(1, [binding(PORT_A, [1, 2, 3])])]); + let mut state = ObservationState::new(STREAM, vec![PORT_A]).unwrap(); + state + .apply(observed_update(STREAM, 1, caller.clone())) + .unwrap(); + caller.scenarios[0].bindings[0].value = Srgb8::new([9, 9, 9]); + assert_eq!( + observed_set(&state).cases()[0].bindings(), + &[Srgb8::new([1, 2, 3])] + ); +} + +#[test] +fn observation_types_do_not_encode_solver_or_output_reductions() { + let source = include_str!("observation.rs"); + for forbidden in [ + "worstIdx", + "worst_idx", + "candidate:", + "percentile", + "weight:", + "output:", + "solver", + ] { + assert!( + !source.contains(forbidden), + "observation admission leaked downstream concern {forbidden}" + ); + } +} diff --git a/packages/colors/bench/wasm.json b/packages/colors/bench/wasm.json index f7b0c39a..4790be07 100644 --- a/packages/colors/bench/wasm.json +++ b/packages/colors/bench/wasm.json @@ -19,13 +19,13 @@ "command": "CARGO_ENCODED_RUSTFLAGS= wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked" }, "measurement": { - "source": "github-actions-run-29679743144", + "source": "github-actions-run-29684632555", "platform": "linux-x64", - "rawBytes": 385589 + "rawBytes": 385346 }, "policy": { - "maxRawBytes": 385589, - "basis": "accepted-f1a-visible-assessment-snapshot", + "maxRawBytes": 385346, + "basis": "accepted-f2a-observation-admission-snapshot", "gzip": "diagnostic-only" } } diff --git a/scripts/check-wasm-size-budget.mjs b/scripts/check-wasm-size-budget.mjs index 06a0dd22..838ce855 100644 --- a/scripts/check-wasm-size-budget.mjs +++ b/scripts/check-wasm-size-budget.mjs @@ -14,7 +14,7 @@ export const DEFAULT_BUDGET = resolve( "packages/colors/bench/wasm.json", ); export const WASM_BUDGET_FILE_SHA256 = - "85547212d6a1b2ba648150c731bc28dc85e13547f0709bbf592c6824e49eb125"; + "7ea4a2d848eb61473aa2d6a653938fb91ea04c2f91da56f6f75b32ea0c8521af"; const SCHEMA_VERSION = 1; const CANONICAL_ARTIFACT = "packages/colors/pkg/labcolors_bg.wasm";