1use std::borrow::Cow;
2use std::collections::HashMap;
3
4use crate::action::ActionRegistry;
5use crate::cluster::{InputMetadata, OutputMetadata, PrimitiveKind, ValueType};
6use crate::common::{doc_anchor_for_rule, ErrorInfo, Phase, Value};
7use crate::compute::ComputeError;
8use crate::compute::PrimitiveRegistry as ComputeRegistry;
9use crate::source::SourceRegistry;
10use crate::trigger::TriggerRegistry;
11
12#[derive(Debug, Clone, PartialEq)]
13pub enum RuntimeEvent {
14 Trigger(crate::trigger::TriggerEvent),
15 Action(crate::action::ActionOutcome),
16}
17
18#[derive(Debug, Clone, PartialEq)]
19pub enum RuntimeValue {
20 Number(f64),
21 Series(Vec<f64>),
22 Bool(bool),
23 Event(RuntimeEvent),
24 String(String),
25}
26
27#[derive(Debug, Clone, PartialEq)]
28pub struct ValidatedNode {
29 pub runtime_id: String,
30 pub impl_id: String,
31 pub version: String,
32 pub kind: PrimitiveKind,
33 pub inputs: Vec<InputMetadata>,
35 pub outputs: HashMap<String, OutputMetadata>,
36 pub parameters: HashMap<String, crate::cluster::ParameterValue>,
37}
38
39#[derive(Debug, Clone, PartialEq)]
40pub struct ValidatedEdge {
41 pub from: Endpoint,
42 pub to: Endpoint,
43}
44
45#[derive(Debug, Clone, PartialEq)]
46pub enum Endpoint {
47 NodePort { node_id: String, port_name: String },
48}
49
50#[derive(Debug, Clone, PartialEq)]
51pub struct ValidatedGraph {
52 pub nodes: HashMap<String, ValidatedNode>,
53 pub edges: Vec<ValidatedEdge>,
54 pub topo_order: Vec<String>,
55 pub boundary_outputs: Vec<crate::cluster::OutputPortSpec>,
56}
57
58pub type ValidationError = GraphValidationError;
61
62#[derive(Debug)]
63#[non_exhaustive]
64pub enum GraphValidationError {
65 CycleDetected,
66 UnknownNode(String),
67 MissingPrimitive {
68 id: String,
69 version: String,
70 },
71 InvalidEdgeKind {
72 from: PrimitiveKind,
73 to: PrimitiveKind,
74 },
75 MissingRequiredInput {
76 node: String,
77 input: String,
78 },
79 MissingInputMetadata {
80 node: String,
81 input: String,
82 },
83 TypeMismatch {
84 from: String,
85 output: String,
86 to: String,
87 input: String,
88 expected: ValueType,
89 got: ValueType,
90 },
91 ActionNotGated(String),
92 MissingOutputMetadata {
93 node: String,
94 output: String,
95 },
96 ExternalInputNotAllowed {
97 name: String,
98 },
99 MultipleInboundEdges {
102 node: String,
103 input: String,
104 },
105}
106
107impl ErrorInfo for GraphValidationError {
108 fn rule_id(&self) -> &'static str {
109 match self {
110 Self::CycleDetected => "V.1",
111 Self::InvalidEdgeKind { .. } => "V.2",
112 Self::MissingRequiredInput { .. } => "V.3",
113 Self::TypeMismatch { .. } => "V.4",
114 Self::ActionNotGated(_) => "V.5",
115 Self::MultipleInboundEdges { .. } => "V.7",
116 Self::MissingPrimitive { .. } => "V.8",
117 Self::UnknownNode(_)
118 | Self::MissingInputMetadata { .. }
119 | Self::MissingOutputMetadata { .. } => "D.2",
120 Self::ExternalInputNotAllowed { .. } => "E.3",
121 }
122 }
123
124 fn phase(&self) -> Phase {
125 Phase::Composition
126 }
127
128 fn doc_anchor(&self) -> &'static str {
129 doc_anchor_for_rule(self.rule_id())
130 }
131
132 fn summary(&self) -> Cow<'static, str> {
133 match self {
134 Self::CycleDetected => Cow::Borrowed("Cycle detected in graph"),
135 Self::UnknownNode(node) => Cow::Owned(format!("Unknown node '{}'", node)),
136 Self::MissingPrimitive { id, version } => {
137 Cow::Owned(format!("Missing primitive '{}@{}'", id, version))
138 }
139 Self::InvalidEdgeKind { from, to } => {
140 Cow::Owned(format!("Invalid edge kind: {:?} -> {:?}", from, to))
141 }
142 Self::MissingRequiredInput { node, input } => Cow::Owned(format!(
143 "Missing required input '{}' on node '{}'",
144 input, node
145 )),
146 Self::MissingInputMetadata { node, input } => Cow::Owned(format!(
147 "Missing input metadata '{}' on node '{}'",
148 input, node
149 )),
150 Self::TypeMismatch {
151 from,
152 output,
153 to,
154 input,
155 expected,
156 got,
157 } => Cow::Owned(format!(
158 "Type mismatch {}.{} -> {}.{} (expected {:?}, got {:?})",
159 from, output, to, input, expected, got
160 )),
161 Self::ActionNotGated(node) => {
162 Cow::Owned(format!("Action '{}' is not gated by a trigger", node))
163 }
164 Self::MissingOutputMetadata { node, output } => Cow::Owned(format!(
165 "Missing output metadata '{}' on node '{}'",
166 output, node
167 )),
168 Self::ExternalInputNotAllowed { name } => Cow::Owned(format!(
169 "External input '{}' is not allowed in execution graph",
170 name
171 )),
172 Self::MultipleInboundEdges { node, input } => {
173 Cow::Owned(format!("Multiple inbound edges to '{}.{}'", node, input))
174 }
175 }
176 }
177
178 fn path(&self) -> Option<Cow<'static, str>> {
179 match self {
180 Self::CycleDetected => Some(Cow::Borrowed("$.edges")),
181 Self::InvalidEdgeKind { .. } => Some(Cow::Borrowed("$.edges")),
182 Self::MissingRequiredInput { .. } => Some(Cow::Borrowed("$.edges")),
183 Self::TypeMismatch { .. } => Some(Cow::Borrowed("$.edges")),
184 Self::ActionNotGated(_) => Some(Cow::Borrowed("$.edges")),
185 Self::MultipleInboundEdges { .. } => Some(Cow::Borrowed("$.edges")),
186 Self::ExternalInputNotAllowed { .. } => Some(Cow::Borrowed("$.edges")),
187 Self::UnknownNode(_)
188 | Self::MissingPrimitive { .. }
189 | Self::MissingInputMetadata { .. }
190 | Self::MissingOutputMetadata { .. } => Some(Cow::Borrowed("$.nodes")),
191 }
192 }
193
194 fn fix(&self) -> Option<Cow<'static, str>> {
195 match self {
196 Self::CycleDetected => Some(Cow::Borrowed("Remove the cycle in the graph")),
197 Self::UnknownNode(_) => Some(Cow::Borrowed("Remove edges referencing missing nodes")),
198 Self::MissingPrimitive { .. } => Some(Cow::Borrowed(
199 "Register the referenced primitive implementation",
200 )),
201 Self::InvalidEdgeKind { .. } => Some(Cow::Borrowed("Remove the invalid edge")),
202 Self::MissingRequiredInput { .. } => Some(Cow::Borrowed(
203 "Connect the required input or mark it optional",
204 )),
205 Self::MissingInputMetadata { .. } => {
206 Some(Cow::Borrowed("Ensure input metadata exists"))
207 }
208 Self::TypeMismatch { .. } => {
209 Some(Cow::Borrowed("Ensure connected ports share the same type"))
210 }
211 Self::ActionNotGated(_) => Some(Cow::Borrowed("Gate the action with a trigger output")),
212 Self::MissingOutputMetadata { .. } => {
213 Some(Cow::Borrowed("Ensure output metadata exists"))
214 }
215 Self::ExternalInputNotAllowed { .. } => Some(Cow::Borrowed(
216 "Remove external inputs; use source nodes instead",
217 )),
218 Self::MultipleInboundEdges { .. } => {
219 Some(Cow::Borrowed("Allow only one inbound edge per input"))
220 }
221 }
222 }
223}
224
225#[derive(Debug)]
226#[non_exhaustive]
227pub enum ExecError {
228 UnknownPrimitive {
229 id: String,
230 version: String,
231 },
232 TypeConversionFailed {
233 node: String,
234 port: String,
235 },
236 ParameterTypeConversionFailed {
237 node: String,
238 parameter: String,
239 },
240 ParameterOutOfRange {
242 node: String,
243 parameter: String,
244 value: i64,
245 },
246 ComputeFailed {
247 node: String,
248 id: String,
249 version: String,
250 error: ComputeError,
251 },
252 NonFiniteOutput {
253 node: String,
254 port: String,
255 },
256 MissingRequiredContextKey {
257 node: String,
258 key: String,
259 },
260 ContextKeyTypeMismatch {
261 node: String,
262 key: String,
263 expected: crate::common::ValueType,
264 got: crate::common::ValueType,
265 },
266 MissingOutput {
267 node: String,
268 output: String,
269 },
270 MissingNode {
271 node: String,
272 },
273 IntentMetadataRequired {
274 node: String,
275 },
276 ActionSkipViolation {
280 node: String,
281 port: String,
282 },
283}
284
285impl ErrorInfo for ExecError {
286 fn rule_id(&self) -> &'static str {
287 match self {
288 Self::TypeConversionFailed { .. } => "V.4",
289 Self::ParameterTypeConversionFailed { .. } => "I.4",
290 Self::MissingOutput { .. } => "CMP-11",
291 Self::ComputeFailed { .. } => "CMP-12",
292 Self::NonFiniteOutput { .. } => "NUM-FINITE-1",
293 Self::ParameterOutOfRange { .. } => "X.11",
294 Self::MissingRequiredContextKey { .. } => "SRC-10",
295 Self::ContextKeyTypeMismatch { .. } => "SRC-11",
296 Self::UnknownPrimitive { .. } => "INTERNAL",
297 Self::MissingNode { .. } => "INTERNAL",
298 Self::IntentMetadataRequired { .. } => "GW-EFX-META-1",
299 Self::ActionSkipViolation { .. } => "R.7",
300 }
301 }
302
303 fn phase(&self) -> Phase {
304 Phase::Execution
305 }
306
307 fn doc_anchor(&self) -> &'static str {
308 doc_anchor_for_rule(self.rule_id())
309 }
310
311 fn summary(&self) -> Cow<'static, str> {
312 match self {
313 Self::UnknownPrimitive { id, version } => {
314 Cow::Owned(format!("Unknown primitive '{}@{}'", id, version))
315 }
316 Self::TypeConversionFailed { node, port } => {
317 Cow::Owned(format!("Type conversion failed at '{}.{}'", node, port))
318 }
319 Self::ParameterTypeConversionFailed { node, parameter } => Cow::Owned(format!(
320 "Parameter type conversion failed at '{}.{}'",
321 node, parameter
322 )),
323 Self::ParameterOutOfRange {
324 node,
325 parameter,
326 value,
327 } => Cow::Owned(format!(
328 "Parameter '{}.{}' out of range (value {})",
329 node, parameter, value
330 )),
331 Self::ComputeFailed {
332 node,
333 id,
334 version,
335 error,
336 } => Cow::Owned(format!(
337 "Compute '{}' ({}@{}) failed: {:?}",
338 node, id, version, error
339 )),
340 Self::NonFiniteOutput { node, port } => {
341 Cow::Owned(format!("Non-finite numeric output at '{}.{}'", node, port))
342 }
343 Self::MissingRequiredContextKey { node, key } => Cow::Owned(format!(
344 "Missing required context key '{}' for source node '{}'",
345 key, node
346 )),
347 Self::ContextKeyTypeMismatch {
348 node,
349 key,
350 expected,
351 got,
352 } => Cow::Owned(format!(
353 "Context key '{}' type mismatch for source node '{}': expected {:?}, got {:?}",
354 key, node, expected, got
355 )),
356 Self::MissingOutput { node, output } => Cow::Owned(format!(
357 "Missing declared output '{}' on node '{}'",
358 output, node
359 )),
360 Self::MissingNode { node } => Cow::Owned(format!("Missing node '{}'", node)),
361 Self::IntentMetadataRequired { node } => Cow::Owned(format!(
362 "Action node '{}' declares intents; execute_with_metadata must be used",
363 node
364 )),
365 Self::ActionSkipViolation { node, port } => Cow::Owned(format!(
366 "NotEmitted trigger reached action value conversion at '{}.{}' — should_skip_action must catch this before execution (R.7)",
367 node, port
368 )),
369 }
370 }
371
372 fn path(&self) -> Option<Cow<'static, str>> {
373 match self {
374 Self::MissingRequiredContextKey { key, .. }
375 | Self::ContextKeyTypeMismatch { key, .. } => {
376 Some(Cow::Owned(format!("$.context.{}", key)))
377 }
378 Self::ComputeFailed { node, .. } => Some(Cow::Owned(format!("$.nodes.{}", node))),
379 Self::ParameterOutOfRange {
380 node, parameter, ..
381 } => Some(Cow::Owned(format!(
382 "$.nodes.{}.parameters.{}",
383 node, parameter
384 ))),
385 Self::NonFiniteOutput { node, port } => {
386 Some(Cow::Owned(format!("$.nodes.{}.outputs.{}", node, port)))
387 }
388 Self::MissingOutput { node, output } => {
389 Some(Cow::Owned(format!("$.nodes.{}.outputs.{}", node, output)))
390 }
391 Self::IntentMetadataRequired { node } => Some(Cow::Owned(format!("$.nodes.{node}"))),
392 _ => None,
393 }
394 }
395
396 fn fix(&self) -> Option<Cow<'static, str>> {
397 match self {
398 Self::MissingRequiredContextKey { key, .. } => Some(Cow::Owned(format!(
399 "Provide required context key '{}' via adapter, or mark it required: false in the source manifest",
400 key
401 ))),
402 Self::ContextKeyTypeMismatch { key, expected, .. } => Some(Cow::Owned(format!(
403 "Provide context key '{}' with type {:?}",
404 key, expected
405 ))),
406 Self::MissingOutput { output, .. } => Some(Cow::Owned(format!(
407 "Ensure the compute implementation produces output '{}' on success",
408 output
409 ))),
410 Self::ComputeFailed { .. } => Some(Cow::Borrowed(
411 "Handle the compute error or adjust inputs/parameters to avoid it",
412 )),
413 Self::NonFiniteOutput { .. } => Some(Cow::Borrowed(
414 "Ensure all numeric outputs are finite (not NaN/inf)",
415 )),
416 Self::ParameterOutOfRange { .. } => Some(Cow::Borrowed(
417 "Use an Int parameter within f64 exact range (|i| <= 2^53)",
418 )),
419 Self::IntentMetadataRequired { .. } => Some(Cow::Borrowed(
420 "Use execute_with_metadata/run path that supplies graph_id and event_id for deterministic intent IDs",
421 )),
422 _ => None,
423 }
424 }
425}
426
427#[derive(Debug, Clone, Default)]
430pub struct ExecutionContext {
431 values: HashMap<String, Value>,
432}
433
434impl ExecutionContext {
435 pub fn from_values(values: HashMap<String, Value>) -> Self {
436 Self { values }
437 }
438
439 pub fn value(&self, key: &str) -> Option<&Value> {
440 self.values.get(key)
441 }
442}
443
444pub struct Registries<'a> {
445 pub sources: &'a SourceRegistry,
446 pub computes: &'a ComputeRegistry,
447 pub triggers: &'a TriggerRegistry,
448 pub actions: &'a ActionRegistry,
449}
450
451#[derive(Debug)]
452pub struct ExecutionReport {
453 pub outputs: HashMap<String, RuntimeValue>,
454 pub effects: Vec<crate::common::ActionEffect>,
455}
456
457impl RuntimeValue {
458 pub fn value_type(&self) -> ValueType {
459 match self {
460 RuntimeValue::Number(_) => ValueType::Number,
461 RuntimeValue::Series(_) => ValueType::Series,
462 RuntimeValue::Bool(_) => ValueType::Bool,
463 RuntimeValue::Event(_) => ValueType::Event,
464 RuntimeValue::String(_) => ValueType::String,
465 }
466 }
467}
468
469impl ValidatedNode {
470 pub fn required_inputs(&self) -> impl Iterator<Item = &InputMetadata> {
471 self.inputs.iter().filter(|i| i.required)
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::{ExecError, GraphValidationError};
478 use crate::cluster::PrimitiveKind;
479 use crate::common::ErrorInfo;
480
481 #[test]
482 fn v8_missing_primitive_maps_to_v8() {
483 let err = GraphValidationError::MissingPrimitive {
484 id: "missing".to_string(),
485 version: "0.1.0".to_string(),
486 };
487
488 assert_eq!(err.rule_id(), "V.8");
489 assert_eq!(
490 err.doc_anchor(),
491 "docs/authoring/cluster-spec.md#64-enforcement-mapping-phase-6"
492 );
493 }
494
495 #[test]
496 fn exec_type_conversion_maps_to_v4() {
497 let err = ExecError::TypeConversionFailed {
498 node: "n".to_string(),
499 port: "p".to_string(),
500 };
501
502 assert_eq!(err.rule_id(), "V.4");
503 assert_eq!(
504 err.doc_anchor(),
505 "docs/authoring/cluster-spec.md#64-enforcement-mapping-phase-6"
506 );
507 }
508
509 #[test]
510 fn exec_parameter_type_conversion_maps_to_i4() {
511 let err = ExecError::ParameterTypeConversionFailed {
512 node: "n".to_string(),
513 parameter: "x".to_string(),
514 };
515
516 assert_eq!(err.rule_id(), "I.4");
517 assert_eq!(
518 err.doc_anchor(),
519 "docs/authoring/cluster-spec.md#64-enforcement-mapping-phase-6"
520 );
521 }
522
523 #[test]
524 fn exec_internal_missing_node_is_explicit() {
525 let err = ExecError::MissingNode {
526 node: "ghost".to_string(),
527 };
528
529 assert_eq!(err.rule_id(), "INTERNAL");
530 assert_eq!(err.phase(), crate::common::Phase::Execution);
531 assert_eq!(err.doc_anchor(), "docs/invariants/INDEX.md");
532 }
533
534 #[test]
535 fn exec_intent_metadata_required_uses_decision_anchor() {
536 let err = ExecError::IntentMetadataRequired {
537 node: "act".to_string(),
538 };
539
540 assert_eq!(err.rule_id(), "GW-EFX-META-1");
541 assert_eq!(
542 err.doc_anchor(),
543 "docs/contracts/ui-runtime.md#3-metadata-requirement-for-intent-effects"
544 );
545 }
546
547 #[test]
548 fn validation_known_rules_unchanged() {
549 let err = GraphValidationError::InvalidEdgeKind {
550 from: PrimitiveKind::Source,
551 to: PrimitiveKind::Action,
552 };
553 assert_eq!(err.rule_id(), "V.2");
554 }
555}