1use super::{scope::TypeScope, TypeChecker};
5use crate::{ast::*, builtin_signatures::TyExt, diagnostic_codes::Code};
6use harn_lexer::Span;
7
8#[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 #[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
52pub 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; };
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 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}