Skip to main content

jig/
schema.rs

1//! `agent-shape.toml` schema.
2//!
3//! Each subject tool ships one of these files at its repo root. `jig`
4//! deserializes it to drive the runner, judge, and report emitter.
5
6use serde::{Deserialize, Serialize};
7
8/// Root of the `agent-shape.toml` file.
9#[derive(Debug, Clone, Deserialize, Serialize)]
10pub struct AgentShape {
11    pub subject: Subject,
12    pub fixture: Fixture,
13    pub run: RunConfig,
14    pub judge: JudgeConfig,
15    pub tasks: Tasks,
16    /// Optional: commands the rubric claims exist. `jig check --binary`
17    /// runs the binary's `--help` and warns about drift in either
18    /// direction (binary advertising commands the rubric omits, or
19    /// rubric listing commands the binary doesn't expose).
20    ///
21    /// Without this list, a rubric that misses a real command will
22    /// have the judge count it as an invention, producing phantom
23    /// regressions.
24    #[serde(default)]
25    pub commands: Option<ExpectedCommands>,
26}
27
28/// Subcommands the rubric claims exist. `jig check --binary` cross-
29/// references this with the binary's `--help` output.
30#[derive(Debug, Clone, Deserialize, Serialize, Default)]
31pub struct ExpectedCommands {
32    /// Top-level subcommands (e.g. `init`, `tree`, `spec`).
33    /// Compared with the first column of `<binary> --help`.
34    #[serde(default)]
35    pub top_level: Vec<String>,
36}
37
38/// Identity of the tool under test.
39#[derive(Debug, Clone, Deserialize, Serialize)]
40pub struct Subject {
41    pub name: String,
42    pub binary: String,
43    pub description: String,
44    /// Optional version pin for retrospective runs.
45    #[serde(default)]
46    pub version_pin: Option<String>,
47}
48
49/// How to stand up and tear down a realistic fixture per trial.
50///
51/// The runner executes `setup` before every trial so state is isolated.
52/// `cleanup` runs after, best-effort.
53#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct Fixture {
55    /// Shell command (path or inline) that prepares `workdir`.
56    pub setup: String,
57    /// Optional teardown command.
58    #[serde(default)]
59    pub cleanup: Option<String>,
60    /// Working directory the agent operates in.
61    pub workdir: String,
62    /// Environment variables to strip from the agent and fixture
63    /// processes before they spawn. Use this for any subject-tool
64    /// variable whose value in the caller's shell would contaminate
65    /// trials (session ids, data-dir overrides, auth tokens, etc.).
66    /// Exact names only; no globbing.
67    #[serde(default)]
68    pub strip_env: Vec<String>,
69}
70
71/// Default run parameters. CLI flags override these.
72#[derive(Debug, Clone, Deserialize, Serialize)]
73pub struct RunConfig {
74    /// Trials per (model, task) cell.
75    pub n: u32,
76    /// Agent models under test.
77    pub models: Vec<String>,
78    /// Max agent turns before the trial is judged unfinished.
79    /// A short cap is enough: correction loops typically decay after
80    /// the first few turns, and a tight cap keeps cost bounded.
81    pub turn_cap: u32,
82    /// Per-trial wall-clock timeout.
83    pub timeout_seconds: u64,
84}
85
86/// LLM-as-judge configuration.
87#[derive(Debug, Clone, Deserialize, Serialize)]
88pub struct JudgeConfig {
89    /// Default judge model. Overridable via `--judge-model`.
90    pub model: String,
91    /// Run the judge twice per transcript for inter-rater reliability.
92    #[serde(default = "default_true")]
93    pub double_score: bool,
94    /// Rubric prompt. The judge receives this plus the transcript.
95    pub rubric: String,
96    /// Fields the judge MUST populate in its JSON response.
97    pub required_fields: Vec<String>,
98}
99
100fn default_true() -> bool {
101    true
102}
103
104/// Task batteries. `tuning` is what the tool designer iterates against.
105/// `holdout` is sealed against contamination; empty in v1.
106#[derive(Debug, Clone, Deserialize, Serialize)]
107pub struct Tasks {
108    #[serde(default)]
109    pub tuning: Vec<Task>,
110    #[serde(default)]
111    pub holdout: Vec<Task>,
112}
113
114/// A single task the agent must complete.
115#[derive(Debug, Clone, Deserialize, Serialize)]
116pub struct Task {
117    /// Stable identifier, referenced in reports.
118    pub id: String,
119    /// One-line human summary.
120    pub summary: String,
121    /// Prompt fed verbatim to the agent.
122    pub prompt: String,
123    /// Human-readable criteria appended to the judge's rubric.
124    #[serde(default)]
125    pub success_criteria: Vec<String>,
126    /// Provenance: who authored this task.
127    pub author: String,
128    /// Provenance: ISO date (YYYY-MM-DD) of authorship.
129    pub created_at: String,
130    /// Provenance: subject tool tag the task was authored against.
131    /// For hold-out tasks, this proves the task predates any treatment.
132    pub sealed_against_tag: String,
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    const EXAMPLE: &str = include_str!("../examples/agent-shape.example.toml");
140
141    #[test]
142    fn deserializes_example() {
143        let parsed: AgentShape = toml::from_str(EXAMPLE).expect("parse example");
144        assert!(!parsed.subject.name.is_empty());
145        assert!(!parsed.subject.binary.is_empty());
146        assert!(parsed.run.n >= 1);
147        assert!(parsed.run.turn_cap >= 1 && parsed.run.turn_cap <= 5);
148        assert!(!parsed.tasks.tuning.is_empty());
149        assert!(parsed.tasks.holdout.is_empty());
150        assert!(!parsed.judge.rubric.is_empty());
151        assert!(parsed.judge.required_fields.contains(&"score".to_string()));
152    }
153
154    #[test]
155    fn task_provenance_is_complete() {
156        let parsed: AgentShape = toml::from_str(EXAMPLE).expect("parse example");
157        for task in &parsed.tasks.tuning {
158            assert!(!task.author.is_empty(), "task {} missing author", task.id);
159            assert!(
160                !task.created_at.is_empty(),
161                "task {} missing created_at",
162                task.id
163            );
164            assert!(
165                !task.sealed_against_tag.is_empty(),
166                "task {} missing sealed_against_tag",
167                task.id
168            );
169        }
170    }
171
172    #[test]
173    fn holdout_uses_same_shape() {
174        // Build a minimal config with one hold-out task; must round-trip.
175        let cfg = AgentShape {
176            subject: Subject {
177                name: "x".into(),
178                binary: "x".into(),
179                description: "x".into(),
180                version_pin: None,
181            },
182            fixture: Fixture {
183                setup: "true".into(),
184                cleanup: None,
185                workdir: "/tmp".into(),
186                strip_env: vec![],
187            },
188            run: RunConfig {
189                n: 1,
190                models: vec!["claude-sonnet-4-6".into()],
191                turn_cap: 3,
192                timeout_seconds: 60,
193            },
194            judge: JudgeConfig {
195                model: "claude-haiku-4-5".into(),
196                double_score: false,
197                rubric: "r".into(),
198                required_fields: vec!["score".into()],
199            },
200            tasks: Tasks {
201                tuning: vec![],
202                holdout: vec![Task {
203                    id: "h1".into(),
204                    summary: "s".into(),
205                    prompt: "p".into(),
206                    success_criteria: vec![],
207                    author: "a".into(),
208                    created_at: "2026-04-24".into(),
209                    sealed_against_tag: "v0.1.0".into(),
210                }],
211            },
212            commands: None,
213        };
214        let ser = toml::to_string(&cfg).expect("serialize");
215        let back: AgentShape = toml::from_str(&ser).expect("round-trip");
216        assert_eq!(back.tasks.holdout.len(), 1);
217        assert_eq!(back.tasks.holdout[0].id, "h1");
218    }
219}