Graph Validation
Added in: 0.5.0
Graph Validation ensures your workflows are structurally sound before execution, catching errors early and providing clear feedback.
Overviewβ
The validation system checks for:
- Empty graphs - At least one node required
- Invalid entry points - Entry point must exist
- Invalid edge references - All edges must reference existing nodes
- Cycles - Graphs must be DAGs (Directed Acyclic Graphs)
- Unreachable nodes - All nodes must be reachable from entry point
Automatic Validationβ
Validation happens automatically before execution:
val graph = graph("my-graph") {
// ... define nodes and edges
}
// Validation runs before execution
val result = runner.run(graph, input)
// If validation fails, returns SpiceResult.Failure
when (result) {
is SpiceResult.Success -> println("Success!")
is SpiceResult.Failure -> {
// ValidationError with details
println("Error: ${result.error.message}")
}
}
Manual Validationβ
You can validate graphs explicitly:
val validation = GraphValidator.validate(graph)
when (validation) {
is SpiceResult.Success -> println("Graph is valid!")
is SpiceResult.Failure -> {
val error = validation.error as SpiceError.ValidationError
println("Validation failed: ${error.message}")
// Access detailed errors
val errors = error.context["errors"] as List<String>
errors.forEach { println(" - $it") }
}
}
Validation Rulesβ
Rule 1: Non-Empty Graphβ
Graphs must have at least one node.
// β Invalid: Empty graph
val invalid = Graph(
id = "empty",
nodes = emptyMap(), // No nodes!
edges = emptyList(),
entryPoint = "start"
)
// Error: "Graph must have at least one node"
Rule 2: Valid Entry Pointβ
Entry point must reference an existing node.
// β Invalid: Non-existent entry point
val invalid = Graph(
id = "bad-entry",
nodes = mapOf("node1" to someNode),
edges = emptyList(),
entryPoint = "nonexistent" // Doesn't exist!
)
// Error: "Entry point 'nonexistent' does not exist in graph"
Rule 3: Valid Edge Referencesβ
All edges must reference existing nodes.
// β Invalid: Edge to non-existent node
val invalid = Graph(
id = "bad-edge",
nodes = mapOf("node1" to someNode),
edges = listOf(
Edge("node1", "node2") // node2 doesn't exist!
),
entryPoint = "node1"
)
// Error: "Edge references non-existent 'to' node: node2"
Rule 4: No Cycles (DAG)β
Added in: 0.6.3 - Optional cycle validation
By default, graphs must be Directed Acyclic Graphs - no cycles allowed.
// β Invalid by default: Cycle in graph
val invalid = Graph(
id = "cyclic",
nodes = mapOf(
"node1" to someNode,
"node2" to someNode,
"node3" to someNode
),
edges = listOf(
Edge("node1", "node2"),
Edge("node2", "node3"),
Edge("node3", "node1") // Cycle!
),
entryPoint = "node1"
)
// Error: "Graph contains cycles involving nodes: node1, node2, node3"
Why DAG by default?
- Predictable execution order
- No infinite loops
- Clear data flow
- Easier to reason about
Allowing Cycles (0.6.3+)β
For use cases requiring loops (e.g., iterative workflows, retry loops), you can explicitly allow cycles:
// β
Valid with allowCycles: Conditional loop
val workflowWithLoop = Graph(
id = "workflow-loop",
nodes = mapOf(
"workflow" to workflowNode,
"response" to responseNode
),
edges = listOf(
Edge("workflow", "workflow") { result ->
// Loop condition: continue if not done
(result.data as? Map<*, *>)?.get("continue") == true
},
Edge("workflow", "response") { result ->
// Exit condition: stop when done
(result.data as? Map<*, *>)?.get("continue") != true
}
),
entryPoint = "workflow",
allowCycles = true // β Explicitly allow cycles
)
Important: When using allowCycles = true:
- Always include exit conditions in edge predicates
- Implement loop guards in your nodes to prevent infinite loops
- Monitor execution - add middleware for timeout protection
- Consider checkpointing for long-running loops
Rule 5: No Unreachable Nodesβ
All nodes must be reachable from the entry point.
// β Invalid: Orphan node
val invalid = Graph(
id = "orphan",
nodes = mapOf(
"node1" to someNode,
"node2" to someNode,
"orphan" to someNode // Not connected!
),
edges = listOf(
Edge("node1", "node2")
),
entryPoint = "node1"
)
// Error: "Graph contains unreachable nodes: orphan"
GraphValidator APIβ
validate()β
Validates entire graph structure:
fun validate(graph: Graph): SpiceResult<Unit>
Returns:
SpiceResult.Successif validSpiceResult.FailurewithValidationErrorif invalid
isDAG()β
Checks if graph is a valid DAG:
fun isDAG(graph: Graph): Boolean
if (!GraphValidator.isDAG(graph)) {
println("Graph contains cycles!")
}
findTerminalNodes()β
Find nodes with no outgoing edges:
fun findTerminalNodes(graph: Graph): List<String>
val terminals = GraphValidator.findTerminalNodes(graph)
println("Terminal nodes: $terminals")
// Useful for finding end points
Cyclic Graph Use Casesβ
Use Case 1: Iterative Refinement Loopβ
Process data until quality threshold is met:
val refinementWorkflow = graph("data-refinement") {
agent("refine", refineAgent)
agent("check-quality", qualityCheckAgent)
output("final-result") { it.state["refine"] }
edges {
edge("refine", "check-quality")
edge("check-quality", "refine") { result ->
// Loop back if quality insufficient
val quality = (result.data as? Map<*, *>)?.get("quality") as? Double ?: 0.0
quality < 0.9 && ctx.state["iterations"] as? Int ?: 0 < 10
}
edge("check-quality", "final-result") { result ->
// Exit when quality sufficient
val quality = (result.data as? Map<*, *>)?.get("quality") as? Double ?: 0.0
quality >= 0.9
}
}
allowCycles = true
}
Use Case 2: User Interaction Loopβ
Collect user input until confirmation:
val userDialogWorkflow = graph("user-dialog") {
human("ask-question", prompt = "Enter your choice:")
agent("validate", validationAgent)
agent("confirm", confirmAgent)
output("confirmed") { it.state["confirm"] }
edges {
edge("ask-question", "validate")
edge("validate", "confirm") { it.data == true }
edge("validate", "ask-question") { it.data == false } // Loop back
edge("confirm", "ask-question") { result ->
// Loop if not confirmed
(result.data as? Boolean) != true
}
edge("confirm", "confirmed") { result ->
// Exit when confirmed
(result.data as? Boolean) == true
}
}
allowCycles = true
}
Use Case 3: Retry with Backoffβ
Retry failing operations with exponential backoff:
val retryWorkflow = graph("api-retry") {
agent("call-api", apiAgent)
agent("check-result", resultCheckAgent)
agent("backoff", backoffAgent)
output("success") { it.state["call-api"] }
edges {
edge("call-api", "check-result")
edge("check-result", "success") { result ->
// Exit on success
(result.data as? Map<*, *>)?.get("success") == true
}
edge("check-result", "backoff") { result ->
// Retry on failure
val retries = ctx.state["retries"] as? Int ?: 0
(result.data as? Map<*, *>)?.get("success") != true && retries < 5
}
edge("backoff", "call-api") // Loop back after waiting
}
allowCycles = true
}
Validation Examplesβ
Example 1: Validate Before Deploymentβ
fun deployGraph(graph: Graph): Result<Unit> {
// Validate before deploying to production
val validation = GraphValidator.validate(graph)
return when (validation) {
is SpiceResult.Success -> {
// Graph is valid, proceed with deployment
deployToProduction(graph)
Result.success(Unit)
}
is SpiceResult.Failure -> {
// Log validation errors
logger.error("Graph validation failed: ${validation.error.message}")
Result.failure(Exception(validation.error.message))
}
}
}
Example 2: CI/CD Validationβ
@Test
fun `test all production graphs are valid`() {
val graphs = listOf(
createUserWorkflow(),
createDataProcessingWorkflow(),
createAnalyticsWorkflow()
)
graphs.forEach { graph ->
val result = GraphValidator.validate(graph)
assertTrue(result.isSuccess, "Graph ${graph.id} should be valid")
}
}
Example 3: Development-Time Checksβ
fun createWorkflow(): Graph {
val graph = graph("my-workflow") {
agent("step1", agent1)
agent("step2", agent2)
agent("step3", agent3)
output("result") { it.state["step3"] }
}
// Validate immediately during development
require(GraphValidator.validate(graph).isSuccess) {
"Graph validation failed"
}
return graph
}
Example 4: Interactive Validationβ
fun validateAndReport(graph: Graph) {
println("π Validating graph: ${graph.id}")
val result = GraphValidator.validate(graph)
when (result) {
is SpiceResult.Success -> {
println("β
Graph is valid!")
println(" Nodes: ${graph.nodes.size}")
println(" Edges: ${graph.edges.size}")
println(" Entry: ${graph.entryPoint}")
val terminals = GraphValidator.findTerminalNodes(graph)
println(" Terminals: $terminals")
val isDAG = GraphValidator.isDAG(graph)
println(" Is DAG: $isDAG")
}
is SpiceResult.Failure -> {
println("β Graph is invalid!")
val error = result.error as SpiceError.ValidationError
println(" Message: ${error.message}")
val errors = error.context["errors"] as? List<String>
errors?.forEach { err ->
println(" - $err")
}
}
}
}
Error Messagesβ
Validation errors are detailed and actionable:
Graph validation failed: Graph must have at least one node
Graph validation failed: Entry point 'start' does not exist in graph
Graph validation failed: Edge references non-existent 'from' node: node1
Graph validation failed: Edge references non-existent 'to' node: node2
Graph validation failed: Graph contains cycles involving nodes: node1, node2, node3
Graph validation failed: Graph contains unreachable nodes: orphan1, orphan2
Multiple errors are combined:
Graph validation failed: Entry point 'start' does not exist in graph;
Edge references non-existent 'to' node: node2;
Graph contains unreachable nodes: orphan
Cycle Detection Algorithmβ
The validator uses Depth-First Search (DFS) with a recursion stack:
1. Mark node as visiting (recursion stack)
2. For each neighbor:
- If neighbor is in recursion stack β Cycle found!
- If neighbor not visited β Recursively visit
3. Mark node as visited
4. Remove from recursion stack
Time Complexity: O(V + E) where V = nodes, E = edges
Self-Loop Detectionβ
Self-loops (node pointing to itself) are automatically detected as cycles:
// β Invalid: Self-loop
val invalid = Graph(
id = "self-loop",
nodes = mapOf("node1" to someNode),
edges = listOf(
Edge("node1", "node1") // Self-loop!
),
entryPoint = "node1"
)
// Error: "Graph contains cycles involving nodes: node1"
Complex Cycle Exampleβ
// β Invalid: Complex cycle
val invalid = graph("complex-cycle") {
agent("a", agent1)
agent("b", agent2)
agent("c", agent3)
agent("d", agent4)
edges {
edge("a", "b")
edge("b", "c")
edge("c", "d")
edge("d", "b") // Creates cycle: b β c β d β b
}
}
// Error: "Graph contains cycles involving nodes: b, c, d"
Best Practicesβ
β Do'sβ
- Validate early - In development, not just production
- Add validation tests - Test graphs in CI/CD
- Use meaningful IDs - Easier to debug validation errors
- Check terminal nodes - Ensure workflows have clear end points
- Document graph structure - Especially for complex workflows
β Don'tsβ
- Don't skip validation - Runtime errors are harder to debug
- Don't ignore warnings - They indicate potential issues
- Don't create complex graphs without testing - Start small
- Don't modify graphs after validation - Re-validate if changed
- Don't suppress validation errors - Fix the root cause
Validation in Productionβ
Strategy 1: Validate on Loadβ
class GraphRepository {
fun loadGraph(id: String): Graph {
val graph = loadFromDatabase(id)
// Validate before returning
val validation = GraphValidator.validate(graph)
if (validation.isFailure) {
throw IllegalStateException("Loaded invalid graph: $id")
}
return graph
}
}
Strategy 2: Validate on Createβ
class GraphBuilder {
fun build(): Graph {
val graph = Graph(
id = id,
nodes = nodes,
edges = edges,
entryPoint = entryPoint
)
// Validate immediately
val validation = GraphValidator.validate(graph)
require(validation.isSuccess) {
"Failed to build graph: ${validation.exceptionOrNull()?.message}"
}
return graph
}
}
Strategy 3: Pre-Deployment Gateβ
fun deployWorkflow(graph: Graph) {
// Gate 1: Validation
val validation = GraphValidator.validate(graph)
if (validation.isFailure) {
throw DeploymentException("Validation failed")
}
// Gate 2: Additional checks
if (graph.nodes.size > 100) {
throw DeploymentException("Graph too large")
}
// Deploy
deploy(graph)
}
Troubleshootingβ
Issue: "Entry point does not exist"β
Problem: Entry point ID doesn't match any node ID
Solution:
// Check node IDs match entry point
println("Entry point: ${graph.entryPoint}")
println("Node IDs: ${graph.nodes.keys}")
Issue: "Graph contains cycles"β
Problem: Circular dependencies in graph
Solution:
// Use isDAG to confirm
if (!GraphValidator.isDAG(graph)) {
// Manually check edges for cycles
graph.edges.forEach { edge ->
println("${edge.from} β ${edge.to}")
}
}
Issue: "Unreachable nodes"β
Problem: Nodes not connected to entry point
Solution:
// Check connectivity
fun printReachability(graph: Graph) {
val reachable = mutableSetOf<String>()
fun dfs(nodeId: String) {
if (nodeId in reachable) return
reachable.add(nodeId)
graph.edges.filter { it.from == nodeId }
.forEach { dfs(it.to) }
}
dfs(graph.entryPoint)
val unreachable = graph.nodes.keys - reachable
println("Unreachable: $unreachable")
}
Next Stepsβ
- Learn Error Handling Strategies
- Review Graph Middleware
- Explore Performance Optimization