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 RuntimeEvaluation,
19}
20
21impl PredicateSiteKind {
22 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
34pub struct PredicateSite {
35 pub id: String,
36 pub kind: PredicateSiteKind,
37 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 #[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
78pub 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
102fn 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
126fn 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 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; };
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 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; };
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 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 [
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}