use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Role {
Builder,
Judge,
Manager,
Adversary,
Researcher,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum Tier {
Cheap,
#[default]
Standard,
Strong,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
pub enum Isolation {
None {},
Worktree {},
Container {
#[serde(default)]
image: Option<String>,
#[serde(default)]
network: bool,
},
}
impl Default for Isolation {
fn default() -> Self {
Isolation::None {}
}
}
impl Isolation {
pub fn needs_worktree(&self) -> bool {
matches!(self, Isolation::Worktree {} | Isolation::Container { .. })
}
pub fn is_container(&self) -> bool {
matches!(self, Isolation::Container { .. })
}
pub fn container_image<'a>(&'a self, graph_default: Option<&'a str>) -> Option<&'a str> {
match self {
Isolation::Container { image, .. } => image
.as_deref()
.or(graph_default)
.filter(|s| !s.trim().is_empty()),
_ => None,
}
}
pub fn without_container(&self) -> Isolation {
match self {
Isolation::Container { .. } => Isolation::Worktree {},
other => other.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NodeSpec {
pub id: String,
pub role: Role,
pub instruction: String,
#[serde(default)]
pub depends_on: Vec<String>,
#[serde(default)]
pub goals: Vec<String>,
#[serde(default)]
pub tier: Tier,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub skills: Vec<String>,
#[serde(default)]
pub stage: Option<String>,
#[serde(default = "one")]
pub weight: f64,
#[serde(default)]
pub isolation: Isolation,
}
fn one() -> f64 {
1.0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "strategy", rename_all = "snake_case", deny_unknown_fields)]
pub enum Join {
WaitForAll {},
Quorum { count: usize },
FirstSuccess {},
}
impl Default for Join {
fn default() -> Self {
Join::WaitForAll {}
}
}
impl Join {
pub fn required_successes(self, wave_width: usize) -> usize {
match self {
Join::WaitForAll {} => wave_width,
Join::Quorum { count } => count.clamp(1, wave_width.max(1)),
Join::FirstSuccess {} => 1,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GraphSpec {
#[serde(default)]
pub nodes: Vec<NodeSpec>,
#[serde(default)]
pub concurrency: Concurrency,
#[serde(default)]
pub join: Join,
#[serde(default)]
pub container_image: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
pub enum Concurrency {
Sequential {},
Fixed { max_parallel: usize },
Auto {
#[serde(default = "default_cap")]
cap: usize,
#[serde(default = "default_min_gain")]
min_marginal_gain: f64,
},
}
fn default_cap() -> usize {
16
}
fn default_min_gain() -> f64 {
0.05
}
impl Default for Concurrency {
fn default() -> Self {
Concurrency::Auto {
cap: default_cap(),
min_marginal_gain: default_min_gain(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_quorum_wider_than_its_wave_does_not_hang_the_run() {
assert_eq!(Join::Quorum { count: 9 }.required_successes(3), 3);
}
#[test]
fn a_zero_quorum_still_needs_one_success() {
assert_eq!(Join::Quorum { count: 0 }.required_successes(3), 1);
}
#[test]
fn container_isolation_implies_a_worktree() {
let c = Isolation::Container {
image: None,
network: false,
};
assert!(c.needs_worktree());
assert_eq!(c.without_container(), Isolation::Worktree {});
}
#[test]
fn degrading_a_non_container_isolation_changes_nothing() {
assert_eq!(Isolation::None {}.without_container(), Isolation::None {});
assert_eq!(Isolation::Worktree {}.without_container(), Isolation::Worktree {});
}
}