1use super::validation::{
17 EvidenceAvailability, SnapshotValidationContext, core_world_entity_exists,
18 resolve_evidence_reference, snapshot_boundary_contract,
19};
20use super::{
21 BTreeMap, BoundaryDirective, BoundaryDomainEntityCuts, BoundaryPhase, BoundaryRecord,
22 BoundarySystemContract, CanwuError, DomainRecord, DomainRecordClass, DomainRecordRef,
23 EntityRef, ErrorCode, PluginRegistry, SimulationSnapshot, boundary_has_event_ingress,
24 boundary_system_due, canonical_text, domain_record_commit_stage, invalid_snapshot,
25 invalid_snapshot_error,
26};
27use canwu_core::{BoundaryId, EvaluationTraceRecord};
28use serde::{Deserialize, Serialize};
29use std::collections::BTreeSet;
30
31#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
39pub struct EvaluationLimitsV1 {
40 pub traces_per_boundary: u32,
42 pub terms_per_trace: u32,
44}
45
46impl EvaluationLimitsV1 {
47 pub const DEFAULT: Self = Self {
50 traces_per_boundary: 4_096,
51 terms_per_trace: 32,
52 };
53 pub const MAX: Self = Self {
55 traces_per_boundary: 65_536,
56 terms_per_trace: 256,
57 };
58 pub const EVIDENCE_PER_TERM: usize = 16;
60 pub const TEXT_BYTES: usize = 256;
62
63 #[allow(clippy::trivially_copy_pass_by_ref)]
64 pub(crate) fn is_default(&self) -> bool {
65 *self == Self::DEFAULT
66 }
67
68 pub(crate) fn validate(self) -> Result<(), CanwuError> {
69 if self.traces_per_boundary > Self::MAX.traces_per_boundary
70 || self.terms_per_trace > Self::MAX.terms_per_trace
71 {
72 return Err(CanwuError::new(
73 ErrorCode::InvalidRunConfiguration,
74 "evaluation trace limits exceed the engine maximum",
75 ));
76 }
77 Ok(())
78 }
79}
80
81impl Default for EvaluationLimitsV1 {
82 fn default() -> Self {
83 Self::DEFAULT
84 }
85}
86
87#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
91pub struct BoundaryEvaluationTrace {
92 pub plugin: String,
93 pub system: String,
94 pub phase: BoundaryPhase,
95 pub trace: EvaluationTraceRecord,
96}
97
98const fn phase_records_traces(phase: BoundaryPhase) -> bool {
99 matches!(
100 phase,
101 BoundaryPhase::DomainDeltaProposal | BoundaryPhase::StrategicAggregation
102 )
103}
104
105fn limit_exceeded(message: &str) -> CanwuError {
106 CanwuError::new(ErrorCode::EvaluationTraceLimitExceeded, message)
107}
108
109fn invalid_trace(message: &str) -> CanwuError {
110 CanwuError::new(ErrorCode::InvalidBoundary, message)
111}
112
113fn validate_text(value: &str, label: &str) -> Result<(), CanwuError> {
114 if !canonical_text(value) {
115 return Err(invalid_trace(&format!(
116 "evaluation trace {label} must be non-empty canonical text"
117 )));
118 }
119 if value.len() > EvaluationLimitsV1::TEXT_BYTES {
120 return Err(limit_exceeded(&format!(
121 "evaluation trace {label} exceeds its byte limit"
122 )));
123 }
124 Ok(())
125}
126
127pub(super) fn validate_trace_shape(
130 phase: BoundaryPhase,
131 trace: &EvaluationTraceRecord,
132 boundary: BoundaryId,
133 limits: EvaluationLimitsV1,
134) -> Result<(), CanwuError> {
135 if !phase_records_traces(phase) {
136 return Err(invalid_trace(
137 "evaluation traces are recorded only by phase-7 and phase-12 systems",
138 ));
139 }
140 if trace.boundary != boundary {
141 return Err(invalid_trace(
142 "an evaluation trace must name the boundary that records it",
143 ));
144 }
145 validate_text(&trace.rule_id, "rule ID")?;
146 validate_text(&trace.rule_version, "rule version")?;
147 if trace.terms.len() > limits.terms_per_trace as usize {
148 return Err(limit_exceeded(
149 "evaluation trace exceeds the run's terms-per-trace limit",
150 ));
151 }
152 let mut term_ids = BTreeSet::new();
153 for term in &trace.terms {
154 validate_text(&term.term_id, "term ID")?;
155 if !term_ids.insert(term.term_id.as_str()) {
156 return Err(invalid_trace(
157 "evaluation trace term IDs must be unique within the trace",
158 ));
159 }
160 if term.evidence.len() > EvaluationLimitsV1::EVIDENCE_PER_TERM {
161 return Err(limit_exceeded(
162 "evaluation term exceeds its evidence-reference limit",
163 ));
164 }
165 if term.evidence.windows(2).any(|pair| pair[0] >= pair[1]) {
166 return Err(invalid_trace(
167 "evaluation term evidence must be strictly sorted and unique",
168 ));
169 }
170 }
171 Ok(())
172}
173
174pub(super) fn check_trace_budget(
177 staged: usize,
178 directives: &[BoundaryDirective],
179 limits: EvaluationLimitsV1,
180) -> Result<(), CanwuError> {
181 let proposed = directives
182 .iter()
183 .filter(|directive| matches!(directive, BoundaryDirective::RecordEvaluationTrace { .. }))
184 .count();
185 if staged.saturating_add(proposed) > limits.traces_per_boundary as usize {
186 return Err(limit_exceeded(
187 "boundary proposals exceed the run's traces-per-boundary limit",
188 ));
189 }
190 Ok(())
191}
192
193pub(super) fn stage_traces(
196 staged: &mut Vec<BoundaryEvaluationTrace>,
197 plugin: &str,
198 contract: &BoundarySystemContract,
199 traces: impl IntoIterator<Item = EvaluationTraceRecord>,
200) {
201 staged.extend(traces.into_iter().map(|trace| BoundaryEvaluationTrace {
202 plugin: plugin.to_owned(),
203 system: contract.name.clone(),
204 phase: contract.phase,
205 trace,
206 }));
207}
208
209pub(super) fn validate_snapshot_boundary_traces(
212 record: &BoundaryRecord,
213 snapshot: &SimulationSnapshot,
214 plugins: &PluginRegistry,
215 final_records: &BTreeMap<DomainRecordRef, DomainRecord>,
216 cuts: &BoundaryDomainEntityCuts,
217 limits: EvaluationLimitsV1,
218) -> Result<(), CanwuError> {
219 if record.evaluation_traces.len() > limits.traces_per_boundary as usize {
220 return invalid_snapshot("boundary evaluation traces exceed the run's limit");
221 }
222 let evidence = SnapshotValidationContext::new(snapshot);
223 let mut previous: Option<(BoundaryPhase, &str, &str)> = None;
224 for entry in &record.evaluation_traces {
225 let Some(contract) = snapshot_boundary_contract(plugins, &entry.plugin, &entry.system)
226 else {
227 return invalid_snapshot("evaluation trace references an unknown boundary system");
228 };
229 let Some(commit_stage) = domain_record_commit_stage(contract.phase, contract.visibility)
230 else {
231 return invalid_snapshot("evaluation trace producer has no settlement stage");
232 };
233 let order = (entry.phase, entry.plugin.as_str(), entry.system.as_str());
234 if entry.phase != contract.phase
235 || previous.is_some_and(|previous| previous > order)
236 || !boundary_system_due(
237 contract,
238 &record.cadences,
239 boundary_has_event_ingress(record),
240 )
241 {
242 return invalid_snapshot(
243 "evaluation trace producer, phase, or execution order is inconsistent",
244 );
245 }
246 previous = Some(order);
247 validate_trace_shape(entry.phase, &entry.trace, record.id, limits).map_err(|error| {
248 invalid_snapshot_error(format!(
249 "evaluation trace evidence is invalid: {}",
250 error.message
251 ))
252 })?;
253 let subject_exists = match &entry.trace.subject {
254 EntityRef::Domain(reference) => {
255 final_records
256 .get(reference)
257 .is_some_and(|record| record.class == DomainRecordClass::Entity)
258 && cuts.identity_exists_for_proposal(
259 final_records,
260 reference,
261 contract.phase,
262 commit_stage,
263 &entry.plugin,
264 &entry.system,
265 )
266 }
267 subject => {
268 snapshot.entities.binary_search(subject).is_ok()
269 || core_world_entity_exists(&snapshot.world, subject)
270 }
271 };
272 if !subject_exists {
273 return invalid_snapshot("evaluation trace names an unknown subject");
274 }
275 if entry
276 .trace
277 .terms
278 .iter()
279 .flat_map(|term| &term.evidence)
280 .any(|reference| {
281 resolve_evidence_reference(&evidence, reference) != EvidenceAvailability::Retained
282 })
283 {
284 return invalid_snapshot("evaluation trace cites missing or wrong-version evidence");
285 }
286 }
287 Ok(())
288}