Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/graph_plan.rs
T
ZacharyZhang-NY 02cf5deebd Add /graph G6: plan-boundary topology optimizer
A restricted optimizer pass now reviews the graph at plan boundaries —
right after initial planning and piggybacked on each replan version
boundary, never mid-execution. An optimizer subagent may emit four ops
over Waiting/Ready nodes only: remove_dep (delete a false dependency,
restoring parallelism — the highest-value edit), reorder (pending
priority for the serial scheduler), merge (fold two tiny nodes; specs
concatenate, deps union, dependents re-point, absorbed self-deps drop),
and split (2-3 focused replacements inheriting the original's deps and
dependents). The optimizer changes graph DATA only; the executor stays
pure deterministic Rust. KIGI_GRAPH_OPTIMIZER=0 disables it entirely.

apply_optimization enforces the contract twice: per-op checks
(pending-only targets, known ids, terminal node untouchable, dead-node
deps rejected as DeadDep, merge/split targets with non-pending
dependents rejected with the true reason instead of tripping the
immutable invariant later), then FINAL invariants — every non-pending
node byte-identical in the result, the gn-final gate rebuilt over all
survivors, node cap, whole-graph acyclicity, and a BIDIRECTIONAL status
re-derivation for pending nodes (adversarial review caught the critical
hole: a merge grafting unsatisfied deps onto a Ready node would
otherwise dispatch it ahead of its new prerequisites, since
recompute_ready is promote-only). Applied passes bump plan_version,
freeze an immutable baseline, and consume a slot of the SHARED replan
cap; an explicit {"ops": []} is a respected free no-op; any failure
degrades to keeping the current plan. Plumbing reuses a new shared
artifact-pass runner (stale-artifact delete, size cap, missing-file
fail-closed) extracted from the replanner.

Tests: remove_dep parallelism restore + loud no-such-dep, immutable and
terminal-node rejections across all four ops, merge/split dependent
rewiring incl. final-gate rebuild and intra-split dep resolution,
result-cycle rejection, dead-dep splits, Ready-demote-on-merge, and
three e2e flows — false-dep removal proven ACTUALLY parallel by the
held-reply fan-out gate, OPTIMIZER=0 spawning zero passes, and the
shared-cap guard. kigi-shell 4959 lib tests green; clippy clean.
2026-07-20 20:21:49 -04:00

