Skip to main content

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}