loopsmith_core/config/graph.rs
1//! The execution graph: nodes are units of work, edges are real dependencies.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7#[serde(rename_all = "snake_case")]
8pub enum Role {
9 /// Produces the work. Most latitude, least constraint.
10 Builder,
11 /// Evaluates the builder's output against a written standard. Must not be
12 /// the same provider instance as the builder it judges.
13 Judge,
14 /// Routes on the verdict and owns the stop condition.
15 Manager,
16 /// Argues the other side. Cheap insurance against consensus.
17 Adversary,
18 /// Gathers material without producing a deliverable.
19 Researcher,
20}
21
22#[derive(
23 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize, JsonSchema,
24)]
25#[serde(rename_all = "lowercase")]
26pub enum Tier {
27 /// High volume, low judgment. Extraction, classification, formatting.
28 Cheap,
29 #[default]
30 Standard,
31 /// Low volume, high judgment. Final review, multi-hop reasoning.
32 Strong,
33}
34
35/// How much of the machine a node is kept away from.
36///
37/// The ladder is real but not linear in cost: `Worktree` is nearly free and is
38/// what makes parallel writers safe, while `Container` buys filesystem and
39/// network separation at the price of a Docker daemon and an image pull.
40///
41/// `Container` degrades rather than fails. If Docker is not present the node
42/// runs under `Worktree` and the run records a warning — a config written on a
43/// machine with Docker must still work on one without, because the same loop
44/// directory gets checked out on developer laptops, CI runners and servers, and
45/// refusing to start there would make container isolation unusable in practice
46/// rather than merely unavailable.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
49pub enum Isolation {
50 /// Runs directly in the loop directory. Correct for a single writer or a
51 /// read-only node.
52 None {},
53 /// Runs in its own git worktree, published back on success. Required for
54 /// parallel writers.
55 Worktree {},
56 /// Runs in a container over its own worktree.
57 Container {
58 /// Image to run in. Defaults to the loop-wide image when unset.
59 #[serde(default)]
60 image: Option<String>,
61 /// Allow the container to reach the network. Off by default: a node
62 /// that does not need the network should not have it.
63 #[serde(default)]
64 network: bool,
65 },
66}
67
68impl Default for Isolation {
69 fn default() -> Self {
70 Isolation::None {}
71 }
72}
73
74impl Isolation {
75 /// Whether this node needs a worktree of its own. Container isolation
76 /// implies one, because the container mounts it.
77 pub fn needs_worktree(&self) -> bool {
78 matches!(self, Isolation::Worktree {} | Isolation::Container { .. })
79 }
80
81 /// What this becomes when Docker is unavailable.
82 pub fn is_container(&self) -> bool {
83 matches!(self, Isolation::Container { .. })
84 }
85
86 /// The image a container node runs in: its own, or the graph's default.
87 /// `None` for a non-container node, or when neither names one.
88 pub fn container_image<'a>(&'a self, graph_default: Option<&'a str>) -> Option<&'a str> {
89 match self {
90 Isolation::Container { image, .. } => image
91 .as_deref()
92 .or(graph_default)
93 .filter(|s| !s.trim().is_empty()),
94 _ => None,
95 }
96 }
97
98 pub fn without_container(&self) -> Isolation {
99 match self {
100 Isolation::Container { .. } => Isolation::Worktree {},
101 other => other.clone(),
102 }
103 }
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
107#[serde(deny_unknown_fields)]
108pub struct NodeSpec {
109 pub id: String,
110 pub role: Role,
111 /// What this node is for, in natural language. Tight descriptions produce
112 /// tight output; vague ones produce whatever the model felt like.
113 pub instruction: String,
114 /// Node ids this node genuinely reads the output of. Only list an edge if
115 /// the answer to "does this step read that step's output?" is yes.
116 #[serde(default)]
117 pub depends_on: Vec<String>,
118 /// Goals this node advances.
119 #[serde(default)]
120 pub goals: Vec<String>,
121 #[serde(default)]
122 pub tier: Tier,
123 /// Pin a provider; otherwise routing picks by tier.
124 #[serde(default)]
125 pub provider: Option<String>,
126 /// Skills this node needs. Acquired per the skill policy.
127 #[serde(default)]
128 pub skills: Vec<String>,
129 /// Execution phase (`execution.phases`) this node belongs to. A node with a
130 /// stage is not dispatched until that phase is active. A node without one
131 /// is always eligible — unstaged work is not gated by a phase it never
132 /// joined.
133 #[serde(default)]
134 pub stage: Option<String>,
135 /// Relative cost weight used for critical-path calculation.
136 #[serde(default = "one")]
137 pub weight: f64,
138 /// How far this node is kept from the rest of the machine.
139 ///
140 /// The 0.3 spelling was `isolated: true`, meaning a worktree. That spelling
141 /// still parses and still means [`Isolation::Worktree`]; the migration
142 /// rewrites it.
143 #[serde(default)]
144 pub isolation: Isolation,
145}
146
147fn one() -> f64 {
148 1.0
149}
150
151/// What it takes for a wave to count as finished.
152///
153/// The default waits for every node, which is the only correct answer when
154/// later waves read every output. The other two exist for the fan-out shape the
155/// reference calls out: several nodes attacking the same question, where the
156/// run does not need all the answers to proceed.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
158#[serde(tag = "strategy", rename_all = "snake_case", deny_unknown_fields)]
159pub enum Join {
160 /// Every node in the wave must finish.
161 WaitForAll {},
162 /// Proceed once this many nodes have finished successfully. Nodes still
163 /// running are left to finish; their output is used if it arrives in time.
164 Quorum { count: usize },
165 /// Proceed as soon as any one node succeeds.
166 FirstSuccess {},
167}
168
169impl Default for Join {
170 fn default() -> Self {
171 Join::WaitForAll {}
172 }
173}
174
175impl Join {
176 /// How many successes release the wave, given its width.
177 pub fn required_successes(self, wave_width: usize) -> usize {
178 match self {
179 Join::WaitForAll {} => wave_width,
180 // A quorum wider than the wave would never be reached and would
181 // hang the run; clamping turns a config mistake into wait-for-all.
182 Join::Quorum { count } => count.clamp(1, wave_width.max(1)),
183 Join::FirstSuccess {} => 1,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
189#[serde(deny_unknown_fields)]
190pub struct GraphSpec {
191 #[serde(default)]
192 pub nodes: Vec<NodeSpec>,
193 /// How much parallelism to use. `auto` derives it from the graph itself.
194 #[serde(default)]
195 pub concurrency: Concurrency,
196 /// What it takes for a wave to count as finished.
197 #[serde(default)]
198 pub join: Join,
199 /// Default image for nodes whose isolation is `container` without one.
200 #[serde(default)]
201 pub container_image: Option<String>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
205#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
206pub enum Concurrency {
207 /// One node at a time.
208 Sequential {},
209 /// Fixed width.
210 Fixed { max_parallel: usize },
211 /// Derived from the graph: widest wave, capped, and trimmed to the point
212 /// where marginal Amdahl speedup still beats marginal cost.
213 Auto {
214 #[serde(default = "default_cap")]
215 cap: usize,
216 /// Stop adding workers once the next one buys less than this fraction
217 /// of additional speedup.
218 #[serde(default = "default_min_gain")]
219 min_marginal_gain: f64,
220 },
221}
222
223fn default_cap() -> usize {
224 16
225}
226fn default_min_gain() -> f64 {
227 0.05
228}
229
230impl Default for Concurrency {
231 fn default() -> Self {
232 Concurrency::Auto {
233 cap: default_cap(),
234 min_marginal_gain: default_min_gain(),
235 }
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn a_quorum_wider_than_its_wave_does_not_hang_the_run() {
245 // Asking for 9 successes from a 3-node wave can never be met. Clamping
246 // turns an unreachable config into wait-for-all; not clamping would
247 // block the run forever with no stop gate able to explain why.
248 assert_eq!(Join::Quorum { count: 9 }.required_successes(3), 3);
249 }
250
251 #[test]
252 fn a_zero_quorum_still_needs_one_success() {
253 assert_eq!(Join::Quorum { count: 0 }.required_successes(3), 1);
254 }
255
256 #[test]
257 fn container_isolation_implies_a_worktree() {
258 // The container mounts the worktree, so asking for one without the
259 // other would mount the live loop directory into the container.
260 let c = Isolation::Container {
261 image: None,
262 network: false,
263 };
264 assert!(c.needs_worktree());
265 assert_eq!(c.without_container(), Isolation::Worktree {});
266 }
267
268 #[test]
269 fn degrading_a_non_container_isolation_changes_nothing() {
270 assert_eq!(Isolation::None {}.without_container(), Isolation::None {});
271 assert_eq!(Isolation::Worktree {}.without_container(), Isolation::Worktree {});
272 }
273}