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