diff --git a/crates/labcolors-core/src/program_joint_integration_tests.rs b/crates/labcolors-core/src/program_joint_integration_tests.rs index a35da7d7..8cc86f13 100644 --- a/crates/labcolors-core/src/program_joint_integration_tests.rs +++ b/crates/labcolors-core/src/program_joint_integration_tests.rs @@ -1,7 +1,11 @@ use crate::Srgb8; use crate::appearance::{OccurrenceId, PaintId, SurfaceId, SurfaceInputPortId}; use crate::constraints::{ - CountingProgramWcag22Srgb8V1, FinalRecheckMutantProgramEvaluatorV1, Wcag22Srgb8V1, + ApplicableWcag22EvaluationErrorV1, CountingProgramWcag22Srgb8V1, ExactSrgb8IdentityV1, + FinalRecheckMutantProgramEvaluatorV1, HardDecision, ProgramConstraintContentV1, + ProgramPointAssessmentErrorV1, ProgramPointEvaluatorContentV1, ProgramPointOccurrenceV1, + ProgramVisiblePointBindingV1, ProgramVisiblePointPassEvidence, + ProgramVisiblePointViolationEvidence, Wcag22Srgb8V1, assess_program_point_hard, }; use crate::joint::FiniteJointOrderErrorV1; use crate::lcs_occurrence::{ @@ -15,10 +19,11 @@ use crate::observation::{ }; use crate::program_session::{ CompositionProfile, ConstraintId, ConstraintInvocation, ConstraintSet, - DeclaredJointSelectionV1, JointCandidateStateV1, ObservationGroup, Occurrence, OutputBinding, - OutputSlotId, Paint, Program, ProgramCompileError, ProgramSessionEvaluationError, Source, - SourceId, Surface, Target, TargetCandidateChoiceV1, TargetCandidateId, TargetCandidateV1, - TargetDomainV1, TargetId, checked_program_evaluation_cell_counts_for_test, + DeclaredJointSelectionV1, HardModeV1, JointCandidateStateV1, ObservationGroup, Occurrence, + OutputBinding, OutputSlotId, Paint, Program, ProgramCompileError, + ProgramConstraintEvaluatorSetV1, ProgramSessionEvaluationError, ReportModeV1, Source, SourceId, + Surface, Target, TargetCandidateChoiceV1, TargetCandidateId, TargetCandidateV1, TargetDomainV1, + TargetId, checked_program_evaluation_cell_counts_for_test, fail_program_preflight_reservation_for_test, }; use crate::session::{SessionState, SessionUpdateError}; @@ -43,6 +48,248 @@ const UPPER_OUTPUT: OutputSlotId = OutputSlotId::new(85); const UPPER_TARGET: TargetId = TargetId::new(86); const UPPER_FIRST: TargetCandidateId = TargetCandidateId::new(87); const UPPER_SECOND: TargetCandidateId = TargetCandidateId::new(88); +const HARD_INVOCATION: Srgb8 = Srgb8::new([0xDD; 3]); +const DIAGNOSTIC_INVOCATION: Srgb8 = Srgb8::new([0xEE; 3]); + +/// A premature report invocation becomes an evaluator error, so this hostile +/// test double detects diagnostic authority leakage instead of merely counting +/// extra calls to a pure evaluator. +#[derive(Debug, Clone)] +struct ReportSelectionIsolationEvaluatorSetV1 { + control: std::rc::Rc, +} + +#[derive(Debug)] +struct ReportSelectionIsolationControlV1 { + selected: Srgb8, + report_invocation: Wcag22CriterionV1, + selected_non_report_calls: std::cell::Cell, + report_calls: std::cell::Cell, + calls: std::cell::RefCell>, +} + +impl ReportSelectionIsolationEvaluatorSetV1 { + fn new(selected: Srgb8, report_invocation: Wcag22CriterionV1) -> Self { + Self { + control: std::rc::Rc::new(ReportSelectionIsolationControlV1 { + selected, + report_invocation, + selected_non_report_calls: std::cell::Cell::new(0), + report_calls: std::cell::Cell::new(0), + calls: std::cell::RefCell::new(Vec::new()), + }), + } + } + + fn report_calls(&self) -> usize { + self.control.report_calls.get() + } + + fn calls(&self) -> Vec { + self.control.calls.borrow().clone() + } +} + +impl ProgramConstraintEvaluatorSetV1 for ReportSelectionIsolationEvaluatorSetV1 { + type Invocation = Wcag22CriterionV1; + type PassEvidence = ProgramVisiblePointPassEvidence; + type ViolationEvidence = ProgramVisiblePointViolationEvidence; + type Error = ApplicableWcag22EvaluationErrorV1; + + fn assess( + &self, + point: ProgramPointOccurrenceV1, + invocation: Self::Invocation, + ) -> Result< + HardDecision, + ProgramPointAssessmentErrorV1, + > { + let visible = Srgb8::new(point.target().encoded().visible()); + self.control.calls.borrow_mut().push(visible); + if invocation == self.control.report_invocation { + // The selected state must complete its search hit and fresh hard + // recheck before the report-only phase may execute. + if self.control.selected_non_report_calls.get() < 2 { + return Err(ProgramPointAssessmentErrorV1::Evaluator( + ApplicableWcag22EvaluationErrorV1::CriterionMismatch { + requested: invocation, + evaluated: Wcag22CriterionV1::Sc143TextLargeScale, + }, + )); + } + self.control + .report_calls + .set(self.control.report_calls.get() + 1); + } else if visible == self.control.selected { + self.control + .selected_non_report_calls + .set(self.control.selected_non_report_calls.get() + 1); + } + assess_program_point_hard(point, &Wcag22Srgb8V1, invocation) + } + + fn pass_binding(evidence: &Self::PassEvidence) -> ProgramVisiblePointBindingV1 { + *evidence.binding() + } + + fn violation_binding(evidence: &Self::ViolationEvidence) -> ProgramVisiblePointBindingV1 { + *evidence.binding() + } + + fn constraint_content(&self, invocation: Self::Invocation) -> ProgramConstraintContentV1 { + Wcag22Srgb8V1.program_constraint_content_v1(invocation) + } +} + +#[derive(Debug, Clone, Copy)] +struct DiagnosticPoisonPassV1(ProgramVisiblePointBindingV1); + +#[derive(Debug, Clone, Copy)] +struct DiagnosticPoisonViolationV1(ProgramVisiblePointBindingV1); + +/// The first diagnostic poisons every later hard decision. A complete conflict +/// is therefore possible only when all state × case hard evidence is frozen +/// before any report-only invocation runs. +#[derive(Debug, Clone, Default)] +struct CrossStateDiagnosticPoisonEvaluatorSetV1 { + control: std::rc::Rc, +} + +#[derive(Debug, Default)] +struct CrossStateDiagnosticPoisonControlV1 { + poisoned: std::cell::Cell, + hard_calls: std::cell::Cell, + first_report_after_hard_calls: std::cell::Cell>, +} + +impl CrossStateDiagnosticPoisonEvaluatorSetV1 { + fn hard_calls_before_first_report(&self) -> Option { + self.control.first_report_after_hard_calls.get() + } +} + +impl ProgramConstraintEvaluatorSetV1 for CrossStateDiagnosticPoisonEvaluatorSetV1 { + type Invocation = Srgb8; + type PassEvidence = DiagnosticPoisonPassV1; + type ViolationEvidence = DiagnosticPoisonViolationV1; + type Error = core::convert::Infallible; + + fn assess( + &self, + point: ProgramPointOccurrenceV1, + invocation: Self::Invocation, + ) -> Result< + HardDecision, + ProgramPointAssessmentErrorV1, + > { + let binding = point.binding(); + if invocation == DIAGNOSTIC_INVOCATION { + if self.control.first_report_after_hard_calls.get().is_none() { + self.control + .first_report_after_hard_calls + .set(Some(self.control.hard_calls.get())); + } + self.control.poisoned.set(true); + return Ok(HardDecision::Pass(DiagnosticPoisonPassV1(binding))); + } + + self.control + .hard_calls + .set(self.control.hard_calls.get() + 1); + if self.control.poisoned.get() { + Ok(HardDecision::Pass(DiagnosticPoisonPassV1(binding))) + } else { + Ok(HardDecision::Violation(DiagnosticPoisonViolationV1( + binding, + ))) + } + } + + fn pass_binding(evidence: &Self::PassEvidence) -> ProgramVisiblePointBindingV1 { + evidence.0 + } + + fn violation_binding(evidence: &Self::ViolationEvidence) -> ProgramVisiblePointBindingV1 { + evidence.0 + } + + fn constraint_content(&self, invocation: Self::Invocation) -> ProgramConstraintContentV1 { + ExactSrgb8IdentityV1.program_constraint_content_v1(invocation) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FinalViolationDiagnosticErrorV1 { + DiagnosticInvoked, +} + +/// The hard evaluator passes candidate search and rejects the fresh selected +/// state recheck. Its diagnostic branch fails loudly, so a leaked diagnostic +/// invocation would mask the authoritative final-recheck verdict. +#[derive(Debug, Clone, Default)] +struct FinalViolationDiagnosticErrorEvaluatorSetV1 { + control: std::rc::Rc, +} + +#[derive(Debug, Default)] +struct FinalViolationDiagnosticErrorControlV1 { + hard_calls: std::cell::Cell, + diagnostic_calls: std::cell::Cell, +} + +impl FinalViolationDiagnosticErrorEvaluatorSetV1 { + fn diagnostic_calls(&self) -> usize { + self.control.diagnostic_calls.get() + } +} + +impl ProgramConstraintEvaluatorSetV1 for FinalViolationDiagnosticErrorEvaluatorSetV1 { + type Invocation = Srgb8; + type PassEvidence = DiagnosticPoisonPassV1; + type ViolationEvidence = DiagnosticPoisonViolationV1; + type Error = FinalViolationDiagnosticErrorV1; + + fn assess( + &self, + point: ProgramPointOccurrenceV1, + invocation: Self::Invocation, + ) -> Result< + HardDecision, + ProgramPointAssessmentErrorV1, + > { + if invocation == DIAGNOSTIC_INVOCATION { + self.control + .diagnostic_calls + .set(self.control.diagnostic_calls.get() + 1); + return Err(ProgramPointAssessmentErrorV1::Evaluator( + FinalViolationDiagnosticErrorV1::DiagnosticInvoked, + )); + } + + let hard_call = self.control.hard_calls.get(); + self.control.hard_calls.set(hard_call + 1); + let binding = point.binding(); + if hard_call == 0 { + Ok(HardDecision::Pass(DiagnosticPoisonPassV1(binding))) + } else { + Ok(HardDecision::Violation(DiagnosticPoisonViolationV1( + binding, + ))) + } + } + + fn pass_binding(evidence: &Self::PassEvidence) -> ProgramVisiblePointBindingV1 { + evidence.0 + } + + fn violation_binding(evidence: &Self::ViolationEvidence) -> ProgramVisiblePointBindingV1 { + evidence.0 + } + + fn constraint_content(&self, invocation: Self::Invocation) -> ProgramConstraintContentV1 { + ExactSrgb8IdentityV1.program_constraint_content_v1(invocation) + } +} fn appearance_context() -> AppearanceContextId { AppearanceContextId::from_inputs( @@ -70,15 +317,30 @@ fn target(candidates: Vec) -> Target { Target::finite(TARGET, SOURCE, candidates) } -fn program( - hard: Vec>, - report_only: Vec>, - candidates: Vec, - order: Vec, -) -> Program { +fn point_program( + source_signal: ColorSignal, + target: Target, + hard: Vec< + ConstraintInvocation< + ::Invocation, + HardModeV1, + >, + >, + report_only: Vec< + ConstraintInvocation< + ::Invocation, + ReportModeV1, + >, + >, + evaluator: Evaluation, +) -> Program +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ::Invocation: Copy, +{ Program::new( - vec![Source::new(SOURCE, signal(0))], - vec![target(candidates)], + vec![Source::new(SOURCE, source_signal)], + vec![target], ObservationGroup::new(GROUP, vec![SURFACE_PORT]), vec![], vec![Paint::Solid { @@ -98,20 +360,44 @@ fn program( )], ConstraintSet::new(hard, report_only), vec![OutputBinding::new(OUTPUT, PAINT)], + evaluator, + ) +} + +fn program( + hard: Vec>, + report_only: Vec>, + candidates: Vec, + order: Vec, +) -> Program { + point_program( + signal(0), + target(candidates), + hard, + report_only, Wcag22Srgb8V1, ) .with_joint_selection(DeclaredJointSelectionV1::new(order)) } fn update(revision: u64, backdrop: u8) -> ObservationUpdateInput { + update_cases(revision, &[backdrop]) +} + +fn update_cases(revision: u64, backdrops: &[u8]) -> ObservationUpdateInput { ObservationUpdateInput { stream: STREAM, revision: Revision::new(revision), payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { - scenarios: vec![ScenarioInput { - id: ScenarioId::new(1), - bindings: vec![SurfaceInputBinding::new(SURFACE_PORT, signal(backdrop))], - }], + scenarios: backdrops + .iter() + .copied() + .enumerate() + .map(|(index, backdrop)| ScenarioInput { + id: ScenarioId::new(u32::try_from(index + 1).unwrap()), + bindings: vec![SurfaceInputBinding::new(SURFACE_PORT, signal(backdrop))], + }) + .collect(), }), } } @@ -902,6 +1188,246 @@ fn rejected_state_runs_once_and_selected_state_runs_fresh_recheck_twice() { ); } +#[test] +fn report_evaluator_error_cannot_poison_candidate_search_or_change_selection() { + let report_invocation = Wcag22CriterionV1::Sc143TextDefault; + let evaluator = + ReportSelectionIsolationEvaluatorSetV1::new(Srgb8::new([0xFF; 3]), report_invocation); + let probe = evaluator.clone(); + let compiled = point_program( + signal(0), + target(vec![candidate(FIRST, 0x55), candidate(SECOND, 0xFF)]), + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![ConstraintInvocation::report_only( + ConstraintId::new(2), + OCCURRENCE, + report_invocation, + )], + evaluator, + ) + .with_joint_selection(DeclaredJointSelectionV1::new(vec![ + state(FIRST), + state(SECOND), + ])) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let SessionState::Ready { current } = session.update(update(1, 0x00)).unwrap() else { + panic!("diagnostics without selection authority cannot poison hard candidate search"); + }; + assert_eq!(current.selected_state_index(), Some(1)); + assert_eq!(current.report().cells().len(), 2); + assert!(current.report().cells()[0].is_hard()); + assert!(!current.report().cells()[0].result().is_violation()); + assert!(!current.report().cells()[1].is_hard()); + assert!(!current.report().cells()[1].result().is_violation()); + assert_eq!(probe.report_calls(), 1); + assert_eq!( + probe.calls(), + vec![ + Srgb8::new([0x55; 3]), + Srgb8::new([0xFF; 3]), + Srgb8::new([0xFF; 3]), + Srgb8::new([0xFF; 3]), + ], + ); +} + +#[test] +fn hard_conflict_runs_report_only_once_in_the_exhaustive_full_pass() { + let evaluator = CountingProgramWcag22Srgb8V1::default(); + let calls = evaluator.clone(); + let compiled = point_program( + signal(0), + target(vec![candidate(FIRST, 0xAA), candidate(SECOND, 0xFF)]), + vec![ConstraintInvocation::hard( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![ConstraintInvocation::report_only( + ConstraintId::new(2), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextDefault, + )], + evaluator, + ) + .with_joint_selection(DeclaredJointSelectionV1::new(vec![ + state(FIRST), + state(SECOND), + ])) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let SessionState::Failed { cause, previous } = session.update(update(1, 0xFF)).unwrap() else { + panic!("both states must fail the hard large-text criterion on white"); + }; + assert!(previous.is_none()); + assert_eq!(cause.considered_state_count(), 2); + assert_eq!(cause.report().cells().len(), 4); + assert_eq!( + calls.calls(), + vec![ + Srgb8::new([0xAA; 3]), + Srgb8::new([0xFF; 3]), + Srgb8::new([0xAA; 3]), + Srgb8::new([0xFF; 3]), + Srgb8::new([0xAA; 3]), + Srgb8::new([0xFF; 3]), + ], + "hard search and the exhaustive all-state hard phase must precede the complete diagnostic phase", + ); +} + +#[test] +fn fixed_program_without_finite_targets_executes_one_complete_evidence_pass() { + let evaluator = CountingProgramWcag22Srgb8V1::default(); + let calls = evaluator.clone(); + let compiled = point_program( + signal(0xFF), + Target::fixed(TARGET, SOURCE), + vec![], + vec![ConstraintInvocation::report_only( + ConstraintId::new(1), + OCCURRENCE, + Wcag22CriterionV1::Sc143TextDefault, + )], + evaluator, + ) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let SessionState::Ready { current } = session.update(update(1, 0x00)).unwrap() else { + panic!("a fixed Program must retain diagnostics in its sole complete pass"); + }; + assert_eq!(current.selected_state_index(), None); + assert_eq!(current.report().cells().len(), 1); + assert!(!current.report().cells()[0].is_hard()); + assert!(!current.report().cells()[0].result().is_violation()); + assert_eq!(calls.calls(), vec![Srgb8::new([0xFF; 3])]); +} + +#[test] +fn fixed_hard_conflict_still_collects_report_only_evidence() { + let evaluator = CountingProgramWcag22Srgb8V1::default(); + let calls = evaluator.clone(); + let report = ConstraintId::new(1); + let hard = ConstraintId::new(2); + let compiled = point_program( + signal(0xAA), + Target::fixed(TARGET, SOURCE), + vec![ConstraintInvocation::hard( + hard, + OCCURRENCE, + Wcag22CriterionV1::Sc143TextLargeScale, + )], + vec![ConstraintInvocation::report_only( + report, + OCCURRENCE, + Wcag22CriterionV1::Sc143TextDefault, + )], + evaluator, + ) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let SessionState::Failed { cause, previous } = session.update(update(1, 0xFF)).unwrap() else { + panic!("a fixed hard conflict must retain its complete diagnostic report"); + }; + assert!(previous.is_none()); + assert_eq!(cause.considered_state_count(), 1); + assert_eq!( + cause + .report() + .cells() + .iter() + .map(|cell| ( + cell.constraint(), + cell.is_hard(), + cell.result().is_violation(), + )) + .collect::>(), + vec![(report, false, true), (hard, true, true)], + ); + assert_eq!( + calls.calls(), + vec![Srgb8::new([0xAA; 3]), Srgb8::new([0xAA; 3])], + "fixed evidence executes the hard phase before report-only diagnostics", + ); +} + +#[test] +fn exhaustive_conflict_freezes_every_state_case_hard_cell_before_any_diagnostic() { + let evaluator = CrossStateDiagnosticPoisonEvaluatorSetV1::default(); + let probe = evaluator.clone(); + let hard = ConstraintId::new(1); + let report = ConstraintId::new(2); + let compiled = point_program( + signal(0), + target(vec![candidate(FIRST, 0xAA), candidate(SECOND, 0xBB)]), + vec![ConstraintInvocation::hard( + hard, + OCCURRENCE, + HARD_INVOCATION, + )], + vec![ConstraintInvocation::report_only( + report, + OCCURRENCE, + DIAGNOSTIC_INVOCATION, + )], + evaluator, + ) + .with_joint_selection(DeclaredJointSelectionV1::new(vec![ + state(FIRST), + state(SECOND), + ])) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let SessionState::Failed { cause, previous } = + session.update(update_cases(1, &[0x00, 0xFF])).unwrap() + else { + panic!("diagnostics cannot poison hard evidence in a later case or state"); + }; + assert!(previous.is_none()); + assert_eq!(cause.considered_state_count(), 2); + assert_eq!(probe.hard_calls_before_first_report(), Some(8)); + assert_eq!(cause.report().cells().len(), 8); + assert_eq!( + cause + .report() + .cells() + .iter() + .map(|cell| ( + cell.candidate_state_index(), + cell.case_index(), + cell.constraint(), + cell.is_hard(), + cell.result().is_violation(), + )) + .collect::>(), + vec![ + (0, 0, hard, true, true), + (0, 0, report, false, false), + (0, 1, hard, true, true), + (0, 1, report, false, false), + (1, 0, hard, true, true), + (1, 0, report, false, false), + (1, 1, hard, true, true), + (1, 1, report, false, false), + ], + ); +} + #[test] fn successful_search_allocations_do_not_scale_with_rejected_states() { let compile = |candidates, order| { @@ -1181,6 +1707,94 @@ fn equivalent_recompiled_owner_is_a_new_generation_and_cannot_revive_old_session )); } +#[test] +fn lower_id_diagnostic_cannot_consume_the_selected_state_final_recheck() { + let evaluator = FinalRecheckMutantProgramEvaluatorV1::default(); + let control = evaluator.clone(); + let hard = ConstraintId::new(2); + let compiled = point_program( + signal(0), + target(vec![candidate(FIRST, 0xFF)]), + vec![ConstraintInvocation::hard( + hard, + OCCURRENCE, + Srgb8::new([0xFF; 3]), + )], + vec![ConstraintInvocation::report_only( + ConstraintId::new(1), + OCCURRENCE, + Srgb8::new([0xFF; 3]), + )], + evaluator, + ) + .with_joint_selection(DeclaredJointSelectionV1::new(vec![state(FIRST)])) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + assert!(matches!( + session.update(update(1, 0x00)).unwrap(), + SessionState::Ready { .. } + )); + control.arm(); + let error = match session.update(update(2, 0x00)) { + Ok(_) => panic!("a lower-ID diagnostic must not consume the hard final recheck"), + Err(error) => error, + }; + assert_eq!( + error, + SessionUpdateError::Plan(ProgramSessionEvaluationError::FinalRecheckViolation { + state_index: 0, + case_index: 0, + constraint: hard, + target: OCCURRENCE, + hard_violation_count: 1, + }), + ); +} + +#[test] +fn diagnostic_error_cannot_mask_a_selected_state_final_recheck_violation() { + let evaluator = FinalViolationDiagnosticErrorEvaluatorSetV1::default(); + let probe = evaluator.clone(); + let hard = ConstraintId::new(1); + let compiled = point_program( + signal(0), + target(vec![candidate(FIRST, 0xFF)]), + vec![ConstraintInvocation::hard( + hard, + OCCURRENCE, + HARD_INVOCATION, + )], + vec![ConstraintInvocation::report_only( + ConstraintId::new(2), + OCCURRENCE, + DIAGNOSTIC_INVOCATION, + )], + evaluator, + ) + .with_joint_selection(DeclaredJointSelectionV1::new(vec![state(FIRST)])) + .compile() + .unwrap(); + let mut session = compiled.instantiate(STREAM).unwrap(); + + let error = match session.update(update(1, 0x00)) { + Ok(_) => panic!("a diagnostic error must not mask the hard final-recheck verdict"), + Err(error) => error, + }; + assert_eq!( + error, + SessionUpdateError::Plan(ProgramSessionEvaluationError::FinalRecheckViolation { + state_index: 0, + case_index: 0, + constraint: hard, + target: OCCURRENCE, + hard_violation_count: 1, + }), + ); + assert_eq!(probe.diagnostic_calls(), 0); +} + #[test] fn final_recheck_violation_is_typed_and_retains_the_previous_certificate() { let evaluator = FinalRecheckMutantProgramEvaluatorV1::default(); diff --git a/crates/labcolors-core/src/program_session.rs b/crates/labcolors-core/src/program_session.rs index ef691a26..a6cc6dd2 100644 --- a/crates/labcolors-core/src/program_session.rs +++ b/crates/labcolors-core/src/program_session.rs @@ -6,6 +6,14 @@ //! occurrences, and outputs bind opaque slots back to Paints. The compiled //! result owns only admitted, canonical topology; runtime observation, //! lifecycle and terminal emission belong to the sole revision-bound Session. +//! Finite candidate search executes only hard constraints. Every fresh hard +//! phase completes across its whole physical support (and across every state +//! of an exhaustive conflict) before diagnostics execute. Report cells are +//! then restored to canonical ID order, keeping evidence order separate from +//! selection authority. A selected finite state whose fresh hard recheck fails +//! exits before diagnostics, preserving the authoritative typed failure. A +//! diagnostic evaluator error may abort fixed or exhaustive report construction +//! only after their hard verdict is fixed; no partial certificate is emitted. //! Output transport and encoded-only assessments retain exact physical //! occurrence evidence plus the declared appearance context. A modeled LCS //! occurrence is derived only through its separate typed capability; neither @@ -1013,6 +1021,45 @@ impl CompiledConstraintModeV1 { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProgramEvaluationPhaseV1 { + Hard, + ReportOnly, +} + +impl ProgramEvaluationPhaseV1 { + /// Phase separation prevents diagnostics from mutating evaluator state + /// before any hard decision in the same authority scope is frozen. + const fn includes(self, mode: CompiledConstraintModeV1) -> bool { + match self { + Self::Hard => mode.rejects_candidate(), + Self::ReportOnly => !mode.rejects_candidate(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CompiledConstraintPhasesV1 { + hard: bool, + report_only: bool, +} + +impl CompiledConstraintPhasesV1 { + fn from_authored(constraints: &ConstraintSet) -> Self { + Self { + hard: !constraints.hard.is_empty(), + report_only: !constraints.report_only.is_empty(), + } + } + + const fn contains(self, phase: ProgramEvaluationPhaseV1) -> bool { + match phase { + ProgramEvaluationPhaseV1::Hard => self.hard, + ProgramEvaluationPhaseV1::ReportOnly => self.report_only, + } + } +} + struct CompiledPointConstraint { id: ConstraintId, target_id: OccurrenceId, @@ -1062,6 +1109,7 @@ where observation_group: CompiledObservationGroupV1, occurrence_contexts: Box<[CompiledOccurrenceContextV1]>, constraints: Box<[CompiledPointConstraint>]>, + constraint_phases: CompiledConstraintPhasesV1, outputs: Box<[CompiledOutputBinding]>, finite_targets: Box<[CompiledFiniteTargetV1]>, joint_selection: Option, @@ -1476,9 +1524,8 @@ where // the exhaustive-cell multiplier remains the multiplicative identity. .unwrap_or(1); let can_conflict = epoch - .constraints - .iter() - .any(|constraint| constraint.mode.rejects_candidate()); + .constraint_phases + .contains(ProgramEvaluationPhaseV1::Hard); checked_program_evaluation_cell_counts( physical_case_count, epoch.constraints.len(), @@ -1705,7 +1752,15 @@ where 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)? { + if !scan_program_candidate( + plan, + epoch, + &observation, + state_index, + ProgramEvaluationPhaseV1::Hard, + None, + None, + )? { // A selected tuple is never certified from its allocation-free // search pass. Re-apply and collect fresh terminal evidence. apply_joint_candidate(plan, &epoch.finite_targets, tuple)?; @@ -1720,26 +1775,11 @@ where SessionDecision::Verified(verified) => { return Ok(SessionDecision::Verified(verified)); } - SessionDecision::Violation(conflict) => { - let first = conflict - .report - .cells - .iter() - .find(|cell| cell.is_hard() && cell.result().is_violation()) - .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; - let hard_violation_count = conflict - .report - .cells - .iter() - .filter(|cell| cell.is_hard() && cell.result().is_violation()) - .count(); - return Err(ProgramSessionEvaluationError::FinalRecheckViolation { - state_index, - case_index: first.case_index, - constraint: first.constraint, - target: first.target, - hard_violation_count, - }); + SessionDecision::Violation(_) => { + // A selected finite state converts every fresh hard failure + // into FinalRecheckViolation inside collect_*. Reaching a + // plain Violation here means that contract was broken. + return Err(ProgramSessionEvaluationError::InternalInvariant); } } } @@ -1752,15 +1792,36 @@ where epoch, &observation, state_index, + ProgramEvaluationPhaseV1::Hard, Some(&mut buffers.conflict_cells), None, )? { return Err(ProgramSessionEvaluationError::InternalInvariant); } } + if epoch + .constraint_phases + .contains(ProgramEvaluationPhaseV1::ReportOnly) + { + 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, + ProgramEvaluationPhaseV1::ReportOnly, + Some(&mut buffers.conflict_cells), + None, + )? { + return Err(ProgramSessionEvaluationError::InternalInvariant); + } + } + } if buffers.conflict_cells.len() != buffers.counts.exhaustive_conflict { return Err(ProgramSessionEvaluationError::InternalInvariant); } + canonicalize_program_report_cells(&mut buffers.conflict_cells); Ok(SessionDecision::Violation(ProgramConflictV1 { report: ProgramReportV1 { @@ -1821,17 +1882,60 @@ where return Err(ProgramSessionEvaluationError::InternalInvariant); } let candidate_state_index = selected_state_index.unwrap_or(0); - let has_hard_violation = scan_program_candidate( - plan, - epoch, - &observation, - candidate_state_index, - Some(&mut cells), - Some(&mut outputs), - )?; + let has_hard_constraints = epoch + .constraint_phases + .contains(ProgramEvaluationPhaseV1::Hard); + let has_report_constraints = epoch + .constraint_phases + .contains(ProgramEvaluationPhaseV1::ReportOnly); + let has_hard_violation = if has_hard_constraints { + scan_program_candidate( + plan, + epoch, + &observation, + candidate_state_index, + ProgramEvaluationPhaseV1::Hard, + Some(&mut cells), + Some(&mut outputs), + )? + } else { + false + }; + if let Some(state_index) = selected_state_index.filter(|_| has_hard_violation) { + // Search only nominates a finite state. Its fresh hard recheck owns the + // terminal verdict, so diagnostics cannot mask or mutate that failure. + let mut violations = cells + .iter() + .filter(|cell| cell.is_hard() && cell.result().is_violation()); + let first = violations + .next() + .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; + let hard_violation_count = 1 + violations.count(); + return Err(ProgramSessionEvaluationError::FinalRecheckViolation { + state_index, + case_index: first.case_index, + constraint: first.constraint, + target: first.target, + hard_violation_count, + }); + } + if has_report_constraints + && scan_program_candidate( + plan, + epoch, + &observation, + candidate_state_index, + ProgramEvaluationPhaseV1::ReportOnly, + Some(&mut cells), + (!has_hard_constraints).then_some(&mut outputs), + )? + { + return Err(ProgramSessionEvaluationError::InternalInvariant); + } if cells.len() != expected_cell_count { return Err(ProgramSessionEvaluationError::InternalInvariant); } + canonicalize_program_report_cells(&mut cells); let report = ProgramReportV1 { content_identity: epoch.content_identity, observation, @@ -1851,11 +1955,24 @@ where } } +/// Evaluation is authority-first, while the emitted contract remains +/// `state × physical case × ConstraintId`. Sorting is in-place and therefore +/// adds no allocation to the preflight-bounded terminal path. +fn canonicalize_program_report_cells(cells: &mut [ProgramConstraintCellV1]) +where + Evaluation: ProgramConstraintEvaluatorSetV1, +{ + cells.sort_unstable_by_key(|cell| { + (cell.candidate_state_index, cell.case_index, cell.constraint) + }); +} + fn scan_program_candidate( plan: &mut ProgramSessionPlan, epoch: &ProgramEpochV1, observation: &RevisionBoundObservationV1, candidate_state_index: usize, + phase: ProgramEvaluationPhaseV1, mut cells: Option<&mut Vec>>, mut outputs: Option<&mut Vec>, ) -> Result>> @@ -1899,7 +2016,11 @@ where .evaluate_admitted_into(&plan.bindings, &mut plan.workspace) .map_err(map_program_execution_binding_error)?; - for constraint in epoch.constraints.iter() { + for constraint in epoch + .constraints + .iter() + .filter(|constraint| phase.includes(constraint.mode)) + { let source = evaluation .occurrence_at(constraint.target) .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; @@ -2099,6 +2220,7 @@ where let all_occurrence_contexts = compile_occurrence_contexts(&graph, &program.occurrences)?; let mut constraints = compile_constraints::(&graph, &all_occurrence_contexts, &program.constraints)?; + let constraint_phases = CompiledConstraintPhasesV1::from_authored(&program.constraints); let occurrence_contexts = compact_constraint_contexts(&all_occurrence_contexts, &mut constraints)?; let outputs = compile_outputs(&graph, &mut program.outputs)?; @@ -2114,6 +2236,7 @@ where }, occurrence_contexts, constraints, + constraint_phases, outputs, finite_targets, joint_selection,