Skip to main content

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}