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/// Which entry point declared a site. Both evaluate one question set through
9/// one evaluator; they differ only in the outcome they project.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum PredicateSiteKind {
13    /// `harness.llm.evaluate_predicate`: one boolean question.
14    Predicate,
15    /// `harness.llm.evaluate`: a declared question set over one state.
16    Evaluation,
17}
18
19impl PredicateSiteKind {
20    /// The outcome schema a site of this kind returns.
21    pub fn outcome_schema(self) -> &'static str {
22        match self {
23            Self::Predicate => "harn.predicate.outcome.v1",
24            Self::Evaluation => "harn.evaluation.outcome.v1",
25        }
26    }
27}
28
29/// A checked source site. Consumers hash the canonical type and question set at
30/// their artifact boundary; this record never contains runtime input values.
31#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
32pub struct PredicateSite {
33    pub id: String,
34    pub kind: PredicateSiteKind,
35    /// Every question this site asks. A predicate site holds exactly one
36    /// boolean question, so both kinds project the same manifest census.
37    pub questions: Vec<super::PredicateQuestionSpec>,
38    pub input_type: TypeExpr,
39    pub line: usize,
40    pub column: usize,
41    pub start: usize,
42    pub end: usize,
43    /// The declaration-time route, before catalog admission. An unknown route
44    /// remains explicit so consumers cannot confuse no measurement with support.
45    #[serde(default)]
46    pub model_route: Option<PredicateModelRoute>,
47}
48
49#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
50pub struct PredicateModelRoute {
51    pub provider: String,
52    pub model: String,
53}
54
55fn model_route(policy: &SNode, scope: &TypeScope) -> Option<PredicateModelRoute> {
56    let crate::const_eval::ConstValue::Dict(fields) = scope.const_value(policy)? else {
57        return None;
58    };
59    let string = |name: &str| {
60        fields.iter().find_map(|(key, value)| {
61            if key != name {
62                return None;
63            }
64            match value {
65                crate::const_eval::ConstValue::String(value) => Some(value.clone()),
66                _ => None,
67            }
68        })
69    };
70    Some(PredicateModelRoute {
71        provider: string("provider")?,
72        model: string("model")?,
73    })
74}
75
76/// A deterministic structural identity, independent of field/union ordering
77/// and source spans. This is material for an artifact/cache digest, not a hash.
78pub fn canonical_type(ty: &TypeExpr) -> String {
79    fn normalize(value: &mut serde_json::Value) {
80        match value {
81            serde_json::Value::Object(fields) => {
82                for (name, value) in fields {
83                    normalize(value);
84                    if matches!(name.as_str(), "Shape" | "Union" | "Intersection") {
85                        if let serde_json::Value::Array(items) = value {
86                            items.sort_by_key(serde_json::Value::to_string);
87                        }
88                    }
89                }
90            }
91            serde_json::Value::Array(items) => items.iter_mut().for_each(normalize),
92            _ => {}
93        }
94    }
95    let mut value = serde_json::to_value(ty).expect("type expression serializes");
96    normalize(&mut value);
97    value.to_string()
98}
99
100/// Undo the site-specific narrowing of a batched outcome's answer map, so an
101/// arm recognizes as the contract arm it is. Without this, every evaluation
102/// whose answers were typed from its own questions would stop counting as an
103/// outcome and would escape the unused, boolean-use, and narrowing checks.
104fn declared_answer_map(mut variant: TypeExpr) -> TypeExpr {
105    let TypeExpr::Shape(fields) = &mut variant else {
106        return variant;
107    };
108    let answered = fields.iter().any(|field| {
109        field.name == "kind"
110            && matches!(&field.type_expr, TypeExpr::LitString(kind)
111                if kind == "answered" || kind == "low_confidence")
112    });
113    if !answered {
114        return variant;
115    }
116    for field in fields.iter_mut() {
117        if matches!(field.name.as_str(), "value" | "candidates") {
118            field.type_expr = declared_answer_map_type().clone();
119        }
120    }
121    variant
122}
123
124/// The contract's own `dict<string, EvaluationAnswer>`, read out of the
125/// `answered` arm rather than rebuilt, so the two spellings cannot drift.
126fn declared_answer_map_type() -> &'static TypeExpr {
127    static DECLARED: std::sync::OnceLock<TypeExpr> = std::sync::OnceLock::new();
128    DECLARED.get_or_init(|| {
129        let TypeExpr::Union(variants) =
130            harn_builtin_meta::predicate::EVALUATION_OUTCOME.to_type_expr()
131        else {
132            unreachable!("an evaluation outcome is a closed union");
133        };
134        variants
135            .into_iter()
136            .find_map(|variant| {
137                let TypeExpr::Shape(fields) = variant else {
138                    return None;
139                };
140                fields.iter().any(|field| {
141                    field.name == "kind"
142                        && matches!(&field.type_expr, TypeExpr::LitString(kind) if kind == "answered")
143                }).then(|| {
144                    fields
145                        .into_iter()
146                        .find(|field| field.name == "value")
147                        .expect("the answered arm carries its answers")
148                        .type_expr
149                })
150            })
151            .expect("the evaluation outcome declares an answered arm")
152    })
153}
154
155fn literal_text(node: &SNode) -> Option<String> {
156    match &node.node {
157        Node::StringLiteral(text) | Node::RawStringLiteral(text) if !text.is_empty() => {
158            Some(text.clone())
159        }
160        _ => None,
161    }
162}
163
164fn serializable(ty: &TypeExpr) -> bool {
165    match ty {
166        TypeExpr::Named(name) => {
167            matches!(name.as_str(), "string" | "bool" | "int" | "float" | "nil")
168        }
169        TypeExpr::LitString(_) | TypeExpr::LitInt(_) => true,
170        TypeExpr::Shape(fields) => fields.iter().all(|field| serializable(&field.type_expr)),
171        TypeExpr::List(item) => serializable(item),
172        TypeExpr::Tuple(items) | TypeExpr::Union(items) => {
173            !items.is_empty() && items.iter().all(serializable)
174        }
175        TypeExpr::DictType(key, value) => {
176            matches!(key.as_ref(), TypeExpr::Named(name) if name == "string") && serializable(value)
177        }
178        _ => false,
179    }
180}
181
182impl TypeChecker {
183    fn predicate_method_name(method: &str) -> bool {
184        crate::builtin_signatures::lookup_capability_method(
185            harn_builtin_meta::CapabilityId::Llm,
186            method,
187        )
188        .is_some_and(|signature| {
189            signature.name == harn_builtin_meta::predicate::EVALUATE.name
190                || signature.name == harn_builtin_meta::predicate::EVALUATE_PREDICATE.name
191        })
192    }
193
194    fn is_predicate_method(&self, object: &SNode, method: &str, scope: &TypeScope) -> bool {
195        if !Self::predicate_method_name(method) {
196            return false;
197        }
198        let Some(ty) = self.infer_type(object, scope) else {
199            return false;
200        };
201        let ty = self.resolve_alias(&ty, scope);
202        let Some(TypeExpr::Named(name)) = super::union::without_nil(&ty) else {
203            return false;
204        };
205        harn_builtin_meta::CapabilityId::from_type_name(&name)
206            == Some(harn_builtin_meta::CapabilityId::Llm)
207    }
208
209    pub(super) fn check_predicate_node(&mut self, node: &SNode, scope: &TypeScope) {
210        let projection = match &node.node {
211            Node::PropertyAccess { object, property }
212            | Node::OptionalPropertyAccess { object, property } => {
213                Some((object, Some(property.as_str())))
214            }
215            Node::SubscriptAccess { object, index }
216            | Node::OptionalSubscriptAccess { object, index } => {
217                let field = match &index.node {
218                    Node::StringLiteral(name) | Node::RawStringLiteral(name) => Some(name.as_str()),
219                    _ => None,
220                };
221                Some((object, field))
222            }
223            _ => None,
224        };
225        if let Some((object, field)) = projection {
226            if let Some(ty) = self.infer_type(object, scope) {
227                self.check_predicate_field(&ty, field, node.span, scope);
228            }
229        }
230        // Only a call. `evaluate` is an ordinary field name, and reading
231        // `config?.evaluate` off an untyped record is data, not an erased
232        // capability. An erased receiver still has to call the method to
233        // evaluate anything, and runtime admission refuses a call that no
234        // checked site in the artifact owns.
235        let named_receiver = match &node.node {
236            Node::MethodCall { object, method, .. }
237            | Node::OptionalMethodCall { object, method, .. } => Some((object, method)),
238            _ => None,
239        };
240        if let Some((object, method)) = named_receiver {
241            if Self::predicate_method_name(method)
242                && self.infer_type(object, scope).is_none_or(|ty| {
243                    matches!(self.resolve_alias(&ty, scope), TypeExpr::Named(name)
244                        if matches!(name.as_str(), "any" | "unknown" | "dict" | "_"))
245                })
246            {
247                self.error_at_with_help(
248                    Code::PredicateSiteInvalid,
249                    "predicate method receiver has no statically resolved type".into(),
250                    node.span,
251                    "retain HarnessLlm in the helper signature instead of erasing it to an unvalidated value".into(),
252                );
253            }
254        }
255        match &node.node {
256            Node::IfElse { condition, .. }
257            | Node::WhileLoop { condition, .. }
258            | Node::GuardStmt { condition, .. }
259            | Node::RequireStmt { condition, .. }
260            | Node::Ternary { condition, .. } => self.check_predicate_boolean(condition, scope),
261            Node::UnaryOp { op, operand } if op == "!" => {
262                self.check_predicate_boolean(operand, scope);
263            }
264            Node::BinaryOp { op, left, right } if op == "&&" || op == "||" => {
265                self.check_predicate_boolean(left, scope);
266                self.check_predicate_boolean(right, scope);
267            }
268            Node::PropertyAccess { object, property }
269            | Node::OptionalPropertyAccess { object, property }
270                if self.is_predicate_method(object, property, scope) =>
271            {
272                self.error_at(Code::PredicateSiteInvalid,
273                    "predicate evaluation cannot be captured as a function value; use a typed helper with a literal site".into(), node.span);
274            }
275            Node::OptionalMethodCall { object, method, .. }
276                if self.is_predicate_method(object, method, scope) =>
277            {
278                self.error_at(Code::PredicateSiteInvalid,
279                    "predicate evaluation requires an unconditional capability call; handle capability absence explicitly".into(), node.span);
280            }
281            _ => {}
282        }
283    }
284
285    pub(super) fn check_predicate_call(&mut self, args: &[SNode], scope: &TypeScope, span: Span) {
286        let [id, question, input, policy] = args else {
287            return; // Ordinary signature checking owns arity.
288        };
289        let (Some(id), Some(question)) = (literal_text(id), literal_text(question)) else {
290            self.error_at(
291                Code::PredicateSiteInvalid,
292                "predicate id and question must be nonempty string literals".into(),
293                span,
294            );
295            return;
296        };
297        // The boolean projection asks one question, named by the site, so both
298        // entry points record the same question census.
299        let questions = vec![super::PredicateQuestionSpec {
300            id: id.clone(),
301            kind: super::PredicateQuestionKind::Boolean,
302            instructions: question,
303            labels: Vec::new(),
304        }];
305        self.record_predicate_site(
306            PredicateSiteKind::Predicate,
307            id,
308            questions,
309            input,
310            policy,
311            scope,
312            span,
313        );
314    }
315
316    pub(super) fn check_evaluation_call(&mut self, args: &[SNode], scope: &TypeScope, span: Span) {
317        let [id, state, questions, policy] = args else {
318            return; // Ordinary signature checking owns arity.
319        };
320        let Some(id) = literal_text(id) else {
321            self.error_at(
322                Code::PredicateSiteInvalid,
323                "evaluation id must be a nonempty string literal".into(),
324                span,
325            );
326            return;
327        };
328        let questions = match self.question_set(questions, scope) {
329            Ok(questions) => questions,
330            Err(error) => {
331                self.error_at_with_help(
332                    Code::PredicateQuestionSetInvalid,
333                    error.message(),
334                    questions.span,
335                    error.help(),
336                );
337                return;
338            }
339        };
340        self.record_predicate_site(
341            PredicateSiteKind::Evaluation,
342            id,
343            questions,
344            state,
345            policy,
346            scope,
347            span,
348        );
349    }
350
351    #[allow(clippy::too_many_arguments)]
352    fn record_predicate_site(
353        &mut self,
354        kind: PredicateSiteKind,
355        id: String,
356        questions: Vec<super::PredicateQuestionSpec>,
357        input: &SNode,
358        policy: &SNode,
359        scope: &TypeScope,
360        span: Span,
361    ) {
362        let Some(input_type) = self.infer_type(input, scope) else {
363            self.predicate_input_error(input.span);
364            return;
365        };
366        let input_type = self.resolve_alias(&input_type, scope);
367        if !serializable(&input_type) {
368            self.predicate_input_error(input.span);
369            return;
370        }
371        // Gradual typing ordinarily allows `any` at a typed call boundary.
372        // Predicate admission must not accept an opaque policy that way.
373        let policy_is_closed = self
374            .infer_type(policy, scope)
375            .is_some_and(|ty| serializable(&self.resolve_alias(&ty, scope)));
376        if !policy_is_closed {
377            self.error_at(
378                Code::PredicateInputInvalid,
379                "predicate policy must have a closed typed record".into(),
380                policy.span,
381            );
382        }
383        if self
384            .predicate_sites
385            .iter()
386            .any(|site| site.id == id && (site.start != span.start || site.end != span.end))
387        {
388            self.error_at(
389                Code::PredicateSiteInvalid,
390                format!("predicate id `{id}` is declared by more than one source site"),
391                span,
392            );
393            return;
394        }
395        if !self
396            .predicate_sites
397            .iter()
398            .any(|site| site.start == span.start && site.end == span.end)
399        {
400            self.predicate_sites.push(PredicateSite {
401                model_route: model_route(policy, scope),
402                id,
403                kind,
404                questions,
405                input_type,
406                line: span.line,
407                column: span.column,
408                start: span.start,
409                end: span.end,
410            });
411        }
412    }
413
414    fn predicate_input_error(&mut self, span: Span) {
415        self.error_at(
416            Code::PredicateInputInvalid,
417            "predicate input must have a closed serializable type; functions, handles, open records and unvalidated values are not accepted".into(),
418            span,
419        );
420    }
421
422    pub(super) fn is_predicate_outcome(&self, ty: &TypeExpr, scope: &TypeScope) -> bool {
423        let ty = self.resolve_alias(ty, scope);
424        let Some(ty) = super::union::without_nil(&ty) else {
425            return false;
426        };
427        let members = match &ty {
428            TypeExpr::Union(members) => members.as_slice(),
429            other => std::slice::from_ref(other),
430        };
431        if members.is_empty()
432            || !members.iter().all(|member| {
433                matches!(member, TypeExpr::Shape(fields) if fields.iter().any(|field| field.name == "receipt"))
434            })
435        {
436            return false;
437        }
438        static VARIANTS: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
439        let variants = VARIANTS.get_or_init(|| {
440            // Both entry points return a closed outcome, and both must be
441            // recognized here: a batched outcome discarded, used as a boolean,
442            // or projected without narrowing is the same defect.
443            [
444                harn_builtin_meta::predicate::OUTCOME,
445                harn_builtin_meta::predicate::EVALUATION_OUTCOME,
446            ]
447            .into_iter()
448            .flat_map(|outcome| {
449                let TypeExpr::Union(variants) = outcome.to_type_expr() else {
450                    unreachable!("an evaluation outcome is a closed union");
451                };
452                variants
453            })
454            .map(|variant| canonical_type(&declared_answer_map(variant)))
455            .collect()
456        });
457        members
458            .iter()
459            .all(|member| variants.contains(&canonical_type(&declared_answer_map(member.clone()))))
460    }
461
462    fn check_predicate_field(
463        &mut self,
464        ty: &TypeExpr,
465        field: Option<&str>,
466        span: Span,
467        scope: &TypeScope,
468    ) {
469        if !self.is_predicate_outcome(ty, scope) {
470            return;
471        }
472        let ty = self.resolve_alias(ty, scope);
473        let Some(ty) = super::union::without_nil(&ty) else {
474            return;
475        };
476        let members = match &ty {
477            TypeExpr::Union(members) => members.as_slice(),
478            other => std::slice::from_ref(other),
479        };
480        if field.is_some_and(|name| members.iter().all(|member| {
481            matches!(member, TypeExpr::Shape(fields) if fields.iter().any(|field| field.name == name))
482        })) { return; }
483        self.error_at_with_help(
484            Code::PredicateOutcomeUnnarrowed,
485            "predicate variant field is not available on every remaining outcome".into(),
486            span,
487            "match outcome.kind before accessing a variant field; use a named field rather than a dynamic index".into(),
488        );
489    }
490
491    pub(super) fn check_predicate_boolean(&mut self, node: &SNode, scope: &TypeScope) {
492        if self
493            .infer_type(node, scope)
494            .is_some_and(|ty| self.is_predicate_outcome(&ty, scope))
495        {
496            self.error_at_with_help(
497                Code::PredicateBooleanUse,
498                "a predicate outcome is not a boolean".into(),
499                node.span,
500                "match outcome.kind, then branch on outcome.value.verdict only in the verdict arm"
501                    .into(),
502            );
503        }
504    }
505
506    pub(super) fn record_predicate_binding(
507        &mut self,
508        pattern: &BindingPattern,
509        inferred: Option<&TypeExpr>,
510        span: Span,
511        scope: &TypeScope,
512    ) {
513        if !inferred.is_some_and(|ty| self.is_predicate_outcome(ty, scope)) {
514            return;
515        }
516        if let (BindingPattern::Dict(fields), Some(ty)) = (pattern, inferred) {
517            for field in fields {
518                if !field.is_rest {
519                    self.check_predicate_field(ty, Some(&field.key), span, scope);
520                }
521            }
522        }
523        if let BindingPattern::Identifier(name) = pattern {
524            if is_discard_name(name) {
525                self.unused_predicate_error(span);
526            } else {
527                let binding = crate::lexical::BindingId {
528                    name: name.clone(),
529                    declaration_start: span.start,
530                    declaration_end: span.end,
531                };
532                if !self
533                    .predicate_bindings
534                    .iter()
535                    .any(|(existing, _)| *existing == binding)
536                {
537                    self.predicate_bindings.push((binding, span));
538                }
539            }
540        }
541    }
542
543    pub(super) fn unused_predicate_error(&mut self, span: Span) {
544        self.error_at_with_help(
545            Code::PredicateOutcomeUnused,
546            "predicate outcome is discarded without a disposition".into(),
547            span,
548            "match the outcome or pass it to a typed outcome policy".into(),
549        );
550    }
551
552    pub(super) fn check_unused_predicate_bindings(&mut self, program: &[SNode]) {
553        let patterns = crate::lexical::module_match_pattern_catalog_with_visible(
554            program,
555            &self.imported_type_decls,
556        );
557        let used = crate::lexical::resolved_identifier_bindings_with_source(
558            &[],
559            program,
560            self.source.as_deref(),
561            &patterns,
562        );
563        let unused: Vec<_> = self
564            .predicate_bindings
565            .iter()
566            .filter(|(binding, _)| !used.values().any(|used| used == binding))
567            .map(|(_, span)| *span)
568            .collect();
569        for span in unused {
570            self.unused_predicate_error(span);
571        }
572    }
573}