feat(graph): add statum-graph export crate#13
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new statum-graph workspace crate that exports machine-local topology from statum::MachineIntrospection::GRAPH into a MachineDoc structure and renders it as Mermaid, with accompanying tests and documentation updates across the repo.
Changes:
- Introduce
statum-graphcrate withMachineDocexport API and Mermaid renderer. - Add integration tests + insta snapshot coverage for branching, roots, cycles, and macro-generated transitions.
- Update workspace membership, publish script, and documentation to reference
statum-graph.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| Cargo.toml | Adds statum-graph to workspace members and publish script. |
| README.md | Mentions statum-graph as a ready-made renderer for MachineIntrospection::GRAPH. |
| docs/introspection.md | Adds a pointer to statum-graph for exporting/rendering the static graph surface. |
| statum/README.md | Notes statum-graph companion crate for topology + Mermaid output. |
| statum-graph/Cargo.toml | New crate manifest (depends on workspace statum, adds insta for snapshots). |
| statum-graph/README.md | New crate README with install + example usage. |
| statum-graph/src/lib.rs | Defines MachineDoc / StateDoc / EdgeDoc and graph export + sorting/root derivation. |
| statum-graph/src/render.rs | Implements Mermaid rendering for an exported MachineDoc. |
| statum-graph/tests/export.rs | Integration tests covering linear, branching, multiple roots, zero-root cycles, and macro transitions. |
| statum-graph/tests/snapshots/export__branching_flow_mermaid.snap | Snapshot for stable Mermaid output of a reconverging branching graph. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
statum-graph/src/lib.rs
Outdated
| pub struct MachineDoc<S: 'static, T: 'static> { | ||
| /// Descriptor for the exported machine family. | ||
| pub machine: MachineDescriptor, | ||
| /// Exported states in the same order as the underlying static graph. | ||
| pub states: Vec<StateDoc<S>>, |
There was a problem hiding this comment.
MachineDoc exposes machine/states/edges as public fields, so callers can construct or mutate an inconsistent document (e.g., an edge referencing a state id not present in states). Downstream code (e.g., the Mermaid renderer) assumes these invariants and may panic; consider making fields private and exposing read-only accessors/constructors, or providing a validated try_* constructor.
| let from = node_id(state_positions[&edge.descriptor.from]); | ||
| for target in edge.descriptor.to { | ||
| let to = node_id(state_positions[target]); |
There was a problem hiding this comment.
These state_positions[...] lookups will panic if an edge references a state id missing from doc.states (possible since MachineDoc is publicly constructible/mutable). Prefer using get() with a descriptive expect, or make the renderer return a Result so invalid docs are reported without an unhelpful panic.
| let from = node_id(state_positions[&edge.descriptor.from]); | |
| for target in edge.descriptor.to { | |
| let to = node_id(state_positions[target]); | |
| let from_index = state_positions | |
| .get(&edge.descriptor.from) | |
| .expect("invalid MachineDoc: edge 'from' state id not present in states"); | |
| let from = node_id(*from_index); | |
| for target in edge.descriptor.to { | |
| let to_index = state_positions | |
| .get(&target) | |
| .expect("invalid MachineDoc: edge 'to' state id not present in states"); | |
| let to = node_id(*to_index); |
| state_positions[&left.descriptor.from] | ||
| .cmp(&state_positions[&right.descriptor.from]) | ||
| .then_with(|| { |
There was a problem hiding this comment.
state_positions[&left.descriptor.from] / state_positions[&right.descriptor.from] will panic if graph.transitions contains a from id that is not present in graph.states. Since from_graph is public, consider validating graph consistency or using get().expect("...") to provide a clearer failure mode.
| let left = left.iter().map(|state| state_positions[state]); | ||
| let right = right.iter().map(|state| state_positions[state]); |
There was a problem hiding this comment.
compare_targets indexes state_positions[state], which will panic if any transition target id is missing from graph.states. Consider switching to get() with a descriptive expect, or validating the graph once up-front in from_graph so sorting can’t panic.
| let left = left.iter().map(|state| state_positions[state]); | |
| let right = right.iter().map(|state| state_positions[state]); | |
| let left = left.iter().map(|state| { | |
| *state_positions | |
| .get(state) | |
| .expect("compare_targets: transition target id missing from state_positions") | |
| }); | |
| let right = right.iter().map(|state| { | |
| *state_positions | |
| .get(state) | |
| .expect("compare_targets: transition target id missing from state_positions") | |
| }); |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
statum-graph/src/lib.rs
Outdated
| fn validate_graph<S, T>(graph: &MachineGraph<S, T>) -> Result<(), MachineDocError> | ||
| where | ||
| S: Copy + Eq + 'static, | ||
| T: Copy + Eq + 'static, | ||
| { | ||
| for transition in graph.transitions.iter() { | ||
| if graph.state(transition.from).is_none() { | ||
| return Err(MachineDocError::MissingSourceState { | ||
| machine: graph.machine.rust_type_path, | ||
| transition: transition.method_name, | ||
| }); | ||
| } | ||
|
|
||
| if transition | ||
| .to | ||
| .iter() | ||
| .copied() | ||
| .any(|target| graph.state(target).is_none()) | ||
| { | ||
| return Err(MachineDocError::MissingTargetState { |
There was a problem hiding this comment.
validate_graph verifies that each transition’s source/targets exist, but it doesn’t validate that graph.states contains unique state ids. If an external MachineGraph supplies duplicate state ids, state_positions()/Mermaid rendering can become inconsistent (HashMap overwrites one entry and edges may point at the “wrong” duplicate). Consider extending validation to reject duplicate state ids (e.g., track seen ids in a HashSet) and reuse that set for O(1) membership checks instead of repeatedly calling graph.state(...) in the inner loops.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| lines.push(format!( | ||
| " {from} -->|{}| {to}", | ||
| escape_label(edge.descriptor.method_name) | ||
| )); |
There was a problem hiding this comment.
render::mermaid inserts edge.descriptor.method_name inside the Mermaid edge-label delimiter (-->|...|). escape_label currently doesn’t escape |, so an externally supplied TransitionDescriptor.method_name containing | will produce invalid Mermaid (and can break downstream renderers). Consider either escaping | (and other Mermaid edge-label metacharacters) for edge labels specifically, or switching to a Mermaid syntax that safely quotes/encodes labels.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let mut transition_ids = Vec::with_capacity(transitions.len()); | ||
| for transition in transitions.iter() { | ||
| if transition_ids.contains(&transition.id) { | ||
| return Err(MachineDocError::DuplicateTransitionId { | ||
| machine: machine.rust_type_path, | ||
| transition: transition.method_name, | ||
| }); | ||
| } | ||
| transition_ids.push(transition.id); |
There was a problem hiding this comment.
validate_graph detects duplicate transition ids by pushing ids into a Vec and calling contains for every transition, which makes this check O(n²) in the number of transitions. Consider switching to a set-based approach (e.g., a HashSet if you can require T: Hash, or another structure that avoids repeated linear scans) to keep export/validation time predictable for large graphs.
| let mut transition_ids = Vec::with_capacity(transitions.len()); | |
| for transition in transitions.iter() { | |
| if transition_ids.contains(&transition.id) { | |
| return Err(MachineDocError::DuplicateTransitionId { | |
| machine: machine.rust_type_path, | |
| transition: transition.method_name, | |
| }); | |
| } | |
| transition_ids.push(transition.id); | |
| let mut transition_ids = HashSet::with_capacity(transitions.len()); | |
| for transition in transitions.iter() { | |
| if !transition_ids.insert(transition.id) { | |
| return Err(MachineDocError::DuplicateTransitionId { | |
| machine: machine.rust_type_path, | |
| transition: transition.method_name, | |
| }); | |
| } |
Summary
Add a new
statum-graphcrate that exports machine-local topology fromstatum::MachineIntrospection::GRAPHand renders it as Mermaid.This includes:
MachineDocexport surface for machine, state, edge, and root metadataAuthority boundary
Claimed authority surface:
Actual observation point:
MachineIntrospection::GRAPHvalue emitted by Statum introspectionUnsupported / intentionally out of scope:
Adversarial coverage in this PR:
statum-graph/tests/export.rsVerification
cargo test -p statum-graph --offlinecargo clippy -p statum-graph --offline --all-targets --all-features -- -D warningsRUSTDOCFLAGS='-D warnings' cargo doc -p statum-graph --offline --no-depscargo test --workspace --offlinecargo clippy --workspace --offline --all-targets --all-features -- -D warningsRUSTDOCFLAGS='-D warnings' cargo doc --workspace --offline --no-deps