1use std::collections::{BTreeMap, BTreeSet};
10
11use rustev_contract::decimal::Decimal;
12use rustev_contract::definition::{Expr, Literal, Predicate, ReasonKind};
13use rustev_contract::judgment::{Excluded, Filtered, RankEntry, Ranking, Shortlist, Unresolved};
14use serde::de::DeserializeOwned;
15use serde::{Deserialize, Serialize};
16use serde_json::Value as Json;
17
18use crate::expr::{
19 Fault, Ref, Scope, StepTy, TyEnv, eval_expr, eval_predicate, eval_ref, expr_refs,
20 expr_structure, predicate_refs, predicate_structure, ref_ty, type_expr, type_predicate,
21};
22use crate::sem::{SemInstances, SemKind, SemValue};
23use crate::value::{JoinMatch, Ty, Value};
24
25pub const REGISTRY: &[(&str, u32)] = &[
27 ("count_where", 1),
28 ("window_filter", 1),
29 ("extremum", 1),
30 ("timestamp_diff", 1),
31 ("compare", 1),
32 ("arith", 1),
33 ("fx_convert", 1),
34 ("member_of", 1),
35 ("field_presence", 1),
36 ("filter_with_reasons", 1),
37 ("top_k", 1),
38 ("weighted_rank", 1),
39];
40
41pub const REGISTRY_V2_ADDS: &[(&str, u32)] =
43 &[("join_keyed", 1), ("merge_fields", 1), ("compose_key", 1)];
44
45pub fn is_registered(op: &str, version: u32) -> bool {
46 REGISTRY.iter().any(|(n, v)| *n == op && *v == version)
47}
48
49pub fn is_registered_v2(op: &str, version: u32) -> bool {
51 is_registered(op, version)
52 || REGISTRY_V2_ADDS
53 .iter()
54 .any(|(n, v)| *n == op && *v == version)
55}
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct ComposeKey {
61 pub parts: Vec<KeyPart>,
62 pub separator: String,
63}
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct KeyPart {
67 pub value: Expr,
68 pub normalize: KeyNormalization,
69}
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum KeyNormalization {
73 Exact,
74 AsciiToken,
75}
76impl ComposeKey {
77 fn eval(&self, scope: &Scope<'_>) -> Result<Value, Fault> {
78 let mut parts = Vec::new();
79 for p in &self.parts {
80 let text = match eval_expr(&p.value, scope)? {
81 Value::Text(s) | Value::Enum(s) => s,
82 Value::Integer(n) => n.to_string(),
83 _ => return Err(Fault::Invalid("compose_key part type".into())),
84 };
85 let text = if p.normalize == KeyNormalization::AsciiToken {
89 let mut out = String::new();
90 for b in text.trim_matches(|c: char| c.is_ascii_whitespace()).bytes() {
91 let b = b.to_ascii_lowercase();
92 if b.is_ascii_whitespace() || b == b'-' || b == b'_' {
93 if !out.ends_with('-') {
94 out.push('-');
95 }
96 } else if b.is_ascii_lowercase() || b.is_ascii_digit() {
97 out.push(char::from(b));
98 } else {
99 return Err(Fault::Invalid(
100 "compose_key invalid ascii_token byte".into(),
101 ));
102 }
103 }
104 out
105 } else {
106 text
107 };
108 if text.is_empty() || text.contains(&self.separator) {
109 return Err(Fault::Invalid(
110 "compose_key empty part or separator in part".into(),
111 ));
112 }
113 parts.push(text);
114 }
115 let key = parts.join(&self.separator);
116 if key.len() > 512 {
117 return Err(Fault::Invalid("compose_key exceeds 512 bytes".into()));
118 }
119 Ok(Value::Text(key))
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct JoinKeyed {
127 pub left: String,
129 pub key: String,
131 pub right: String,
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct MergeFields {
139 pub base: String,
140 pub overlay: String,
141 pub rules: Vec<FieldRule>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub present_when: Option<rustev_contract::grade::PresentWhen>,
145}
146
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct FieldRule {
150 pub field: String,
151 pub rule: MergeRule,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case", deny_unknown_fields)]
156pub enum MergeRule {
157 OverlayOnly,
158 BaseOnly,
159 OverlayElseBaseIfGradeAtLeast {
160 grade: rustev_contract::grade::Grade,
161 },
162}
163
164impl MergeRule {
165 pub fn name(&self) -> &'static str {
166 match self {
167 MergeRule::OverlayOnly => "overlay_only",
168 MergeRule::BaseOnly => "base_only",
169 MergeRule::OverlayElseBaseIfGradeAtLeast { .. } => {
170 "overlay_else_base_if_grade_at_least"
171 }
172 }
173 }
174
175 pub fn reads_base(&self) -> bool {
176 !matches!(self, MergeRule::OverlayOnly)
177 }
178
179 pub fn reads_overlay(&self) -> bool {
180 !matches!(self, MergeRule::BaseOnly)
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185#[serde(deny_unknown_fields)]
186pub struct CountWhere {
187 pub list: String,
188 pub predicate: Predicate,
189}
190
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct WindowFilter {
194 pub list: String,
195 pub field: String,
196 pub within_ms: u64,
197 pub predicate: Predicate,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub enum Which {
203 Min,
204 Max,
205}
206
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208#[serde(deny_unknown_fields)]
209pub struct Extremum {
210 pub list: String,
211 pub field: String,
212 pub which: Which,
213}
214
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216#[serde(deny_unknown_fields)]
217pub struct TimestampDiff {
218 pub later: Expr,
219 pub earlier: Expr,
220 pub unit_ms: u64,
221}
222
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224#[serde(deny_unknown_fields)]
225pub struct Compare {
226 pub predicate: Predicate,
227}
228
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230#[serde(deny_unknown_fields)]
231pub struct Arith {
232 pub expr: Expr,
233}
234
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct FxConvert {
238 pub amount: Expr,
239 pub from: Expr,
240 pub to: Expr,
241 pub rates: String,
242 pub scale: u32,
243}
244
245#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
246#[serde(deny_unknown_fields)]
247pub struct MemberOf {
248 pub value: Expr,
249 pub set: Vec<Literal>,
250}
251
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253#[serde(deny_unknown_fields)]
254pub struct FieldPresence {
255 pub inputs: Vec<String>,
257}
258
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260#[serde(deny_unknown_fields)]
261pub struct NamedRule {
262 pub name: String,
263 pub predicate: Predicate,
264}
265
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
267#[serde(deny_unknown_fields)]
268pub struct FilterWithReasons {
269 pub list: String,
270 pub id_field: String,
271 pub rules: Vec<NamedRule>,
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "snake_case")]
276pub enum Direction {
277 Ascending,
278 Descending,
279}
280
281#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
282#[serde(deny_unknown_fields)]
283pub struct TopK {
284 pub list: String,
285 pub id_field: String,
286 pub among: String,
288 pub key: Expr,
289 pub direction: Direction,
290 pub k: u64,
291}
292
293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
294#[serde(deny_unknown_fields)]
295pub struct WeightTable {
296 pub list: String,
298 pub key_field: String,
299 pub class_field: String,
300 pub table: Vec<ClassWeight>,
301}
302
303#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
304#[serde(deny_unknown_fields)]
305pub struct ClassWeight {
306 pub class: String,
307 pub weight: Decimal,
308}
309
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
311#[serde(rename_all = "snake_case", deny_unknown_fields)]
312pub enum Source {
313 PropositionMean {
316 step: String,
317 candidate_bind: String,
318 weights: WeightTable,
319 },
320 RubricExpectation { step: String },
322 ShortlistPosition,
324}
325
326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
327#[serde(deny_unknown_fields)]
328pub struct Component {
329 pub name: String,
330 pub weight: Decimal,
331 pub source: Source,
332}
333
334#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
335#[serde(deny_unknown_fields)]
336pub struct WeightedRank {
337 pub candidates: String,
339 pub components: Vec<Component>,
340}
341
342#[derive(Debug, Clone, PartialEq)]
344pub enum OpArgs {
345 CountWhere(CountWhere),
346 WindowFilter(WindowFilter),
347 Extremum(Extremum),
348 TimestampDiff(TimestampDiff),
349 Compare(Compare),
350 Arith(Arith),
351 FxConvert(FxConvert),
352 MemberOf(MemberOf),
353 FieldPresence(FieldPresence),
354 FilterWithReasons(FilterWithReasons),
355 TopK(TopK),
356 WeightedRank(WeightedRank),
357 JoinKeyed(JoinKeyed),
358 MergeFields(MergeFields),
359 ComposeKey(ComposeKey),
360}
361
362fn de<T: DeserializeOwned>(args: &Json) -> Result<T, String> {
363 serde_json::from_value(args.clone()).map_err(|e| e.to_string())
364}
365
366impl OpArgs {
367 pub fn op(&self) -> (&'static str, u32) {
369 match self {
370 OpArgs::CountWhere(_) => ("count_where", 1),
371 OpArgs::WindowFilter(_) => ("window_filter", 1),
372 OpArgs::Extremum(_) => ("extremum", 1),
373 OpArgs::TimestampDiff(_) => ("timestamp_diff", 1),
374 OpArgs::Compare(_) => ("compare", 1),
375 OpArgs::Arith(_) => ("arith", 1),
376 OpArgs::FxConvert(_) => ("fx_convert", 1),
377 OpArgs::MemberOf(_) => ("member_of", 1),
378 OpArgs::FieldPresence(_) => ("field_presence", 1),
379 OpArgs::FilterWithReasons(_) => ("filter_with_reasons", 1),
380 OpArgs::TopK(_) => ("top_k", 1),
381 OpArgs::WeightedRank(_) => ("weighted_rank", 1),
382 OpArgs::JoinKeyed(_) => ("join_keyed", 1),
383 OpArgs::MergeFields(_) => ("merge_fields", 1),
384 OpArgs::ComposeKey(_) => ("compose_key", 1),
385 }
386 }
387
388 pub fn to_json(&self) -> Json {
390 let v = match self {
391 OpArgs::CountWhere(a) => serde_json::to_value(a),
392 OpArgs::WindowFilter(a) => serde_json::to_value(a),
393 OpArgs::Extremum(a) => serde_json::to_value(a),
394 OpArgs::TimestampDiff(a) => serde_json::to_value(a),
395 OpArgs::Compare(a) => serde_json::to_value(a),
396 OpArgs::Arith(a) => serde_json::to_value(a),
397 OpArgs::FxConvert(a) => serde_json::to_value(a),
398 OpArgs::MemberOf(a) => serde_json::to_value(a),
399 OpArgs::FieldPresence(a) => serde_json::to_value(a),
400 OpArgs::FilterWithReasons(a) => serde_json::to_value(a),
401 OpArgs::TopK(a) => serde_json::to_value(a),
402 OpArgs::WeightedRank(a) => serde_json::to_value(a),
403 OpArgs::JoinKeyed(a) => serde_json::to_value(a),
404 OpArgs::MergeFields(a) => serde_json::to_value(a),
405 OpArgs::ComposeKey(a) => serde_json::to_value(a),
406 };
407 v.unwrap_or(Json::Null)
409 }
410}
411
412fn plain_field_name(what: &str, name: &str) -> Result<(), String> {
415 if name.is_empty() {
416 return Err(format!("{what} is empty"));
417 }
418 if name.contains(['/', '.', '*']) {
419 return Err(format!(
420 "{what} {name:?} is not a field name: it holds '/', '.' or '*'"
421 ));
422 }
423 Ok(())
424}
425
426pub fn decode(op: &str, version: u32, args: &Json) -> Option<Result<OpArgs, String>> {
429 if !is_registered(op, version) {
430 return None;
431 }
432 decode_any(op, args)
433}
434
435pub fn decode_v2(op: &str, version: u32, args: &Json) -> Option<Result<OpArgs, String>> {
437 if !is_registered_v2(op, version) {
438 return None;
439 }
440 match op {
441 "compose_key" => Some(de(args).map(OpArgs::ComposeKey)),
442 "join_keyed" => Some(de(args).map(OpArgs::JoinKeyed)),
443 "merge_fields" => Some(de(args).map(OpArgs::MergeFields)),
444 _ => decode_any(op, args),
445 }
446}
447
448fn decode_any(op: &str, args: &Json) -> Option<Result<OpArgs, String>> {
449 Some(match op {
450 "count_where" => de(args).map(OpArgs::CountWhere),
451 "window_filter" => de(args).map(OpArgs::WindowFilter),
452 "extremum" => de(args).map(OpArgs::Extremum),
453 "timestamp_diff" => de(args).map(OpArgs::TimestampDiff),
454 "compare" => de(args).map(OpArgs::Compare),
455 "arith" => de(args).map(OpArgs::Arith),
456 "fx_convert" => de(args).map(OpArgs::FxConvert),
457 "member_of" => de(args).map(OpArgs::MemberOf),
458 "field_presence" => de(args).map(OpArgs::FieldPresence),
459 "filter_with_reasons" => de(args).map(OpArgs::FilterWithReasons),
460 "top_k" => de(args).map(OpArgs::TopK),
461 _ => de(args).map(OpArgs::WeightedRank),
462 })
463}
464
465fn has_fx_expr(e: &Expr) -> bool {
466 match e {
467 Expr::Fx { .. } => true,
468 Expr::Ref(_) | Expr::Lit(_) => false,
469 Expr::Add { left, right }
470 | Expr::Sub { left, right }
471 | Expr::Mul { left, right }
472 | Expr::Div { left, right } => has_fx_expr(left) || has_fx_expr(right),
473 Expr::Round { value, .. } | Expr::Len(value) | Expr::ToDecimal(value) => has_fx_expr(value),
474 Expr::Graded { .. } => false,
475 }
476}
477
478fn has_fx_pred(p: &Predicate) -> bool {
479 match p {
480 Predicate::Cmp { left, right, .. } => has_fx_expr(left) || has_fx_expr(right),
481 Predicate::All(ps) | Predicate::Any(ps) => ps.iter().any(has_fx_pred),
482 Predicate::Not(p) => has_fx_pred(p),
483 Predicate::Member { value, .. } | Predicate::MemberRef { value, .. } => has_fx_expr(value),
484 Predicate::Present(_) | Predicate::IsConstructed { .. } => false,
485 }
486}
487
488fn deps_only(refs: Vec<String>) -> Vec<String> {
491 let mut seen = BTreeSet::new();
492 refs.into_iter()
493 .filter(|r| r.starts_with("input:") || r.starts_with("step:"))
494 .map(|r| crate::expr::base_ref(&r))
495 .filter(|r| seen.insert(r.clone()))
496 .collect()
497}
498
499impl OpArgs {
500 pub fn refs(&self) -> Vec<String> {
502 let mut out = Vec::new();
503 match self {
504 OpArgs::ComposeKey(a) => {
505 for p in &a.parts {
506 expr_refs(&p.value, &mut out);
507 }
508 }
509 OpArgs::CountWhere(a) => {
510 out.push(a.list.clone());
511 predicate_refs(&a.predicate, &mut out);
512 }
513 OpArgs::WindowFilter(a) => {
514 out.push(a.list.clone());
515 predicate_refs(&a.predicate, &mut out);
516 }
517 OpArgs::Extremum(a) => out.push(a.list.clone()),
518 OpArgs::TimestampDiff(a) => {
519 expr_refs(&a.later, &mut out);
520 expr_refs(&a.earlier, &mut out);
521 }
522 OpArgs::Compare(a) => predicate_refs(&a.predicate, &mut out),
523 OpArgs::Arith(a) => expr_refs(&a.expr, &mut out),
524 OpArgs::FxConvert(a) => {
525 expr_refs(&a.amount, &mut out);
526 expr_refs(&a.from, &mut out);
527 expr_refs(&a.to, &mut out);
528 out.push(a.rates.clone());
529 }
530 OpArgs::MemberOf(a) => expr_refs(&a.value, &mut out),
531 OpArgs::FieldPresence(a) => out.extend(a.inputs.iter().cloned()),
532 OpArgs::FilterWithReasons(a) => {
533 out.push(a.list.clone());
534 a.rules
535 .iter()
536 .for_each(|r| predicate_refs(&r.predicate, &mut out));
537 }
538 OpArgs::TopK(a) => {
539 out.push(a.list.clone());
540 out.push(a.among.clone());
541 expr_refs(&a.key, &mut out);
542 }
543 OpArgs::WeightedRank(a) => {
544 out.push(a.candidates.clone());
545 for c in &a.components {
546 match &c.source {
547 Source::PropositionMean { step, weights, .. } => {
548 out.push(step.clone());
549 out.push(weights.list.clone());
550 }
551 Source::RubricExpectation { step } => out.push(step.clone()),
552 Source::ShortlistPosition => {}
553 }
554 }
555 }
556 OpArgs::JoinKeyed(a) => {
557 out.push(a.left.clone());
558 out.push(a.right.clone());
559 }
560 OpArgs::MergeFields(a) => {
561 out.push(a.base.clone());
562 out.push(a.overlay.clone());
563 }
564 }
565 deps_only(out)
566 }
567
568 pub fn all_ref_strings(&self) -> Vec<String> {
571 let mut out = Vec::new();
572 match self {
573 OpArgs::CountWhere(a) => {
574 out.push(a.list.clone());
575 predicate_refs(&a.predicate, &mut out);
576 }
577 OpArgs::WindowFilter(a) => {
578 out.push(a.list.clone());
579 predicate_refs(&a.predicate, &mut out);
580 }
581 OpArgs::FilterWithReasons(a) => {
582 out.push(a.list.clone());
583 a.rules
584 .iter()
585 .for_each(|r| predicate_refs(&r.predicate, &mut out));
586 }
587 OpArgs::TopK(a) => {
588 out.push(a.list.clone());
589 out.push(a.among.clone());
590 expr_refs(&a.key, &mut out);
591 }
592 _ => out = self.refs(),
593 }
594 out
595 }
596
597 pub fn uses_v2(&self) -> bool {
600 use crate::expr::{expr_uses_v2, predicate_uses_v2};
601 match self {
602 OpArgs::CountWhere(a) => predicate_uses_v2(&a.predicate),
603 OpArgs::WindowFilter(a) => predicate_uses_v2(&a.predicate),
604 OpArgs::Extremum(_) | OpArgs::FieldPresence(_) | OpArgs::WeightedRank(_) => false,
605 OpArgs::JoinKeyed(_) | OpArgs::MergeFields(_) | OpArgs::ComposeKey(_) => true,
606 OpArgs::TimestampDiff(a) => expr_uses_v2(&a.later) || expr_uses_v2(&a.earlier),
607 OpArgs::Compare(a) => predicate_uses_v2(&a.predicate),
608 OpArgs::Arith(a) => expr_uses_v2(&a.expr),
609 OpArgs::FxConvert(a) => {
610 expr_uses_v2(&a.amount) || expr_uses_v2(&a.from) || expr_uses_v2(&a.to)
611 }
612 OpArgs::MemberOf(a) => expr_uses_v2(&a.value),
613 OpArgs::FilterWithReasons(a) => a.rules.iter().any(|r| predicate_uses_v2(&r.predicate)),
614 OpArgs::TopK(a) => expr_uses_v2(&a.key),
615 }
616 }
617
618 pub fn structure(&self) -> Result<(), String> {
620 match self {
621 OpArgs::ComposeKey(a) => {
622 if !(2..=8).contains(&a.parts.len())
623 || ![":", "/", "|", "."].contains(&a.separator.as_str())
624 {
625 return Err("compose_key needs 2..=8 parts and separator : / | .".into());
626 }
627 a.parts.iter().try_for_each(|p| expr_structure(&p.value))
628 }
629 OpArgs::CountWhere(a) => predicate_structure(&a.predicate),
630 OpArgs::WindowFilter(a) => predicate_structure(&a.predicate),
631 OpArgs::Extremum(_) => Ok(()),
632 OpArgs::TimestampDiff(a) => {
633 if a.unit_ms == 0 {
634 return Err("unit_ms must be positive".into());
635 }
636 expr_structure(&a.later)?;
637 expr_structure(&a.earlier)
638 }
639 OpArgs::Compare(a) => predicate_structure(&a.predicate),
640 OpArgs::Arith(a) => expr_structure(&a.expr),
641 OpArgs::FxConvert(a) => {
642 if a.scale > 9 {
643 return Err(format!("scale {} exceeds 9", a.scale));
644 }
645 expr_structure(&a.amount)
646 }
647 OpArgs::MemberOf(a) => {
648 if a.set.is_empty() {
649 return Err("member set is empty".into());
650 }
651 expr_structure(&a.value)
652 }
653 OpArgs::FieldPresence(a) => {
654 if a.inputs.is_empty() {
655 return Err("field_presence names no input".into());
656 }
657 a.inputs.iter().try_for_each(|r| match Ref::parse(r)? {
658 Ref::Input(_, p) if p.is_empty() => Ok(()),
659 _ => Err(format!("field_presence takes inputs, not {r:?}")),
660 })
661 }
662 OpArgs::FilterWithReasons(a) => {
663 let mut names = BTreeSet::new();
664 for r in &a.rules {
665 if !names.insert(&r.name) {
666 return Err(format!("duplicate rule name {:?}", r.name));
667 }
668 predicate_structure(&r.predicate)?;
669 }
670 Ok(())
671 }
672 OpArgs::TopK(a) => {
673 if a.k == 0 {
674 return Err("k must be positive".into());
675 }
676 expr_structure(&a.key)
677 }
678 OpArgs::WeightedRank(a) => {
679 let mut names = BTreeSet::new();
680 for c in &a.components {
681 if !names.insert(&c.name) {
682 return Err(format!("duplicate component {:?}", c.name));
683 }
684 if c.weight < Decimal::ZERO {
685 return Err(format!("negative weight for {:?}", c.name));
686 }
687 }
688 if a.components.is_empty() {
689 return Err("weighted_rank has no component".into());
690 }
691 Ok(())
692 }
693 OpArgs::JoinKeyed(a) => {
694 if a.key.is_empty() {
695 return Err("join_keyed names no key".into());
696 }
697 plain_field_name("join_keyed key", &a.key)
698 }
699 OpArgs::MergeFields(a) => {
700 let mut fields = BTreeSet::new();
701 for r in &a.rules {
702 plain_field_name("merge rule field", &r.field)?;
703 if !fields.insert(&r.field) {
704 return Err(format!("merge rule names {:?} twice", r.field));
705 }
706 }
707 if let Some(p) = &a.present_when {
708 crate::quality::present_when_structure(p)?;
709 }
710 Ok(())
711 }
712 }
713 }
714
715 pub fn own_reasons(&self) -> BTreeSet<ReasonKind> {
717 let mut r = BTreeSet::new();
718 let fx = match self {
719 OpArgs::CountWhere(a) => has_fx_pred(&a.predicate),
720 OpArgs::WindowFilter(a) => has_fx_pred(&a.predicate),
721 OpArgs::Compare(a) => has_fx_pred(&a.predicate),
722 OpArgs::Arith(a) => has_fx_expr(&a.expr),
723 OpArgs::ComposeKey(a) => a.parts.iter().any(|p| has_fx_expr(&p.value)),
724 OpArgs::MemberOf(a) => has_fx_expr(&a.value),
725 OpArgs::FxConvert(_) => true,
726 _ => false,
727 };
728 match self {
729 OpArgs::Extremum(_)
730 | OpArgs::FieldPresence(_)
731 | OpArgs::WeightedRank(_)
732 | OpArgs::MergeFields(_) => {}
733 _ => {
736 r.insert(ReasonKind::InvalidInput);
737 }
738 }
739 if fx {
740 r.insert(ReasonKind::MissingEvidence);
741 r.insert(ReasonKind::Conflict);
742 }
743 r
744 }
745
746 pub fn type_check(&self, env: &TyEnv<'_>) -> Result<Ty, String> {
748 match self {
749 OpArgs::ComposeKey(a) => {
750 for p in &a.parts {
751 if !matches!(
752 type_expr(&p.value, env)?,
753 Ty::Text { .. } | Ty::Integer | Ty::Enum(_)
754 ) {
755 return Err("compose_key parts must be text, integer or enum".into());
756 }
757 }
758 Ok(Ty::Text { max_bytes: 512 })
759 }
760 OpArgs::CountWhere(a) => {
761 let item = list_item(&a.list, env)?;
762 type_predicate(&a.predicate, &with_item(env, &item))?;
763 Ok(Ty::Integer)
764 }
765 OpArgs::WindowFilter(a) => {
766 let (item, max) = list_item_max(&a.list, env)?;
767 match record_field(&item, &a.field)? {
768 Ty::Timestamp => {}
769 t => {
770 return Err(format!(
771 "window field {:?} is {}, not a timestamp",
772 a.field,
773 t.name()
774 ));
775 }
776 }
777 type_predicate(&a.predicate, &with_item(env, &item))?;
778 Ok(Ty::List {
779 item: Box::new(item),
780 max,
781 })
782 }
783 OpArgs::Extremum(a) => {
784 let item = list_item(&a.list, env)?;
785 let t = record_field(&item, &a.field)?;
786 if !t.is_orderable() {
787 return Err(format!("{} has no order", t.name()));
788 }
789 Ok(Ty::Maybe(Box::new(t)))
790 }
791 OpArgs::TimestampDiff(a) => {
792 let mut maybe = false;
793 for e in [&a.later, &a.earlier] {
794 match type_expr(e, env)? {
795 Ty::Timestamp => {}
796 Ty::Maybe(t) if *t == Ty::Timestamp => maybe = true,
797 t => return Err(format!("timestamp_diff of {}", t.name())),
798 }
799 }
800 Ok(if maybe {
801 Ty::Maybe(Box::new(Ty::Integer))
802 } else {
803 Ty::Integer
804 })
805 }
806 OpArgs::Compare(a) => type_predicate(&a.predicate, env).map(|_| Ty::Bool),
807 OpArgs::Arith(a) => match type_expr(&a.expr, env)? {
808 t @ (Ty::Integer | Ty::Decimal) => Ok(t),
809 t => Err(format!("arith yields {}, not a number", t.name())),
810 },
811 OpArgs::FxConvert(a) => {
812 let e = Expr::Fx {
813 amount: Box::new(a.amount.clone()),
814 from: Box::new(a.from.clone()),
815 to: Box::new(a.to.clone()),
816 rates: a.rates.clone(),
817 scale: a.scale,
818 };
819 type_expr(&e, env)
820 }
821 OpArgs::MemberOf(a) => {
822 let p = Predicate::Member {
823 value: a.value.clone(),
824 set: a.set.clone(),
825 };
826 type_predicate(&p, env).map(|_| Ty::Bool)
827 }
828 OpArgs::FieldPresence(a) => {
829 let mut longest = 0u64;
830 for r in &a.inputs {
831 let name = r.trim_start_matches("input:");
832 if !env.inputs.contains_key(name) {
833 return Err(format!("unknown input {name:?}"));
834 }
835 longest = longest.max(name.len() as u64);
836 }
837 Ok(Ty::List {
838 item: Box::new(Ty::Text { max_bytes: longest }),
839 max: a.inputs.len() as u64,
840 })
841 }
842 OpArgs::FilterWithReasons(a) => {
843 let (item, max) = list_item_max(&a.list, env)?;
844 let id_bytes = id_field_bytes(&item, &a.id_field)?;
845 let ienv = with_item(env, &item);
846 for r in &a.rules {
847 type_predicate(&r.predicate, &ienv)?;
848 }
849 Ok(Ty::Filtered { max, id_bytes })
850 }
851 OpArgs::TopK(a) => {
852 let (item, _) = list_item_max(&a.list, env)?;
853 let id_bytes = id_field_bytes(&item, &a.id_field)?;
854 let among_max = match ref_ty(&a.among, env)? {
855 Ty::Filtered { max, .. } => max,
856 t => return Err(format!("top_k among {}, not a filter result", t.name())),
857 };
858 let kt = type_expr(&a.key, &with_item(env, &item))?;
859 if !kt.is_orderable() {
860 return Err(format!("top_k key is {}, which has no order", kt.name()));
861 }
862 Ok(Ty::Shortlist {
863 max: a.k.min(among_max),
864 id_bytes,
865 })
866 }
867 OpArgs::WeightedRank(a) => {
868 let max = match ref_ty(&a.candidates, env)? {
869 Ty::Shortlist { max, .. } | Ty::Filtered { max, .. } => max,
870 t => {
871 return Err(format!(
872 "weighted_rank over {}, not a shortlist or filter",
873 t.name()
874 ));
875 }
876 };
877 for c in &a.components {
878 match &c.source {
879 Source::PropositionMean {
880 step,
881 candidate_bind,
882 weights,
883 } => {
884 match semantic(step, env)? {
885 (k, binds)
886 if k.has_mass()
887 && binds.len() == 2
888 && binds.contains(candidate_bind) => {}
889 (k, binds) => {
890 return Err(format!(
891 "{step} is {} over {} index(es); proposition_mean needs mass over two, one bound as {candidate_bind:?}",
892 k.name(),
893 binds.len()
894 ));
895 }
896 }
897 let item = list_item(&weights.list, env)?;
898 id_field_bytes(&item, &weights.key_field)?;
899 id_field_bytes(&item, &weights.class_field)?;
900 }
901 Source::RubricExpectation { step } => match semantic(step, env)? {
902 (SemKind::Ordinal(_), binds) if binds.len() == 1 => {}
903 (k, _) => {
904 return Err(format!(
905 "{step} is {}; rubric_expectation needs an ordinal level over one index",
906 k.name()
907 ));
908 }
909 },
910 Source::ShortlistPosition => {}
911 }
912 }
913 Ok(Ty::Ranking { max })
914 }
915 OpArgs::JoinKeyed(a) => {
916 let (item, max) = list_item_max(&a.left, env)?;
917 let id_bytes = if a.key == "id" {
918 match &item {
919 Ty::Text { max_bytes } => *max_bytes,
920 Ty::Enum(o) => o.iter().map(|s| s.len() as u64).max().unwrap_or(0),
921 _ => id_field_bytes(&item, &a.key)?,
922 }
923 } else {
924 id_field_bytes(&item, &a.key)?
925 };
926 match ref_ty(&a.right, env)? {
927 Ty::Map { value, .. } => Ok(Ty::Joined {
928 value,
929 max,
930 id_bytes,
931 }),
932 t => Err(format!("join_keyed right is {}, not a map", t.name())),
933 }
934 }
935 OpArgs::MergeFields(a) => {
936 let (base, overlay, list) = merge_sides(a, env)?;
937 let mut out = Vec::new();
938 for r in &a.rules {
939 let b = record_field(&base, &r.field).ok();
940 let o = record_field(&overlay, &r.field).ok();
941 let t = match (&r.rule, b, o) {
942 (MergeRule::OverlayOnly, _, Some(o)) => o,
943 (MergeRule::BaseOnly, Some(b), _) => b,
944 (MergeRule::OverlayElseBaseIfGradeAtLeast { .. }, Some(b), Some(o)) => {
945 if !unwrap_nullable(&b).same_shape(unwrap_nullable(&o)) {
946 return Err(format!(
947 "field {:?} is {} in the base and {} in the overlay",
948 r.field,
949 unwrap_nullable(&b).name(),
950 unwrap_nullable(&o).name()
951 ));
952 }
953 o
954 }
955 _ => return Err(format!("no side of {:?} carries it", r.field)),
956 };
957 let t = match t {
960 Ty::Nullable(t) => Ty::Nullable(t),
961 t => Ty::Nullable(Box::new(t)),
962 };
963 out.push((r.field.clone(), t));
964 }
965 out.sort_by(|(a, _), (b, _)| a.cmp(b));
966 if let Some(p) = &a.present_when {
969 let mut named = Vec::new();
970 p.fields(&mut named);
971 if let Some(n) = named.iter().find(|n| !out.iter().any(|(f, _)| f == *n)) {
972 return Err(format!(
973 "present_when names {n:?}, which is not a merged field"
974 ));
975 }
976 }
977 let rec = Ty::Record(out);
978 Ok(match list {
979 Some(max) => Ty::List {
980 item: Box::new(rec),
981 max,
982 },
983 None => rec,
984 })
985 }
986 }
987 }
988
989 pub fn merge_rule_error(&self, env: &TyEnv<'_>) -> Option<String> {
993 let OpArgs::MergeFields(a) = self else {
994 return None;
995 };
996 let (base, overlay, _) = merge_sides(a, env).ok()?;
997 let names = |t: &Ty| -> Vec<String> {
998 match t {
999 Ty::Record(f) => f.iter().map(|(n, _)| n.clone()).collect(),
1000 _ => vec![],
1001 }
1002 };
1003 let (bn, on) = (names(&base), names(&overlay));
1004 for r in &a.rules {
1005 if r.rule.reads_base() && !bn.contains(&r.field) {
1006 return Some(format!(
1007 "{} reads {:?}, which the base record lacks",
1008 r.rule.name(),
1009 r.field
1010 ));
1011 }
1012 if r.rule.reads_overlay() && !on.contains(&r.field) {
1013 return Some(format!(
1014 "{} reads {:?}, which the overlay record lacks",
1015 r.rule.name(),
1016 r.field
1017 ));
1018 }
1019 }
1020 let missing: BTreeSet<&String> = bn
1022 .iter()
1023 .chain(on.iter())
1024 .filter(|f| !a.rules.iter().any(|r| &r.field == *f))
1025 .collect();
1026 (!missing.is_empty()).then(|| {
1027 let each: Vec<String> = missing
1028 .iter()
1029 .map(|f| format!("field {f:?} has no merge rule"))
1030 .collect();
1031 format!("{}, and there is no default", each.join("; "))
1032 })
1033 }
1034}
1035
1036fn unwrap_nullable(t: &Ty) -> &Ty {
1037 match t {
1038 Ty::Nullable(t) => t,
1039 t => t,
1040 }
1041}
1042
1043fn merge_sides(a: &MergeFields, env: &TyEnv<'_>) -> Result<(Ty, Ty, Option<u64>), String> {
1046 match (ref_ty(&a.base, env)?, ref_ty(&a.overlay, env)?) {
1047 (b @ Ty::Record(_), o @ Ty::Record(_)) => Ok((b, o, None)),
1048 (Ty::List { item, max }, Ty::Joined { value, .. }) => match (*item, *value) {
1049 (b @ Ty::Record(_), o @ Ty::Record(_)) => Ok((b, o, Some(max))),
1050 _ => Err("a per-item merge joins records to records".into()),
1051 },
1052 (b, o) => Err(format!(
1053 "merge_fields takes two records, or a list of records and a join over it; got {} and {}",
1054 b.name(),
1055 o.name()
1056 )),
1057 }
1058}
1059
1060fn semantic<'e>(r: &str, env: &'e TyEnv<'_>) -> Result<(SemKind, &'e Vec<String>), String> {
1061 match Ref::parse(r)? {
1062 Ref::Step(s) => match env.steps.get(&s) {
1063 Some(StepTy::Semantic(k, binds)) => Ok((*k, binds)),
1064 Some(StepTy::ModelRanking(_)) | Some(StepTy::Selection(_)) => {
1065 Err(format!("{r} is a distinct ranking value, not semantic"))
1066 }
1067 Some(StepTy::Exact(t)) => Err(format!("{r} is exact ({}), not semantic", t.name())),
1068 None => Err(format!("unknown step {s:?}")),
1069 },
1070 _ => Err(format!("{r} is not a step")),
1071 }
1072}
1073
1074fn with_item<'a>(env: &TyEnv<'a>, item: &'a Ty) -> TyEnv<'a> {
1075 TyEnv {
1076 inputs: env.inputs,
1077 steps: env.steps,
1078 item: Some(item),
1079 v2: env.v2,
1080 }
1081}
1082
1083fn list_item_max(r: &str, env: &TyEnv<'_>) -> Result<(Ty, u64), String> {
1084 match ref_ty(r, env)? {
1085 Ty::List { item, max } => Ok((*item, max)),
1086 Ty::Joined {
1088 value,
1089 max,
1090 id_bytes,
1091 } if env.v2 => Ok((Ty::join_item(&value, id_bytes), max)),
1092 t => Err(format!("{r} is {}, not a list", t.name())),
1093 }
1094}
1095
1096fn list_item(r: &str, env: &TyEnv<'_>) -> Result<Ty, String> {
1097 list_item_max(r, env).map(|(t, _)| t)
1098}
1099
1100fn record_field(item: &Ty, name: &str) -> Result<Ty, String> {
1101 match item {
1102 Ty::Record(fields) => fields
1103 .iter()
1104 .find(|(n, _)| n == name)
1105 .map(|(_, t)| t.clone())
1106 .ok_or_else(|| format!("no field {name:?}")),
1107 t => Err(format!("{} has no field {name:?}", t.name())),
1108 }
1109}
1110
1111fn id_field_bytes(item: &Ty, name: &str) -> Result<u64, String> {
1112 match record_field(item, name)? {
1113 Ty::Text { max_bytes } => Ok(max_bytes),
1114 Ty::Nullable(t) if matches!(*t, Ty::Text { .. } | Ty::Enum(_)) => {
1117 id_field_bytes(&Ty::Record(vec![(name.to_string(), *t)]), name)
1118 }
1119 Ty::Enum(o) => Ok(o.iter().map(|s| s.len() as u64).max().unwrap_or(0)),
1120 t => Err(format!("id field {name:?} is {}, not text", t.name())),
1121 }
1122}
1123
1124pub struct OpContext<'a> {
1126 pub scope: Scope<'a>,
1127 pub semantic: &'a BTreeMap<String, SemInstances>,
1128 pub inputs_status: &'a BTreeMap<String, Result<(), Unresolved>>,
1130 pub lineage_inputs: &'a [String],
1132 pub binds: &'a BTreeMap<String, Vec<String>>,
1134}
1135
1136fn fault(f: Fault, cx: &OpContext<'_>) -> Unresolved {
1137 match f {
1138 Fault::Invalid(d) => Unresolved::invalid(cx.lineage_inputs.iter().cloned(), d),
1139 Fault::Missing(m) => Unresolved::missing([m]),
1140 Fault::Conflict(c) => Unresolved::conflict([c]),
1141 Fault::Absent(p) => Unresolved::missing([p]),
1142 }
1143}
1144
1145fn items(r: &str, cx: &OpContext<'_>) -> Result<Vec<Value>, Unresolved> {
1146 match eval_ref(r, &cx.scope) {
1147 Ok(Value::List(v)) if cx.scope.v2 && v.iter().any(|i| matches!(i, Value::Absent(_))) => {
1150 Err(missing_items(r))
1151 }
1152 Ok(Value::Joined(entries)) if cx.scope.v2 => entries
1153 .iter()
1154 .map(|e| e.item().ok_or_else(|| missing_items(r)))
1155 .collect(),
1156 Ok(Value::List(v)) => Ok(v),
1157 Ok(_) => Err(Unresolved::invalid(
1158 cx.lineage_inputs.iter().cloned(),
1159 format!("{r} is not a list"),
1160 )),
1161 Err(f) => Err(fault(f, cx)),
1162 }
1163}
1164
1165fn item_scope<'a>(cx: &'a OpContext<'a>, item: &'a Value) -> Scope<'a> {
1166 Scope {
1167 constructed: cx.scope.constructed,
1168 now: cx.scope.now,
1169 inputs: cx.scope.inputs,
1170 steps: cx.scope.steps,
1171 present: cx.scope.present,
1172 item: Some(item),
1173 v2: cx.scope.v2,
1174 }
1175}
1176
1177fn get_field<'v>(item: &'v Value, name: &str) -> Option<&'v Value> {
1178 match item {
1179 Value::Record(m) => m.get(name),
1180 _ => None,
1181 }
1182}
1183
1184fn id_of(item: &Value, id_field: &str) -> Option<String> {
1185 match get_field(item, id_field) {
1186 Some(Value::Text(s) | Value::Enum(s)) => Some(s.clone()),
1187 _ => None,
1188 }
1189}
1190
1191fn keyed<'v>(
1193 list: &'v [Value],
1194 id_field: &str,
1195 cx: &OpContext<'_>,
1196) -> Result<Vec<(String, &'v Value)>, Unresolved> {
1197 let mut seen = BTreeSet::new();
1198 let mut out = Vec::with_capacity(list.len());
1199 for it in list {
1200 let id = id_of(it, id_field).ok_or_else(|| {
1201 Unresolved::invalid(
1202 cx.lineage_inputs.iter().cloned(),
1203 format!("item without {id_field:?}"),
1204 )
1205 })?;
1206 if !seen.insert(id.clone()) {
1207 return Err(Unresolved::invalid(
1208 cx.lineage_inputs.iter().cloned(),
1209 format!("duplicate id {id:?}"),
1210 ));
1211 }
1212 out.push((id, it));
1213 }
1214 Ok(out)
1215}
1216
1217fn missing_items(r: &str) -> Unresolved {
1220 let path = r.split_once(':').map_or(r, |(_, p)| p);
1221 Unresolved::missing([format!("{path}/*")])
1222}
1223
1224type Row = (String, Result<Value, &'static str>);
1227
1228fn rows_v2(list: &str, id_field: &str, cx: &OpContext<'_>) -> Result<Vec<Row>, Unresolved> {
1231 let invalid = |d: String| Unresolved::invalid(cx.lineage_inputs.iter().cloned(), d);
1232 let rows: Vec<(String, Result<Value, &'static str>)> =
1233 match eval_ref(list, &cx.scope).map_err(|f| fault(f, cx))? {
1234 Value::Joined(entries) => entries
1235 .iter()
1236 .map(|e| {
1237 let row = e.item().ok_or(match &e.matched {
1240 JoinMatch::MatchedAbsent(a) => a.reason.name(),
1241 _ => "unmatched",
1242 });
1243 (e.id.clone(), row)
1244 })
1245 .collect(),
1246 Value::List(items) => items
1247 .iter()
1248 .enumerate()
1249 .map(|(i, it)| match it {
1250 Value::Absent(a) => Ok((crate::grading::position_id(i), Err(a.reason.name()))),
1251 it => id_of(it, id_field)
1252 .map(|id| (id, Ok(it.clone())))
1253 .ok_or_else(|| invalid(format!("item without {id_field:?}"))),
1254 })
1255 .collect::<Result<_, _>>()?,
1256 _ => return Err(invalid(format!("{list} is not a list"))),
1257 };
1258 let mut seen = BTreeSet::new();
1259 for (id, _) in &rows {
1260 if !seen.insert(id.clone()) {
1261 return Err(invalid(format!("duplicate id {id:?}")));
1262 }
1263 }
1264 Ok(rows)
1265}
1266
1267fn filter_v2(a: &FilterWithReasons, cx: &OpContext<'_>) -> Result<Value, Unresolved> {
1271 let mut eligible = Vec::new();
1272 let mut excluded = Vec::new();
1273 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
1274 for (id, row) in rows_v2(&a.list, &a.id_field, cx)? {
1275 let mut reasons: Vec<String> = Vec::new();
1276 match row {
1277 Err(r) => reasons.push(r.into()),
1278 Ok(item) => {
1279 let scope = item_scope(cx, &item);
1280 for r in &a.rules {
1281 let reason = match eval_predicate(&r.predicate, &scope) {
1282 Ok(true) => continue,
1283 Ok(false) => r.name.clone(),
1284 Err(Fault::Absent(p)) => format!("missing:{p}"),
1285 Err(_) => format!("unevaluable:{}", r.name),
1286 };
1287 if !reasons.contains(&reason) {
1288 reasons.push(reason);
1289 }
1290 }
1291 }
1292 }
1293 if reasons.is_empty() {
1294 eligible.push(id);
1295 } else {
1296 for r in &reasons {
1297 *counts.entry(r.clone()).or_default() += 1;
1298 }
1299 excluded.push(Excluded { id, reasons });
1300 }
1301 }
1302 Ok(Value::Filtered(Filtered {
1303 eligible,
1304 excluded,
1305 counts,
1306 }))
1307}
1308
1309fn top_k_v2(a: &TopK, cx: &OpContext<'_>) -> Result<Value, Unresolved> {
1312 let f = |e: Fault| fault(e, cx);
1313 let rows = rows_v2(&a.list, &a.id_field, cx)?;
1314 let among: BTreeSet<String> = match eval_ref(&a.among, &cx.scope).map_err(f)? {
1315 Value::Filtered(fl) => fl.eligible.into_iter().collect(),
1316 _ => return Err(f(Fault::Invalid("top_k among a non-filter".into()))),
1317 };
1318 if let Some(missing) = among
1319 .iter()
1320 .find(|id| !rows.iter().any(|(k, r)| k == *id && r.is_ok()))
1321 {
1322 return Err(f(Fault::Invalid(format!(
1323 "eligible id {missing:?} is not in {}",
1324 a.list
1325 ))));
1326 }
1327 let mut scored = Vec::new();
1328 let mut excluded = Vec::new();
1329 for (id, row) in rows.into_iter().filter(|(id, _)| among.contains(id)) {
1330 let Ok(item) = row else { continue };
1331 match eval_expr(&a.key, &item_scope(cx, &item)) {
1332 Ok(v) => match order_key(&v) {
1333 Some(k) => scored.push((k, id)),
1334 None => excluded.push(Excluded {
1335 id,
1336 reasons: vec!["unevaluable:key".into()],
1337 }),
1338 },
1339 Err(Fault::Absent(p)) => excluded.push(Excluded {
1340 id,
1341 reasons: vec![format!("missing:{p}")],
1342 }),
1343 Err(_) => excluded.push(Excluded {
1344 id,
1345 reasons: vec!["unevaluable:key".into()],
1346 }),
1347 }
1348 }
1349 scored.sort_by(|(ka, ia), (kb, ib)| {
1350 let by_key = match a.direction {
1351 Direction::Ascending => ka.cmp(kb),
1352 Direction::Descending => kb.cmp(ka),
1353 };
1354 by_key.then_with(|| ia.as_bytes().cmp(ib.as_bytes()))
1355 });
1356 let k = usize::try_from(a.k).unwrap_or(usize::MAX);
1357 let truncated = scored.len().saturating_sub(k) as u64;
1358 let ids = scored.into_iter().take(k).map(|(_, id)| id).collect();
1359 Ok(Value::Shortlist(Shortlist {
1360 ids,
1361 truncated,
1362 excluded,
1363 }))
1364}
1365
1366fn order_key(v: &Value) -> Option<(u8, i128)> {
1367 match v {
1368 Value::Integer(i) => Some((0, i128::from(*i))),
1369 Value::Decimal(d) => Some((1, d.units())),
1370 Value::Timestamp(t) => Some((2, i128::from(t.as_ms()))),
1371 Value::Duration(d) => Some((3, i128::from(d.0))),
1372 _ => None,
1373 }
1374}
1375
1376impl OpArgs {
1377 pub fn eval(&self, cx: &OpContext<'_>) -> Result<Value, Unresolved> {
1380 let f = |e: Fault| fault(e, cx);
1381 match self {
1382 OpArgs::ComposeKey(a) => a.eval(&cx.scope).map_err(f),
1383 OpArgs::JoinKeyed(_) | OpArgs::MergeFields(_) => Err(Unresolved::Unsupported {
1386 capability: format!("{}@1 evaluation", self.op().0),
1387 }),
1388 OpArgs::CountWhere(a) => {
1389 let list = items(&a.list, cx)?;
1390 let mut n: i64 = 0;
1391 for it in &list {
1392 if eval_predicate(&a.predicate, &item_scope(cx, it)).map_err(f)? {
1393 n += 1;
1394 }
1395 }
1396 Ok(Value::Integer(n))
1397 }
1398 OpArgs::WindowFilter(a) => {
1399 let list = items(&a.list, cx)?;
1400 let now = cx.scope.now;
1401 let floor = now
1402 .as_ms()
1403 .saturating_sub(i64::try_from(a.within_ms).unwrap_or(i64::MAX));
1404 let mut out = Vec::new();
1405 for it in list {
1406 let Some(Value::Timestamp(t)) = get_field(&it, &a.field) else {
1407 return Err(f(Fault::Invalid(format!(
1408 "item without timestamp {:?}",
1409 a.field
1410 ))));
1411 };
1412 if *t > now {
1413 return Err(f(Fault::Invalid(format!(
1414 "{:?} is after the evaluation time",
1415 a.field
1416 ))));
1417 }
1418 if t.as_ms() >= floor
1419 && eval_predicate(&a.predicate, &item_scope(cx, &it)).map_err(f)?
1420 {
1421 out.push(it);
1422 }
1423 }
1424 Ok(Value::List(out))
1425 }
1426 OpArgs::Extremum(a) => {
1427 let list = items(&a.list, cx)?;
1428 let mut best: Option<&Value> = None;
1429 for it in &list {
1430 let Some(v) = get_field(it, &a.field) else {
1431 continue;
1432 };
1433 let better = match best {
1434 None => true,
1435 Some(b) => match a.which {
1436 Which::Min => order_key(v) < order_key(b),
1437 Which::Max => order_key(v) > order_key(b),
1438 },
1439 };
1440 if better {
1441 best = Some(v);
1442 }
1443 }
1444 Ok(Value::Maybe(best.cloned().map(Box::new)))
1445 }
1446 OpArgs::TimestampDiff(a) => {
1447 let later = eval_expr(&a.later, &cx.scope).map_err(f)?;
1448 let earlier = eval_expr(&a.earlier, &cx.scope).map_err(f)?;
1449 let is_maybe =
1451 matches!(later, Value::Maybe(_)) || matches!(earlier, Value::Maybe(_));
1452 let unwrap = |v: Value| match v {
1453 Value::Timestamp(t) => Ok(Some(t)),
1454 Value::Maybe(None) => Ok(None),
1455 Value::Maybe(Some(b)) => match *b {
1456 Value::Timestamp(t) => Ok(Some(t)),
1457 _ => Err(Fault::Invalid("timestamp_diff of a non-timestamp".into())),
1458 },
1459 _ => Err(Fault::Invalid("timestamp_diff of a non-timestamp".into())),
1460 };
1461 match (unwrap(later).map_err(f)?, unwrap(earlier).map_err(f)?) {
1462 (Some(l), Some(e)) => {
1463 let d = l.since(e).map_err(|_| {
1464 f(Fault::Invalid("negative timestamp difference".into()))
1465 })?;
1466 let q = i64::try_from(d.0 / a.unit_ms)
1467 .map_err(|_| f(Fault::Invalid("overflow".into())))?;
1468 Ok(if is_maybe {
1469 Value::Maybe(Some(Box::new(Value::Integer(q))))
1470 } else {
1471 Value::Integer(q)
1472 })
1473 }
1474 _ => Ok(Value::Maybe(None)),
1475 }
1476 }
1477 OpArgs::Compare(a) => eval_predicate(&a.predicate, &cx.scope)
1478 .map(Value::Bool)
1479 .map_err(f),
1480 OpArgs::Arith(a) => eval_expr(&a.expr, &cx.scope).map_err(f),
1481 OpArgs::FxConvert(a) => {
1482 let e = Expr::Fx {
1483 amount: Box::new(a.amount.clone()),
1484 from: Box::new(a.from.clone()),
1485 to: Box::new(a.to.clone()),
1486 rates: a.rates.clone(),
1487 scale: a.scale,
1488 };
1489 eval_expr(&e, &cx.scope).map_err(f)
1490 }
1491 OpArgs::MemberOf(a) => {
1492 let v = eval_expr(&a.value, &cx.scope).map_err(f)?;
1493 Ok(Value::Bool(
1494 a.set.iter().any(|l| Value::from_literal(l) == v),
1495 ))
1496 }
1497 OpArgs::FieldPresence(a) => {
1498 let mut missing = Vec::new();
1499 for r in &a.inputs {
1500 let name = r.trim_start_matches("input:");
1501 match cx.inputs_status.get(name) {
1502 Some(Ok(())) => {}
1503 Some(Err(Unresolved::MissingEvidence { .. })) | None => {
1504 missing.push(Value::Text(name.to_string()))
1505 }
1506 Some(Err(other)) => return Err(other.clone()),
1507 }
1508 }
1509 Ok(Value::List(missing))
1510 }
1511 OpArgs::FilterWithReasons(a) if cx.scope.v2 => filter_v2(a, cx),
1512 OpArgs::TopK(a) if cx.scope.v2 => top_k_v2(a, cx),
1513 OpArgs::FilterWithReasons(a) => {
1514 let list = items(&a.list, cx)?;
1515 let keyed = keyed(&list, &a.id_field, cx)?;
1516 let mut eligible = Vec::new();
1517 let mut excluded = Vec::new();
1518 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
1519 for (id, it) in keyed {
1520 let scope = item_scope(cx, it);
1521 let mut reasons = Vec::new();
1522 for r in &a.rules {
1523 match eval_predicate(&r.predicate, &scope) {
1524 Ok(true) => {}
1525 Ok(false) => reasons.push(r.name.clone()),
1526 Err(_) => reasons.push(format!("unevaluable:{}", r.name)),
1527 }
1528 }
1529 if reasons.is_empty() {
1530 eligible.push(id);
1531 } else {
1532 for r in &reasons {
1533 *counts.entry(r.clone()).or_default() += 1;
1534 }
1535 excluded.push(Excluded { id, reasons });
1536 }
1537 }
1538 Ok(Value::Filtered(Filtered {
1539 eligible,
1540 excluded,
1541 counts,
1542 }))
1543 }
1544 OpArgs::TopK(a) => {
1545 let list = items(&a.list, cx)?;
1546 let keyed = keyed(&list, &a.id_field, cx)?;
1547 let among: BTreeSet<String> = match eval_ref(&a.among, &cx.scope).map_err(f)? {
1548 Value::Filtered(fl) => fl.eligible.into_iter().collect(),
1549 _ => return Err(f(Fault::Invalid("top_k among a non-filter".into()))),
1550 };
1551 if let Some(missing) = among.iter().find(|id| !keyed.iter().any(|(k, _)| k == *id))
1552 {
1553 return Err(f(Fault::Invalid(format!(
1554 "eligible id {missing:?} is not in {}",
1555 a.list
1556 ))));
1557 }
1558 let mut scored = Vec::new();
1559 let mut excluded = Vec::new();
1560 for (id, it) in keyed.into_iter().filter(|(id, _)| among.contains(id)) {
1561 match eval_expr(&a.key, &item_scope(cx, it))
1562 .ok()
1563 .as_ref()
1564 .and_then(order_key)
1565 {
1566 Some(k) => scored.push((k, id)),
1567 None => excluded.push(Excluded {
1568 id,
1569 reasons: vec!["unevaluable:key".into()],
1570 }),
1571 }
1572 }
1573 scored.sort_by(|(ka, ia), (kb, ib)| {
1574 let by_key = match a.direction {
1575 Direction::Ascending => ka.cmp(kb),
1576 Direction::Descending => kb.cmp(ka),
1577 };
1578 by_key.then_with(|| ia.as_bytes().cmp(ib.as_bytes()))
1579 });
1580 let k = usize::try_from(a.k).unwrap_or(usize::MAX);
1581 let truncated = scored.len().saturating_sub(k) as u64;
1582 let ids = scored.into_iter().take(k).map(|(_, id)| id).collect();
1583 Ok(Value::Shortlist(Shortlist {
1584 ids,
1585 truncated,
1586 excluded,
1587 }))
1588 }
1589 OpArgs::WeightedRank(a) => weighted_rank(a, cx),
1590 }
1591 }
1592}
1593
1594fn weights_by_key(
1595 w: &WeightTable,
1596 cx: &OpContext<'_>,
1597) -> Result<BTreeMap<String, Option<f64>>, Unresolved> {
1598 let list = items(&w.list, cx)?;
1599 let table: BTreeMap<&str, f64> = w
1600 .table
1601 .iter()
1602 .map(|c| (c.class.as_str(), c.weight.to_f64_nearest()))
1603 .collect();
1604 let mut out = BTreeMap::new();
1605 for it in &list {
1606 if let (Some(k), Some(c)) = (id_of(it, &w.key_field), id_of(it, &w.class_field)) {
1607 out.insert(k, table.get(c.as_str()).copied());
1608 }
1609 }
1610 Ok(out)
1611}
1612
1613fn weighted_rank(a: &WeightedRank, cx: &OpContext<'_>) -> Result<Value, Unresolved> {
1614 let candidates: Vec<String> =
1615 match eval_ref(&a.candidates, &cx.scope).map_err(|e| fault(e, cx))? {
1616 v @ (Value::Shortlist(_) | Value::Filtered(_)) => v.ids().unwrap_or_default(),
1617 _ => {
1618 return Err(fault(
1619 Fault::Invalid("weighted_rank over a non-shortlist".into()),
1620 cx,
1621 ));
1622 }
1623 };
1624 let n = candidates.len();
1625 let mut entries: Vec<RankEntry> = Vec::new();
1626 let mut excluded: Vec<Excluded> = Vec::new();
1627 'candidate: for (i, c) in candidates.iter().enumerate() {
1628 let mut components = BTreeMap::new();
1629 let mut score = 0.0f64;
1630 for comp in &a.components {
1631 let value = match &comp.source {
1632 Source::ShortlistPosition => {
1633 if n <= 1 {
1634 1.0
1635 } else {
1636 1.0 - (i as f64) / ((n - 1) as f64)
1637 }
1638 }
1639 Source::RubricExpectation { step } => {
1640 let inst = semantic_of(step, cx)?;
1641 match inst.get(std::slice::from_ref(c)) {
1642 Some(Ok(SemValue::Ordinal(o))) => {
1643 let top = (o.levels().len().saturating_sub(1)).max(1) as f64;
1644 o.expectation() / top
1645 }
1646 Some(Err(u)) => {
1647 excluded.push(Excluded {
1648 id: c.clone(),
1649 reasons: vec![format!("{}:{u}", comp.name)],
1650 });
1651 continue 'candidate;
1652 }
1653 _ => {
1654 excluded.push(Excluded {
1655 id: c.clone(),
1656 reasons: vec![format!("{}:no value", comp.name)],
1657 });
1658 continue 'candidate;
1659 }
1660 }
1661 }
1662 Source::PropositionMean {
1663 step,
1664 candidate_bind,
1665 weights,
1666 } => {
1667 let inst = semantic_of(step, cx)?;
1668 let pos = cx
1669 .binds
1670 .get(step_name(step))
1671 .and_then(|b| b.iter().position(|b| b == candidate_bind))
1672 .unwrap_or(0);
1673 let other = 1 - pos.min(1);
1674 let wmap = weights_by_key(weights, cx)?;
1675 let (mut num, mut den) = (0.0f64, 0.0f64);
1676 for (key, v) in inst.iter().filter(|(k, _)| k.get(pos) == Some(c)) {
1677 let claim = key.get(other).cloned().unwrap_or_default();
1678 let w = match wmap.get(&claim) {
1679 Some(Some(w)) => *w,
1680 _ => {
1681 excluded.push(Excluded {
1682 id: c.clone(),
1683 reasons: vec![format!("{}:unweighted {claim}", comp.name)],
1684 });
1685 continue 'candidate;
1686 }
1687 };
1688 match v {
1689 Ok(sv) => match sv.mass("true") {
1690 Some(p) => {
1691 num += w * p;
1692 den += w;
1693 }
1694 None => {
1695 excluded.push(Excluded {
1696 id: c.clone(),
1697 reasons: vec![format!("{}:no mass", comp.name)],
1698 });
1699 continue 'candidate;
1700 }
1701 },
1702 Err(u) => {
1703 excluded.push(Excluded {
1704 id: c.clone(),
1705 reasons: vec![format!("{}:{u}", comp.name)],
1706 });
1707 continue 'candidate;
1708 }
1709 }
1710 }
1711 if den > 0.0 { num / den } else { 0.0 }
1712 }
1713 };
1714 components.insert(comp.name.clone(), value);
1715 score += comp.weight.to_f64_nearest() * value;
1716 }
1717 entries.push(RankEntry {
1718 candidate: c.clone(),
1719 position: 0,
1720 score,
1721 components,
1722 });
1723 }
1724 entries.sort_by(|x, y| {
1725 y.score
1726 .total_cmp(&x.score)
1727 .then_with(|| x.candidate.as_bytes().cmp(y.candidate.as_bytes()))
1728 });
1729 for (i, e) in entries.iter_mut().enumerate() {
1730 e.position = i as u64 + 1;
1731 }
1732 Ok(Value::Ranking(Ranking { entries, excluded }))
1733}
1734
1735fn step_name(r: &str) -> &str {
1736 r.strip_prefix("step:").unwrap_or(r)
1737}
1738
1739fn semantic_of<'c>(r: &str, cx: &'c OpContext<'_>) -> Result<&'c SemInstances, Unresolved> {
1740 cx.semantic.get(step_name(r)).ok_or_else(|| {
1741 Unresolved::invalid(
1742 cx.lineage_inputs.iter().cloned(),
1743 format!("{r} has no value"),
1744 )
1745 })
1746}
1747
1748#[cfg(test)]
1749mod tests {
1750 use super::*;
1751 use serde_json::json;
1752
1753 #[test]
1754 fn joins_and_merges_are_only_in_registry_2() {
1755 for (op, v) in REGISTRY_V2_ADDS {
1756 assert!(!is_registered(op, *v), "{op} is not in rustev.exact/1");
1757 assert!(is_registered_v2(op, *v), "{op} is in rustev.exact/2");
1758 assert!(decode(op, *v, &json!({})).is_none());
1759 }
1760 assert!(REGISTRY.iter().all(|(op, v)| is_registered_v2(op, *v)));
1761 assert!(!is_registered_v2("join_keyed", 2));
1762 }
1763
1764 #[test]
1765 fn a_merge_presence_predicate_names_merged_fields_only() {
1766 let nullable_decimal = Ty::Nullable(Box::new(Ty::Decimal));
1767 let inputs = BTreeMap::from([
1768 (
1769 "base".to_string(),
1770 Ty::Record(vec![
1771 ("id".into(), Ty::Text { max_bytes: 8 }),
1772 ("price".into(), nullable_decimal.clone()),
1773 ]),
1774 ),
1775 (
1776 "over".to_string(),
1777 Ty::Record(vec![("price".into(), nullable_decimal)]),
1778 ),
1779 ]);
1780 let steps = BTreeMap::new();
1781 let env = TyEnv {
1782 inputs: &inputs,
1783 steps: &steps,
1784 item: None,
1785 v2: true,
1786 };
1787 let merge = |present: &str| {
1788 decode_v2(
1789 "merge_fields",
1790 1,
1791 &json!({"base": "input:base", "overlay": "input:over", "rules": [
1792 {"field": "id", "rule": "base_only"},
1793 {"field": "price", "rule": "overlay_only"}],
1794 "present_when": {"any_non_null": [present]}}),
1795 )
1796 .unwrap()
1797 .unwrap()
1798 };
1799 assert!(merge("price").type_check(&env).is_ok());
1800 let bare = decode_v2(
1802 "merge_fields",
1803 1,
1804 &json!({"base": "input:base", "overlay": "input:over", "rules": []}),
1805 )
1806 .unwrap()
1807 .unwrap();
1808 assert_eq!(
1809 bare.merge_rule_error(&env).as_deref(),
1810 Some(
1811 "field \"id\" has no merge rule; field \"price\" has no merge rule, \
1812 and there is no default"
1813 )
1814 );
1815 let e = merge("total").type_check(&env).unwrap_err();
1816 assert!(e.contains("not a merged field"), "{e}");
1817 }
1818
1819 #[test]
1820 fn a_join_key_is_a_plain_field_name() {
1821 let join = |key: &str| {
1822 decode_v2(
1823 "join_keyed",
1824 1,
1825 &json!({"left": "input:hotels", "key": key, "right": "input:quotes"}),
1826 )
1827 .unwrap()
1828 .unwrap()
1829 };
1830 assert!(join("id").structure().is_ok());
1831 for bad in ["", "price/net", "price.net", "*"] {
1832 assert!(join(bad).structure().is_err(), "{bad:?}");
1833 }
1834 let merge = |field: &str| {
1835 decode_v2(
1836 "merge_fields",
1837 1,
1838 &json!({"base": "input:base", "overlay": "input:over",
1839 "rules": [{"field": field, "rule": "base_only"}]}),
1840 )
1841 .unwrap()
1842 .unwrap()
1843 };
1844 assert!(merge("id").structure().is_ok());
1845 for bad in ["", "price/net", "price.net", "*"] {
1846 assert!(merge(bad).structure().is_err(), "{bad:?}");
1847 }
1848 }
1849
1850 #[test]
1851 fn registry_2_decodes_strictly() {
1852 assert!(matches!(
1853 decode_v2("join_keyed", 1, &json!({})),
1854 Some(Err(_))
1855 ));
1856 assert!(matches!(
1857 decode_v2("merge_fields", 1, &json!({"surprise": 1})),
1858 Some(Err(_))
1859 ));
1860 assert!(decode_v2("join_keyed", 2, &json!({})).is_none());
1861 }
1862}