Skip to main content

canwu_sim/runtime/
evaluation.rs

1//! Rule-evaluation trace evidence.
2//!
3//! A phase-7 or phase-12 boundary system may explain an application rule it
4//! evaluated with [`super::BoundaryDirective::RecordEvaluationTrace`]. The
5//! kernel records each trace, with its producing system, on the boundary
6//! record, so the trace is part of the hash-chained boundary evidence and is
7//! sealed and archived with that record. Traces are never simulation state:
8//! no boundary system, command handler, or decision policy can read one back
9//! to decide an outcome.
10//!
11//! The per-boundary bounds come from [`super::RunConfiguration`]. A proposal
12//! set that exceeds them fails the boundary with
13//! [`ErrorCode::EvaluationTraceLimitExceeded`]; the engine never samples or
14//! drops traces. A host that does not want traces does not emit them.
15
16use 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/// Per-boundary bounds on rule-evaluation traces, declared in
32/// [`super::RunConfiguration::evaluation_limits`].
33///
34/// Both bounds are enforced fail-closed and deterministically: the boundary
35/// whose proposals exceed either bound fails with
36/// [`ErrorCode::EvaluationTraceLimitExceeded`] and commits nothing. Zero is a
37/// valid bound; `traces_per_boundary: 0` forbids traces for the run.
38#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
39pub struct EvaluationLimitsV1 {
40    /// Traces recorded by all systems of one boundary together.
41    pub traces_per_boundary: u32,
42    /// Terms in one trace.
43    pub terms_per_trace: u32,
44}
45
46impl EvaluationLimitsV1 {
47    /// The bounds used when a run configuration declares none: 4,096 traces
48    /// of up to 32 terms per boundary.
49    pub const DEFAULT: Self = Self {
50        traces_per_boundary: 4_096,
51        terms_per_trace: 32,
52    };
53    /// The largest bounds a run configuration may declare.
54    pub const MAX: Self = Self {
55        traces_per_boundary: 65_536,
56        terms_per_trace: 256,
57    };
58    /// Fixed bound on the evidence references of one term.
59    pub const EVIDENCE_PER_TERM: usize = 16;
60    /// Fixed byte bound on a rule ID, rule version, or term ID.
61    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/// Committed rule-evaluation trace evidence in a boundary record, with its
88/// producing system. Entries follow system execution order: phase, then
89/// plugin and system name, then proposal order.
90#[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
127/// Checks the producing phase, shape, and per-trace bounds shared by live
128/// admission and snapshot loading.
129pub(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
174/// Fails before any trace of a proposal is validated when the proposal would
175/// take the boundary total past the run's limit.
176pub(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
193/// Appends one system's validated, budget-checked traces to the boundary's
194/// pending evidence.
195pub(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
209/// Validates one persisted boundary's trace evidence against its producing
210/// contracts, the run's limits, subject identities, and retained evidence.
211pub(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}