Skip to main content

pointlock_store/projection/
graph.rs

1//! `FlowGraphView` — the deterministic graph projection of a `FlowIR`
2//! (spine §10.1, 08 §3.1). Pure function over `FlowIR.body`: node kinds
3//! align with step kinds; edges are the closed three-class semantic set
4//! seq / branch / hook; call nodes are the collapse nodes (callee loaded
5//! lazily by `irHash`); foreach nodes are the aggregate nodes. No
6//! coordinates, no layout, no React Flow concepts — those are renderer
7//! concerns (spine §10.5).
8
9use pointlock_ir::{
10    ActionStepIR, AssertStepIR, CallStepIR, EffectClassAction, FlowIR, ForeachStepIR,
11    HandlerBinding, HumanStepIR, IfStepIR, LetStepIR, PathFrame, StepIR, render_run_path,
12};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16use super::ProjectionVersion;
17
18/// The graph projection of one `FlowIR` (spine §10.1).
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
20#[serde(rename_all = "camelCase", deny_unknown_fields)]
21pub struct FlowGraphView {
22    /// Protocol version (spine §10.3).
23    pub projection_version: ProjectionVersion,
24    /// The projected flow.
25    pub flow_id: String,
26    /// The projected flow's content hash (full `sha256:` form).
27    pub ir_hash: String,
28    /// Flat node list; nesting is expressed by `parentId` + `region`.
29    pub nodes: Vec<GraphNode>,
30    /// The closed three-class semantic edges (seq / branch / hook).
31    pub edges: Vec<GraphEdge>,
32    /// Flow-level handler badges (hooks with no host step; step-level
33    /// hooks ride on `hook` edges anchored at their host node).
34    pub flow_hooks: Vec<HookBadge>,
35}
36
37/// Which nested sub-region of the parent a node lives in.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
39#[serde(rename_all = "camelCase")]
40pub enum NodeRegion {
41    /// `if.then` body.
42    Then,
43    /// `if.else` body.
44    Else,
45    /// `foreach.body`.
46    Body,
47}
48
49/// One graph node. `id` is the flow-scoped `stepId` (unique across the
50/// whole flow incl. nested regions — compiler-guaranteed, 08 §3.2).
51///
52/// Serde stays lenient because of the flattened kind body (the StepIR
53/// pattern); the schema closes the shape via schemars.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
55#[serde(rename_all = "camelCase")]
56#[schemars(deny_unknown_fields)]
57pub struct GraphNode {
58    /// Node id = static `stepId`.
59    pub id: String,
60    /// The static runPath anchor (canonical string, no iteration/attempt
61    /// frames): the join point for run-state overlay and deep links
62    /// (08 §3.2 — strip `[i]`/`[i:key]` from `RunOverview.steps` keys to
63    /// land on this anchor).
64    pub run_path: String,
65    /// Enclosing step node, when nested inside if/foreach.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub parent_id: Option<String>,
68    /// Sub-region of the parent this node belongs to.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub region: Option<NodeRegion>,
71    /// Kind-specific node body; the discriminant aligns with step kinds.
72    #[serde(flatten)]
73    pub body: GraphNodeBody,
74}
75
76/// Kind-specific node payloads (discriminant = step kind, A.4-aligned).
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
78#[serde(tag = "kind", rename_all = "camelCase")]
79pub enum GraphNodeBody {
80    /// `ActionStepIR` (08 §3.1): verb/action, effect badge, act chain.
81    #[serde(rename_all = "camelCase")]
82    Action {
83        /// Canonical verb, when the step used one (snake_case values).
84        #[serde(skip_serializing_if = "Option::is_none")]
85        verb: Option<String>,
86        /// First bound action name (the primary attempt's).
87        action_name: String,
88        /// `mutating` (solid badge) vs `readonly` (hollow badge).
89        mutating: bool,
90        /// The act chain channel chips, in `binding.attempts` order
91        /// (08 §3.4 — one attempt means no fallback, none is invented).
92        act_chain: Vec<String>,
93        /// Assertion count badge.
94        assertion_count: u32,
95    },
96    /// `AssertStepIR`: observation source + assertion summaries.
97    #[serde(rename_all = "camelCase")]
98    Assert {
99        /// `"fresh"` or the referenced `fromStep` step id.
100        observe: String,
101        /// Per-assertion summary rows: assertId + verify chain chips.
102        assertions: Vec<AssertionSummary>,
103    },
104    /// `CallStepIR` — the collapse node (08 §3.3): callee identity only;
105    /// the callee graph loads lazily by `calleeIrHash`.
106    #[serde(rename_all = "camelCase")]
107    Call {
108        /// Callee flow id.
109        callee_flow_id: String,
110        /// Callee content hash (full `sha256:` form; renderers may
111        /// abbreviate to the 8-hex prefix).
112        callee_ir_hash: String,
113        /// Input key names (values are authoring detail, not graph).
114        input_keys: Vec<String>,
115    },
116    /// `HumanStepIR` (08 §3.5): the pause-for-a-person node.
117    #[serde(rename_all = "camelCase")]
118    Human {
119        /// Interaction mode.
120        mode: String,
121        /// First line of the prompt.
122        prompt_head: String,
123        /// Response deadline; `on_timeout: unknown` is fixed vocabulary.
124        timeout_ms: u64,
125    },
126    /// `IfStepIR`: condition summary; then/else children reference this
127    /// node via `parentId` + `region`.
128    #[serde(rename_all = "camelCase")]
129    If {
130        /// Rendered condition expression summary.
131        cond: String,
132        /// Whether an else region exists.
133        has_else: bool,
134    },
135    /// `ForeachStepIR` — the aggregate node (08 §3.2): iterations are
136    /// folded onto this single node, never fanned out.
137    #[serde(rename_all = "camelCase")]
138    Foreach {
139        /// Rendered items expression summary.
140        items: String,
141        /// Iteration variable name.
142        r#as: String,
143    },
144    /// `LetStepIR`: binding key names (small node).
145    #[serde(rename_all = "camelCase")]
146    Let {
147        /// Bound variable names.
148        binding_keys: Vec<String>,
149    },
150}
151
152/// One assertion summary row on an assert/action node (08 §3.4).
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
154#[serde(rename_all = "camelCase", deny_unknown_fields)]
155pub struct AssertionSummary {
156    /// The assertion id.
157    pub assert_id: String,
158    /// Predicate discriminant (`elementState`/`elementText`/`expr`/`visual`).
159    pub predicate: String,
160    /// Verify chain chips in `verifyVia` order (vision is always the
161    /// chain tail and verify-only — principle 7).
162    pub verify_via: Vec<String>,
163}
164
165/// The closed semantic edge classes (spine §10.1).
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
167#[serde(rename_all = "camelCase")]
168pub enum GraphEdgeKind {
169    /// Sibling textual order (= v0.1 execution order).
170    Seq,
171    /// `if` → then/else region entry.
172    Branch,
173    /// Step → handler badge (handlers are never regular nodes — spine
174    /// concept 5; the badge payload rides on the edge).
175    Hook,
176}
177
178/// One semantic edge.
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
180#[serde(rename_all = "camelCase", deny_unknown_fields)]
181pub struct GraphEdge {
182    /// Edge class.
183    pub kind: GraphEdgeKind,
184    /// Source node id.
185    pub from: String,
186    /// Target node id; absent on `hook` edges (the badge is the target).
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub to: Option<String>,
189    /// Branch label (`"then"` / `"else"`), present on `branch` edges.
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub label: Option<String>,
192    /// Handler badge payload, present on `hook` edges.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub hook: Option<HookBadge>,
195}
196
197/// A handler badge (08 §3.1 hook row): hook + disposition + budget.
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
199#[serde(rename_all = "camelCase", deny_unknown_fields)]
200pub struct HookBadge {
201    /// Hook name (`onFail`/`onUnknown`/`onError`/`onResumeDrift`).
202    pub hook: String,
203    /// Disposition discriminant (`retry`/`continue`/`escalate`/`abort`/`repair`).
204    pub disposition: String,
205    /// Trigger budget per instance.
206    pub max_triggers: u32,
207    /// `onError` class filter, when declared (snake_case values).
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub error_classes: Option<Vec<String>>,
210    /// Repair target `flowId@sha256:…`, on `repair` dispositions.
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub repair_target: Option<String>,
213}
214
215/// Projects one `FlowIR` into its graph view. Deterministic and pure
216/// (08 §3.1); subflow bodies are NOT inlined — call nodes carry the
217/// callee identity for lazy loading (spine §10.1).
218pub fn flow_graph_view(flow: &FlowIR) -> FlowGraphView {
219    let mut nodes = Vec::new();
220    let mut edges = Vec::new();
221    let root = vec![PathFrame::Flow {
222        flow_id: flow.flow_id.clone(),
223        ir_hash: flow.ir_hash.clone(),
224    }];
225    project_body(&flow.body, &root, None, None, &mut nodes, &mut edges);
226    FlowGraphView {
227        projection_version: ProjectionVersion,
228        flow_id: flow.flow_id.to_string(),
229        ir_hash: flow.ir_hash.to_string(),
230        nodes,
231        edges,
232        flow_hooks: flow
233            .handlers
234            .as_deref()
235            .unwrap_or_default()
236            .iter()
237            .map(hook_badge)
238            .collect(),
239    }
240}
241
242/// Walks one body region: emits its nodes, seq edges between siblings,
243/// and recurses into nested regions.
244fn project_body(
245    body: &[StepIR],
246    prefix: &[PathFrame],
247    parent_id: Option<&str>,
248    region: Option<NodeRegion>,
249    nodes: &mut Vec<GraphNode>,
250    edges: &mut Vec<GraphEdge>,
251) {
252    let mut previous: Option<String> = None;
253    for step in body {
254        let step_id = step.step_id().to_string();
255        let mut anchor = prefix.to_vec();
256        // The anchor mirrors the runner's frame construction so overlay
257        // keys and deep links join: call steps anchor at the `Call` frame
258        // itself (rendered `/<stepId>/call→<callee>@<hash8>`, 07 §2.1),
259        // every other kind at a `Step` frame.
260        match step {
261            StepIR::Call(CallStepIR { flow_ref, .. }) => anchor.push(PathFrame::Call {
262                step_id: Some(step.step_id().clone()),
263                callee_flow_id: flow_ref.flow_id.clone(),
264                callee_ir_hash: flow_ref.ir_hash.clone(),
265            }),
266            _ => anchor.push(PathFrame::Step {
267                step_id: step.step_id().clone(),
268            }),
269        }
270
271        if let Some(prev) = previous.take() {
272            edges.push(GraphEdge {
273                kind: GraphEdgeKind::Seq,
274                from: prev,
275                to: Some(step_id.clone()),
276                label: None,
277                hook: None,
278            });
279        }
280        previous = Some(step_id.clone());
281
282        nodes.push(GraphNode {
283            id: step_id.clone(),
284            run_path: render_run_path(&anchor),
285            parent_id: parent_id.map(str::to_owned),
286            region,
287            body: node_body(step),
288        });
289
290        // Step-level hooks become hook edges anchored at the host node.
291        if let Some(handlers) = step.base().handlers.as_deref() {
292            for binding in handlers {
293                edges.push(GraphEdge {
294                    kind: GraphEdgeKind::Hook,
295                    from: step_id.clone(),
296                    to: None,
297                    label: None,
298                    hook: Some(hook_badge(binding)),
299                });
300            }
301        }
302
303        // Nested regions: branch edges into region entries, then recurse.
304        match step {
305            StepIR::If(IfStepIR { then, r#else, .. }) => {
306                if let Some(first) = then.first() {
307                    edges.push(branch_edge(&step_id, first.step_id().as_ref(), "then"));
308                }
309                project_body(
310                    then,
311                    &anchor,
312                    Some(&step_id),
313                    Some(NodeRegion::Then),
314                    nodes,
315                    edges,
316                );
317                if let Some(else_body) = r#else.as_deref() {
318                    if let Some(first) = else_body.first() {
319                        edges.push(branch_edge(&step_id, first.step_id().as_ref(), "else"));
320                    }
321                    project_body(
322                        else_body,
323                        &anchor,
324                        Some(&step_id),
325                        Some(NodeRegion::Else),
326                        nodes,
327                        edges,
328                    );
329                }
330            }
331            StepIR::Foreach(ForeachStepIR { body, .. }) => {
332                project_body(
333                    body,
334                    &anchor,
335                    Some(&step_id),
336                    Some(NodeRegion::Body),
337                    nodes,
338                    edges,
339                );
340            }
341            _ => {}
342        }
343    }
344}
345
346fn branch_edge(from: &str, to: &str, label: &str) -> GraphEdge {
347    GraphEdge {
348        kind: GraphEdgeKind::Branch,
349        from: from.to_owned(),
350        to: Some(to.to_owned()),
351        label: Some(label.to_owned()),
352        hook: None,
353    }
354}
355
356/// Serializes a unit-enum value to its wire literal.
357fn wire<T: Serialize>(value: &T) -> String {
358    serde_json::to_value(value)
359        .ok()
360        .and_then(|v| v.as_str().map(str::to_owned))
361        .unwrap_or_default()
362}
363
364/// Renders an expression as a compact human-readable summary.
365fn expr_summary(expr: &pointlock_ir::Expr) -> String {
366    serde_json::to_string(expr).unwrap_or_default()
367}
368
369fn hook_badge(binding: &HandlerBinding) -> HookBadge {
370    let (disposition, repair_target) = match &binding.action {
371        pointlock_ir::HandlerAction::Retry { .. } => ("retry", None),
372        pointlock_ir::HandlerAction::Continue => ("continue", None),
373        pointlock_ir::HandlerAction::Escalate { .. } => ("escalate", None),
374        pointlock_ir::HandlerAction::Abort => ("abort", None),
375        pointlock_ir::HandlerAction::Repair { flow_ref } => (
376            "repair",
377            Some(format!("{}@{}", flow_ref.flow_id, flow_ref.ir_hash)),
378        ),
379    };
380    HookBadge {
381        hook: wire(&binding.hook),
382        disposition: disposition.to_owned(),
383        max_triggers: binding.max_triggers,
384        error_classes: binding
385            .error_classes
386            .as_ref()
387            .map(|classes| classes.iter().map(wire).collect()),
388        repair_target,
389    }
390}
391
392fn node_body(step: &StepIR) -> GraphNodeBody {
393    match step {
394        StepIR::Action(ActionStepIR {
395            verb,
396            effect,
397            binding,
398            assertions,
399            ..
400        }) => GraphNodeBody::Action {
401            verb: verb.as_ref().map(wire),
402            action_name: binding
403                .attempts
404                .first()
405                .map(|attempt| attempt.action_name.to_string())
406                .unwrap_or_default(),
407            mutating: *effect == EffectClassAction::Mutating,
408            act_chain: binding
409                .attempts
410                .iter()
411                .map(|attempt| wire(&attempt.channel))
412                .collect(),
413            assertion_count: assertions.len() as u32,
414        },
415        StepIR::Assert(AssertStepIR {
416            observe,
417            assertions,
418            ..
419        }) => GraphNodeBody::Assert {
420            observe: match serde_json::to_value(observe) {
421                Ok(serde_json::Value::String(fresh)) => fresh,
422                Ok(other) => other
423                    .get("fromStep")
424                    .and_then(|v| v.as_str())
425                    .unwrap_or_default()
426                    .to_owned(),
427                Err(_) => String::new(),
428            },
429            assertions: assertions.iter().map(assertion_summary).collect(),
430        },
431        StepIR::Call(CallStepIR {
432            flow_ref, inputs, ..
433        }) => GraphNodeBody::Call {
434            callee_flow_id: flow_ref.flow_id.to_string(),
435            callee_ir_hash: flow_ref.ir_hash.to_string(),
436            input_keys: inputs.keys().map(ToString::to_string).collect(),
437        },
438        StepIR::Human(HumanStepIR {
439            mode,
440            prompt,
441            timeout_ms,
442            ..
443        }) => GraphNodeBody::Human {
444            mode: wire(mode),
445            prompt_head: prompt.lines().next().unwrap_or_default().to_owned(),
446            timeout_ms: *timeout_ms,
447        },
448        StepIR::If(IfStepIR { cond, r#else, .. }) => GraphNodeBody::If {
449            cond: expr_summary(cond),
450            has_else: r#else.is_some(),
451        },
452        StepIR::Foreach(ForeachStepIR { items, r#as, .. }) => GraphNodeBody::Foreach {
453            items: expr_summary(items),
454            r#as: r#as.to_string(),
455        },
456        StepIR::Let(LetStepIR { bindings, .. }) => GraphNodeBody::Let {
457            binding_keys: bindings.keys().map(ToString::to_string).collect(),
458        },
459    }
460}
461
462fn assertion_summary(assertion: &pointlock_ir::AssertionIR) -> AssertionSummary {
463    let predicate = serde_json::to_value(&assertion.predicate)
464        .ok()
465        .and_then(|v| v.get("type").and_then(|t| t.as_str()).map(str::to_owned))
466        .unwrap_or_default();
467    AssertionSummary {
468        assert_id: assertion.assert_id.to_string(),
469        predicate,
470        verify_via: assertion.verify_via.iter().map(wire).collect(),
471    }
472}