Skip to main content

semantic_operators/
filter.rs

1//! Boolean filtering with one recorded outcome per input.
2use crate::each::{BatchOptions, Item, ItemResult, ask_each, validate_ids};
3use crate::{Answer, AnswerValue, Boolean, Error, Result, State};
4use serde_json::{Map, Value};
5use std::collections::BTreeSet;
6
7/// Exhaustive filtering outcome.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum FilterOutcome {
10    /// Decided true.
11    Match,
12    /// Decided false.
13    No,
14    /// Undecided because of a tie or confidence threshold.
15    Unsure,
16    /// Provider, timeout, or response failure.
17    Failed,
18}
19/// One filtering result, retaining its input position and evidence.
20#[derive(Clone, Debug)]
21pub struct Verdict {
22    id: String,
23    position: usize,
24    outcome: FilterOutcome,
25    result: ItemResult,
26}
27impl Verdict {
28    /// Local input identifier.
29    pub fn id(&self) -> &str {
30        &self.id
31    }
32    /// Zero-based position in the input sequence.
33    pub fn position(&self) -> usize {
34        self.position
35    }
36    /// Match, no, unsure, or failed.
37    pub fn outcome(&self) -> FilterOutcome {
38        self.outcome
39    }
40    /// Validated answer, including evidence for an unsure result.
41    pub fn answer(&self) -> Option<&Answer<AnswerValue>> {
42        self.result.answer()
43    }
44    /// Provider failure, if any.
45    pub fn error(&self) -> Option<&Error> {
46        self.result.error()
47    }
48}
49/// Complete filtering report, in input order.
50#[derive(Clone, Debug)]
51pub struct Filtered {
52    question: Boolean,
53    verdicts: Vec<Verdict>,
54}
55impl Filtered {
56    /// Question used for every item.
57    pub fn question(&self) -> &Boolean {
58        &self.question
59    }
60    /// Exactly one outcome per input, in input order.
61    pub fn verdicts(&self) -> &[Verdict] {
62        &self.verdicts
63    }
64    /// Decided true results, in input order.
65    pub fn matched(&self) -> Vec<&Verdict> {
66        self.with_outcome(FilterOutcome::Match)
67    }
68    /// Decided false results, in input order.
69    pub fn rejected(&self) -> Vec<&Verdict> {
70        self.with_outcome(FilterOutcome::No)
71    }
72    /// Undecided results, in input order.
73    pub fn unsure(&self) -> Vec<&Verdict> {
74        self.with_outcome(FilterOutcome::Unsure)
75    }
76    /// Failed calls, in input order.
77    pub fn failed(&self) -> Vec<&Verdict> {
78        self.with_outcome(FilterOutcome::Failed)
79    }
80    /// Nonempty model identities actually reported, including unsure results.
81    pub fn models(&self) -> BTreeSet<&str> {
82        self.verdicts
83            .iter()
84            .filter_map(|v| v.answer())
85            .filter_map(|a| a.call())
86            .filter_map(|c| c.model.as_deref())
87            .filter(|m| !m.is_empty())
88            .collect()
89    }
90    fn with_outcome(&self, outcome: FilterOutcome) -> Vec<&Verdict> {
91        self.verdicts
92            .iter()
93            .filter(|v| v.outcome == outcome)
94            .collect()
95    }
96}
97/// Independently classify every item; IDs are never added to provider state.
98///
99/// With a non-null context, each state becomes `{"context": ..., "item": ...}`.
100/// Duplicate IDs fail before I/O. Provider failures affect only their own items;
101/// invalid configuration and unsupported capabilities abort the batch.
102///
103/// # Errors
104/// Returns `InvalidArgument` for duplicate item identifiers and propagates
105/// local validation or unsupported-capability errors from [`ask_each`].
106/// Provider, timeout, and malformed-response failures become `Failed`
107/// verdicts for their individual items.
108pub async fn filter_items(
109    provider: &dyn crate::Provider,
110    question: &Boolean,
111    items: &[Item],
112    options: &BatchOptions,
113) -> Result<Filtered> {
114    validate_ids(items.iter().map(Item::id))?;
115    let mut states = Vec::with_capacity(items.len());
116    for item in items {
117        let state = if let Some(context) = options.context.as_ref().filter(|v| !v.is_null()) {
118            let mut value = Map::new();
119            value.insert("context".into(), context.clone());
120            value.insert("item".into(), item.state().as_value().clone());
121            State::try_from(Value::Object(value))?
122        } else {
123            item.state().clone()
124        };
125        states.push(state);
126    }
127    let results = ask_each(provider, &question.clone().into(), &states, options).await?;
128    let verdicts = items
129        .iter()
130        .zip(results)
131        .enumerate()
132        .map(|(position, (item, result))| {
133            let outcome = match &result {
134                ItemResult::Error(_) => FilterOutcome::Failed,
135                ItemResult::Answer(answer) => match answer.value() {
136                    Some(AnswerValue::Boolean(true)) => FilterOutcome::Match,
137                    Some(AnswerValue::Boolean(false)) => FilterOutcome::No,
138                    _ => FilterOutcome::Unsure,
139                },
140            };
141            Verdict {
142                id: item.id().to_owned(),
143                position,
144                outcome,
145                result,
146            }
147        })
148        .collect();
149    Ok(Filtered {
150        question: question.clone(),
151        verdicts,
152    })
153}