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.
This commit is contained in:
@@ -81,6 +81,9 @@ pub(crate) enum GraphPlanError {
|
||||
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 {
|
||||
@@ -123,6 +126,7 @@ impl std::fmt::Display for GraphPlanError {
|
||||
"node {slug:?} depends on {dep:?}, which already failed; depend on \
|
||||
live nodes only (or none)"
|
||||
),
|
||||
Self::OpInvalid(reason) => write!(f, "invalid optimization op: {reason}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -539,6 +543,373 @@ pub(crate) fn validate_replan(
|
||||
.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::*;
|
||||
@@ -718,6 +1089,245 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user