1422 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Graph planner output contract: parsing, static validation, and
//! canonicalization.
//!
//! The graph planner subagent writes a JSON file shaped as
//! `{"nodes": [{"id": "<slug>", "title": "...", "spec": "...",
//! "deps": ["<slug>", ...]}]}`. Before anything executes, the harness
//! runs the Agentproof-style static gate in [`parse_and_validate`]:
//! parse errors, empty graphs, duplicate/malformed slugs, unknown or
//! self dependencies, and cycles all fail CLOSED with a precise reason
//! (the caller retries planning once, then pauses the graph).
//!
//! Canonicalization: slugs become stable content-derived ids
//! (`gn-<fnv1a32 hex>` of the slug) so the same planned node keeps the
//! same id across replans and across machines (line-mergeable in the
//! G4 project-level graph file), nodes are re-ordered into a
//! planner-order-stable topological order (deterministic serial
//! scheduling), and the harness appends the terminal
//! [`FINAL_NODE_ID`](super::graph_tracker::FINAL_NODE_ID) verification
//! node depending on every planner node — the whole-objective gate is
//! structural, never left to the planner's discretion.
use super::graph_tracker::{DepKind, FINAL_NODE_ID, GraphNode, NodeDep, NodeStatus};
/// Hard cap on planner nodes (the prompt guides 310; this bound is the
/// fail-fast backstop against a runaway planner, not a target).
pub(crate) const MAX_GRAPH_NODES: usize = 24;
/// Byte cap for reading the planner's JSON file — same defensive posture
/// as the goal nudge reader: a runaway artifact must not blow up memory.
pub(crate) const MAX_GRAPH_JSON_BYTES: u64 = 256 * 1024;
#[derive(Debug, serde::Deserialize)]
struct PlannedGraph {
nodes: Vec<PlannedNode>,
}
#[derive(Debug, serde::Deserialize)]
struct PlannedNode {
id: String,
title: String,
spec: String,
#[serde(default)]
deps: Vec<String>,
/// Replan artifacts only: EXISTING node ids (`gn-…`) whose execution
/// surfaced this node. Ignored by the initial-plan path.
#[serde(default)]
discovered_from: Vec<String>,
}
/// Why a planner artifact was rejected. Rendered verbatim into the
/// planning-failure pause message and the retry prompt, so each variant
/// states the fix.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum GraphPlanError {
Parse(String),
Empty,
TooManyNodes(usize),
BadSlug(String),
DuplicateSlug(String),
EmptyField {
slug: String,
field: &'static str,
},
UnknownDep {
slug: String,
dep: String,
},
SelfDep(String),
Cycle(Vec<String>),
IdCollision(String, String),
/// Replan: a new node's canonical id collides with an existing node.
ExistingCollision(String),
/// Replan: a `deps` entry targets a Failed/Blocked node — the new
/// node could never become Ready.
DeadDep {
slug: String,
dep: String,
},
/// Replan: `discovered_from` references a node id not in the graph.
UnknownOrigin {
slug: String,
origin: String,
},
/// Optimizer: an operation violated the restricted-op contract
/// (touched an immutable node, unknown id, malformed shape, …).
OpInvalid(String),
}
impl std::fmt::Display for GraphPlanError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse(e) => write!(f, "graph JSON failed to parse: {e}"),
Self::Empty => write!(f, "graph has no nodes"),
Self::TooManyNodes(n) => {
write!(f, "graph has {n} nodes; the cap is {MAX_GRAPH_NODES}")
}
Self::BadSlug(s) => write!(
f,
"node id {s:?} is invalid: use 1-64 chars of [A-Za-z0-9_-]"
),
Self::DuplicateSlug(s) => write!(f, "duplicate node id {s:?}"),
Self::EmptyField { slug, field } => {
write!(f, "node {slug:?} has an empty {field}")
}
Self::UnknownDep { slug, dep } => {
write!(f, "node {slug:?} depends on unknown node {dep:?}")
}
Self::SelfDep(s) => write!(f, "node {s:?} depends on itself"),
Self::Cycle(nodes) => {
write!(f, "dependency cycle among nodes: {}", nodes.join(", "))
}
Self::IdCollision(a, b) => write!(
f,
"hash id collision between slugs {a:?} and {b:?}; rename one"
),
Self::ExistingCollision(s) => write!(
f,
"new node {s:?} collides with an existing graph node; rename it"
),
Self::UnknownOrigin { slug, origin } => write!(
f,
"node {slug:?} claims discovered_from unknown node {origin:?}"
),
Self::DeadDep { slug, dep } => write!(
f,
"node {slug:?} depends on {dep:?}, which already failed; depend on \
live nodes only (or none)"
),
Self::OpInvalid(reason) => write!(f, "invalid optimization op: {reason}"),
}
}
}
/// FNV-1a 32-bit over the slug, rendered as 8 lowercase hex chars.
/// Stable across builds, platforms, and Rust versions — the property
/// the project-level graph file (G4) needs for line-level merges.
fn fnv1a32_hex(s: &str) -> String {
let mut hash: u32 = 0x811c_9dc5;
for byte in s.bytes() {
hash ^= u32::from(byte);
hash = hash.wrapping_mul(0x0100_0193);
}
format!("{hash:08x}")
}
/// Canonical node id for a planner slug.
pub(crate) fn node_id_for_slug(slug: &str) -> String {
format!("gn-{}", fnv1a32_hex(slug))
}
fn valid_slug(slug: &str) -> bool {
!slug.is_empty()
&& slug.len() <= 64
&& slug
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// Parse, statically validate, and canonicalize a planner artifact.
///
/// On success the returned nodes are in planner-order-stable
/// topological order, carry `gn-` hash ids (`title` is kept verbatim;
/// the slug survives only inside the id hash), all start `Waiting`,
/// and end with the harness-appended final verification node.
pub(crate) fn parse_and_validate(
json: &str,
objective: &str,
) -> Result<Vec<GraphNode>, GraphPlanError> {
let mut planned: PlannedGraph =
serde_json::from_str(json).map_err(|e| GraphPlanError::Parse(e.to_string()))?;
if planned.nodes.is_empty() {
return Err(GraphPlanError::Empty);
}
// Dedup repeated dep entries (first occurrence kept): harmless
// planner redundancy, and the indegree seed below would otherwise
// misreport a duplicated edge as a cycle.
for node in &mut planned.nodes {
let mut seen_deps = std::collections::HashSet::new();
node.deps.retain(|d| seen_deps.insert(d.clone()));
}
if planned.nodes.len() > MAX_GRAPH_NODES {
return Err(GraphPlanError::TooManyNodes(planned.nodes.len()));
}
// Slug hygiene + uniqueness + non-empty payload fields.
let mut seen = std::collections::HashSet::new();
for node in &planned.nodes {
if !valid_slug(&node.id) {
return Err(GraphPlanError::BadSlug(node.id.clone()));
}
if !seen.insert(node.id.as_str()) {
return Err(GraphPlanError::DuplicateSlug(node.id.clone()));
}
if node.title.trim().is_empty() {
return Err(GraphPlanError::EmptyField {
slug: node.id.clone(),
field: "title",
});
}
if node.spec.trim().is_empty() {
return Err(GraphPlanError::EmptyField {
slug: node.id.clone(),
field: "spec",
});
}
}
// Dependency resolution.
for node in &planned.nodes {
for dep in &node.deps {
if dep == &node.id {
return Err(GraphPlanError::SelfDep(node.id.clone()));
}
if !seen.contains(dep.as_str()) {
return Err(GraphPlanError::UnknownDep {
slug: node.id.clone(),
dep: dep.clone(),
});
}
}
}
// Kahn's algorithm, planner-order-stable: each round takes the
// FIRST remaining zero-indegree node in planner order, so the
// serial scheduler's "first Ready in storage order" rule inherits
// the planner's intent.
let order = stable_topo_order(&planned)?;
// Canonical ids; collisions between distinct slugs fail fast.
let mut id_of: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
let mut owner_of_id: std::collections::HashMap<String, &str> = std::collections::HashMap::new();
for node in &planned.nodes {
let id = node_id_for_slug(&node.id);
if let Some(prior) = owner_of_id.insert(id.clone(), node.id.as_str()) {
return Err(GraphPlanError::IdCollision(
prior.to_owned(),
node.id.clone(),
));
}
id_of.insert(node.id.as_str(), id);
}
let mut nodes: Vec<GraphNode> = order
.into_iter()
.map(|idx| {
let p = &planned.nodes[idx];
GraphNode {
id: id_of[p.id.as_str()].clone(),
title: p.title.trim().to_owned(),
spec: p.spec.trim().to_owned(),
deps: p
.deps
.iter()
.map(|d| NodeDep {
on: id_of[d.as_str()].clone(),
kind: DepKind::Blocks,
})
.collect(),
status: NodeStatus::Waiting,
goal_id: None,
rounds: 0,
tokens_used: 0,
failure: None,
}
})
.collect();
nodes.push(final_verification_node(objective, &nodes));
Ok(nodes)
}
/// Planner-order-stable Kahn topological sort; `Err(Cycle)` lists the
/// slugs left when no zero-indegree node remains.
fn stable_topo_order(planned: &PlannedGraph) -> Result<Vec<usize>, GraphPlanError> {
let n = planned.nodes.len();
let index_of: std::collections::HashMap<&str, usize> = planned
.nodes
.iter()
.enumerate()
.map(|(i, node)| (node.id.as_str(), i))
.collect();
let mut indegree = vec![0usize; n];
for node in &planned.nodes {
let i = index_of[node.id.as_str()];
indegree[i] = node.deps.len();
}
let mut done = vec![false; n];
let mut order = Vec::with_capacity(n);
while order.len() < n {
let Some(next) = (0..n).find(|&i| !done[i] && indegree[i] == 0) else {
let cycle: Vec<String> = (0..n)
.filter(|&i| !done[i])
.map(|i| planned.nodes[i].id.clone())
.collect();
return Err(GraphPlanError::Cycle(cycle));
};
done[next] = true;
order.push(next);
let slug = planned.nodes[next].id.as_str();
for node in &planned.nodes {
if node.deps.iter().any(|d| d == slug) {
indegree[index_of[node.id.as_str()]] -= 1;
}
}
}
Ok(order)
}
/// The harness-appended terminal gate: a normal goal whose objective is
/// to independently re-verify the WHOLE graph objective. Depends on
/// every planner node, so it is always the last schedulable node.
fn final_verification_node(objective: &str, planner_nodes: &[GraphNode]) -> GraphNode {
GraphNode {
id: FINAL_NODE_ID.to_owned(),
title: "Final verification of the overall objective".to_owned(),
spec: format!(
"Independently verify that the OVERALL objective below is fully achieved, \
end to end, in the current state of the project. Re-run the relevant \
builds/tests/commands yourself; do not trust prior claims. If you find a \
gap, close it. Do not add features beyond the objective.\n\n\
OVERALL OBJECTIVE:\n{objective}"
),
deps: planner_nodes
.iter()
.map(|n| NodeDep {
on: n.id.clone(),
kind: DepKind::Blocks,
})
.collect(),
status: NodeStatus::Waiting,
goal_id: None,
rounds: 0,
tokens_used: 0,
failure: None,
}
}
/// Parse and validate a REPLAN artifact against the existing graph:
/// strictly append-only. New nodes may depend on existing `gn-…` ids or
/// on each other; the combined graph must stay acyclic; existing nodes
/// are never modified. Returns the canonicalized appendix — `Waiting`
/// status, `Blocks` deps, plus one `DiscoveredFrom` edge per validated
/// `discovered_from` origin.
pub(crate) fn validate_replan(
existing: &[GraphNode],
json: &str,
) -> Result<Vec<GraphNode>, GraphPlanError> {
let mut planned: PlannedGraph =
serde_json::from_str(json).map_err(|e| GraphPlanError::Parse(e.to_string()))?;
if planned.nodes.is_empty() {
return Err(GraphPlanError::Empty);
}
// Whole-graph cap: MAX_GRAPH_NODES planner nodes + gn-final. The
// payload excludes the final node so "the cap is N" stays truthful
// for replans too.
if planned.nodes.len() + existing.len() > MAX_GRAPH_NODES + 1 {
return Err(GraphPlanError::TooManyNodes(
planned.nodes.len() + existing.len() - 1,
));
}
for node in &mut planned.nodes {
let mut seen_deps = std::collections::HashSet::new();
node.deps.retain(|d| seen_deps.insert(d.clone()));
}
let existing_ids: std::collections::HashSet<&str> =
existing.iter().map(|n| n.id.as_str()).collect();
let mut seen = std::collections::HashSet::new();
for node in &planned.nodes {
if !valid_slug(&node.id) {
return Err(GraphPlanError::BadSlug(node.id.clone()));
}
if !seen.insert(node.id.as_str()) {
return Err(GraphPlanError::DuplicateSlug(node.id.clone()));
}
if node.title.trim().is_empty() {
return Err(GraphPlanError::EmptyField {
slug: node.id.clone(),
field: "title",
});
}
if node.spec.trim().is_empty() {
return Err(GraphPlanError::EmptyField {
slug: node.id.clone(),
field: "spec",
});
}
for origin in &node.discovered_from {
if !existing_ids.contains(origin.as_str()) {
return Err(GraphPlanError::UnknownOrigin {
slug: node.id.clone(),
origin: origin.clone(),
});
}
// Any edge onto the terminal node would cycle the moment
// append_replan_nodes gates it on the appendix. Fail fast.
if origin == FINAL_NODE_ID {
return Err(GraphPlanError::UnknownDep {
slug: node.id.clone(),
dep: FINAL_NODE_ID.to_owned(),
});
}
}
if node.deps.iter().any(|d| d == FINAL_NODE_ID) {
return Err(GraphPlanError::UnknownDep {
slug: node.id.clone(),
dep: FINAL_NODE_ID.to_owned(),
});
}
}
// Canonical ids for the appendix; must not collide with anything.
let mut id_of: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
let mut owner_of_id: std::collections::HashMap<String, &str> = std::collections::HashMap::new();
for node in &planned.nodes {
let id = node_id_for_slug(&node.id);
if existing_ids.contains(id.as_str()) {
return Err(GraphPlanError::ExistingCollision(node.id.clone()));
}
if let Some(prior) = owner_of_id.insert(id.clone(), node.id.as_str()) {
return Err(GraphPlanError::IdCollision(
prior.to_owned(),
node.id.clone(),
));
}
id_of.insert(node.id.as_str(), id);
}
// Deps resolve against existing ids (verbatim) or new slugs.
let resolve = |dep: &str| -> Option<String> {
if existing_ids.contains(dep) {
Some(dep.to_owned())
} else {
id_of.get(dep).cloned()
}
};
let dead_ids: std::collections::HashSet<&str> = existing
.iter()
.filter(|n| matches!(n.status, NodeStatus::Failed | NodeStatus::Blocked))
.map(|n| n.id.as_str())
.collect();
for node in &planned.nodes {
for dep in &node.deps {
if dep == &node.id {
return Err(GraphPlanError::SelfDep(node.id.clone()));
}
if resolve(dep).is_none() {
return Err(GraphPlanError::UnknownDep {
slug: node.id.clone(),
dep: dep.clone(),
});
}
// An ordering dep on a dead node can never satisfy; fail
// fast so the attempt-2 feedback loop repairs the artifact.
// (`discovered_from` origins are exempt — audit-only edges,
// and failed origins are the NORMAL salvage case.)
if dead_ids.contains(dep.as_str()) {
return Err(GraphPlanError::DeadDep {
slug: node.id.clone(),
dep: dep.clone(),
});
}
}
}
// Combined-graph acyclicity (Kahn over existing edges + appendix).
// Existing nodes only ever depend on existing nodes, so seeding
// their edges verbatim is sound.
{
let mut ids: Vec<String> = existing.iter().map(|n| n.id.clone()).collect();
ids.extend(planned.nodes.iter().map(|n| id_of[n.id.as_str()].clone()));
let index_of: std::collections::HashMap<&str, usize> = ids
.iter()
.enumerate()
.map(|(i, id)| (id.as_str(), i))
.collect();
let mut edges: Vec<(usize, usize)> = Vec::new();
for n in existing {
for d in &n.deps {
edges.push((index_of[d.on.as_str()], index_of[n.id.as_str()]));
}
}
for n in &planned.nodes {
let to = index_of[id_of[n.id.as_str()].as_str()];
for d in &n.deps {
edges.push((index_of[resolve(d).expect("validated").as_str()], to));
}
}
let mut indegree = vec![0usize; ids.len()];
for (_, to) in &edges {
indegree[*to] += 1;
}
let mut done = vec![false; ids.len()];
for _ in 0..ids.len() {
let Some(next) = (0..ids.len()).find(|&i| !done[i] && indegree[i] == 0) else {
let cycle: Vec<String> = (0..ids.len())
.filter(|&i| !done[i])
.map(|i| ids[i].clone())
.collect();
return Err(GraphPlanError::Cycle(cycle));
};
done[next] = true;
for (from, to) in &edges {
if *from == next {
indegree[*to] -= 1;
}
}
}
}
Ok(planned
.nodes
.iter()
.map(|p| {
let mut deps: Vec<NodeDep> = p
.deps
.iter()
.map(|d| NodeDep {
on: resolve(d).expect("validated"),
kind: DepKind::Blocks,
})
.collect();
for origin in &p.discovered_from {
if !deps.iter().any(|d| &d.on == origin) {
deps.push(NodeDep {
on: origin.clone(),
kind: DepKind::DiscoveredFrom,
});
}
}
GraphNode {
id: id_of[p.id.as_str()].clone(),
title: p.title.trim().to_owned(),
spec: p.spec.trim().to_owned(),
deps,
status: NodeStatus::Waiting,
goal_id: None,
rounds: 0,
tokens_used: 0,
failure: None,
}
})
.collect())
}
#[derive(serde::Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
enum OptimizeOp {
RemoveDep {
node: String,
dep: String,
},
Reorder {
order: Vec<String>,
},
Merge {
into: String,
from: String,
},
Split {
node: String,
replacements: Vec<PlannedNode>,
},
}
#[derive(serde::Deserialize)]
struct OptimizePlan {
ops: Vec<OptimizeOp>,
}
fn is_pending(status: NodeStatus) -> bool {
matches!(status, NodeStatus::Waiting | NodeStatus::Ready)
}
/// Apply a restricted optimization-op list to the graph, returning the
/// transformed node set — or `Ok(None)` for an explicitly empty op list
/// (a respected "already good" answer).
///
/// Contract enforced twice: per-op checks (pending-only targets, known
/// ids), then a FINAL diff invariant — every node that was NOT
/// Waiting/Ready must be byte-identical in the result, `gn-final`'s
/// gate is rebuilt over all surviving non-final nodes, and the combined
/// graph must remain acyclic.
pub(crate) fn apply_optimization(
existing: &[GraphNode],
json: &str,
) -> Result<Option<Vec<GraphNode>>, GraphPlanError> {
let plan: OptimizePlan =
serde_json::from_str(json).map_err(|e| GraphPlanError::Parse(e.to_string()))?;
if plan.ops.is_empty() {
return Ok(None);
}
let mut nodes: Vec<GraphNode> = existing.to_vec();
let find = |nodes: &[GraphNode], id: &str| -> Result<usize, GraphPlanError> {
nodes
.iter()
.position(|n| n.id == id)
.ok_or_else(|| GraphPlanError::OpInvalid(format!("unknown node {id:?}")))
};
let pending_or_err = |nodes: &[GraphNode], idx: usize| -> Result<(), GraphPlanError> {
if !is_pending(nodes[idx].status) {
return Err(GraphPlanError::OpInvalid(format!(
"node {:?} is {:?}; only Waiting/Ready nodes may be edited",
nodes[idx].id, nodes[idx].status
)));
}
Ok(())
};
for op in plan.ops {
match op {
OptimizeOp::RemoveDep { node, dep } => {
let idx = find(&nodes, &node)?;
pending_or_err(&nodes, idx)?;
let before = nodes[idx].deps.len();
nodes[idx].deps.retain(|d| d.on != dep);
if nodes[idx].deps.len() == before {
return Err(GraphPlanError::OpInvalid(format!(
"node {node:?} has no dependency on {dep:?}"
)));
}
}
OptimizeOp::Reorder { order } => {
let mut seen = std::collections::HashSet::new();
for id in &order {
let idx = find(&nodes, id)?;
pending_or_err(&nodes, idx)?;
if !seen.insert(id.as_str()) {
return Err(GraphPlanError::OpInvalid(format!(
"reorder lists {id:?} twice"
)));
}
}
// Stable rearrangement: listed nodes adopt the listed
// relative order across the positions they occupied.
let positions: Vec<usize> = nodes
.iter()
.enumerate()
.filter(|(_, n)| order.contains(&n.id))
.map(|(i, _)| i)
.collect();
let picked: Vec<GraphNode> = order
.iter()
.map(|id| nodes[find(&nodes, id).expect("checked above")].clone())
.collect();
for (&pos, node) in positions.iter().zip(picked) {
nodes[pos] = node;
}
}
OptimizeOp::Merge { into, from } => {
if into == from {
return Err(GraphPlanError::OpInvalid("merge into == from".to_owned()));
}
// Rewiring dependents must never touch an immutable node:
// a Blocked dependent (dead chain) would either be
// mutated (tripping the final invariant) or left with a
// dangling dep. Reject up front with the true reason.
if let Some(dependent) = nodes.iter().find(|n| {
n.id != FINAL_NODE_ID
&& !is_pending(n.status)
&& n.deps.iter().any(|d| d.on == from)
}) {
return Err(GraphPlanError::OpInvalid(format!(
"node {from:?} has non-pending dependent {:?}; it cannot be merged",
dependent.id
)));
}
if from == FINAL_NODE_ID || into == FINAL_NODE_ID {
return Err(GraphPlanError::OpInvalid(
"the terminal node cannot participate in a merge".to_owned(),
));
}
let into_idx = find(&nodes, &into)?;
let from_idx = find(&nodes, &from)?;
pending_or_err(&nodes, into_idx)?;
pending_or_err(&nodes, from_idx)?;
let from_node = nodes.remove(from_idx);
let into_idx = find(&nodes, &into)?;
nodes[into_idx].spec =
format!("{}\n\nAND: {}", nodes[into_idx].spec, from_node.spec);
for dep in from_node.deps {
if dep.on != into && !nodes[into_idx].deps.iter().any(|d| d.on == dep.on) {
nodes[into_idx].deps.push(dep);
}
}
for node in &mut nodes {
for dep in &mut node.deps {
if dep.on == from {
dep.on = into.clone();
}
}
// A dependent of BOTH from and into now lists into
// twice; collapse. And `into` itself must never
// keep a re-pointed self-dependency (into depended
// on from ⇒ that ordering is absorbed by the merge).
let own_id = node.id.clone();
let mut seen = std::collections::HashSet::new();
node.deps
.retain(|d| d.on != own_id && seen.insert(d.on.clone()));
}
}
OptimizeOp::Split { node, replacements } => {
if node == FINAL_NODE_ID {
return Err(GraphPlanError::OpInvalid(
"the terminal node cannot be split".to_owned(),
));
}
let idx = find(&nodes, &node)?;
pending_or_err(&nodes, idx)?;
if let Some(dependent) = nodes.iter().find(|n| {
n.id != FINAL_NODE_ID
&& !is_pending(n.status)
&& n.deps.iter().any(|d| d.on == node)
}) {
return Err(GraphPlanError::OpInvalid(format!(
"node {node:?} has non-pending dependent {:?}; it cannot be split",
dependent.id
)));
}
if replacements.len() < 2 || replacements.len() > 3 {
return Err(GraphPlanError::OpInvalid(format!(
"split of {node:?} needs 2-3 replacements, got {}",
replacements.len()
)));
}
let original = nodes.remove(idx);
let mut new_ids = Vec::new();
for rep in &replacements {
if !valid_slug(&rep.id) {
return Err(GraphPlanError::BadSlug(rep.id.clone()));
}
if rep.title.trim().is_empty() || rep.spec.trim().is_empty() {
return Err(GraphPlanError::OpInvalid(format!(
"split replacement {:?} has an empty title/spec",
rep.id
)));
}
let id = node_id_for_slug(&rep.id);
if nodes.iter().any(|n| n.id == id) || new_ids.contains(&id) {
return Err(GraphPlanError::ExistingCollision(rep.id.clone()));
}
new_ids.push(id);
}
let dead_ids: std::collections::HashSet<String> = nodes
.iter()
.filter(|n| matches!(n.status, NodeStatus::Failed | NodeStatus::Blocked))
.map(|n| n.id.clone())
.collect();
for (rep, id) in replacements.iter().zip(&new_ids) {
let mut deps: Vec<NodeDep> = original.deps.clone();
for d in &rep.deps {
let resolved = if nodes.iter().any(|n| n.id == *d) {
d.clone()
} else if let Some(pos) = replacements.iter().position(|r| r.id == *d) {
new_ids[pos].clone()
} else {
return Err(GraphPlanError::UnknownDep {
slug: rep.id.clone(),
dep: d.clone(),
});
};
// Ordering dep on a dead node can never satisfy
// (same rule as validate_replan's DeadDep).
if dead_ids.contains(&resolved) {
return Err(GraphPlanError::DeadDep {
slug: rep.id.clone(),
dep: resolved,
});
}
if !deps.iter().any(|existing| existing.on == resolved) {
deps.push(NodeDep {
on: resolved,
kind: DepKind::Blocks,
});
}
}
nodes.push(GraphNode {
id: id.clone(),
title: rep.title.trim().to_owned(),
spec: rep.spec.trim().to_owned(),
deps,
status: NodeStatus::Waiting,
goal_id: None,
rounds: 0,
tokens_used: 0,
failure: None,
});
}
for n in &mut nodes {
if let Some(pos) = n.deps.iter().position(|d| d.on == node) {
let kind = n.deps[pos].kind;
n.deps.remove(pos);
for id in &new_ids {
if !n.deps.iter().any(|d| &d.on == id) {
n.deps.push(NodeDep {
on: id.clone(),
kind,
});
}
}
}
}
}
}
}
// Rebuild the terminal gate over all surviving non-final nodes.
if let Some(final_idx) = nodes.iter().position(|n| n.id == FINAL_NODE_ID) {
let gate: Vec<NodeDep> = nodes
.iter()
.filter(|n| n.id != FINAL_NODE_ID)
.map(|n| NodeDep {
on: n.id.clone(),
kind: DepKind::Blocks,
})
.collect();
nodes[final_idx].deps = gate;
}
// Re-derive EVERY pending node's status in both directions: ops can
// remove a Ready node's last blocker (→ stays Ready via the same
// rule) or graft unsatisfied deps onto a Ready node (merge), which
// must demote it — `recompute_ready` downstream is promote-only and
// would leave a Ready node whose Blocks deps are unmet, silently
// violating ordering at dispatch.
let achieved: std::collections::HashSet<String> = nodes
.iter()
.filter(|n| n.status == NodeStatus::Achieved)
.map(|n| n.id.clone())
.collect();
let derived: Vec<NodeStatus> = nodes
.iter()
.map(|n| {
if !is_pending(n.status) {
n.status
} else if n
.deps
.iter()
.filter(|d| d.kind == DepKind::Blocks)
.all(|d| achieved.contains(&d.on))
{
NodeStatus::Ready
} else {
NodeStatus::Waiting
}
})
.collect();
for (node, status) in nodes.iter_mut().zip(derived) {
node.status = status;
}
// FINAL diff invariant: immutable nodes byte-identical (Debug repr
// covers every field; GraphNode has no Eq).
for old in existing {
if is_pending(old.status) || old.id == FINAL_NODE_ID {
continue;
}
match nodes.iter().find(|n| n.id == old.id) {
Some(new) if format!("{new:?}") == format!("{old:?}") => {}
_ => {
return Err(GraphPlanError::OpInvalid(format!(
"immutable node {:?} was modified or removed",
old.id
)));
}
}
}
if nodes.len() > MAX_GRAPH_NODES + 1 {
return Err(GraphPlanError::TooManyNodes(nodes.len() - 1));
}
// Acyclicity over the whole result.
{
let index_of: std::collections::HashMap<&str, usize> = nodes
.iter()
.enumerate()
.map(|(i, n)| (n.id.as_str(), i))
.collect();
let mut indegree = vec![0usize; nodes.len()];
for n in &nodes {
for d in &n.deps {
if !index_of.contains_key(d.on.as_str()) {
return Err(GraphPlanError::UnknownDep {
slug: n.id.clone(),
dep: d.on.clone(),
});
}
indegree[index_of[n.id.as_str()]] += 1;
}
}
let mut done = vec![false; nodes.len()];
for _ in 0..nodes.len() {
let Some(next) = (0..nodes.len()).find(|&i| !done[i] && indegree[i] == 0) else {
let cycle: Vec<String> = (0..nodes.len())
.filter(|&i| !done[i])
.map(|i| nodes[i].id.clone())
.collect();
return Err(GraphPlanError::Cycle(cycle));
};
done[next] = true;
let next_id = nodes[next].id.clone();
for n in &nodes {
if n.deps.iter().any(|d| d.on == next_id) {
indegree[index_of[n.id.as_str()]] -= 1;
}
}
}
}
Ok(Some(nodes))
}
#[cfg(test)]
mod tests {
use super::*;
fn plan_json(nodes: &[(&str, &[&str])]) -> String {
let nodes: Vec<serde_json::Value> = nodes
.iter()
.map(|(id, deps)| {
serde_json::json!({
"id": id,
"title": format!("Title {id}"),
"spec": format!("Spec for {id}"),
"deps": deps,
})
})
.collect();
serde_json::json!({ "nodes": nodes }).to_string()
}
#[test]
fn valid_plan_canonicalizes_topologically_and_appends_final_node() {
// Planner order deliberately lists a dependent before its dep.
let json = plan_json(&[("b", &["a"]), ("a", &[]), ("c", &["a", "b"])]);
let nodes = parse_and_validate(&json, "ship the feature").unwrap();
assert_eq!(nodes.len(), 4);
let ids: Vec<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
// a before b before c; final last.
assert_eq!(ids[0], node_id_for_slug("a"));
assert_eq!(ids[1], node_id_for_slug("b"));
assert_eq!(ids[2], node_id_for_slug("c"));
assert_eq!(ids[3], FINAL_NODE_ID);
// Final node depends on all three, and carries the objective.
assert_eq!(nodes[3].deps.len(), 3);
assert!(nodes[3].spec.contains("ship the feature"));
// Deps rewritten to canonical ids.
assert_eq!(nodes[1].deps[0].on, node_id_for_slug("a"));
}
#[test]
fn ids_are_stable_content_hashes() {
assert_eq!(node_id_for_slug("auth-flow"), node_id_for_slug("auth-flow"));
assert_ne!(node_id_for_slug("auth-flow"), node_id_for_slug("auth_flow"));
assert!(node_id_for_slug("x").starts_with("gn-"));
assert_eq!(node_id_for_slug("x").len(), 3 + 8);
}
#[test]
fn cycle_is_rejected_with_members_listed() {
let json = plan_json(&[("a", &["b"]), ("b", &["a"]), ("c", &[])]);
match parse_and_validate(&json, "o") {
Err(GraphPlanError::Cycle(members)) => {
assert!(members.contains(&"a".to_owned()));
assert!(members.contains(&"b".to_owned()));
assert!(!members.contains(&"c".to_owned()));
}
other => panic!("expected Cycle, got {other:?}"),
}
}
#[test]
fn structural_errors_are_precise() {
assert_eq!(
parse_and_validate(r#"{"nodes":[]}"#, "o").unwrap_err(),
GraphPlanError::Empty
);
assert!(matches!(
parse_and_validate("not json", "o"),
Err(GraphPlanError::Parse(_))
));
let dup = plan_json(&[("a", &[]), ("a", &[])]);
assert_eq!(
parse_and_validate(&dup, "o").unwrap_err(),
GraphPlanError::DuplicateSlug("a".into())
);
let self_dep = plan_json(&[("a", &["a"])]);
assert_eq!(
parse_and_validate(&self_dep, "o").unwrap_err(),
GraphPlanError::SelfDep("a".into())
);
let unknown = plan_json(&[("a", &["ghost"])]);
assert_eq!(
parse_and_validate(&unknown, "o").unwrap_err(),
GraphPlanError::UnknownDep {
slug: "a".into(),
dep: "ghost".into()
}
);
let bad = plan_json(&[("has space", &[])]);
assert_eq!(
parse_and_validate(&bad, "o").unwrap_err(),
GraphPlanError::BadSlug("has space".into())
);
}
#[test]
fn empty_title_or_spec_rejected() {
let json = serde_json::json!({
"nodes": [{"id": "a", "title": " ", "spec": "s", "deps": []}]
})
.to_string();
assert_eq!(
parse_and_validate(&json, "o").unwrap_err(),
GraphPlanError::EmptyField {
slug: "a".into(),
field: "title"
}
);
}
#[test]
fn node_cap_enforced() {
let slugs: Vec<String> = (0..MAX_GRAPH_NODES + 1).map(|i| format!("n{i}")).collect();
let pairs: Vec<(&str, &[&str])> = slugs.iter().map(|s| (s.as_str(), &[][..])).collect();
let json = plan_json(&pairs);
assert_eq!(
parse_and_validate(&json, "o").unwrap_err(),
GraphPlanError::TooManyNodes(MAX_GRAPH_NODES + 1)
);
}
#[test]
fn planner_order_breaks_topo_ties() {
// Two independent roots: planner listed z first, so z schedules first.
let json = plan_json(&[("z", &[]), ("a", &[])]);
let nodes = parse_and_validate(&json, "o").unwrap();
assert_eq!(nodes[0].id, node_id_for_slug("z"));
assert_eq!(nodes[1].id, node_id_for_slug("a"));
}
fn existing_graph() -> Vec<GraphNode> {
parse_and_validate(&plan_json(&[("a", &[]), ("b", &["a"])]), "objective").unwrap()
}
#[test]
fn replan_appendix_resolves_existing_ids_and_adds_discovered_from_edges() {
let existing = existing_graph();
let a_id = node_id_for_slug("a");
let json = serde_json::json!({
"nodes": [{
"id": "docs",
"title": "Docs",
"spec": "write docs",
"deps": [a_id.clone()],
"discovered_from": [a_id.clone()],
}]
})
.to_string();
let appendix = validate_replan(&existing, &json).unwrap();
assert_eq!(appendix.len(), 1);
let node = &appendix[0];
assert_eq!(node.status, NodeStatus::Waiting);
// Blocks dep on the existing id, deduped against the
// DiscoveredFrom edge (same target keeps the Blocks edge only).
assert_eq!(node.deps.len(), 1);
assert_eq!(node.deps[0].on, a_id);
assert_eq!(node.deps[0].kind, DepKind::Blocks);
// Distinct origin gets its own DiscoveredFrom edge.
let b_id = node_id_for_slug("b");
let json = serde_json::json!({
"nodes": [{
"id": "docs2",
"title": "Docs 2",
"spec": "s",
"deps": [a_id.clone()],
"discovered_from": [b_id.clone()],
}]
})
.to_string();
let appendix = validate_replan(&existing, &json).unwrap();
let node = &appendix[0];
assert_eq!(node.deps.len(), 2);
assert!(
node.deps
.iter()
.any(|d| d.on == b_id && d.kind == DepKind::DiscoveredFrom)
);
}
fn opt_state() -> Vec<GraphNode> {
// a(Achieved) → b(Ready, FALSE dep on c), c(Ready), final gate.
let mut nodes = parse_and_validate(
&plan_json(&[("a", &[]), ("b", &["a", "c"]), ("c", &[])]),
"o",
)
.unwrap();
let a = node_id_for_slug("a");
for n in &mut nodes {
if n.id == a {
n.status = NodeStatus::Achieved;
} else if n.deps.iter().all(|d| d.on == a) {
n.status = NodeStatus::Ready;
}
}
nodes
}
#[test]
fn optimizer_remove_dep_restores_parallelism_and_empty_ops_is_noop() {
let existing = opt_state();
let b = node_id_for_slug("b");
let c = node_id_for_slug("c");
assert!(
apply_optimization(&existing, r#"{"ops": []}"#)
.unwrap()
.is_none(),
"explicit empty ops is a respected no-op"
);
let json = serde_json::json!({
"ops": [{"op": "remove_dep", "node": b.clone(), "dep": c.clone()}]
})
.to_string();
let optimized = apply_optimization(&existing, &json).unwrap().unwrap();
let b_node = optimized.iter().find(|n| n.id == b).unwrap();
assert!(
!b_node.deps.iter().any(|d| d.on == c),
"false dep removed — b and c can now run in parallel"
);
// Removing a dep that does not exist is loud.
let bad = serde_json::json!({
"ops": [{"op": "remove_dep", "node": c, "dep": b}]
})
.to_string();
assert!(matches!(
apply_optimization(&existing, &bad).unwrap_err(),
GraphPlanError::OpInvalid(_)
));
}
#[test]
fn optimizer_rejects_touching_immutable_nodes() {
let existing = opt_state();
let a = node_id_for_slug("a"); // Achieved — immutable
for json in [
serde_json::json!({"ops": [{"op": "remove_dep", "node": a.clone(), "dep": "x"}]}),
serde_json::json!({"ops": [{"op": "reorder", "order": [a.clone()]}]}),
serde_json::json!({"ops": [{"op": "merge", "into": node_id_for_slug("b"), "from": a.clone()}]}),
serde_json::json!({"ops": [{"op": "split", "node": a.clone(), "replacements": [
{"id": "p1", "title": "P1", "spec": "s"},
{"id": "p2", "title": "P2", "spec": "s"},
]}]}),
] {
assert!(
matches!(
apply_optimization(&existing, &json.to_string()).unwrap_err(),
GraphPlanError::OpInvalid(_)
),
"op touching an Achieved node must be rejected: {json}"
);
}
// The terminal node is likewise untouchable.
let final_merge = serde_json::json!({
"ops": [{"op": "merge", "into": node_id_for_slug("b"), "from": FINAL_NODE_ID}]
});
assert!(matches!(
apply_optimization(&existing, &final_merge.to_string()).unwrap_err(),
GraphPlanError::OpInvalid(_)
));
}
#[test]
fn optimizer_merge_and_split_rewire_dependents_and_final_gate() {
let existing = opt_state();
let b = node_id_for_slug("b");
let c = node_id_for_slug("c");
// Merge c into b: b absorbs the spec; final gate loses c.
let json = serde_json::json!({
"ops": [{"op": "merge", "into": b.clone(), "from": c.clone()}]
})
.to_string();
let merged = apply_optimization(&existing, &json).unwrap().unwrap();
assert!(merged.iter().all(|n| n.id != c));
let final_node = merged.iter().find(|n| n.id == FINAL_NODE_ID).unwrap();
assert!(!final_node.deps.iter().any(|d| d.on == c));
assert!(final_node.deps.iter().any(|d| d.on == b));
let b_node = merged.iter().find(|n| n.id == b).unwrap();
assert!(b_node.spec.contains("AND:"));
assert!(
!b_node.deps.iter().any(|d| d.on == b),
"merge must not self-depend"
);
// Split b into two parts: dependents (final) gate on both parts.
let json = serde_json::json!({
"ops": [{"op": "split", "node": b, "replacements": [
{"id": "b-core", "title": "Core half", "spec": "s1"},
{"id": "b-glue", "title": "Glue half", "spec": "s2", "deps": ["b-core"]},
]}]
})
.to_string();
let split = apply_optimization(&existing, &json).unwrap().unwrap();
let p1 = node_id_for_slug("b-core");
let p2 = node_id_for_slug("b-glue");
let final_node = split.iter().find(|n| n.id == FINAL_NODE_ID).unwrap();
assert!(final_node.deps.iter().any(|d| d.on == p1));
assert!(final_node.deps.iter().any(|d| d.on == p2));
let glue = split.iter().find(|n| n.id == p2).unwrap();
assert!(
glue.deps.iter().any(|d| d.on == p1),
"intra-split dep resolved"
);
assert!(
glue.deps.iter().any(|d| d.on == node_id_for_slug("a")),
"replacements inherit the split node's deps"
);
}
#[test]
fn merge_grafting_unsatisfied_deps_demotes_ready_into_to_waiting() {
// d(Waiting, dep on c) — merge d into b (b Ready): b absorbs the
// unsatisfied dep on c and MUST demote to Waiting, or dispatch
// would run b ahead of c (critical review finding).
let mut existing = opt_state();
let c = node_id_for_slug("c");
existing.insert(
3,
GraphNode {
id: node_id_for_slug("d"),
title: "D".into(),
spec: "d".into(),
deps: vec![NodeDep {
on: c.clone(),
kind: DepKind::Blocks,
}],
status: NodeStatus::Waiting,
goal_id: None,
rounds: 0,
tokens_used: 0,
failure: None,
},
);
let b = node_id_for_slug("b");
// Give b a satisfied-only dep set first (remove the false dep on c).
let json = serde_json::json!({
"ops": [
{"op": "remove_dep", "node": b.clone(), "dep": c.clone()},
{"op": "merge", "into": b.clone(), "from": node_id_for_slug("d")},
]
})
.to_string();
let optimized = apply_optimization(&existing, &json).unwrap().unwrap();
let b_node = optimized.iter().find(|n| n.id == b).unwrap();
assert!(b_node.deps.iter().any(|d| d.on == c), "dep absorbed");
assert_eq!(
b_node.status,
NodeStatus::Waiting,
"Ready node absorbing an unmet dep must demote"
);
}
#[test]
fn split_rejects_deps_on_dead_nodes() {
let mut existing = opt_state();
let a = node_id_for_slug("a");
existing.iter_mut().find(|n| n.id == a).unwrap().status = NodeStatus::Failed;
let json = serde_json::json!({
"ops": [{"op": "split", "node": node_id_for_slug("b"), "replacements": [
{"id": "p1", "title": "P1", "spec": "s", "deps": [a]},
{"id": "p2", "title": "P2", "spec": "s"},
]}]
})
.to_string();
assert!(matches!(
apply_optimization(&existing, &json).unwrap_err(),
GraphPlanError::DeadDep { .. }
));
}
#[test]
fn merge_and_split_reject_targets_with_non_pending_dependents() {
// B(Blocked) deps on p(Ready): merging or splitting p would have
// to rewire an immutable node — reject with the TRUE reason.
let mut existing = opt_state();
let b = node_id_for_slug("b");
let c = node_id_for_slug("c");
existing.iter_mut().find(|n| n.id == b).unwrap().status = NodeStatus::Blocked;
// b already deps on c in opt_state, so c has a Blocked dependent.
let merge = serde_json::json!({
"ops": [{"op": "merge", "into": node_id_for_slug("a"), "from": c.clone()}]
});
// (a is Achieved, so this would fail pending_or_err anyway — use
// a fresh pending target instead.)
let _ = merge;
let split = serde_json::json!({
"ops": [{"op": "split", "node": c, "replacements": [
{"id": "p1", "title": "P1", "spec": "s"},
{"id": "p2", "title": "P2", "spec": "s"},
]}]
});
match apply_optimization(&existing, &split.to_string()).unwrap_err() {
GraphPlanError::OpInvalid(reason) => {
assert!(
reason.contains("non-pending dependent"),
"true reason surfaces: {reason}"
);
}
other => panic!("expected OpInvalid, got {other:?}"),
}
}
#[test]
fn optimizer_rejects_result_cycles() {
let existing = opt_state();
// b already deps c; a reorder is fine, but adding a cycle via
// split deps pointing at a dependent must fail the final check.
let json = serde_json::json!({
"ops": [{"op": "split", "node": node_id_for_slug("c"), "replacements": [
{"id": "c1", "title": "C1", "spec": "s", "deps": [node_id_for_slug("b")]},
{"id": "c2", "title": "C2", "spec": "s"},
]}]
})
.to_string();
assert!(matches!(
apply_optimization(&existing, &json).unwrap_err(),
GraphPlanError::Cycle(_)
));
}
#[test]
fn replan_rejects_blocks_deps_on_dead_nodes_but_allows_dead_origins() {
let mut existing = existing_graph();
let a_id = node_id_for_slug("a");
existing.iter_mut().find(|n| n.id == a_id).unwrap().status = NodeStatus::Failed;
let dead_dep = serde_json::json!({
"nodes": [{"id": "x", "title": "T", "spec": "s", "deps": [a_id.clone()]}]
})
.to_string();
assert!(matches!(
validate_replan(&existing, &dead_dep).unwrap_err(),
GraphPlanError::DeadDep { .. }
));
// A dead ORIGIN is the normal salvage case — allowed, and the
// audit-only DiscoveredFrom edge never gates scheduling.
let dead_origin = serde_json::json!({
"nodes": [{"id": "x", "title": "T", "spec": "s", "deps": [],
"discovered_from": [a_id]}]
})
.to_string();
assert!(validate_replan(&existing, &dead_origin).is_ok());
}
#[test]
fn replan_rejects_edges_onto_the_terminal_node() {
let existing = existing_graph();
for json in [
serde_json::json!({"nodes": [{"id": "x", "title": "T", "spec": "s",
"deps": [FINAL_NODE_ID]}]}),
serde_json::json!({"nodes": [{"id": "x", "title": "T", "spec": "s",
"deps": [], "discovered_from": [FINAL_NODE_ID]}]}),
] {
assert!(
matches!(
validate_replan(&existing, &json.to_string()).unwrap_err(),
GraphPlanError::UnknownDep { .. }
),
"an edge onto gn-final would cycle after the final-gating extension"
);
}
}
#[test]
fn replan_rejects_collisions_unknown_origins_and_cycles() {
let existing = existing_graph();
// Re-using an existing slug collides on the canonical id.
let dup = serde_json::json!({
"nodes": [{"id": "a", "title": "T", "spec": "s", "deps": []}]
})
.to_string();
assert_eq!(
validate_replan(&existing, &dup).unwrap_err(),
GraphPlanError::ExistingCollision("a".into())
);
// Unknown discovered_from origin.
let bad_origin = serde_json::json!({
"nodes": [{"id": "x", "title": "T", "spec": "s", "deps": [],
"discovered_from": ["gn-ghost"]}]
})
.to_string();
assert!(matches!(
validate_replan(&existing, &bad_origin).unwrap_err(),
GraphPlanError::UnknownOrigin { .. }
));
// New-node cycle.
let cyc = serde_json::json!({
"nodes": [
{"id": "x", "title": "T", "spec": "s", "deps": ["y"]},
{"id": "y", "title": "T", "spec": "s", "deps": ["x"]},
]
})
.to_string();
assert!(matches!(
validate_replan(&existing, &cyc).unwrap_err(),
GraphPlanError::Cycle(_)
));
}
/// A repeated dep entry is harmless planner redundancy: it must be
/// deduped, NOT misreported as a cycle by the indegree seed.
#[test]
fn duplicate_dep_entries_are_deduped_not_a_cycle() {
let json = plan_json(&[("a", &[]), ("b", &["a", "a"])]);
let nodes = parse_and_validate(&json, "o").unwrap();
assert_eq!(nodes.len(), 3, "a, b, final");
let b = &nodes[1];
assert_eq!(b.id, node_id_for_slug("b"));
assert_eq!(b.deps.len(), 1, "duplicate edge collapsed");
assert_eq!(b.deps[0].on, node_id_for_slug("a"));
}
}