Skip to main content

harn_parser/typechecker/
predicate_questions.rs

1//! The question set of a batched evaluation site.
2//!
3//! A question set is read statically for two reasons that share one
4//! requirement. The site manifest records which questions a site asks, and the
5//! checker types each answer from its own question, so a choice answer's
6//! `choice` is the literal union of that question's criteria keys and a `match`
7//! on it is exhaustive. Both need the labels at check time, so the question set
8//! must be a literal at the call site.
9//!
10//! Question kind is decided from the inferred type of the entry, not from the
11//! spelling of the callee: a question is whatever carries one of the three
12//! contract shapes. The labels themselves then come from the literal argument,
13//! because a `dict<string, string>` type has already lost its keys.
14
15use super::{scope::TypeScope, TypeChecker};
16use crate::ast::*;
17use crate::builtin_signatures::TyExt;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum PredicateQuestionKind {
22    Boolean,
23    Choice,
24    Score,
25}
26
27impl PredicateQuestionKind {
28    pub fn as_str(self) -> &'static str {
29        match self {
30            Self::Boolean => "boolean",
31            Self::Choice => "choice",
32            Self::Score => "score",
33        }
34    }
35
36    fn from_literal(kind: &str) -> Option<Self> {
37        match kind {
38            "boolean" => Some(Self::Boolean),
39            "choice" => Some(Self::Choice),
40            "score" => Some(Self::Score),
41            _ => None,
42        }
43    }
44}
45
46/// One checked question. `labels` are the criteria keys of a choice or the
47/// ordered levels of a score, and are empty for a boolean.
48#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
49pub struct PredicateQuestionSpec {
50    pub id: String,
51    pub kind: PredicateQuestionKind,
52    pub instructions: String,
53    pub labels: Vec<String>,
54}
55
56impl PredicateQuestionSpec {
57    /// The answer type this question produces, with its label field narrowed
58    /// to the literal union of its own labels.
59    pub fn answer_type(&self) -> TypeExpr {
60        let (contract, label_field) = match self.kind {
61            PredicateQuestionKind::Boolean => {
62                return harn_builtin_meta::predicate::BOOLEAN_ANSWER.to_type_expr()
63            }
64            PredicateQuestionKind::Choice => {
65                (harn_builtin_meta::predicate::CHOICE_ANSWER, "choice")
66            }
67            PredicateQuestionKind::Score => (harn_builtin_meta::predicate::SCORE_ANSWER, "level"),
68        };
69        let mut answer = contract.to_type_expr();
70        if self.labels.is_empty() {
71            return answer;
72        }
73        let labels: Vec<TypeExpr> = self
74            .labels
75            .iter()
76            .map(|label| TypeExpr::LitString(label.clone()))
77            .collect();
78        let narrowed = if labels.len() == 1 {
79            labels.into_iter().next().expect("one label")
80        } else {
81            TypeExpr::Union(labels)
82        };
83        if let TypeExpr::Shape(fields) = &mut answer {
84            if let Some(field) = fields.iter_mut().find(|field| field.name == label_field) {
85                field.type_expr = narrowed;
86            }
87        }
88        answer
89    }
90}
91
92/// Why a question set could not be read. Each maps to one diagnostic message;
93/// the checker owns reporting so the span points at the offending entry.
94pub(super) enum QuestionSetError {
95    NotLiteral,
96    NoQuestions,
97    DuplicateId(String),
98    EntryNotAQuestion(String),
99    LabelsNotLiteral(String),
100    NoLabels(String),
101    DuplicateLabel(String, String),
102    InstructionsNotLiteral(String),
103}
104
105impl QuestionSetError {
106    pub(super) fn message(&self) -> String {
107        match self {
108            Self::NotLiteral => {
109                "evaluation questions must be a dict literal of question builders".into()
110            }
111            Self::NoQuestions => "evaluation declares no questions".into(),
112            Self::DuplicateId(id) => format!("question id `{id}` is declared more than once"),
113            Self::EntryNotAQuestion(id) => format!(
114                "question `{id}` is not a boolean, choice, or score question from std/predicate"
115            ),
116            Self::LabelsNotLiteral(id) => format!(
117                "question `{id}` must declare its criteria or levels as a literal so answers can be typed"
118            ),
119            Self::NoLabels(id) => format!("question `{id}` declares no criteria or levels"),
120            Self::DuplicateLabel(id, label) => {
121                format!("question `{id}` declares label `{label}` more than once")
122            }
123            Self::InstructionsNotLiteral(id) => {
124                format!("question `{id}` must declare its instructions as a nonempty string literal")
125            }
126        }
127    }
128
129    pub(super) fn help(&self) -> String {
130        match self {
131            Self::EntryNotAQuestion(_) | Self::NotLiteral => {
132                "build each question with boolean(...), choice(...), or score(...) from std/predicate, inline at the call".into()
133            }
134            _ => "the question set is part of the site's cache identity and types every answer, so it is read at check time".into(),
135        }
136    }
137}
138
139fn literal_text(node: &SNode) -> Option<String> {
140    match &node.node {
141        Node::StringLiteral(text) | Node::RawStringLiteral(text) if !text.is_empty() => {
142            Some(text.clone())
143        }
144        _ => None,
145    }
146}
147
148fn entry_key(entry: &DictEntry) -> Option<String> {
149    match &entry.key.node {
150        Node::StringLiteral(key) | Node::RawStringLiteral(key) | Node::Identifier(key)
151            if !key.is_empty() =>
152        {
153            Some(key.clone())
154        }
155        _ => None,
156    }
157}
158
159impl TypeChecker {
160    /// The `kind` discriminant of a question, read from its inferred type.
161    fn question_kind(&self, node: &SNode, scope: &TypeScope) -> Option<PredicateQuestionKind> {
162        let ty = self.resolve_alias(&self.infer_type(node, scope)?, scope);
163        let TypeExpr::Shape(fields) = super::union::without_nil(&ty)? else {
164            return None;
165        };
166        let kind = fields.iter().find(|field| field.name == "kind")?;
167        let TypeExpr::LitString(kind) = &kind.type_expr else {
168            return None;
169        };
170        PredicateQuestionKind::from_literal(kind)
171    }
172
173    /// Read one question entry. The kind comes from the type; the labels and
174    /// instructions come from the literal arguments of the builder call.
175    fn question_spec(
176        &self,
177        id: &str,
178        node: &SNode,
179        scope: &TypeScope,
180    ) -> Result<PredicateQuestionSpec, QuestionSetError> {
181        let kind = self
182            .question_kind(node, scope)
183            .ok_or_else(|| QuestionSetError::EntryNotAQuestion(id.to_string()))?;
184        let Node::FunctionCall { args, .. } = &node.node else {
185            return Err(QuestionSetError::LabelsNotLiteral(id.to_string()));
186        };
187        let instructions = args
188            .first()
189            .and_then(literal_text)
190            .ok_or_else(|| QuestionSetError::InstructionsNotLiteral(id.to_string()))?;
191        let labels = match kind {
192            PredicateQuestionKind::Boolean => Vec::new(),
193            PredicateQuestionKind::Choice => {
194                let Some(SNode {
195                    node: Node::DictLiteral(entries),
196                    ..
197                }) = args.get(1)
198                else {
199                    return Err(QuestionSetError::LabelsNotLiteral(id.to_string()));
200                };
201                entries
202                    .iter()
203                    .map(|entry| {
204                        entry_key(entry)
205                            .ok_or_else(|| QuestionSetError::LabelsNotLiteral(id.to_string()))
206                    })
207                    .collect::<Result<Vec<_>, _>>()?
208            }
209            PredicateQuestionKind::Score => {
210                let Some(SNode {
211                    node: Node::ListLiteral(items),
212                    ..
213                }) = args.get(1)
214                else {
215                    return Err(QuestionSetError::LabelsNotLiteral(id.to_string()));
216                };
217                items
218                    .iter()
219                    .map(|item| {
220                        literal_text(item)
221                            .ok_or_else(|| QuestionSetError::LabelsNotLiteral(id.to_string()))
222                    })
223                    .collect::<Result<Vec<_>, _>>()?
224            }
225        };
226        if kind != PredicateQuestionKind::Boolean && labels.is_empty() {
227            return Err(QuestionSetError::NoLabels(id.to_string()));
228        }
229        for (index, label) in labels.iter().enumerate() {
230            if labels[..index].contains(label) {
231                return Err(QuestionSetError::DuplicateLabel(
232                    id.to_string(),
233                    label.clone(),
234                ));
235            }
236        }
237        Ok(PredicateQuestionSpec {
238            id: id.to_string(),
239            kind,
240            instructions,
241            labels,
242        })
243    }
244
245    /// Read the whole question set of an evaluation site.
246    pub(super) fn question_set(
247        &self,
248        node: &SNode,
249        scope: &TypeScope,
250    ) -> Result<Vec<PredicateQuestionSpec>, QuestionSetError> {
251        let Node::DictLiteral(entries) = &node.node else {
252            return Err(QuestionSetError::NotLiteral);
253        };
254        if entries.is_empty() {
255            return Err(QuestionSetError::NoQuestions);
256        }
257        let mut specs: Vec<PredicateQuestionSpec> = Vec::new();
258        for entry in entries {
259            let id = entry_key(entry).ok_or(QuestionSetError::NotLiteral)?;
260            if specs.iter().any(|spec| spec.id == id) {
261                return Err(QuestionSetError::DuplicateId(id));
262            }
263            specs.push(self.question_spec(&id, &entry.value, scope)?);
264        }
265        Ok(specs)
266    }
267
268    /// The record of answers this question set produces, keyed by question id.
269    /// `None` whenever the set is not statically readable; the declared
270    /// `dict<string, EvaluationAnswer>` then stands, and the site check reports
271    /// the reason.
272    pub(in crate::typechecker) fn evaluation_answer_record(
273        &self,
274        args: &[SNode],
275        scope: &TypeScope,
276    ) -> Option<TypeExpr> {
277        let specs = self.question_set(args.get(2)?, scope).ok()?;
278        Some(TypeExpr::Shape(
279            specs
280                .iter()
281                .map(|spec| ShapeField::synthetic(spec.id.clone(), spec.answer_type(), false))
282                .collect(),
283        ))
284    }
285
286    /// Replace the answer maps of the `answered` and `low_confidence` arms with
287    /// the site's own record of typed answers.
288    pub(in crate::typechecker) fn narrow_evaluation_answers(
289        mut outcome: TypeExpr,
290        answers: TypeExpr,
291    ) -> TypeExpr {
292        let TypeExpr::Union(members) = &mut outcome else {
293            return outcome;
294        };
295        for member in members.iter_mut() {
296            let TypeExpr::Shape(fields) = member else {
297                continue;
298            };
299            let arm = fields
300                .iter()
301                .find(|field| field.name == "kind")
302                .map(|field| {
303                    matches!(&field.type_expr, TypeExpr::LitString(kind)
304                    if kind == "answered" || kind == "low_confidence")
305                });
306            if arm != Some(true) {
307                continue;
308            }
309            if let Some(field) = fields
310                .iter_mut()
311                .find(|field| matches!(field.name.as_str(), "value" | "candidates"))
312            {
313                field.type_expr = answers.clone();
314            }
315        }
316        outcome
317    }
318}