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}