Skip to main content

semantic_operators/
wire.rs

1//! Strict Python-compatible text wire schema 1.
2use crate::{Answer, AnswerValue, Boolean, Choice, Error, ErrorKind, Question, Score, State};
3use indexmap::IndexMap;
4use serde_json::{Map, Value, json};
5/// A decoded schema-1 request. Images and Box questions are excluded.
6#[derive(Clone, Debug)]
7pub struct WireRequest {
8    /// Validated text, object, or array state.
9    pub state: State,
10    /// Unique named questions in request order.
11    pub questions: IndexMap<String, Question>,
12    /// Optional caller identifier.
13    pub request_id: Option<String>,
14}
15fn invalid(path: &str, message: impl Into<String>) -> Error {
16    Error::new(ErrorKind::InvalidRequest, message).with_path(path)
17}
18fn no_unknown(object: &Map<String, Value>, allowed: &[&str], path: &str) -> Result<(), Error> {
19    if let Some(key) = object
20        .keys()
21        .filter(|k| !allowed.contains(&k.as_str()))
22        .min()
23    {
24        return Err(invalid(
25            &if path.is_empty() {
26                key.clone()
27            } else {
28                format!("{path}.{key}")
29            },
30            "unknown field",
31        ));
32    }
33    Ok(())
34}
35/// Decode a parsed JSON request using the frozen schema-1 rules.
36///
37/// # Errors
38/// Returns `InvalidRequest` with a field path for unsupported schema versions,
39/// unknown fields, invalid state/question definitions, empty question lists,
40/// or duplicate names. Box is not part of schema 1.
41pub fn decode_request(request: Value) -> Result<WireRequest, Error> {
42    let object = request
43        .as_object()
44        .ok_or_else(|| invalid("", "the request must be a JSON object"))?;
45    no_unknown(
46        object,
47        &["schema_version", "state", "questions", "request_id"],
48        "",
49    )?;
50    if object
51        .get("schema_version")
52        .is_some_and(|v| v.as_str() != Some("1"))
53    {
54        return Err(invalid("schema_version", "only \"1\" is supported"));
55    }
56    let value = object
57        .get("state")
58        .ok_or_else(|| invalid("state", "required"))?;
59    let state = State::try_from(value.clone())
60        .map_err(|_| invalid("state", "must be text, a JSON object, or an array"))?;
61    let request_id = optional_string(object.get("request_id"), "request_id")?;
62    let specs = object
63        .get("questions")
64        .and_then(Value::as_array)
65        .filter(|v| !v.is_empty())
66        .ok_or_else(|| invalid("questions", "must be a non-empty array"))?;
67    let mut questions = IndexMap::new();
68    for (i, spec) in specs.iter().enumerate() {
69        let path = format!("questions[{i}]");
70        let (name, question) = decode_question(spec, &path)?;
71        if questions.contains_key(&name) {
72            return Err(invalid(
73                &format!("{path}.name"),
74                format!("{} is used twice", python_repr(&name)),
75            ));
76        }
77        questions.insert(name, question);
78    }
79    Ok(WireRequest {
80        state,
81        questions,
82        request_id,
83    })
84}
85fn optional_string(value: Option<&Value>, path: &str) -> Result<Option<String>, Error> {
86    match value {
87        None | Some(Value::Null) => Ok(None),
88        Some(Value::String(s)) => Ok(Some(s.clone())),
89        _ => Err(invalid(path, "must be a string")),
90    }
91}
92fn decode_question(spec: &Value, path: &str) -> Result<(String, Question), Error> {
93    let obj = spec
94        .as_object()
95        .ok_or_else(|| invalid(path, "must be a JSON object"))?;
96    let kind = obj.get("type").and_then(Value::as_str).unwrap_or("");
97    let mut allowed = vec!["name", "type", "instructions", "min_confidence"];
98    match kind {
99        "boolean" => allowed.extend(["true", "false"]),
100        "choice" => allowed.push("options"),
101        "score" => allowed.push("levels"),
102        _ => {
103            return Err(invalid(
104                &format!("{path}.type"),
105                "must be 'boolean', 'choice', or 'score'",
106            ));
107        }
108    }
109    no_unknown(obj, &allowed, path)?;
110    let name = obj
111        .get("name")
112        .and_then(Value::as_str)
113        .filter(|s| !s.is_empty())
114        .ok_or_else(|| invalid(&format!("{path}.name"), "must be a non-empty string"))?;
115    // Python validates side/options/levels before constructing the question.
116    let descriptions = if kind == "boolean" {
117        (
118            optional_string(obj.get("true"), &format!("{path}.true"))?,
119            optional_string(obj.get("false"), &format!("{path}.false"))?,
120        )
121    } else {
122        (None, None)
123    };
124    let options = if kind == "choice" {
125        Some(decode_options(obj.get("options"), path)?)
126    } else {
127        None
128    };
129    let levels = if kind == "score" {
130        let levels = obj
131            .get("levels")
132            .and_then(Value::as_array)
133            .ok_or_else(|| invalid(&format!("{path}.levels"), "must be an array of strings"))?;
134        Some(levels)
135    } else {
136        None
137    };
138    let instructions = obj
139        .get("instructions")
140        .and_then(Value::as_str)
141        .filter(|s| !crate::question::is_blank(s))
142        .ok_or_else(|| invalid(path, "instructions must be a non-empty string"))?;
143    let threshold = match obj.get("min_confidence") {
144        None => 0.0,
145        Some(v) => v
146            .as_f64()
147            .filter(|n| n.is_finite() && (0.0..=1.0).contains(n))
148            .ok_or_else(|| invalid(path, "min_confidence must be a number from 0 to 1"))?,
149    };
150    let question = match kind {
151        "boolean" => Boolean::new(instructions)?
152            .with_descriptions(descriptions.0, descriptions.1)
153            .with_min_confidence(threshold)?
154            .into(),
155        "choice" => {
156            let options = options.unwrap_or_default();
157            if options.len() < 2 {
158                return Err(invalid(
159                    path,
160                    format!("need at least 2 options, got {}", options.len()),
161                ));
162            }
163            Choice::new(instructions, options)?
164                .with_min_confidence(threshold)?
165                .into()
166        }
167        _ => {
168            let levels = levels.map(Vec::as_slice).unwrap_or(&[]);
169            if levels.len() < 2 {
170                return Err(invalid(
171                    path,
172                    format!("need at least 2 levels, got {}", levels.len()),
173                ));
174            }
175            let levels: Vec<String> = levels
176                .iter()
177                .map(|v| {
178                    v.as_str()
179                        .filter(|s| !s.is_empty())
180                        .map(str::to_owned)
181                        .ok_or_else(|| invalid(path, "levels must be non-empty strings"))
182                })
183                .collect::<Result<_, _>>()?;
184            if levels
185                .iter()
186                .collect::<std::collections::HashSet<_>>()
187                .len()
188                != levels.len()
189            {
190                return Err(invalid(path, "levels must be unique"));
191            }
192            Score::new(instructions, levels)?
193                .with_min_confidence(threshold)?
194                .into()
195        }
196    };
197    Ok((name.to_owned(), question))
198}
199fn decode_options(
200    value: Option<&Value>,
201    path: &str,
202) -> Result<IndexMap<String, Option<String>>, Error> {
203    let options = value
204        .and_then(Value::as_array)
205        .ok_or_else(|| invalid(&format!("{path}.options"), "must be an array"))?;
206    let mut result = IndexMap::new();
207    for (i, option) in options.iter().enumerate() {
208        let where_ = format!("{path}.options[{i}]");
209        let (name, description) = match option {
210            Value::String(s) => (Some(s.as_str()), None),
211            Value::Object(obj) => {
212                no_unknown(obj, &["name", "description"], &where_)?;
213                let description =
214                    optional_string(obj.get("description"), &format!("{where_}.description"))?;
215                (obj.get("name").and_then(Value::as_str), description)
216            }
217            _ => {
218                return Err(invalid(
219                    &where_,
220                    "must be a name or {\"name\", \"description\"}",
221                ));
222            }
223        };
224        let name = name
225            .filter(|s| !s.is_empty())
226            .ok_or_else(|| invalid(&where_, "needs a non-empty name"))?;
227        if result.contains_key(name) {
228            return Err(invalid(
229                &where_,
230                format!("option {} is used twice", python_repr(name)),
231            ));
232        }
233        result.insert(name.to_owned(), description);
234    }
235    Ok(result)
236}
237fn python_repr(s: &str) -> String {
238    let quote = if s.contains('\'') && !s.contains('"') {
239        '"'
240    } else {
241        '\''
242    };
243    let mut out = String::new();
244    out.push(quote);
245    for c in s.chars() {
246        match c {
247            '\\' => out.push_str("\\\\"),
248            '\n' => out.push_str("\\n"),
249            '\r' => out.push_str("\\r"),
250            '\t' => out.push_str("\\t"),
251            c if c == quote => {
252                out.push('\\');
253                out.push(c);
254            }
255            c if c.is_control() => {
256                let n = c as u32;
257                if n < 256 {
258                    out.push_str(&format!("\\x{n:02x}"));
259                } else {
260                    out.push_str(&format!("\\u{n:04x}"));
261                }
262            }
263            c => out.push(c),
264        }
265    }
266    out.push(quote);
267    out
268}
269fn significant(value: f64) -> Result<Value, Error> {
270    let rounded = format!("{value:.3e}")
271        .parse::<f64>()
272        .map_err(|_| Error::new(ErrorKind::Runtime, "decimal conversion failed"))?;
273    serde_json::Number::from_f64(rounded)
274        .map(Value::Number)
275        .ok_or_else(|| Error::new(ErrorKind::InvalidResponse, "wire numbers must be finite"))
276}
277/// Encode normalized answers, excluding the optional [`Answer::raw`] payload.
278/// Schema 1 projects only the first answer's call.
279/// The answer set must exactly cover the decoded request. This is a projection
280/// of already-normalized answers; call the checked provider boundary first to
281/// apply the request thresholds. Existing abstentions are preserved.
282///
283/// # Errors
284/// Returns `InvalidResponse` for missing/extra or incompatible answers,
285/// `Unsupported` for Box values/questions, or a numeric conversion error.
286pub fn encode_response(
287    request: &WireRequest,
288    answers: &IndexMap<String, Answer>,
289) -> Result<Value, Error> {
290    if answers.is_empty()
291        || answers.len() != request.questions.len()
292        || request
293            .questions
294            .keys()
295            .any(|name| !answers.contains_key(name))
296    {
297        return Err(Error::new(
298            ErrorKind::InvalidResponse,
299            "wire answers must cover exactly the requested questions",
300        ));
301    }
302    let mut response = Map::new();
303    if let Some(id) = &request.request_id {
304        response.insert("request_id".into(), json!(id));
305    }
306    let mut entries = Map::new();
307    for (name, answer) in answers {
308        let question = &request.questions[name];
309        if matches!(question, Question::Box(_)) {
310            return Err(Error::new(
311                ErrorKind::Unsupported,
312                "wire schema 1 does not support Box",
313            ));
314        }
315        let value = match answer.value() {
316            None => Value::Null,
317            Some(AnswerValue::Boolean(v)) => json!(v),
318            Some(AnswerValue::Choice(v)) => json!(v),
319            Some(AnswerValue::Score(v)) => significant(*v)?,
320            Some(AnswerValue::Box(_)) => {
321                return Err(Error::new(
322                    ErrorKind::Unsupported,
323                    "wire schema 1 does not support Box",
324                ));
325            }
326        };
327        // Verify compatibility, while preserving deliberate caller abstentions.
328        answer.revalidate(question)?;
329        let probabilities: Map<String, Value> = answer
330            .probabilities()
331            .iter()
332            .map(|(k, v)| Ok((k.clone(), significant(*v)?)))
333            .collect::<Result<_, Error>>()?;
334        entries.insert(name.clone(),json!({"type":question.kind(),"value":value,"decided":answer.decided(),"confidence":significant(answer.confidence())?,"probabilities":probabilities}));
335    }
336    response.insert("answers".into(), Value::Object(entries));
337    let call=answers.first().and_then(|(_,a)|a.call()).map(|c|json!({"provider":c.provider,"model":c.model,"input_tokens":c.input_tokens,"output_tokens":c.output_tokens})).unwrap_or(Value::Null);
338    response.insert("call".into(), call);
339    Ok(Value::Object(response))
340}
341/// Encode a classified failure in schema 1.
342pub fn encode_error(error: &Error, request_id: Option<&str>) -> Value {
343    let code = match error.kind() {
344        ErrorKind::Timeout => "timeout",
345        ErrorKind::InvalidRequest
346        | ErrorKind::InvalidQuestion
347        | ErrorKind::InvalidState
348        | ErrorKind::InvalidArgument => "invalid_request",
349        _ => "provider_error",
350    };
351    let mut response = Map::new();
352    if let Some(id) = request_id {
353        response.insert("request_id".into(), json!(id));
354    }
355    response.insert(
356        "error".into(),
357        json!({"code":code,"message":error.to_string()}),
358    );
359    Value::Object(response)
360}