Skip to main content

cuttlefish_host/
accept.rs

1//! Acceptance checks: what "done" means for a node beyond its declared type.
2//!
3//! A node's `Ty` signature says what *shape* its output has. That is a real
4//! contract, and it catches real mistakes — but two failure modes slip
5//! straight through it. A truncated model reply still parses as `json` and
6//! satisfies a `json` signature completely. And output that is well-formed,
7//! correctly typed, and simply *wrong* is invisible to any type system.
8//!
9//! `accept = [ ... ]` closes that gap with an ordered, short-circuiting list
10//! of checks. Order is load-bearing rather than cosmetic: [`AcceptCheck::Schema`]
11//! is deterministic and costs nothing, while [`AcceptCheck::Judge`] costs a
12//! whole inference — so a schema-first list never pays for a judge on output
13//! that is structurally broken, which is also the output a judge grades least
14//! coherently.
15//!
16//! This module only reaches verdicts. Reacting to a failed one — retrying,
17//! rerouting, escalating — is the ladder's job, in [`crate::runner`].
18
19use crate::infer::{InferBackend, InferRequest};
20use cuttlefish_core::graph::AcceptCheck;
21use cuttlefish_core::spec::ModelRef;
22use std::sync::Arc;
23
24/// Upper bound on a judge's reply.
25///
26/// A verdict is `{"accept": bool, "reason": "..."}` — small by construction.
27/// Capping it keeps a judge that starts rambling from costing more than the
28/// work it is grading, and a truncated ramble lands as
29/// [`JudgeVerdict::Unusable`] rather than being mistaken for a verdict.
30const JUDGE_MAX_TOKENS: u32 = 256;
31
32/// What a judge concluded about one output.
33#[derive(Debug)]
34pub enum JudgeVerdict {
35    /// `{"accept": true, ...}` — or there were no judges to ask.
36    Accepted,
37    /// `{"accept": false, "reason": "..."}`.
38    ///
39    /// The reason is retained because it becomes the text a human reads in
40    /// `cuttlefish escalations`, long after the run.
41    Rejected(String),
42    /// The judge's reply did not parse as a verdict, or its inference
43    /// errored.
44    ///
45    /// Deliberately *not* folded into [`JudgeVerdict::Rejected`]. "The judge
46    /// never returned a usable verdict" and "the judge read this and said no"
47    /// call for completely different responses from whoever reads the
48    /// escalation — one is a broken grader, the other is broken work — and
49    /// collapsing them sends that person hunting for a rejection that never
50    /// happened.
51    Unusable(String),
52}
53
54/// A node's `accept` list, with schemas already compiled.
55///
56/// Compiled once at daemon startup rather than per attempt: a malformed
57/// schema is a property of the spec, so it should stop the daemon coming up
58/// rather than surface as a bizarre acceptance failure partway through a
59/// campaign.
60pub struct CompiledChecks {
61    schemas: Vec<(std::path::PathBuf, jsonschema::Validator)>,
62    judges: Vec<(Option<ModelRef>, String)>,
63}
64
65impl CompiledChecks {
66    /// Read and compile every `Schema`, and collect every `Judge`.
67    ///
68    /// Fails if a schema file is unreadable or is not a valid JSON Schema.
69    pub fn compile(checks: &[AcceptCheck]) -> anyhow::Result<Self> {
70        let mut schemas = Vec::new();
71        let mut judges = Vec::new();
72        for check in checks {
73            match check {
74                AcceptCheck::Schema(path) => {
75                    let text = std::fs::read_to_string(path).map_err(|e| {
76                        anyhow::anyhow!("reading accept schema {}: {e}", path.display())
77                    })?;
78                    let value: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
79                        anyhow::anyhow!("accept schema {} is not valid JSON: {e}", path.display())
80                    })?;
81                    let validator = jsonschema::validator_for(&value).map_err(|e| {
82                        anyhow::anyhow!(
83                            "accept schema {} is not a valid JSON Schema: {e}",
84                            path.display()
85                        )
86                    })?;
87                    schemas.push((path.clone(), validator));
88                }
89                AcceptCheck::Judge { model, prompt } => {
90                    judges.push((model.clone(), prompt.clone()))
91                }
92            }
93        }
94        Ok(Self { schemas, judges })
95    }
96
97    /// Whether this node declares any check at all.
98    pub fn is_empty(&self) -> bool {
99        self.schemas.is_empty() && self.judges.is_empty()
100    }
101
102    /// Validate `value` against every compiled schema.
103    ///
104    /// Reports *all* violations rather than only the first, matching
105    /// `cuttlefish validate-json`: someone fixing a prompt wants the whole
106    /// list, not one round trip per mistake.
107    pub fn check_schemas(&self, value: &serde_json::Value) -> Result<(), String> {
108        for (path, validator) in &self.schemas {
109            let violations: Vec<String> = validator
110                .iter_errors(value)
111                .map(|e| format!("{}: {e}", e.instance_path()))
112                .collect();
113            if !violations.is_empty() {
114                return Err(format!(
115                    "does not conform to {}:\n{}",
116                    path.display(),
117                    violations.join("\n")
118                ));
119            }
120        }
121        Ok(())
122    }
123
124    /// Ask every judge, in order, stopping at the first non-acceptance.
125    ///
126    /// `default` serves a judge that named no model of its own; `alternates`
127    /// serves one that did. A judge naming a model absent from `alternates`
128    /// is [`JudgeVerdict::Unusable`] rather than a panic — startup resolution
129    /// should have caught it, so reaching here means a bug, and failing the
130    /// attempt beats taking the daemon down mid-campaign.
131    pub async fn run_judges(
132        &self,
133        input: &serde_json::Value,
134        output: &serde_json::Value,
135        default: &Arc<dyn InferBackend>,
136        alternates: &crate::runner::Alternates,
137    ) -> JudgeVerdict {
138        for (model, prompt) in &self.judges {
139            let backend = match model {
140                None => default,
141                Some(m) => match alternates.get(m) {
142                    Some(b) => b,
143                    None => {
144                        return JudgeVerdict::Unusable(format!(
145                            "judge names model `{m}`, which was not resolved at startup"
146                        ))
147                    }
148                },
149            };
150
151            let full = judge_prompt(prompt, input, output);
152            let mut sink = |_: &str| true;
153            let reply = match backend
154                .infer(
155                    InferRequest {
156                        prompt: &full,
157                        max_tokens: JUDGE_MAX_TOKENS,
158                        images: &[],
159                    },
160                    &mut sink,
161                )
162                .await
163            {
164                Ok(r) => r.text,
165                Err(e) => return JudgeVerdict::Unusable(format!("judge inference failed: {e}")),
166            };
167
168            match parse_verdict(&reply) {
169                Ok(true) => continue,
170                Ok(false) => {
171                    return JudgeVerdict::Rejected(
172                        verdict_reason(&reply).unwrap_or_else(|| "no reason given".to_string()),
173                    )
174                }
175                Err(why) => return JudgeVerdict::Unusable(why),
176            }
177        }
178        JudgeVerdict::Accepted
179    }
180}
181
182impl std::fmt::Debug for CompiledChecks {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        // `jsonschema::Validator` is not Debug; name what's here instead of
185        // trying to render it.
186        f.debug_struct("CompiledChecks")
187            .field("schemas", &self.schemas.len())
188            .field("judges", &self.judges.len())
189            .finish()
190    }
191}
192
193/// The author's prompt, then the input and the output under delimited
194/// headings.
195///
196/// Both are needed: "does this finding cite specific numbers *from the
197/// input*" — the motivating case — is unanswerable with the output alone.
198fn judge_prompt(prompt: &str, input: &serde_json::Value, output: &serde_json::Value) -> String {
199    format!(
200        "{prompt}\n\n\
201         --- INPUT ---\n{input}\n\n\
202         --- OUTPUT UNDER REVIEW ---\n{output}\n\n\
203         --- END ---\n\
204         Reply with JSON only: {{\"accept\": true|false, \"reason\": \"...\"}}"
205    )
206}
207
208/// Pull `accept` out of a judge's reply.
209///
210/// Tolerates surrounding prose, since a small model asked for JSON often
211/// wraps it — but a reply with no object at all, or an object without a
212/// boolean `accept`, is unusable rather than a rejection.
213fn parse_verdict(reply: &str) -> Result<bool, String> {
214    let value = extract_json(reply)
215        .ok_or_else(|| format!("judge reply contained no JSON object: {reply:?}"))?;
216    value
217        .get("accept")
218        .and_then(|v| v.as_bool())
219        .ok_or_else(|| format!("judge reply has no boolean `accept` field: {reply:?}"))
220}
221
222fn verdict_reason(reply: &str) -> Option<String> {
223    extract_json(reply)?
224        .get("reason")
225        .and_then(|v| v.as_str())
226        .map(str::to_string)
227}
228
229/// The first `{...}` span in `reply` that parses as JSON.
230fn extract_json(reply: &str) -> Option<serde_json::Value> {
231    if let Ok(v) = serde_json::from_str::<serde_json::Value>(reply.trim()) {
232        return Some(v);
233    }
234    let start = reply.find('{')?;
235    let end = reply.rfind('}')?;
236    if end <= start {
237        return None;
238    }
239    serde_json::from_str(&reply[start..=end]).ok()
240}