1use super::{scope::TypeScope, TypeChecker};
5use crate::{ast::*, builtin_signatures::TyExt, diagnostic_codes::Code};
6use harn_lexer::Span;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum PredicateSiteKind {
13 Predicate,
15 Evaluation,
17}
18
19impl PredicateSiteKind {
20 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
32pub struct PredicateSite {
33 pub id: String,
34 pub kind: PredicateSiteKind,
35 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 #[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
76pub 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
100fn 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
124fn 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 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; };
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 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; };
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 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 [
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}