Skip to main content

harn_parser/typechecker/
predicate.rs

1//! Static obligations of the registered predicate capability. Ordinary method
2//! syntax preserves lexical capability resolution and existing editor tooling.
3
4use super::{scope::TypeScope, TypeChecker};
5use crate::{ast::*, builtin_signatures::TyExt, diagnostic_codes::Code};
6use harn_lexer::Span;
7
8/// A checked source site. Consumers hash the canonical type and question at
9/// their artifact boundary; this record never contains runtime input values.
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
11pub struct PredicateSite {
12    pub id: String,
13    pub question: String,
14    pub input_type: TypeExpr,
15    pub line: usize,
16    pub column: usize,
17    pub start: usize,
18    pub end: usize,
19    /// The declaration-time route, before catalog admission. An unknown route
20    /// remains explicit so consumers cannot confuse no measurement with support.
21    #[serde(default)]
22    pub model_route: Option<PredicateModelRoute>,
23}
24
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct PredicateModelRoute {
27    pub provider: String,
28    pub model: String,
29}
30
31fn model_route(policy: &SNode, scope: &TypeScope) -> Option<PredicateModelRoute> {
32    let crate::const_eval::ConstValue::Dict(fields) = scope.const_value(policy)? else {
33        return None;
34    };
35    let string = |name: &str| {
36        fields.iter().find_map(|(key, value)| {
37            if key != name {
38                return None;
39            }
40            match value {
41                crate::const_eval::ConstValue::String(value) => Some(value.clone()),
42                _ => None,
43            }
44        })
45    };
46    Some(PredicateModelRoute {
47        provider: string("provider")?,
48        model: string("model")?,
49    })
50}
51
52/// A deterministic structural identity, independent of field/union ordering
53/// and source spans. This is material for an artifact/cache digest, not a hash.
54pub fn canonical_type(ty: &TypeExpr) -> String {
55    fn normalize(value: &mut serde_json::Value) {
56        match value {
57            serde_json::Value::Object(fields) => {
58                for (name, value) in fields {
59                    normalize(value);
60                    if matches!(name.as_str(), "Shape" | "Union" | "Intersection") {
61                        if let serde_json::Value::Array(items) = value {
62                            items.sort_by_key(serde_json::Value::to_string);
63                        }
64                    }
65                }
66            }
67            serde_json::Value::Array(items) => items.iter_mut().for_each(normalize),
68            _ => {}
69        }
70    }
71    let mut value = serde_json::to_value(ty).expect("type expression serializes");
72    normalize(&mut value);
73    value.to_string()
74}
75
76fn serializable(ty: &TypeExpr) -> bool {
77    match ty {
78        TypeExpr::Named(name) => {
79            matches!(name.as_str(), "string" | "bool" | "int" | "float" | "nil")
80        }
81        TypeExpr::LitString(_) | TypeExpr::LitInt(_) => true,
82        TypeExpr::Shape(fields) => fields.iter().all(|field| serializable(&field.type_expr)),
83        TypeExpr::List(item) => serializable(item),
84        TypeExpr::Tuple(items) | TypeExpr::Union(items) => {
85            !items.is_empty() && items.iter().all(serializable)
86        }
87        TypeExpr::DictType(key, value) => {
88            matches!(key.as_ref(), TypeExpr::Named(name) if name == "string") && serializable(value)
89        }
90        _ => false,
91    }
92}
93
94impl TypeChecker {
95    fn predicate_method_name(method: &str) -> bool {
96        crate::builtin_signatures::lookup_capability_method(
97            harn_builtin_meta::CapabilityId::Llm,
98            method,
99        )
100        .is_some_and(|signature| signature.name == harn_builtin_meta::predicate::EVALUATE.name)
101    }
102
103    fn is_predicate_method(&self, object: &SNode, method: &str, scope: &TypeScope) -> bool {
104        if !Self::predicate_method_name(method) {
105            return false;
106        }
107        let Some(ty) = self.infer_type(object, scope) else {
108            return false;
109        };
110        let ty = self.resolve_alias(&ty, scope);
111        let Some(TypeExpr::Named(name)) = super::union::without_nil(&ty) else {
112            return false;
113        };
114        harn_builtin_meta::CapabilityId::from_type_name(&name)
115            == Some(harn_builtin_meta::CapabilityId::Llm)
116    }
117
118    pub(super) fn check_predicate_node(&mut self, node: &SNode, scope: &TypeScope) {
119        let projection = match &node.node {
120            Node::PropertyAccess { object, property }
121            | Node::OptionalPropertyAccess { object, property } => {
122                Some((object, Some(property.as_str())))
123            }
124            Node::SubscriptAccess { object, index }
125            | Node::OptionalSubscriptAccess { object, index } => {
126                let field = match &index.node {
127                    Node::StringLiteral(name) | Node::RawStringLiteral(name) => Some(name.as_str()),
128                    _ => None,
129                };
130                Some((object, field))
131            }
132            _ => None,
133        };
134        if let Some((object, field)) = projection {
135            if let Some(ty) = self.infer_type(object, scope) {
136                self.check_predicate_field(&ty, field, node.span, scope);
137            }
138        }
139        let named_receiver = match &node.node {
140            Node::MethodCall { object, method, .. }
141            | Node::OptionalMethodCall { object, method, .. } => Some((object, method)),
142            Node::PropertyAccess { object, property }
143            | Node::OptionalPropertyAccess { object, property } => Some((object, property)),
144            _ => None,
145        };
146        if let Some((object, method)) = named_receiver {
147            if Self::predicate_method_name(method)
148                && self.infer_type(object, scope).is_none_or(|ty| {
149                    matches!(self.resolve_alias(&ty, scope), TypeExpr::Named(name)
150                        if matches!(name.as_str(), "any" | "unknown" | "dict" | "_"))
151                })
152            {
153                self.error_at_with_help(
154                    Code::PredicateSiteInvalid,
155                    "predicate method receiver has no statically resolved type".into(),
156                    node.span,
157                    "retain HarnessLlm in the helper signature instead of erasing it to an unvalidated value".into(),
158                );
159            }
160        }
161        match &node.node {
162            Node::IfElse { condition, .. }
163            | Node::WhileLoop { condition, .. }
164            | Node::GuardStmt { condition, .. }
165            | Node::RequireStmt { condition, .. }
166            | Node::Ternary { condition, .. } => self.check_predicate_boolean(condition, scope),
167            Node::UnaryOp { op, operand } if op == "!" => {
168                self.check_predicate_boolean(operand, scope);
169            }
170            Node::BinaryOp { op, left, right } if op == "&&" || op == "||" => {
171                self.check_predicate_boolean(left, scope);
172                self.check_predicate_boolean(right, scope);
173            }
174            Node::PropertyAccess { object, property }
175            | Node::OptionalPropertyAccess { object, property }
176                if self.is_predicate_method(object, property, scope) =>
177            {
178                self.error_at(Code::PredicateSiteInvalid,
179                    "predicate evaluation cannot be captured as a function value; use a typed helper with a literal site".into(), node.span);
180            }
181            Node::OptionalMethodCall { object, method, .. }
182                if self.is_predicate_method(object, method, scope) =>
183            {
184                self.error_at(Code::PredicateSiteInvalid,
185                    "predicate evaluation requires an unconditional capability call; handle capability absence explicitly".into(), node.span);
186            }
187            _ => {}
188        }
189    }
190
191    pub(super) fn check_predicate_call(&mut self, args: &[SNode], scope: &TypeScope, span: Span) {
192        let [id, question, input, policy] = args else {
193            return; // Ordinary signature checking owns arity.
194        };
195        let literal = |node: &SNode| match &node.node {
196            Node::StringLiteral(text) | Node::RawStringLiteral(text) if !text.is_empty() => {
197                Some(text.clone())
198            }
199            _ => None,
200        };
201        let (Some(id), Some(question)) = (literal(id), literal(question)) else {
202            self.error_at(
203                Code::PredicateSiteInvalid,
204                "predicate id and question must be nonempty string literals".into(),
205                span,
206            );
207            return;
208        };
209        let Some(input_type) = self.infer_type(input, scope) else {
210            self.predicate_input_error(input.span);
211            return;
212        };
213        let input_type = self.resolve_alias(&input_type, scope);
214        if !serializable(&input_type) {
215            self.predicate_input_error(input.span);
216            return;
217        }
218        // Gradual typing ordinarily allows `any` at a typed call boundary.
219        // Predicate admission must not accept an opaque policy that way.
220        let policy_is_closed = self
221            .infer_type(policy, scope)
222            .is_some_and(|ty| serializable(&self.resolve_alias(&ty, scope)));
223        if !policy_is_closed {
224            self.error_at(
225                Code::PredicateInputInvalid,
226                "predicate policy must have a closed typed record".into(),
227                policy.span,
228            );
229        }
230        if self
231            .predicate_sites
232            .iter()
233            .any(|site| site.id == id && (site.start != span.start || site.end != span.end))
234        {
235            self.error_at(
236                Code::PredicateSiteInvalid,
237                format!("predicate id `{id}` is declared by more than one source site"),
238                span,
239            );
240            return;
241        }
242        if !self
243            .predicate_sites
244            .iter()
245            .any(|site| site.start == span.start && site.end == span.end)
246        {
247            self.predicate_sites.push(PredicateSite {
248                model_route: model_route(policy, scope),
249                id,
250                question,
251                input_type,
252                line: span.line,
253                column: span.column,
254                start: span.start,
255                end: span.end,
256            });
257        }
258    }
259
260    fn predicate_input_error(&mut self, span: Span) {
261        self.error_at(
262            Code::PredicateInputInvalid,
263            "predicate input must have a closed serializable type; functions, handles, open records and unvalidated values are not accepted".into(),
264            span,
265        );
266    }
267
268    pub(super) fn is_predicate_outcome(&self, ty: &TypeExpr, scope: &TypeScope) -> bool {
269        let ty = self.resolve_alias(ty, scope);
270        let Some(ty) = super::union::without_nil(&ty) else {
271            return false;
272        };
273        let members = match &ty {
274            TypeExpr::Union(members) => members.as_slice(),
275            other => std::slice::from_ref(other),
276        };
277        if members.is_empty()
278            || !members.iter().all(|member| {
279                matches!(member, TypeExpr::Shape(fields) if fields.iter().any(|field| field.name == "receipt"))
280            })
281        {
282            return false;
283        }
284        static VARIANTS: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
285        let variants = VARIANTS.get_or_init(|| {
286            let TypeExpr::Union(variants) = harn_builtin_meta::predicate::OUTCOME.to_type_expr()
287            else {
288                unreachable!("predicate outcome is a closed union");
289            };
290            variants.iter().map(canonical_type).collect()
291        });
292        members
293            .iter()
294            .all(|member| variants.contains(&canonical_type(member)))
295    }
296
297    fn check_predicate_field(
298        &mut self,
299        ty: &TypeExpr,
300        field: Option<&str>,
301        span: Span,
302        scope: &TypeScope,
303    ) {
304        if !self.is_predicate_outcome(ty, scope) {
305            return;
306        }
307        let ty = self.resolve_alias(ty, scope);
308        let Some(ty) = super::union::without_nil(&ty) else {
309            return;
310        };
311        let members = match &ty {
312            TypeExpr::Union(members) => members.as_slice(),
313            other => std::slice::from_ref(other),
314        };
315        if field.is_some_and(|name| members.iter().all(|member| {
316            matches!(member, TypeExpr::Shape(fields) if fields.iter().any(|field| field.name == name))
317        })) { return; }
318        self.error_at_with_help(
319            Code::PredicateOutcomeUnnarrowed,
320            "predicate variant field is not available on every remaining outcome".into(),
321            span,
322            "match outcome.kind before accessing a variant field; use a named field rather than a dynamic index".into(),
323        );
324    }
325
326    pub(super) fn check_predicate_boolean(&mut self, node: &SNode, scope: &TypeScope) {
327        if self
328            .infer_type(node, scope)
329            .is_some_and(|ty| self.is_predicate_outcome(&ty, scope))
330        {
331            self.error_at_with_help(
332                Code::PredicateBooleanUse,
333                "a predicate outcome is not a boolean".into(),
334                node.span,
335                "match outcome.kind, then branch on outcome.value.verdict only in the verdict arm"
336                    .into(),
337            );
338        }
339    }
340
341    pub(super) fn record_predicate_binding(
342        &mut self,
343        pattern: &BindingPattern,
344        inferred: Option<&TypeExpr>,
345        span: Span,
346        scope: &TypeScope,
347    ) {
348        if !inferred.is_some_and(|ty| self.is_predicate_outcome(ty, scope)) {
349            return;
350        }
351        if let (BindingPattern::Dict(fields), Some(ty)) = (pattern, inferred) {
352            for field in fields {
353                if !field.is_rest {
354                    self.check_predicate_field(ty, Some(&field.key), span, scope);
355                }
356            }
357        }
358        if let BindingPattern::Identifier(name) = pattern {
359            if is_discard_name(name) {
360                self.unused_predicate_error(span);
361            } else {
362                let binding = crate::lexical::BindingId {
363                    name: name.clone(),
364                    declaration_start: span.start,
365                    declaration_end: span.end,
366                };
367                if !self
368                    .predicate_bindings
369                    .iter()
370                    .any(|(existing, _)| *existing == binding)
371                {
372                    self.predicate_bindings.push((binding, span));
373                }
374            }
375        }
376    }
377
378    pub(super) fn unused_predicate_error(&mut self, span: Span) {
379        self.error_at_with_help(
380            Code::PredicateOutcomeUnused,
381            "predicate outcome is discarded without a disposition".into(),
382            span,
383            "match the outcome or pass it to a typed outcome policy".into(),
384        );
385    }
386
387    pub(super) fn check_unused_predicate_bindings(&mut self, program: &[SNode]) {
388        let patterns = crate::lexical::module_match_pattern_catalog_with_visible(
389            program,
390            &self.imported_type_decls,
391        );
392        let used = crate::lexical::resolved_identifier_bindings_with_source(
393            &[],
394            program,
395            self.source.as_deref(),
396            &patterns,
397        );
398        let unused: Vec<_> = self
399            .predicate_bindings
400            .iter()
401            .filter(|(binding, _)| !used.values().any(|used| used == binding))
402            .map(|(_, span)| *span)
403            .collect();
404        for span in unused {
405            self.unused_predicate_error(span);
406        }
407    }
408}