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}