polyc-judgment 2026.10.0

Provider-agnostic judgment trait: typed yes/no and choice questions over a state, answered with calibrated probabilities.
Documentation
//! A canned [`JudgmentProvider`] for wiring and tests.
//!
//! Production never installs one: a deterministic stub that reaches
//! production is a silent loss of the judgment, the failure #1565 D6 closed
//! for the summarizer.

use std::{
    collections::BTreeMap,
    sync::{Arc, Mutex},
};

use async_trait::async_trait;

use crate::{
    Answer, JudgmentProvider, JudgmentRequest, JudgmentResponse, JudgmentSource, JudgmentUsage,
};

/// Answers every request with the same fixed answers and records each
/// request it saw.
///
/// A test builds one with the answers a scenario needs, runs the decision
/// site, then reads [`FixedJudgment::requests`] to assert on the state and
/// questions the site built.
#[derive(Debug, Clone)]
pub struct FixedJudgment {
    answers: BTreeMap<String, Answer>,
    requests: Arc<Mutex<Vec<JudgmentRequest>>>,
}

impl FixedJudgment {
    /// Builds a stub that answers with `answers` on every call.
    #[must_use]
    pub fn new(answers: BTreeMap<String, Answer>) -> Self {
        Self {
            answers,
            requests: Arc::new(Mutex::new(Vec::new())),
        }
    }

    /// Builds a stub whose only answer is a Noul under `id` with value `noul`.
    #[must_use]
    pub fn noul(id: &str, noul: f64) -> Self {
        let mut answers = BTreeMap::new();
        answers.insert(id.to_owned(), Answer::Noul { noul });
        Self::new(answers)
    }

    /// Every request this stub answered, oldest first.
    ///
    /// # Panics
    ///
    /// Panics when a previous holder of the request log panicked while
    /// holding it. A test that reaches this state has already failed.
    #[must_use]
    pub fn requests(&self) -> Vec<JudgmentRequest> {
        self.requests.lock().expect("request log poisoned").clone()
    }
}

/// The error a stub can never produce.
#[derive(Debug, thiserror::Error)]
#[error("the fixed judgment stub never fails")]
pub struct StubError;

#[async_trait]
impl JudgmentProvider for FixedJudgment {
    type Error = StubError;

    async fn judge(&self, request: JudgmentRequest) -> Result<JudgmentResponse, Self::Error> {
        self.requests
            .lock()
            .expect("request log poisoned")
            .push(request);
        Ok(JudgmentResponse {
            model: "fixed".to_owned(),
            answers: self.answers.clone(),
            usage: JudgmentUsage::default(),
            source: JudgmentSource::Primary,
        })
    }
}

/// Fails every request with a fixed error, for fail-closed tests.
#[derive(Debug, Clone, Copy, Default)]
pub struct FailingJudgment;

/// The error [`FailingJudgment`] answers with.
#[derive(Debug, thiserror::Error)]
#[error("the failing judgment stub refuses every request")]
pub struct FailingError;

#[async_trait]
impl JudgmentProvider for FailingJudgment {
    type Error = FailingError;

    async fn judge(&self, _: JudgmentRequest) -> Result<JudgmentResponse, Self::Error> {
        Err(FailingError)
    }
}