polyc_judgment/lib.rs
1//! Provider-agnostic judgment trait and wire types for polychrome.
2//!
3//! A judgment is a typed decision over a state: a yes/no probability
4//! ([`Question::Noul`]) or one option from a closed set
5//! ([`Question::Choice`]). A judgment model returns probabilities, never
6//! generated text, so code can threshold and combine the answers without
7//! parsing prose.
8//!
9//! [`JudgmentProvider`] is the seam between a decision site and any concrete
10//! backend, the same way [`polyc_llm::LlmProvider`] is for completions: one
11//! implementation crate per backend, dispatched behind [`DynJudgment`]. The
12//! core never names a vendor.
13//!
14//! [`polyc_llm::LlmProvider`]: https://docs.rs/polyc-llm
15
16use std::{collections::BTreeMap, sync::Arc};
17
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20
21pub mod fallback;
22/// Canned [`JudgmentProvider`] stubs for wiring and tests.
23///
24/// The `test-fixtures` feature gates the module, so a normal build cannot
25/// link a stub. The module doc says why that matters.
26#[cfg(feature = "test-fixtures")]
27pub mod stub;
28
29/// What a yes and a no mean for a [`Question::Noul`], when the boundary needs
30/// spelling out.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct NoulCriteria {
33 /// What a value near 1 means.
34 #[serde(rename = "true")]
35 pub yes: String,
36 /// What a value near 0 means.
37 #[serde(rename = "false")]
38 pub no: String,
39}
40
41/// One typed question over a state.
42///
43/// The variant sets the answer shape. Every variant carries `instructions`,
44/// the judgment itself, phrased so the model can answer it from the state
45/// alone. Question ids are chosen by the caller and never reach the model.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(tag = "type", rename_all = "snake_case")]
48pub enum Question {
49 /// A yes/no question. The answer is the probability of yes.
50 Noul {
51 /// The yes/no question to evaluate.
52 instructions: String,
53 /// Optional descriptions of what yes and no mean.
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 criteria: Option<NoulCriteria>,
56 },
57 /// One option from a closed set. The answer is the chosen option plus the
58 /// full distribution.
59 Choice {
60 /// What the model decides.
61 instructions: String,
62 /// Option name to rubric description. `None` when an option needs no
63 /// extra detail.
64 criteria: BTreeMap<String, Option<String>>,
65 },
66}
67
68/// One request: a state and the questions to answer over it.
69///
70/// Every question sees the same state and is answered independently, so a
71/// caller asks every question it might need in one request.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct JudgmentRequest {
74 /// The content to judge: a string, an object with named fields, or an
75 /// array of records.
76 pub state: serde_json::Value,
77 /// Caller-chosen id to question. Answers come back under the same ids.
78 pub questions: BTreeMap<String, Question>,
79}
80
81/// One typed answer, matching its question's variant.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83#[serde(tag = "type", rename_all = "snake_case")]
84pub enum Answer {
85 /// The probability that the answer is yes, 0 to 1.
86 Noul {
87 /// Probability of yes.
88 noul: f64,
89 },
90 /// The highest-probability option and the full distribution.
91 Choice {
92 /// The chosen option.
93 choice: String,
94 /// Every option to its probability. The values sum to 1.
95 probabilities: BTreeMap<String, f64>,
96 /// `confidence` is the maximum probability in `probabilities`: the
97 /// probability mass on `choice`, 0 to 1.
98 confidence: f64,
99 },
100}
101
102/// Token accounting for one request.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
104pub struct JudgmentUsage {
105 /// Tokens the backend read.
106 pub input_tokens: u64,
107 /// Tokens the backend wrote.
108 pub output_tokens: u64,
109}
110
111/// Which path answered a [`JudgmentRequest`].
112///
113/// Lets a caller or an evaluation tell a dedicated judgment backend's answer
114/// apart from a [`fallback::FallbackJudgment`] answer produced by its
115/// fallback instead. A wire response that omits this field (every judgment
116/// backend's own wire shape does — the field is local to this seam) decodes
117/// as [`Self::Primary`].
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum JudgmentSource {
121 /// A dedicated judgment backend answered directly.
122 #[default]
123 Primary,
124 /// The primary backend failed in a way a fallback can fix, and a
125 /// fallback backend answered instead.
126 Fallback,
127}
128
129/// One response: an answer per question id, plus usage.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct JudgmentResponse {
132 /// The backend model that answered.
133 pub model: String,
134 /// Question id to answer.
135 pub answers: BTreeMap<String, Answer>,
136 /// Token accounting.
137 pub usage: JudgmentUsage,
138 /// Which path answered. Defaults to [`JudgmentSource::Primary`] when a
139 /// wire response omits it, which every backend's own response does.
140 #[serde(default)]
141 pub source: JudgmentSource,
142}
143
144impl JudgmentResponse {
145 /// Returns the yes probability of the [`Answer::Noul`] under `id`.
146 ///
147 /// `None` when the id is absent or the answer is not a Noul. A caller
148 /// that needs a hard decision thresholds the value itself, and treats
149 /// `None` as its fail-closed branch.
150 #[must_use]
151 pub fn noul(&self, id: &str) -> Option<f64> {
152 match self.answers.get(id) {
153 Some(Answer::Noul { noul }) => Some(*noul),
154 _ => None,
155 }
156 }
157}
158
159/// The failure classes a judgment backend reports.
160///
161/// The classes are stable across backends so a decision site can fail closed
162/// the same way whichever backend answers. No variant carries a request body,
163/// a response body, or a credential.
164#[derive(Debug, thiserror::Error)]
165pub enum JudgmentError {
166 /// The backend rejected the request shape. Retrying the same request
167 /// cannot succeed.
168 #[error("judgment request rejected: {0}")]
169 Invalid(String),
170 /// The backend refused the credential.
171 #[error("judgment backend refused the credential")]
172 Unauthorized,
173 /// The backend refused for lack of credit or quota.
174 #[error("judgment backend refused for lack of credit or quota")]
175 Exhausted,
176 /// The backend asked the caller to back off.
177 #[error("judgment backend rate limited (retry after {retry_after:?})")]
178 RateLimited {
179 /// The backoff the backend suggested, when it sent one.
180 retry_after: Option<std::time::Duration>,
181 },
182 /// The backend is temporarily unavailable.
183 #[error("judgment backend unavailable (status {status})")]
184 Unavailable {
185 /// The HTTP status the backend answered with.
186 status: u16,
187 },
188 /// The request did not complete on the transport.
189 #[error("judgment transport failed: {0}")]
190 Transport(Box<dyn std::error::Error + Send + Sync + 'static>),
191 /// The response did not decode into the answer types.
192 #[error("judgment response malformed: {0}")]
193 Malformed(String),
194}
195
196/// The seam between a decision site and any concrete judgment backend.
197///
198/// One implementation per backend. A decision site holds a
199/// [`DynJudgment`] and never names the backend.
200#[async_trait]
201pub trait JudgmentProvider: Send + Sync + 'static {
202 /// The backend's concrete error type.
203 type Error: std::error::Error + Send + Sync + 'static;
204
205 /// Answers every question in `request` over its state.
206 ///
207 /// # Errors
208 ///
209 /// Returns the backend's error when the request is rejected, the
210 /// credential is refused, the transport fails, or the response does not
211 /// decode.
212 async fn judge(&self, request: JudgmentRequest) -> Result<JudgmentResponse, Self::Error>;
213}
214
215/// A backend error erased to one concrete type.
216///
217/// Transparent wrapper: [`Display`](std::fmt::Display) and
218/// [`source`](std::error::Error::source) delegate to the inner error.
219#[derive(Debug)]
220pub struct BoxError(Box<dyn std::error::Error + Send + Sync + 'static>);
221
222impl BoxError {
223 /// Erases any backend error.
224 #[must_use]
225 pub fn new<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
226 Self(Box::new(err))
227 }
228}
229
230impl std::fmt::Display for BoxError {
231 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232 std::fmt::Display::fmt(&self.0, f)
233 }
234}
235
236impl std::error::Error for BoxError {
237 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
238 Some(&*self.0)
239 }
240}
241
242/// Wraps a concrete [`JudgmentProvider`] and erases its error to
243/// [`BoxError`], so the wrapped value coerces to [`DynJudgment`].
244pub struct ErasedJudgment<P>(P);
245
246#[async_trait]
247impl<P: JudgmentProvider> JudgmentProvider for ErasedJudgment<P> {
248 type Error = BoxError;
249
250 async fn judge(&self, request: JudgmentRequest) -> Result<JudgmentResponse, Self::Error> {
251 self.0.judge(request).await.map_err(BoxError::new)
252 }
253}
254
255/// The single trait-object type a decision site stores.
256pub type DynJudgment = dyn JudgmentProvider<Error = BoxError>;
257
258/// Erases a concrete backend and wraps it in an `Arc` as a [`DynJudgment`].
259#[must_use]
260pub fn into_dyn<P: JudgmentProvider>(provider: P) -> Arc<DynJudgment> {
261 Arc::new(ErasedJudgment(provider))
262}
263
264#[cfg(test)]
265mod tests {
266 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
267
268 use super::*;
269
270 #[test]
271 fn noul_question_serializes_to_the_wire_shape() {
272 let q = Question::Noul {
273 instructions: "Is it urgent?".to_owned(),
274 criteria: Some(NoulCriteria {
275 yes: "Time-sensitive".to_owned(),
276 no: "No urgency".to_owned(),
277 }),
278 };
279 let json = serde_json::to_value(&q).expect("serializable");
280 assert_eq!(
281 json,
282 serde_json::json!({
283 "type": "noul",
284 "instructions": "Is it urgent?",
285 "criteria": {"true": "Time-sensitive", "false": "No urgency"}
286 })
287 );
288 }
289
290 #[test]
291 fn noul_question_without_criteria_omits_the_field() {
292 let q = Question::Noul {
293 instructions: "Is it urgent?".to_owned(),
294 criteria: None,
295 };
296 let json = serde_json::to_value(&q).expect("serializable");
297 assert!(json.get("criteria").is_none(), "{json}");
298 }
299
300 #[test]
301 fn choice_answer_round_trips() {
302 let raw = serde_json::json!({
303 "type": "choice",
304 "choice": "billing",
305 "probabilities": {"billing": 0.9, "sales": 0.1},
306 "confidence": 0.85
307 });
308 let answer: Answer = serde_json::from_value(raw.clone()).expect("decodes");
309 assert!(matches!(&answer, Answer::Choice { choice, .. } if choice == "billing"));
310 assert_eq!(serde_json::to_value(&answer).expect("encodes"), raw);
311 }
312
313 #[test]
314 fn response_noul_accessor_is_none_for_other_shapes() {
315 let mut answers = BTreeMap::new();
316 answers.insert("yes".to_owned(), Answer::Noul { noul: 0.7 });
317 answers.insert(
318 "pick".to_owned(),
319 Answer::Choice {
320 choice: "a".to_owned(),
321 probabilities: BTreeMap::new(),
322 confidence: 1.0,
323 },
324 );
325 let response = JudgmentResponse {
326 model: "m".to_owned(),
327 answers,
328 usage: JudgmentUsage::default(),
329 source: JudgmentSource::Primary,
330 };
331 assert_eq!(response.noul("yes"), Some(0.7));
332 assert_eq!(response.noul("pick"), None);
333 assert_eq!(response.noul("absent"), None);
334 }
335}