Skip to main content

ergo_runtime/runtime/
types.rs

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    /// Input metadata is used for validation only (required + type checks).
34    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
58/// Backward-compatible alias — external crates may reference
59/// `ValidationError` from before the rename to `GraphValidationError`.
60pub 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    /// V.MULTI-EDGE: Multiple edges targeting the same input port.
100    /// All inputs currently have Cardinality::Single; fan-in is not supported.
101    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    /// X.11: Int parameter value exceeds f64 exact representation range (|i| > 2^53).
241    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    /// R.7: NotEmitted trigger reached an Action that should_skip_action
277    /// should have caught.  This indicates a kernel scheduling invariant
278    /// violation.
279    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/// Execution context for runtime invocation.
428/// Adapter-provided context values are exposed to context-aware sources.
429#[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}