Skip to main content

loopsmith_core/config/
graph.rs

1//! The execution graph: nodes are units of work, edges are real dependencies.
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum Role {
8    /// Produces the work. Most latitude, least constraint.
9    Builder,
10    /// Evaluates the builder's output against a written standard. Must not be
11    /// the same provider instance as the builder it judges.
12    Judge,
13    /// Routes on the verdict and owns the stop condition.
14    Manager,
15    /// Argues the other side. Cheap insurance against consensus.
16    Adversary,
17    /// Gathers material without producing a deliverable.
18    Researcher,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum Tier {
24    /// High volume, low judgment. Extraction, classification, formatting.
25    Cheap,
26    #[default]
27    Standard,
28    /// Low volume, high judgment. Final review, multi-hop reasoning.
29    Strong,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct NodeSpec {
35    pub id: String,
36    pub role: Role,
37    /// What this node is for, in natural language. Tight descriptions produce
38    /// tight output; vague ones produce whatever the model felt like.
39    pub instruction: String,
40    /// Node ids this node genuinely reads the output of. Only list an edge if
41    /// the answer to "does this step read that step's output?" is yes.
42    #[serde(default)]
43    pub depends_on: Vec<String>,
44    /// Goals this node advances.
45    #[serde(default)]
46    pub goals: Vec<String>,
47    #[serde(default)]
48    pub tier: Tier,
49    /// Pin a provider; otherwise routing picks by tier.
50    #[serde(default)]
51    pub provider: Option<String>,
52    /// Skills this node needs. Acquired per the skill policy.
53    #[serde(default)]
54    pub skills: Vec<String>,
55    /// Execution guideline (section I) this node belongs to. A node with a
56    /// stage is not dispatched until that phase is active. A node without one
57    /// is always eligible — unstaged work is not gated by a phase it never
58    /// joined.
59    #[serde(default)]
60    pub stage: Option<String>,
61    /// Relative cost weight used for critical-path calculation.
62    #[serde(default = "one")]
63    pub weight: f64,
64    /// Run in its own git worktree. Required for parallel writers.
65    #[serde(default)]
66    pub isolated: bool,
67}
68
69fn one() -> f64 {
70    1.0
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, Default)]
74#[serde(deny_unknown_fields)]
75pub struct GraphSpec {
76    #[serde(default)]
77    pub nodes: Vec<NodeSpec>,
78    /// How much parallelism to use. `auto` derives it from the graph itself.
79    #[serde(default)]
80    pub concurrency: Concurrency,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[serde(tag = "mode", rename_all = "snake_case")]
85pub enum Concurrency {
86    /// One node at a time.
87    Sequential,
88    /// Fixed width.
89    Fixed { max_parallel: usize },
90    /// Derived from the graph: widest wave, capped, and trimmed to the point
91    /// where marginal Amdahl speedup still beats marginal cost.
92    Auto {
93        #[serde(default = "default_cap")]
94        cap: usize,
95        /// Stop adding workers once the next one buys less than this fraction
96        /// of additional speedup.
97        #[serde(default = "default_min_gain")]
98        min_marginal_gain: f64,
99    },
100}
101
102fn default_cap() -> usize {
103    16
104}
105fn default_min_gain() -> f64 {
106    0.05
107}
108
109impl Default for Concurrency {
110    fn default() -> Self {
111        Concurrency::Auto {
112            cap: default_cap(),
113            min_marginal_gain: default_min_gain(),
114        }
115    }
116}