1use noether_core::effects::Effect;
23use noether_core::stage::Stage;
24use serde_json::Value;
25
26use crate::executor::{ExecutionError, StageExecutor};
27
28#[derive(Debug, Clone, PartialEq)]
30pub enum ExampleOutcome {
31 Ok,
33 Mismatch { expected: Value, actual: Value },
35 Errored { message: String },
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum StageSkipReason {
42 Network,
44 Llm,
46 NonDeterministic,
48 Process,
50 NoExamples,
52 NoImplementation,
54}
55
56impl std::fmt::Display for StageSkipReason {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 Self::Network => write!(f, "network effect — example outputs are illustrative"),
60 Self::Llm => write!(f, "LLM effect — output is non-reproducible"),
61 Self::NonDeterministic => write!(f, "non-deterministic effect"),
62 Self::Process => write!(f, "process effect — side-effectful"),
63 Self::NoExamples => write!(f, "no examples declared"),
64 Self::NoImplementation => write!(f, "no implementation available in this executor"),
65 }
66 }
67}
68
69#[derive(Debug, Clone)]
71pub struct StageReport {
72 pub stage_id: String,
73 pub description: String,
74 pub outcome: ReportOutcome,
75}
76
77#[derive(Debug, Clone)]
78pub enum ReportOutcome {
79 Skipped { reason: StageSkipReason },
81 Tested { examples: Vec<ExampleOutcome> },
83}
84
85impl StageReport {
86 pub fn passed(&self) -> bool {
88 match &self.outcome {
89 ReportOutcome::Skipped { .. } => true,
90 ReportOutcome::Tested { examples } => {
91 examples.iter().all(|e| matches!(e, ExampleOutcome::Ok))
92 }
93 }
94 }
95
96 pub fn failed(&self) -> bool {
98 matches!(&self.outcome, ReportOutcome::Tested { examples }
99 if examples.iter().any(|e| !matches!(e, ExampleOutcome::Ok)))
100 }
101}
102
103fn skip_reason(stage: &Stage) -> Option<StageSkipReason> {
105 if stage.examples.is_empty() {
106 return Some(StageSkipReason::NoExamples);
107 }
108 for effect in stage.signature.effects.iter() {
109 match effect {
110 Effect::Network => return Some(StageSkipReason::Network),
111 Effect::Llm { .. } => return Some(StageSkipReason::Llm),
112 Effect::NonDeterministic => return Some(StageSkipReason::NonDeterministic),
113 Effect::Process => return Some(StageSkipReason::Process),
114 _ => {}
115 }
116 }
117 None
118}
119
120fn canonical_eq(a: &Value, b: &Value) -> bool {
124 match (serde_jcs::to_vec(a), serde_jcs::to_vec(b)) {
125 (Ok(x), Ok(y)) => x == y,
126 _ => a == b, }
128}
129
130pub fn verify_stage<E: StageExecutor>(stage: &Stage, executor: &E) -> StageReport {
135 if let Some(reason) = skip_reason(stage) {
136 return StageReport {
137 stage_id: stage.id.0.clone(),
138 description: stage.description.clone(),
139 outcome: ReportOutcome::Skipped { reason },
140 };
141 }
142
143 let mut examples = Vec::with_capacity(stage.examples.len());
144 for example in &stage.examples {
145 let outcome = match executor.execute(&stage.id, &example.input) {
146 Ok(actual) => {
147 if canonical_eq(&actual, &example.output) {
148 ExampleOutcome::Ok
149 } else {
150 ExampleOutcome::Mismatch {
151 expected: example.output.clone(),
152 actual,
153 }
154 }
155 }
156 Err(ExecutionError::StageNotFound(_)) => {
157 return StageReport {
158 stage_id: stage.id.0.clone(),
159 description: stage.description.clone(),
160 outcome: ReportOutcome::Skipped {
161 reason: StageSkipReason::NoImplementation,
162 },
163 };
164 }
165 Err(e) => ExampleOutcome::Errored {
166 message: format!("{e}"),
167 },
168 };
169 examples.push(outcome);
170 }
171
172 StageReport {
173 stage_id: stage.id.0.clone(),
174 description: stage.description.clone(),
175 outcome: ReportOutcome::Tested { examples },
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use crate::executor::ExecutionError;
183 use noether_core::capability::Capability;
184 use noether_core::effects::EffectSet;
185 use noether_core::stage::{CostEstimate, Example, Stage, StageId, StageSignature};
186 use noether_core::types::NType;
187 use serde_json::json;
188 use std::collections::BTreeSet;
189
190 struct ConstExec {
192 out: Value,
193 }
194
195 impl StageExecutor for ConstExec {
196 fn execute(
197 &self,
198 _id: &StageId,
199 _input: &Value,
200 ) -> Result<Value, crate::executor::ExecutionError> {
201 Ok(self.out.clone())
202 }
203 }
204
205 struct EchoExec;
207
208 impl StageExecutor for EchoExec {
209 fn execute(&self, _id: &StageId, input: &Value) -> Result<Value, ExecutionError> {
210 Ok(input.clone())
211 }
212 }
213
214 fn make_stage(effects: EffectSet, examples: Vec<Example>) -> Stage {
215 Stage {
216 id: StageId("test-stage".into()),
217 canonical_id: None,
218 signature: StageSignature {
219 input: NType::Any,
220 output: NType::Any,
221 effects,
222 implementation_hash: "hash".into(),
223 },
224 capabilities: BTreeSet::new(),
225 cost: CostEstimate {
226 time_ms_p50: None,
227 tokens_est: None,
228 memory_mb: None,
229 },
230 description: "test".into(),
231 examples,
232 lifecycle: noether_core::stage::StageLifecycle::Active,
233 ed25519_signature: None,
234 signer_public_key: None,
235 implementation_code: None,
236 implementation_language: None,
237 ui_style: None,
238 tags: vec![],
239 aliases: vec![],
240 }
241 }
242
243 #[test]
244 fn pure_stage_passes_when_executor_matches() {
245 let stage = make_stage(
246 EffectSet::pure(),
247 vec![Example {
248 input: json!({"x": 1}),
249 output: json!({"x": 1}),
250 }],
251 );
252 let report = verify_stage(&stage, &EchoExec);
253 assert!(report.passed());
254 }
255
256 #[test]
257 fn pure_stage_fails_when_executor_diverges() {
258 let stage = make_stage(
259 EffectSet::pure(),
260 vec![Example {
261 input: json!({"x": 1}),
262 output: json!({"x": 2}),
263 }],
264 );
265 let report = verify_stage(
266 &stage,
267 &ConstExec {
268 out: json!({"x": 1}),
269 },
270 );
271 assert!(report.failed());
272 }
273
274 #[test]
275 fn network_stage_is_skipped() {
276 let stage = make_stage(
277 EffectSet::new(vec![Effect::Network]),
278 vec![Example {
279 input: json!(null),
280 output: json!(null),
281 }],
282 );
283 let report = verify_stage(&stage, &EchoExec);
284 assert!(matches!(
285 report.outcome,
286 ReportOutcome::Skipped {
287 reason: StageSkipReason::Network
288 }
289 ));
290 }
291
292 #[test]
293 fn llm_stage_is_skipped() {
294 let stage = make_stage(
295 EffectSet::new(vec![Effect::Llm {
296 model: "any".into(),
297 }]),
298 vec![Example {
299 input: json!(null),
300 output: json!(null),
301 }],
302 );
303 let report = verify_stage(&stage, &EchoExec);
304 assert!(matches!(
305 report.outcome,
306 ReportOutcome::Skipped {
307 reason: StageSkipReason::Llm
308 }
309 ));
310 }
311
312 #[test]
313 fn canonical_eq_ignores_field_order_and_numeric_form() {
314 assert!(canonical_eq(
315 &json!({"a": 1, "b": 2}),
316 &json!({"b": 2, "a": 1})
317 ));
318 assert!(canonical_eq(&json!(1.0), &json!(1)));
319 assert!(!canonical_eq(&json!({"a": 1}), &json!({"a": 2})));
320 }
321
322 #[test]
323 fn no_examples_is_skipped() {
324 let stage = make_stage(EffectSet::pure(), vec![]);
325 let report = verify_stage(&stage, &EchoExec);
326 assert!(matches!(
327 report.outcome,
328 ReportOutcome::Skipped {
329 reason: StageSkipReason::NoExamples
330 }
331 ));
332 }
333
334 #[allow(dead_code)]
337 fn _capability_use() -> Capability {
338 Capability::Network
339 }
340}