mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-12 23:02:41 +00:00
Random Edge Selection (#12)
This PR introduces a `selection="random"` node attribute that enables
weighted-random tiebreaking when choosing among candidate outgoing
edges. The existing deterministic behavior (highest weight, then lexical
node ID) remains the default. The cascade priority—conditions →
preferred label → suggested next → unconditional → fallback—is
unchanged; randomness only replaces the final pick-one-from-candidates
step within each tier. A new `weighted_random` function handles the
sampling, treating edges with weight ≤ 0 as weight 1, while a
`pick_edge` dispatcher routes to either the random or deterministic
strategy based on the node's `selection()` accessor.
A validation rule (`RandomSelectionNoConditionsRule`) rejects nodes that
combine `selection="random"` with conditional edges, since condition
evaluation order would conflict with random selection. A companion rule
(`SelectionValidRule`) warns on unrecognized selection values. Both are
registered as built-in lint rules with appropriate error/warning
severities and actionable fix suggestions.
Documentation is updated in the transitions guide with a new "Random
selection" section explaining the behavior and constraints, and the DOT
language reference gains a `selection` row in the node attributes table.
All changes were developed following red/green TDD cycles with
comprehensive test coverage for the accessor, weighted random sampling,
edge selection integration, and both validation rules.
### Fabro Details
<details>
<summary>Ran 7 stages in 17m 1s for $4.07</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 0s | – | 0 |
| preflight_lint | 0s | – | 0 |
| implement | 0s | $2.43 | 0 |
| simplify | 0s | $1.63 | 0 |
| verify | 0s | – | 0 |
| **Total** | **17m 1s** | **$4.07** | **0** |
</details>
<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (10 nodes and 13
edges)</summary>
```dot
digraph ImplementAndSimplify {
graph [
goal="Implement and simplify",
model_stylesheet="
* { backend: api; model: claude-opus-4-6;}
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan."]
simplify [label="Simplify", prompt="@prompts/simplify.md"]
verify [label="Verify", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1 && cargo test 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=success"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=success"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=success"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify -> verify
verify -> exit [condition="outcome=success"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
This commit is contained in:
parent
bcfd16b833
commit
0d7ae73857
5 changed files with 346 additions and 14 deletions
|
|
@ -189,6 +189,7 @@ Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be
|
|||
| `retry_target` | String | Node ID to jump to on retry |
|
||||
| `goal_gate` | Boolean | When `true`, workflow fails if this node doesn't succeed |
|
||||
| `auto_status` | Boolean | Auto-generate status updates |
|
||||
| `selection` | String | Edge tiebreaking strategy: `deterministic` (default) or `random` (weighted-random). Cannot be combined with conditional edges. |
|
||||
|
||||
### Agent and prompt nodes
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ title: "Transitions"
|
|||
description: "How Fabro decides which node to execute next"
|
||||
---
|
||||
|
||||
After each node finishes, Fabro must decide which edge to follow to the next node. This decision is fully deterministic — given the same outcome and context, Fabro always picks the same edge. Understanding the transition logic helps you design workflows that route reliably.
|
||||
After each node finishes, Fabro must decide which edge to follow to the next node. This decision is deterministic by default — given the same outcome and context, Fabro always picks the same edge. Nodes can opt into [random selection](#random-selection) for weighted-random tiebreaking instead. Understanding the transition logic helps you design workflows that route reliably.
|
||||
|
||||
## How transitions work
|
||||
|
||||
|
|
@ -155,3 +155,20 @@ node -> fallback [weight=1]
|
|||
```
|
||||
|
||||
If weights are equal, the edge with the lexicographically first target node ID is chosen. This makes the behavior fully deterministic.
|
||||
|
||||
## Random selection
|
||||
|
||||
By default, tiebreaking between candidate edges is deterministic (highest weight, then lexical node ID). Setting `selection="random"` on a node switches to weighted-random tiebreaking for its outgoing edges:
|
||||
|
||||
```dot
|
||||
picker [label="Pick path", selection="random"]
|
||||
|
||||
picker -> path_a [weight=3]
|
||||
picker -> path_b [weight=1]
|
||||
```
|
||||
|
||||
In this example, `path_a` is chosen ~75% of the time and `path_b` ~25%. Edges with weight ≤ 0 are treated as weight 1. The cascade priority (conditions → preferred label → suggested next → unconditional → fallback) is unchanged — randomness only affects the pick-one-from-candidates step within each tier.
|
||||
|
||||
<Note>
|
||||
`selection="random"` cannot be combined with conditional edges on the same node. Validation rejects this combination because condition evaluation order would conflict with random selection. Use unconditional edges with weights instead.
|
||||
</Note>
|
||||
|
|
|
|||
|
|
@ -390,6 +390,46 @@ fn best_by_weight_then_lexical<'a>(edges: &[&'a Edge]) -> Option<&'a Edge> {
|
|||
Some(best)
|
||||
}
|
||||
|
||||
/// Pick a random edge using weighted-random selection.
|
||||
/// Edges with `weight <= 0` are treated as weight 1 for probability calculation.
|
||||
fn weighted_random<'a>(edges: &[&'a Edge]) -> Option<&'a Edge> {
|
||||
if edges.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if edges.len() == 1 {
|
||||
return Some(edges[0]);
|
||||
}
|
||||
let weights: Vec<f64> = edges
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let w = e.weight();
|
||||
if w <= 0 {
|
||||
1.0
|
||||
} else {
|
||||
w as f64
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let total: f64 = weights.iter().sum();
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut roll: f64 = rng.gen_range(0.0..total);
|
||||
for (i, &w) in weights.iter().enumerate() {
|
||||
roll -= w;
|
||||
if roll < 0.0 {
|
||||
return Some(edges[i]);
|
||||
}
|
||||
}
|
||||
Some(edges[edges.len() - 1])
|
||||
}
|
||||
|
||||
/// Dispatch to the appropriate edge-picking strategy.
|
||||
fn pick_edge<'a>(edges: &[&'a Edge], selection: &str) -> Option<&'a Edge> {
|
||||
match selection {
|
||||
"random" => weighted_random(edges),
|
||||
_ => best_by_weight_then_lexical(edges),
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the next edge from a node's outgoing edges (spec Section 3.3).
|
||||
#[must_use]
|
||||
/// Result of edge selection: the chosen edge and the reason it was selected.
|
||||
|
|
@ -403,6 +443,7 @@ pub fn select_edge<'a>(
|
|||
outcome: &Outcome,
|
||||
context: &Context,
|
||||
graph: &'a Graph,
|
||||
selection: &str,
|
||||
) -> Option<EdgeSelection<'a>> {
|
||||
let edges = graph.outgoing_edges(node_id);
|
||||
if edges.is_empty() {
|
||||
|
|
@ -419,7 +460,7 @@ pub fn select_edge<'a>(
|
|||
.copied()
|
||||
.collect();
|
||||
if !condition_matched.is_empty() {
|
||||
return best_by_weight_then_lexical(&condition_matched).map(|edge| EdgeSelection {
|
||||
return pick_edge(&condition_matched, selection).map(|edge| EdgeSelection {
|
||||
edge,
|
||||
reason: "condition",
|
||||
});
|
||||
|
|
@ -459,14 +500,14 @@ pub fn select_edge<'a>(
|
|||
.copied()
|
||||
.collect();
|
||||
if !unconditional.is_empty() {
|
||||
return best_by_weight_then_lexical(&unconditional).map(|edge| EdgeSelection {
|
||||
return pick_edge(&unconditional, selection).map(|edge| EdgeSelection {
|
||||
edge,
|
||||
reason: "unconditional",
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: any edge
|
||||
best_by_weight_then_lexical(&edges).map(|edge| EdgeSelection {
|
||||
pick_edge(&edges, selection).map(|edge| EdgeSelection {
|
||||
edge,
|
||||
reason: "fallback",
|
||||
})
|
||||
|
|
@ -1598,7 +1639,13 @@ impl WorkflowRunEngine {
|
|||
previous_node_id = Some(node.id.clone());
|
||||
stage_index += 1;
|
||||
// Select next edge and continue
|
||||
let selection = select_edge(&node.id, &Outcome::skipped(), &context, graph);
|
||||
let selection = select_edge(
|
||||
&node.id,
|
||||
&Outcome::skipped(),
|
||||
&context,
|
||||
graph,
|
||||
node.selection(),
|
||||
);
|
||||
if let Some(sel) = selection {
|
||||
current_node_id = sel.edge.to.clone();
|
||||
incoming_edge = Some(sel.edge);
|
||||
|
|
@ -1815,7 +1862,7 @@ impl WorkflowRunEngine {
|
|||
});
|
||||
(None, Some(target.clone()))
|
||||
} else {
|
||||
let selection = select_edge(&node.id, &outcome, &context, graph);
|
||||
let selection = select_edge(&node.id, &outcome, &context, graph, node.selection());
|
||||
if let Some(sel) = &selection {
|
||||
self.services.emitter.emit(&WorkflowRunEvent::EdgeSelected {
|
||||
from_node: node.id.clone(),
|
||||
|
|
@ -2550,6 +2597,65 @@ mod tests {
|
|||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// --- weighted_random tests ---
|
||||
|
||||
#[test]
|
||||
fn weighted_random_empty_returns_none() {
|
||||
assert!(weighted_random(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighted_random_single_edge() {
|
||||
let e = Edge::new("a", "b");
|
||||
let result = weighted_random(&[&e]).unwrap();
|
||||
assert_eq!(result.to, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighted_random_zero_weight_all_selected() {
|
||||
let e1 = Edge::new("a", "b");
|
||||
let e2 = Edge::new("a", "c");
|
||||
let edges = vec![&e1, &e2];
|
||||
let mut seen_b = false;
|
||||
let mut seen_c = false;
|
||||
for _ in 0..200 {
|
||||
let pick = weighted_random(&edges).unwrap();
|
||||
if pick.to == "b" {
|
||||
seen_b = true;
|
||||
}
|
||||
if pick.to == "c" {
|
||||
seen_c = true;
|
||||
}
|
||||
}
|
||||
assert!(seen_b, "expected target 'b' to be selected at least once");
|
||||
assert!(seen_c, "expected target 'c' to be selected at least once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighted_random_high_weight_dominates() {
|
||||
let mut heavy = Edge::new("a", "heavy");
|
||||
heavy
|
||||
.attrs
|
||||
.insert("weight".to_string(), AttrValue::Integer(100));
|
||||
let mut light = Edge::new("a", "light");
|
||||
light
|
||||
.attrs
|
||||
.insert("weight".to_string(), AttrValue::Integer(1));
|
||||
let edges = vec![&heavy, &light];
|
||||
let mut heavy_count = 0;
|
||||
for _ in 0..500 {
|
||||
let pick = weighted_random(&edges).unwrap();
|
||||
if pick.to == "heavy" {
|
||||
heavy_count += 1;
|
||||
}
|
||||
}
|
||||
let ratio = heavy_count as f64 / 500.0;
|
||||
assert!(
|
||||
ratio > 0.90,
|
||||
"expected heavy edge to win >90% of the time, got {ratio:.2}"
|
||||
);
|
||||
}
|
||||
|
||||
// --- select_edge tests ---
|
||||
|
||||
fn make_graph_with_edges(edges: Vec<Edge>) -> Graph {
|
||||
|
|
@ -2571,7 +2677,7 @@ mod tests {
|
|||
let g = Graph::new("test");
|
||||
let outcome = Outcome::success();
|
||||
let context = Context::new();
|
||||
assert!(select_edge("a", &outcome, &context, &g).is_none());
|
||||
assert!(select_edge("a", &outcome, &context, &g, "deterministic").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2579,7 +2685,7 @@ mod tests {
|
|||
let g = make_graph_with_edges(vec![Edge::new("a", "b")]);
|
||||
let outcome = Outcome::success();
|
||||
let context = Context::new();
|
||||
let sel = select_edge("a", &outcome, &context, &g).unwrap();
|
||||
let sel = select_edge("a", &outcome, &context, &g, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "b");
|
||||
assert_eq!(sel.reason, "unconditional");
|
||||
}
|
||||
|
|
@ -2599,7 +2705,7 @@ mod tests {
|
|||
let g = make_graph_with_edges(vec![e1, e2]);
|
||||
let outcome = Outcome::success();
|
||||
let context = Context::new();
|
||||
let sel = select_edge("a", &outcome, &context, &g).unwrap();
|
||||
let sel = select_edge("a", &outcome, &context, &g, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "success_path");
|
||||
assert_eq!(sel.reason, "condition");
|
||||
}
|
||||
|
|
@ -2620,7 +2726,7 @@ mod tests {
|
|||
let mut outcome = Outcome::success();
|
||||
outcome.preferred_label = Some("Fix".to_string());
|
||||
let context = Context::new();
|
||||
let sel = select_edge("a", &outcome, &context, &g).unwrap();
|
||||
let sel = select_edge("a", &outcome, &context, &g, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "fix");
|
||||
assert_eq!(sel.reason, "preferred_label");
|
||||
}
|
||||
|
|
@ -2633,7 +2739,7 @@ mod tests {
|
|||
let mut outcome = Outcome::success();
|
||||
outcome.suggested_next_ids = vec!["path2".to_string()];
|
||||
let context = Context::new();
|
||||
let sel = select_edge("a", &outcome, &context, &g).unwrap();
|
||||
let sel = select_edge("a", &outcome, &context, &g, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "path2");
|
||||
assert_eq!(sel.reason, "suggested_next");
|
||||
}
|
||||
|
|
@ -2648,7 +2754,7 @@ mod tests {
|
|||
let g = make_graph_with_edges(vec![e1, e2]);
|
||||
let outcome = Outcome::success();
|
||||
let context = Context::new();
|
||||
let sel = select_edge("a", &outcome, &context, &g).unwrap();
|
||||
let sel = select_edge("a", &outcome, &context, &g, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "high");
|
||||
assert_eq!(sel.reason, "unconditional");
|
||||
}
|
||||
|
|
@ -2660,7 +2766,7 @@ mod tests {
|
|||
let g = make_graph_with_edges(vec![e1, e2]);
|
||||
let outcome = Outcome::success();
|
||||
let context = Context::new();
|
||||
let sel = select_edge("a", &outcome, &context, &g).unwrap();
|
||||
let sel = select_edge("a", &outcome, &context, &g, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "alpha");
|
||||
assert_eq!(sel.reason, "unconditional");
|
||||
}
|
||||
|
|
@ -2676,11 +2782,40 @@ mod tests {
|
|||
let g = make_graph_with_edges(vec![e_cond, e_uncond]);
|
||||
let outcome = Outcome::success();
|
||||
let context = Context::new();
|
||||
let sel = select_edge("a", &outcome, &context, &g).unwrap();
|
||||
let sel = select_edge("a", &outcome, &context, &g, "deterministic").unwrap();
|
||||
assert_eq!(sel.edge.to, "cond_path");
|
||||
assert_eq!(sel.reason, "condition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_edge_random_returns_some_edge() {
|
||||
let e1 = Edge::new("a", "b");
|
||||
let e2 = Edge::new("a", "c");
|
||||
let g = make_graph_with_edges(vec![e1, e2]);
|
||||
let outcome = Outcome::success();
|
||||
let context = Context::new();
|
||||
let sel = select_edge("a", &outcome, &context, &g, "random").unwrap();
|
||||
assert!(sel.edge.to == "b" || sel.edge.to == "c");
|
||||
assert_eq!(sel.reason, "unconditional");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_edge_random_preferred_label_still_wins() {
|
||||
let mut e1 = Edge::new("a", "approve");
|
||||
e1.attrs.insert(
|
||||
"label".to_string(),
|
||||
AttrValue::String("Approve".to_string()),
|
||||
);
|
||||
let e2 = Edge::new("a", "other");
|
||||
let g = make_graph_with_edges(vec![e1, e2]);
|
||||
let mut outcome = Outcome::success();
|
||||
outcome.preferred_label = Some("Approve".to_string());
|
||||
let context = Context::new();
|
||||
let sel = select_edge("a", &outcome, &context, &g, "random").unwrap();
|
||||
assert_eq!(sel.edge.to, "approve");
|
||||
assert_eq!(sel.reason, "preferred_label");
|
||||
}
|
||||
|
||||
// --- check_goal_gates tests ---
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -235,6 +235,11 @@ impl Node {
|
|||
self.str_attr("backend")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn selection(&self) -> &str {
|
||||
self.str_attr("selection").unwrap_or("deterministic")
|
||||
}
|
||||
|
||||
/// Resolve the handler type for this node using explicit type or shape mapping.
|
||||
#[must_use]
|
||||
pub fn handler_type(&self) -> Option<&str> {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ pub fn built_in_rules() -> Vec<Box<dyn LintRule>> {
|
|||
Box::new(StylesheetModelKnownRule),
|
||||
Box::new(UnresolvedFileRefRule),
|
||||
Box::new(ThreadIdRequiresFidelityFullRule),
|
||||
Box::new(SelectionValidRule),
|
||||
Box::new(RandomSelectionNoConditionsRule),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -1090,6 +1092,76 @@ impl LintRule for ThreadIdRequiresFidelityFullRule {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Rule 23: selection_valid (WARNING) ---
|
||||
|
||||
struct SelectionValidRule;
|
||||
|
||||
const VALID_SELECTIONS: &[&str] = &["deterministic", "random"];
|
||||
|
||||
impl LintRule for SelectionValidRule {
|
||||
fn name(&self) -> &'static str {
|
||||
"selection_valid"
|
||||
}
|
||||
|
||||
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
|
||||
let mut diagnostics = Vec::new();
|
||||
for node in graph.nodes.values() {
|
||||
if let Some(sel) = node.attrs.get("selection").and_then(AttrValue::as_str) {
|
||||
if !VALID_SELECTIONS.contains(&sel) {
|
||||
diagnostics.push(Diagnostic {
|
||||
rule: self.name().to_string(),
|
||||
severity: Severity::Warning,
|
||||
message: format!("Node '{}' has invalid selection mode '{sel}'", node.id),
|
||||
node_id: Some(node.id.clone()),
|
||||
edge: None,
|
||||
fix: Some(format!("Use one of: {}", VALID_SELECTIONS.join(", "))),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rule 24: random_selection_no_conditions (ERROR) ---
|
||||
|
||||
struct RandomSelectionNoConditionsRule;
|
||||
|
||||
impl LintRule for RandomSelectionNoConditionsRule {
|
||||
fn name(&self) -> &'static str {
|
||||
"random_selection_no_conditions"
|
||||
}
|
||||
|
||||
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
|
||||
let mut diagnostics = Vec::new();
|
||||
for node in graph.nodes.values() {
|
||||
if node.selection() != "random" {
|
||||
continue;
|
||||
}
|
||||
let has_conditional = graph
|
||||
.outgoing_edges(&node.id)
|
||||
.iter()
|
||||
.any(|e| e.condition().is_some_and(|c| !c.is_empty()));
|
||||
if has_conditional {
|
||||
diagnostics.push(Diagnostic {
|
||||
rule: self.name().to_string(),
|
||||
severity: Severity::Error,
|
||||
message: format!(
|
||||
"Node '{}' has selection=\"random\" but also has conditional edges; random selection and conditions cannot be combined",
|
||||
node.id
|
||||
),
|
||||
node_id: Some(node.id.clone()),
|
||||
edge: None,
|
||||
fix: Some(
|
||||
"Remove the condition attributes from outgoing edges, or remove selection=\"random\" from the node".to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -3166,4 +3238,106 @@ mod tests {
|
|||
let d = rule.apply(&g);
|
||||
assert!(d.is_empty());
|
||||
}
|
||||
|
||||
// --- selection_valid rule tests ---
|
||||
|
||||
#[test]
|
||||
fn selection_valid_known_values() {
|
||||
let mut g = minimal_graph();
|
||||
let mut node = Node::new("pick");
|
||||
node.attrs.insert(
|
||||
"selection".to_string(),
|
||||
AttrValue::String("random".to_string()),
|
||||
);
|
||||
g.nodes.insert("pick".to_string(), node);
|
||||
let rule = SelectionValidRule;
|
||||
let d = rule.apply(&g);
|
||||
assert!(d.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_valid_unknown_value_warns() {
|
||||
let mut g = minimal_graph();
|
||||
let mut node = Node::new("pick");
|
||||
node.attrs.insert(
|
||||
"selection".to_string(),
|
||||
AttrValue::String("randon".to_string()),
|
||||
);
|
||||
g.nodes.insert("pick".to_string(), node);
|
||||
let rule = SelectionValidRule;
|
||||
let d = rule.apply(&g);
|
||||
assert_eq!(d.len(), 1);
|
||||
assert_eq!(d[0].severity, Severity::Warning);
|
||||
assert_eq!(d[0].node_id.as_deref(), Some("pick"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_valid_no_attr_ok() {
|
||||
let g = minimal_graph();
|
||||
let rule = SelectionValidRule;
|
||||
let d = rule.apply(&g);
|
||||
assert!(d.is_empty());
|
||||
}
|
||||
|
||||
// --- random_selection_no_conditions rule tests ---
|
||||
|
||||
#[test]
|
||||
fn random_selection_no_conditions_clean() {
|
||||
let mut g = minimal_graph();
|
||||
let mut node = Node::new("pick");
|
||||
node.attrs.insert(
|
||||
"selection".to_string(),
|
||||
AttrValue::String("random".to_string()),
|
||||
);
|
||||
g.nodes.insert("pick".to_string(), node);
|
||||
g.edges.push(Edge::new("pick", "start"));
|
||||
g.edges.push(Edge::new("pick", "exit"));
|
||||
let rule = RandomSelectionNoConditionsRule;
|
||||
let d = rule.apply(&g);
|
||||
assert!(d.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_selection_with_conditions_errors() {
|
||||
let mut g = minimal_graph();
|
||||
let mut node = Node::new("pick");
|
||||
node.attrs.insert(
|
||||
"selection".to_string(),
|
||||
AttrValue::String("random".to_string()),
|
||||
);
|
||||
g.nodes.insert("pick".to_string(), node);
|
||||
let mut e = Edge::new("pick", "exit");
|
||||
e.attrs.insert(
|
||||
"condition".to_string(),
|
||||
AttrValue::String("outcome=success".to_string()),
|
||||
);
|
||||
g.edges.push(e);
|
||||
g.edges.push(Edge::new("pick", "start"));
|
||||
let rule = RandomSelectionNoConditionsRule;
|
||||
let d = rule.apply(&g);
|
||||
assert_eq!(d.len(), 1);
|
||||
assert_eq!(d[0].severity, Severity::Error);
|
||||
assert_eq!(d[0].node_id.as_deref(), Some("pick"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_selection_with_conditions_ok() {
|
||||
let mut g = minimal_graph();
|
||||
let mut node = Node::new("gate");
|
||||
node.attrs.insert(
|
||||
"selection".to_string(),
|
||||
AttrValue::String("deterministic".to_string()),
|
||||
);
|
||||
g.nodes.insert("gate".to_string(), node);
|
||||
let mut e = Edge::new("gate", "exit");
|
||||
e.attrs.insert(
|
||||
"condition".to_string(),
|
||||
AttrValue::String("outcome=success".to_string()),
|
||||
);
|
||||
g.edges.push(e);
|
||||
g.edges.push(Edge::new("gate", "start"));
|
||||
let rule = RandomSelectionNoConditionsRule;
|
||||
let d = rule.apply(&g);
|
||||
assert!(d.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue