diff --git a/crates/labcolors-core/src/generic_boundary_tests.rs b/crates/labcolors-core/src/generic_boundary_tests.rs index e180305e..4a133ad0 100644 --- a/crates/labcolors-core/src/generic_boundary_tests.rs +++ b/crates/labcolors-core/src/generic_boundary_tests.rs @@ -92,12 +92,17 @@ fn assert_only_in_compile_fail(source: &str, needle: &str) { _ => {} } + assert!( + !in_compile_fail || doc.is_some() || line.trim().is_empty(), + "compile_fail sentinel was interrupted by live code at line {}", + line_index + 1, + ); let line_occurrences = line.matches(needle).count(); if line_occurrences == 0 { continue; } assert!( - in_compile_fail, + in_compile_fail && doc.is_some(), "`{needle}` escaped its negative compile_fail sentinel at line {}", line_index + 1, ); @@ -111,6 +116,16 @@ fn assert_only_in_compile_fail(source: &str, needle: &str) { ); } +#[test] +fn compile_fail_scanner_rejects_live_code_between_document_fences() { + let escaped = "/// ```compile_fail\npub type PackageProgram = u8;\n/// ```"; + assert!( + std::panic::catch_unwind(|| assert_only_in_compile_fail(escaped, "PackageProgram")) + .is_err(), + "a live declaration must never inherit compile_fail state from adjacent documentation", + ); +} + fn production_rust_sources() -> Vec<(String, String)> { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); let mut pending = vec![root.clone()]; diff --git a/crates/labcolors-core/src/joint.rs b/crates/labcolors-core/src/joint.rs index 922cee76..64689f67 100644 --- a/crates/labcolors-core/src/joint.rs +++ b/crates/labcolors-core/src/joint.rs @@ -44,12 +44,19 @@ impl FiniteDomainOrdinalV1 { /// becomes an implicit tie-break. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AdmittedFiniteJointOrderV1 { - tuples: Box<[Box<[FiniteDomainOrdinalV1]>]>, + first: Box<[FiniteDomainOrdinalV1]>, + rest: Box<[Box<[FiniteDomainOrdinalV1]>]>, } impl AdmittedFiniteJointOrderV1 { - pub(crate) fn tuples(&self) -> impl ExactSizeIterator + '_ { - self.tuples.iter().map(Box::as_ref) + pub(crate) fn tuples(&self) -> impl Iterator + '_ { + std::iter::once(self.first.as_ref()).chain(self.rest.iter().map(Box::as_ref)) + } + + pub(crate) fn state_count(&self) -> usize { + // `first` makes the admitted order structurally non-empty; the + // remaining slice length is bounded by Rust's allocation limit. + self.rest.len() + 1 } } @@ -120,10 +127,10 @@ pub(crate) fn admit_finite_joint_order_v1( .map_err(|_| FiniteJointOrderErrorV1::ResourceExhausted)?; first_seen.resize(expected, usize::MAX); - let mut tuples = Vec::new(); - tuples - .try_reserve_exact(authored.len()) + let mut rest = Vec::new(); + rest.try_reserve_exact(authored.len() - 1) .map_err(|_| FiniteJointOrderErrorV1::ResourceExhausted)?; + let mut first_tuple = None; for (tuple_index, tuple) in authored.into_iter().enumerate() { if tuple.len() != domain_lengths.len() { return Err(FiniteJointOrderErrorV1::TupleArity { @@ -157,11 +164,19 @@ pub(crate) fn admit_finite_joint_order_v1( }); } *first = tuple_index; - tuples.push(tuple.into_boxed_slice()); + let tuple = tuple.into_boxed_slice(); + if first_tuple.is_none() { + first_tuple = Some(tuple); + } else { + rest.push(tuple); + } } Ok(AdmittedFiniteJointOrderV1 { - tuples: tuples.into_boxed_slice(), + // Empty input returned above, so the loop always materialises a first + // tuple; keep the typed branch instead of encoding that proof as panic. + first: first_tuple.ok_or(FiniteJointOrderErrorV1::EmptyOrder)?, + rest: rest.into_boxed_slice(), }) } use crate::session::SessionObservationBindingPermitV1; diff --git a/crates/labcolors-core/src/program.rs b/crates/labcolors-core/src/program.rs index 976c045f..c8e8558a 100644 --- a/crates/labcolors-core/src/program.rs +++ b/crates/labcolors-core/src/program.rs @@ -1258,6 +1258,37 @@ pub struct OwnerV1 { compiled: CompiledCoreProgramV1, } +/// Верхние границы числа клеток в новом сертификате одного Observed-update. +/// +/// Границы относятся только к текущим клеткам доказательства. Они не включают +/// сохранённый прошлый сертификат, observation/provenance, выходы, операции или +/// байты конкретного транспорта. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EvidenceCellBoundsV1 { + verified_cells: usize, + conflict_cells: usize, +} + +impl EvidenceCellBoundsV1 { + /// Максимум клеток успешного сертификата. + pub const fn verified_cells(self) -> usize { + self.verified_cells + } + + /// Максимум клеток исчерпывающего конфликтного сертификата. + pub const fn conflict_cells(self) -> usize { + self.conflict_cells + } +} + +/// Закрытая причина невозможности вычислить границы сертификата. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EvidenceBoundsErrorV1 { + /// Произведение числа сценариев, ограничений и состояний не помещается в + /// адресное пространство платформы. + CardinalityOverflow, +} + /// Отказ доступа из-за несовпадения точной owner-эпохи. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccessErrorV1 { @@ -1279,6 +1310,26 @@ impl OwnerV1 { ContentIdentityV1::from_core(self.compiled.content_identity()) } + /// Вычисляет верхние границы клеток для prospective Observed-update. + /// + /// `scenario_count` — число объявленных клиентом сценариев до admission. + /// Core сам схлопывает физически одинаковые сценарии, поэтому фактический + /// сертификат может быть короче. Нулевое значение разрешено только как + /// чистый арифметический preflight; пустой Observed-update по-прежнему не + /// допускается. Запрос не создаёт Session и не меняет состояние. + pub fn evidence_cell_bounds( + &self, + scenario_count: usize, + ) -> Result { + self.compiled + .evidence_cell_bounds(scenario_count) + .map(|(verified_cells, conflict_cells)| EvidenceCellBoundsV1 { + verified_cells, + conflict_cells, + }) + .ok_or(EvidenceBoundsErrorV1::CardinalityOverflow) + } + /// Число значений Surface в каждом schema-ordered сценарии. pub fn surface_input_port_count(&self) -> usize { self.compiled.surface_input_ports().len() diff --git a/crates/labcolors-core/src/program_session.rs b/crates/labcolors-core/src/program_session.rs index 83d151c3..de47e97f 100644 --- a/crates/labcolors-core/src/program_session.rs +++ b/crates/labcolors-core/src/program_session.rs @@ -1014,6 +1014,12 @@ enum CompiledConstraintModeV1 { ReportOnly, } +impl CompiledConstraintModeV1 { + const fn rejects_candidate(self) -> bool { + matches!(self, Self::Hard) + } +} + struct CompiledPointConstraint { id: ConstraintId, target_id: OccurrenceId, @@ -1127,6 +1133,11 @@ where self.owner_generation.outputs.len() } + pub(crate) fn evidence_cell_bounds(&self, scenario_count: usize) -> Option<(usize, usize)> { + checked_program_epoch_evaluation_cell_counts(&self.owner_generation, scenario_count) + .map(|counts| (counts.selected, counts.exhaustive_conflict)) + } + pub(crate) fn output_slot_at(&self, index: usize) -> Option { self.owner_generation .outputs @@ -1268,7 +1279,7 @@ where } pub const fn is_hard(&self) -> bool { - matches!(self.mode, CompiledConstraintModeV1::Hard) + self.mode.rejects_candidate() } pub const fn result(&self) -> &ProgramConstraintResultV1 { @@ -1466,22 +1477,54 @@ fn checked_program_evaluation_cell_counts( physical_case_count: usize, constraint_count: usize, state_count: usize, + can_conflict: bool, ) -> Option { let selected = physical_case_count.checked_mul(constraint_count)?; - let exhaustive_conflict = selected.checked_mul(state_count)?; + let exhaustive_conflict = if can_conflict { + selected.checked_mul(state_count)? + } else { + 0 + }; Some(ProgramEvaluationCellCountsV1 { selected, exhaustive_conflict, }) } +fn checked_program_epoch_evaluation_cell_counts( + epoch: &ProgramEpochV1, + physical_case_count: usize, +) -> Option +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + let state_count = epoch + .joint_selection + .as_ref() + .map(|selection| selection.order.state_count()) + // Without joint selection the epoch has one fixed configuration, so + // the exhaustive-cell multiplier remains the multiplicative identity. + .unwrap_or(1); + let can_conflict = epoch + .constraints + .iter() + .any(|constraint| constraint.mode.rejects_candidate()); + checked_program_evaluation_cell_counts( + physical_case_count, + epoch.constraints.len(), + state_count, + can_conflict, + ) +} + #[cfg(test)] pub(crate) fn checked_program_evaluation_cell_counts_for_test( physical_case_count: usize, constraint_count: usize, state_count: usize, ) -> Option<(usize, usize)> { - checked_program_evaluation_cell_counts(physical_case_count, constraint_count, state_count) + checked_program_evaluation_cell_counts(physical_case_count, constraint_count, state_count, true) .map(|counts| (counts.selected, counts.exhaustive_conflict)) } @@ -1585,7 +1628,6 @@ where fn prepare_program_evaluation_buffers( epoch: &ProgramEpochV1, observation: &RevisionBoundObservationV1, - joint_state_count: Option, ) -> Result< PreparedProgramEvaluationBuffersV1, ProgramSessionEvaluationError>, @@ -1594,22 +1636,15 @@ where Evaluation: ProgramConstraintEvaluatorSetV1, ProgramConstraintInvocationOf: Copy, { - let state_count = joint_state_count.unwrap_or(1); - if state_count == 0 { - return Err(ProgramSessionEvaluationError::InternalInvariant); - } - let counts = checked_program_evaluation_cell_counts( - observation.physical_case_count(), - epoch.constraints.len(), - state_count, - ) - .ok_or(ProgramSessionEvaluationError::ResourceExhausted)?; + let counts = + checked_program_epoch_evaluation_cell_counts(epoch, observation.physical_case_count()) + .ok_or(ProgramSessionEvaluationError::ResourceExhausted)?; let mut selected_cells = Vec::new(); try_reserve_program_evaluation_buffer(&mut selected_cells, counts.selected) .map_err(|()| ProgramSessionEvaluationError::ResourceExhausted)?; let mut conflict_cells = Vec::new(); - if joint_state_count.is_some() { + if epoch.joint_selection.is_some() && counts.exhaustive_conflict != 0 { try_reserve_program_evaluation_buffer(&mut conflict_cells, counts.exhaustive_conflict) .map_err(|()| ProgramSessionEvaluationError::ResourceExhausted)?; } @@ -1687,7 +1722,7 @@ where ProgramConstraintInvocationOf: Copy, { let Some(selection) = &epoch.joint_selection else { - let mut buffers = prepare_program_evaluation_buffers(epoch, &observation, None)?; + let mut buffers = prepare_program_evaluation_buffers(epoch, &observation)?; return collect_program_candidate_into( plan, epoch, @@ -1698,8 +1733,8 @@ where ); }; - let state_count = selection.order.tuples().len(); - let mut buffers = prepare_program_evaluation_buffers(epoch, &observation, Some(state_count))?; + let state_count = selection.order.state_count(); + let mut buffers = prepare_program_evaluation_buffers(epoch, &observation)?; for (state_index, tuple) in selection.order.tuples().enumerate() { apply_joint_candidate(plan, &epoch.finite_targets, tuple)?; if !scan_program_candidate(plan, epoch, &observation, state_index, None, None)? { @@ -1972,7 +2007,7 @@ where let result = match decision { HardDecision::Pass(evidence) => ProgramConstraintResultV1::Pass(evidence), HardDecision::Violation(evidence) => { - if matches!(constraint.mode, CompiledConstraintModeV1::Hard) { + if constraint.mode.rejects_candidate() { has_hard_violation = true; } ProgramConstraintResultV1::Violation(evidence) diff --git a/crates/labcolors-core/tests/program_boundary.rs b/crates/labcolors-core/tests/program_boundary.rs index d3588da4..e158ca95 100644 --- a/crates/labcolors-core/tests/program_boundary.rs +++ b/crates/labcolors-core/tests/program_boundary.rs @@ -10,12 +10,12 @@ use labcolors_core::Srgb8; use labcolors_core::program::{ AppearanceContextErrorKindV1, AppearanceContextFieldV1, AppearanceContextV1, AssessmentV1, CertificateV1, CompileErrorHandleV1, CompileErrorKindV1, CompileErrorV1, ConstraintIdV1, - DraftErrorV1, DraftV1, InstantiateErrorV1, JointChoiceV1, JointOrderErrorV1, JointStateV1, - ModeledPointV1, NumericDomainErrorV1, ObservationHeadV1, OccurrenceIdV1, OpacityInputIdV1, - OperationV1, OutputSlotIdV1, OwnerV1, PaintIdV1, PhysicalPointV1, ProjectionV1, ScenarioV1, - SessionV1, SignalV1, SourceIdV1, StateKindV1, SurfaceIdV1, SurfaceInputPortIdV1, SurroundV1, - TargetCandidateIdV1, TargetCandidateV1, TargetIdV1, UpdateErrorKindV1, UpdateErrorV1, UpdateV1, - VerdictV1, + DraftErrorV1, DraftV1, EvidenceBoundsErrorV1, InstantiateErrorV1, JointChoiceV1, + JointOrderErrorV1, JointStateV1, ModeledPointV1, NumericDomainErrorV1, ObservationHeadV1, + OccurrenceIdV1, OpacityInputIdV1, OperationV1, OutputSlotIdV1, OwnerV1, PaintIdV1, + PhysicalPointV1, ProjectionV1, ScenarioV1, SessionV1, SignalV1, SourceIdV1, StateKindV1, + SurfaceIdV1, SurfaceInputPortIdV1, SurroundV1, TargetCandidateIdV1, TargetCandidateV1, + TargetIdV1, UpdateErrorKindV1, UpdateErrorV1, UpdateV1, VerdictV1, }; use labcolors_core::wcag22::Wcag22CriterionV1; @@ -237,6 +237,201 @@ fn attach_target_assessment(draft: &mut DraftV1, target: TargetIdV1) { draft.push_exact_report_only(constraint, occurrence, Srgb8::new([0; 3])); } +fn joint_draft(hard: bool) -> DraftV1 { + let source = SourceIdV1::new(1); + let target = TargetIdV1::new(2); + let black = TargetCandidateIdV1::new(3); + let white = TargetCandidateIdV1::new(4); + let input = SurfaceInputPortIdV1::new(5); + let paint = PaintIdV1::new(6); + let surface = SurfaceIdV1::new(7); + let occurrence = OccurrenceIdV1::new(8); + let constraint = ConstraintIdV1::new(9); + let output = OutputSlotIdV1::new(10); + let context = AppearanceContextV1::try_new(64.0, 0.2, SurroundV1::Average).unwrap(); + + let mut draft = DraftV1::new(); + draft.push_source(source, Srgb8::new([0; 3])); + draft.push_finite_target( + target, + source, + vec![ + TargetCandidateV1::new(black, Srgb8::new([0; 3])), + TargetCandidateV1::new(white, Srgb8::new([255; 3])), + ], + ); + draft + .set_joint_selection(vec![ + JointStateV1::new(vec![JointChoiceV1::new(target, black)]), + JointStateV1::new(vec![JointChoiceV1::new(target, white)]), + ]) + .unwrap(); + draft.push_surface_input_port(input); + draft.push_solid_paint(paint, target); + draft.push_input_surface(surface, input); + draft.push_source_over_occurrence(occurrence, paint, surface, context); + if hard { + draft.push_exact_hard(constraint, occurrence, Srgb8::new([128; 3])); + } else { + draft.push_exact_report_only(constraint, occurrence, Srgb8::new([0; 3])); + } + draft.push_output(output, paint); + draft +} + +#[test] +fn evidence_cell_bounds_cover_fixed_and_joint_evaluation_laws() { + let input = SurfaceInputPortIdV1::new(50); + let fixed = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input) + .compile() + .unwrap(); + + let empty = fixed.evidence_cell_bounds(0).unwrap(); + assert_eq!(empty.verified_cells(), 0); + assert_eq!(empty.conflict_cells(), 0); + + // The second fixed constraint is report-only. Counting only hard + // constraints would under-reserve a successful certificate. + let fixed_bounds = fixed.evidence_cell_bounds(3).unwrap(); + assert_eq!(fixed_bounds.verified_cells(), 6); + assert_eq!(fixed_bounds.conflict_cells(), 6); + + let report_only_joint = joint_draft(false).compile().unwrap(); + let report_only_bounds = report_only_joint.evidence_cell_bounds(4).unwrap(); + assert_eq!(report_only_bounds.verified_cells(), 4); + assert_eq!(report_only_bounds.conflict_cells(), 0); + + let hard_joint = joint_draft(true).compile().unwrap(); + let hard_bounds = hard_joint.evidence_cell_bounds(4).unwrap(); + assert_eq!(hard_bounds.verified_cells(), 4); + assert_eq!(hard_bounds.conflict_cells(), 8); +} + +#[test] +fn evidence_cell_bounds_report_both_checked_multiplication_overflows() { + let input = SurfaceInputPortIdV1::new(50); + let two_constraints = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input) + .compile() + .unwrap(); + assert!(matches!( + two_constraints.evidence_cell_bounds(usize::MAX), + Err(EvidenceBoundsErrorV1::CardinalityOverflow) + )); + + // One constraint keeps the first product representable; the two-state + // joint order forces the independent exhaustive-conflict product to fail. + let two_states = joint_draft(true).compile().unwrap(); + assert!(matches!( + two_states.evidence_cell_bounds(usize::MAX), + Err(EvidenceBoundsErrorV1::CardinalityOverflow) + )); +} + +#[test] +fn evidence_cell_bounds_cover_actual_joint_conflict_and_duplicate_case_reduction() { + let joint = joint_draft(true).compile().unwrap(); + let mut joint_session = joint.instantiate(21).unwrap(); + let red = [Srgb8::new([255, 0, 0])]; + let blue = [Srgb8::new([0, 0, 255])]; + let unique_scenarios = [ScenarioV1::new(1, &red), ScenarioV1::new(2, &blue)]; + let joint_bounds = joint.evidence_cell_bounds(unique_scenarios.len()).unwrap(); + let projection = joint + .update( + &mut joint_session, + UpdateV1::Observed { + revision: 1, + scenarios: &unique_scenarios, + }, + ) + .unwrap(); + let Some(CertificateV1::Conflict(certificate)) = projection.evidence().certificates().next() + else { + panic!("both authored joint states must violate the hard exact constraint"); + }; + assert_eq!(certificate.cells().len(), joint_bounds.conflict_cells()); + + let input = SurfaceInputPortIdV1::new(50); + let fixed = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input) + .compile() + .unwrap(); + let mut fixed_session = fixed.instantiate(22).unwrap(); + let white = [Srgb8::new([0xFF; 3])]; + let duplicate_scenarios = [ScenarioV1::new(1, &white), ScenarioV1::new(2, &white)]; + let fixed_bounds = fixed + .evidence_cell_bounds(duplicate_scenarios.len()) + .unwrap(); + let projection = fixed + .update( + &mut fixed_session, + UpdateV1::Observed { + revision: 1, + scenarios: &duplicate_scenarios, + }, + ) + .unwrap(); + let Some(CertificateV1::Verified(certificate)) = projection.evidence().certificates().next() + else { + panic!("duplicate physical scenarios must preserve a valid certificate"); + }; + assert!(certificate.cells().len() < fixed_bounds.verified_cells()); +} + +#[test] +fn evidence_cell_bounds_query_is_pure_across_session_updates() { + let input = SurfaceInputPortIdV1::new(50); + let owner = fixed_nested_draft(1.0, SourceIdV1::new(1), input, input) + .compile() + .unwrap(); + let expected = owner.evidence_cell_bounds(1).unwrap(); + assert_eq!(expected.verified_cells(), 2); + assert_eq!(expected.conflict_cells(), 2); + + let mut session = owner.instantiate(13).unwrap(); + let after_instantiation = owner.evidence_cell_bounds(1).unwrap(); + assert_eq!( + after_instantiation.verified_cells(), + expected.verified_cells() + ); + assert_eq!( + after_instantiation.conflict_cells(), + expected.conflict_cells() + ); + + let white = [Srgb8::new([0xFF; 3])]; + let scenarios = [ScenarioV1::new(1, &white)]; + { + let projection = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 1, + scenarios: &scenarios, + }, + ) + .unwrap(); + let Some(CertificateV1::Verified(certificate)) = + projection.evidence().certificates().next() + else { + panic!("the fixed admissible program must produce Verified evidence"); + }; + assert_eq!(certificate.cells().len(), expected.verified_cells()); + } + + let after_update = owner.evidence_cell_bounds(1).unwrap(); + assert_eq!(after_update.verified_cells(), expected.verified_cells()); + assert_eq!(after_update.conflict_cells(), expected.conflict_cells()); + let projection = owner + .update( + &mut session, + UpdateV1::Observed { + revision: 2, + scenarios: &scenarios, + }, + ) + .unwrap(); + assert_eq!(projection.evidence().kind(), StateKindV1::Ready); +} + #[test] fn external_authoring_lowers_the_actual_closed_program_and_returns_canonical_input_ports() { let source = SourceIdV1::new(91);