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::Exact(t)) => Err(format!("{r} is exact ({}), not semantic", t.name())),
1065 None => Err(format!("unknown step {s:?}")),
1066 },
1067 _ => Err(format!("{r} is not a step")),
1068 }
1069}
1070
1071fn with_item<'a>(env: &TyEnv<'a>, item: &'a Ty) -> TyEnv<'a> {
1072 TyEnv {
1073 inputs: env.inputs,
1074 steps: env.steps,
1075 item: Some(item),
1076 v2: env.v2,
1077 }
1078}
1079
1080fn list_item_max(r: &str, env: &TyEnv<'_>) -> Result<(Ty, u64), String> {
1081 match ref_ty(r, env)? {
1082 Ty::List { item, max } => Ok((*item, max)),
1083 Ty::Joined {
1085 value,
1086 max,
1087 id_bytes,
1088 } if env.v2 => Ok((Ty::join_item(&value, id_bytes), max)),
1089 t => Err(format!("{r} is {}, not a list", t.name())),
1090 }
1091}
1092
1093fn list_item(r: &str, env: &TyEnv<'_>) -> Result<Ty, String> {
1094 list_item_max(r, env).map(|(t, _)| t)
1095}
1096
1097fn record_field(item: &Ty, name: &str) -> Result<Ty, String> {
1098 match item {
1099 Ty::Record(fields) => fields
1100 .iter()
1101 .find(|(n, _)| n == name)
1102 .map(|(_, t)| t.clone())
1103 .ok_or_else(|| format!("no field {name:?}")),
1104 t => Err(format!("{} has no field {name:?}", t.name())),
1105 }
1106}
1107
1108fn id_field_bytes(item: &Ty, name: &str) -> Result<u64, String> {
1109 match record_field(item, name)? {
1110 Ty::Text { max_bytes } => Ok(max_bytes),
1111 Ty::Nullable(t) if matches!(*t, Ty::Text { .. } | Ty::Enum(_)) => {
1114 id_field_bytes(&Ty::Record(vec![(name.to_string(), *t)]), name)
1115 }
1116 Ty::Enum(o) => Ok(o.iter().map(|s| s.len() as u64).max().unwrap_or(0)),
1117 t => Err(format!("id field {name:?} is {}, not text", t.name())),
1118 }
1119}
1120
1121pub struct OpContext<'a> {
1123 pub scope: Scope<'a>,
1124 pub semantic: &'a BTreeMap<String, SemInstances>,
1125 pub inputs_status: &'a BTreeMap<String, Result<(), Unresolved>>,
1127 pub lineage_inputs: &'a [String],
1129 pub binds: &'a BTreeMap<String, Vec<String>>,
1131}
1132
1133fn fault(f: Fault, cx: &OpContext<'_>) -> Unresolved {
1134 match f {
1135 Fault::Invalid(d) => Unresolved::invalid(cx.lineage_inputs.iter().cloned(), d),
1136 Fault::Missing(m) => Unresolved::missing([m]),
1137 Fault::Conflict(c) => Unresolved::conflict([c]),
1138 Fault::Absent(p) => Unresolved::missing([p]),
1139 }
1140}
1141
1142fn items(r: &str, cx: &OpContext<'_>) -> Result<Vec<Value>, Unresolved> {
1143 match eval_ref(r, &cx.scope) {
1144 Ok(Value::List(v)) if cx.scope.v2 && v.iter().any(|i| matches!(i, Value::Absent(_))) => {
1147 Err(missing_items(r))
1148 }
1149 Ok(Value::Joined(entries)) if cx.scope.v2 => entries
1150 .iter()
1151 .map(|e| e.item().ok_or_else(|| missing_items(r)))
1152 .collect(),
1153 Ok(Value::List(v)) => Ok(v),
1154 Ok(_) => Err(Unresolved::invalid(
1155 cx.lineage_inputs.iter().cloned(),
1156 format!("{r} is not a list"),
1157 )),
1158 Err(f) => Err(fault(f, cx)),
1159 }
1160}
1161
1162fn item_scope<'a>(cx: &'a OpContext<'a>, item: &'a Value) -> Scope<'a> {
1163 Scope {
1164 constructed: cx.scope.constructed,
1165 now: cx.scope.now,
1166 inputs: cx.scope.inputs,
1167 steps: cx.scope.steps,
1168 present: cx.scope.present,
1169 item: Some(item),
1170 v2: cx.scope.v2,
1171 }
1172}
1173
1174fn get_field<'v>(item: &'v Value, name: &str) -> Option<&'v Value> {
1175 match item {
1176 Value::Record(m) => m.get(name),
1177 _ => None,
1178 }
1179}
1180
1181fn id_of(item: &Value, id_field: &str) -> Option<String> {
1182 match get_field(item, id_field) {
1183 Some(Value::Text(s) | Value::Enum(s)) => Some(s.clone()),
1184 _ => None,
1185 }
1186}
1187
1188fn keyed<'v>(
1190 list: &'v [Value],
1191 id_field: &str,
1192 cx: &OpContext<'_>,
1193) -> Result<Vec<(String, &'v Value)>, Unresolved> {
1194 let mut seen = BTreeSet::new();
1195 let mut out = Vec::with_capacity(list.len());
1196 for it in list {
1197 let id = id_of(it, id_field).ok_or_else(|| {
1198 Unresolved::invalid(
1199 cx.lineage_inputs.iter().cloned(),
1200 format!("item without {id_field:?}"),
1201 )
1202 })?;
1203 if !seen.insert(id.clone()) {
1204 return Err(Unresolved::invalid(
1205 cx.lineage_inputs.iter().cloned(),
1206 format!("duplicate id {id:?}"),
1207 ));
1208 }
1209 out.push((id, it));
1210 }
1211 Ok(out)
1212}
1213
1214fn missing_items(r: &str) -> Unresolved {
1217 let path = r.split_once(':').map_or(r, |(_, p)| p);
1218 Unresolved::missing([format!("{path}/*")])
1219}
1220
1221type Row = (String, Result<Value, &'static str>);
1224
1225fn rows_v2(list: &str, id_field: &str, cx: &OpContext<'_>) -> Result<Vec<Row>, Unresolved> {
1228 let invalid = |d: String| Unresolved::invalid(cx.lineage_inputs.iter().cloned(), d);
1229 let rows: Vec<(String, Result<Value, &'static str>)> =
1230 match eval_ref(list, &cx.scope).map_err(|f| fault(f, cx))? {
1231 Value::Joined(entries) => entries
1232 .iter()
1233 .map(|e| {
1234 let row = e.item().ok_or(match &e.matched {
1237 JoinMatch::MatchedAbsent(a) => a.reason.name(),
1238 _ => "unmatched",
1239 });
1240 (e.id.clone(), row)
1241 })
1242 .collect(),
1243 Value::List(items) => items
1244 .iter()
1245 .enumerate()
1246 .map(|(i, it)| match it {
1247 Value::Absent(a) => Ok((crate::grading::position_id(i), Err(a.reason.name()))),
1248 it => id_of(it, id_field)
1249 .map(|id| (id, Ok(it.clone())))
1250 .ok_or_else(|| invalid(format!("item without {id_field:?}"))),
1251 })
1252 .collect::<Result<_, _>>()?,
1253 _ => return Err(invalid(format!("{list} is not a list"))),
1254 };
1255 let mut seen = BTreeSet::new();
1256 for (id, _) in &rows {
1257 if !seen.insert(id.clone()) {
1258 return Err(invalid(format!("duplicate id {id:?}")));
1259 }
1260 }
1261 Ok(rows)
1262}
1263
1264fn filter_v2(a: &FilterWithReasons, cx: &OpContext<'_>) -> Result<Value, Unresolved> {
1268 let mut eligible = Vec::new();
1269 let mut excluded = Vec::new();
1270 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
1271 for (id, row) in rows_v2(&a.list, &a.id_field, cx)? {
1272 let mut reasons: Vec<String> = Vec::new();
1273 match row {
1274 Err(r) => reasons.push(r.into()),
1275 Ok(item) => {
1276 let scope = item_scope(cx, &item);
1277 for r in &a.rules {
1278 let reason = match eval_predicate(&r.predicate, &scope) {
1279 Ok(true) => continue,
1280 Ok(false) => r.name.clone(),
1281 Err(Fault::Absent(p)) => format!("missing:{p}"),
1282 Err(_) => format!("unevaluable:{}", r.name),
1283 };
1284 if !reasons.contains(&reason) {
1285 reasons.push(reason);
1286 }
1287 }
1288 }
1289 }
1290 if reasons.is_empty() {
1291 eligible.push(id);
1292 } else {
1293 for r in &reasons {
1294 *counts.entry(r.clone()).or_default() += 1;
1295 }
1296 excluded.push(Excluded { id, reasons });
1297 }
1298 }
1299 Ok(Value::Filtered(Filtered {
1300 eligible,
1301 excluded,
1302 counts,
1303 }))
1304}
1305
1306fn top_k_v2(a: &TopK, cx: &OpContext<'_>) -> Result<Value, Unresolved> {
1309 let f = |e: Fault| fault(e, cx);
1310 let rows = rows_v2(&a.list, &a.id_field, cx)?;
1311 let among: BTreeSet<String> = match eval_ref(&a.among, &cx.scope).map_err(f)? {
1312 Value::Filtered(fl) => fl.eligible.into_iter().collect(),
1313 _ => return Err(f(Fault::Invalid("top_k among a non-filter".into()))),
1314 };
1315 if let Some(missing) = among
1316 .iter()
1317 .find(|id| !rows.iter().any(|(k, r)| k == *id && r.is_ok()))
1318 {
1319 return Err(f(Fault::Invalid(format!(
1320 "eligible id {missing:?} is not in {}",
1321 a.list
1322 ))));
1323 }
1324 let mut scored = Vec::new();
1325 let mut excluded = Vec::new();
1326 for (id, row) in rows.into_iter().filter(|(id, _)| among.contains(id)) {
1327 let Ok(item) = row else { continue };
1328 match eval_expr(&a.key, &item_scope(cx, &item)) {
1329 Ok(v) => match order_key(&v) {
1330 Some(k) => scored.push((k, id)),
1331 None => excluded.push(Excluded {
1332 id,
1333 reasons: vec!["unevaluable:key".into()],
1334 }),
1335 },
1336 Err(Fault::Absent(p)) => excluded.push(Excluded {
1337 id,
1338 reasons: vec![format!("missing:{p}")],
1339 }),
1340 Err(_) => excluded.push(Excluded {
1341 id,
1342 reasons: vec!["unevaluable:key".into()],
1343 }),
1344 }
1345 }
1346 scored.sort_by(|(ka, ia), (kb, ib)| {
1347 let by_key = match a.direction {
1348 Direction::Ascending => ka.cmp(kb),
1349 Direction::Descending => kb.cmp(ka),
1350 };
1351 by_key.then_with(|| ia.as_bytes().cmp(ib.as_bytes()))
1352 });
1353 let k = usize::try_from(a.k).unwrap_or(usize::MAX);
1354 let truncated = scored.len().saturating_sub(k) as u64;
1355 let ids = scored.into_iter().take(k).map(|(_, id)| id).collect();
1356 Ok(Value::Shortlist(Shortlist {
1357 ids,
1358 truncated,
1359 excluded,
1360 }))
1361}
1362
1363fn order_key(v: &Value) -> Option<(u8, i128)> {
1364 match v {
1365 Value::Integer(i) => Some((0, i128::from(*i))),
1366 Value::Decimal(d) => Some((1, d.units())),
1367 Value::Timestamp(t) => Some((2, i128::from(t.as_ms()))),
1368 Value::Duration(d) => Some((3, i128::from(d.0))),
1369 _ => None,
1370 }
1371}
1372
1373impl OpArgs {
1374 pub fn eval(&self, cx: &OpContext<'_>) -> Result<Value, Unresolved> {
1377 let f = |e: Fault| fault(e, cx);
1378 match self {
1379 OpArgs::ComposeKey(a) => a.eval(&cx.scope).map_err(f),
1380 OpArgs::JoinKeyed(_) | OpArgs::MergeFields(_) => Err(Unresolved::Unsupported {
1383 capability: format!("{}@1 evaluation", self.op().0),
1384 }),
1385 OpArgs::CountWhere(a) => {
1386 let list = items(&a.list, cx)?;
1387 let mut n: i64 = 0;
1388 for it in &list {
1389 if eval_predicate(&a.predicate, &item_scope(cx, it)).map_err(f)? {
1390 n += 1;
1391 }
1392 }
1393 Ok(Value::Integer(n))
1394 }
1395 OpArgs::WindowFilter(a) => {
1396 let list = items(&a.list, cx)?;
1397 let now = cx.scope.now;
1398 let floor = now
1399 .as_ms()
1400 .saturating_sub(i64::try_from(a.within_ms).unwrap_or(i64::MAX));
1401 let mut out = Vec::new();
1402 for it in list {
1403 let Some(Value::Timestamp(t)) = get_field(&it, &a.field) else {
1404 return Err(f(Fault::Invalid(format!(
1405 "item without timestamp {:?}",
1406 a.field
1407 ))));
1408 };
1409 if *t > now {
1410 return Err(f(Fault::Invalid(format!(
1411 "{:?} is after the evaluation time",
1412 a.field
1413 ))));
1414 }
1415 if t.as_ms() >= floor
1416 && eval_predicate(&a.predicate, &item_scope(cx, &it)).map_err(f)?
1417 {
1418 out.push(it);
1419 }
1420 }
1421 Ok(Value::List(out))
1422 }
1423 OpArgs::Extremum(a) => {
1424 let list = items(&a.list, cx)?;
1425 let mut best: Option<&Value> = None;
1426 for it in &list {
1427 let Some(v) = get_field(it, &a.field) else {
1428 continue;
1429 };
1430 let better = match best {
1431 None => true,
1432 Some(b) => match a.which {
1433 Which::Min => order_key(v) < order_key(b),
1434 Which::Max => order_key(v) > order_key(b),
1435 },
1436 };
1437 if better {
1438 best = Some(v);
1439 }
1440 }
1441 Ok(Value::Maybe(best.cloned().map(Box::new)))
1442 }
1443 OpArgs::TimestampDiff(a) => {
1444 let later = eval_expr(&a.later, &cx.scope).map_err(f)?;
1445 let earlier = eval_expr(&a.earlier, &cx.scope).map_err(f)?;
1446 let is_maybe =
1448 matches!(later, Value::Maybe(_)) || matches!(earlier, Value::Maybe(_));
1449 let unwrap = |v: Value| match v {
1450 Value::Timestamp(t) => Ok(Some(t)),
1451 Value::Maybe(None) => Ok(None),
1452 Value::Maybe(Some(b)) => match *b {
1453 Value::Timestamp(t) => Ok(Some(t)),
1454 _ => Err(Fault::Invalid("timestamp_diff of a non-timestamp".into())),
1455 },
1456 _ => Err(Fault::Invalid("timestamp_diff of a non-timestamp".into())),
1457 };
1458 match (unwrap(later).map_err(f)?, unwrap(earlier).map_err(f)?) {
1459 (Some(l), Some(e)) => {
1460 let d = l.since(e).map_err(|_| {
1461 f(Fault::Invalid("negative timestamp difference".into()))
1462 })?;
1463 let q = i64::try_from(d.0 / a.unit_ms)
1464 .map_err(|_| f(Fault::Invalid("overflow".into())))?;
1465 Ok(if is_maybe {
1466 Value::Maybe(Some(Box::new(Value::Integer(q))))
1467 } else {
1468 Value::Integer(q)
1469 })
1470 }
1471 _ => Ok(Value::Maybe(None)),
1472 }
1473 }
1474 OpArgs::Compare(a) => eval_predicate(&a.predicate, &cx.scope)
1475 .map(Value::Bool)
1476 .map_err(f),
1477 OpArgs::Arith(a) => eval_expr(&a.expr, &cx.scope).map_err(f),
1478 OpArgs::FxConvert(a) => {
1479 let e = Expr::Fx {
1480 amount: Box::new(a.amount.clone()),
1481 from: Box::new(a.from.clone()),
1482 to: Box::new(a.to.clone()),
1483 rates: a.rates.clone(),
1484 scale: a.scale,
1485 };
1486 eval_expr(&e, &cx.scope).map_err(f)
1487 }
1488 OpArgs::MemberOf(a) => {
1489 let v = eval_expr(&a.value, &cx.scope).map_err(f)?;
1490 Ok(Value::Bool(
1491 a.set.iter().any(|l| Value::from_literal(l) == v),
1492 ))
1493 }
1494 OpArgs::FieldPresence(a) => {
1495 let mut missing = Vec::new();
1496 for r in &a.inputs {
1497 let name = r.trim_start_matches("input:");
1498 match cx.inputs_status.get(name) {
1499 Some(Ok(())) => {}
1500 Some(Err(Unresolved::MissingEvidence { .. })) | None => {
1501 missing.push(Value::Text(name.to_string()))
1502 }
1503 Some(Err(other)) => return Err(other.clone()),
1504 }
1505 }
1506 Ok(Value::List(missing))
1507 }
1508 OpArgs::FilterWithReasons(a) if cx.scope.v2 => filter_v2(a, cx),
1509 OpArgs::TopK(a) if cx.scope.v2 => top_k_v2(a, cx),
1510 OpArgs::FilterWithReasons(a) => {
1511 let list = items(&a.list, cx)?;
1512 let keyed = keyed(&list, &a.id_field, cx)?;
1513 let mut eligible = Vec::new();
1514 let mut excluded = Vec::new();
1515 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
1516 for (id, it) in keyed {
1517 let scope = item_scope(cx, it);
1518 let mut reasons = Vec::new();
1519 for r in &a.rules {
1520 match eval_predicate(&r.predicate, &scope) {
1521 Ok(true) => {}
1522 Ok(false) => reasons.push(r.name.clone()),
1523 Err(_) => reasons.push(format!("unevaluable:{}", r.name)),
1524 }
1525 }
1526 if reasons.is_empty() {
1527 eligible.push(id);
1528 } else {
1529 for r in &reasons {
1530 *counts.entry(r.clone()).or_default() += 1;
1531 }
1532 excluded.push(Excluded { id, reasons });
1533 }
1534 }
1535 Ok(Value::Filtered(Filtered {
1536 eligible,
1537 excluded,
1538 counts,
1539 }))
1540 }
1541 OpArgs::TopK(a) => {
1542 let list = items(&a.list, cx)?;
1543 let keyed = keyed(&list, &a.id_field, cx)?;
1544 let among: BTreeSet<String> = match eval_ref(&a.among, &cx.scope).map_err(f)? {
1545 Value::Filtered(fl) => fl.eligible.into_iter().collect(),
1546 _ => return Err(f(Fault::Invalid("top_k among a non-filter".into()))),
1547 };
1548 if let Some(missing) = among.iter().find(|id| !keyed.iter().any(|(k, _)| k == *id))
1549 {
1550 return Err(f(Fault::Invalid(format!(
1551 "eligible id {missing:?} is not in {}",
1552 a.list
1553 ))));
1554 }
1555 let mut scored = Vec::new();
1556 let mut excluded = Vec::new();
1557 for (id, it) in keyed.into_iter().filter(|(id, _)| among.contains(id)) {
1558 match eval_expr(&a.key, &item_scope(cx, it))
1559 .ok()
1560 .as_ref()
1561 .and_then(order_key)
1562 {
1563 Some(k) => scored.push((k, id)),
1564 None => excluded.push(Excluded {
1565 id,
1566 reasons: vec!["unevaluable:key".into()],
1567 }),
1568 }
1569 }
1570 scored.sort_by(|(ka, ia), (kb, ib)| {
1571 let by_key = match a.direction {
1572 Direction::Ascending => ka.cmp(kb),
1573 Direction::Descending => kb.cmp(ka),
1574 };
1575 by_key.then_with(|| ia.as_bytes().cmp(ib.as_bytes()))
1576 });
1577 let k = usize::try_from(a.k).unwrap_or(usize::MAX);
1578 let truncated = scored.len().saturating_sub(k) as u64;
1579 let ids = scored.into_iter().take(k).map(|(_, id)| id).collect();
1580 Ok(Value::Shortlist(Shortlist {
1581 ids,
1582 truncated,
1583 excluded,
1584 }))
1585 }
1586 OpArgs::WeightedRank(a) => weighted_rank(a, cx),
1587 }
1588 }
1589}
1590
1591fn weights_by_key(
1592 w: &WeightTable,
1593 cx: &OpContext<'_>,
1594) -> Result<BTreeMap<String, Option<f64>>, Unresolved> {
1595 let list = items(&w.list, cx)?;
1596 let table: BTreeMap<&str, f64> = w
1597 .table
1598 .iter()
1599 .map(|c| (c.class.as_str(), c.weight.to_f64_nearest()))
1600 .collect();
1601 let mut out = BTreeMap::new();
1602 for it in &list {
1603 if let (Some(k), Some(c)) = (id_of(it, &w.key_field), id_of(it, &w.class_field)) {
1604 out.insert(k, table.get(c.as_str()).copied());
1605 }
1606 }
1607 Ok(out)
1608}
1609
1610fn weighted_rank(a: &WeightedRank, cx: &OpContext<'_>) -> Result<Value, Unresolved> {
1611 let candidates: Vec<String> =
1612 match eval_ref(&a.candidates, &cx.scope).map_err(|e| fault(e, cx))? {
1613 v @ (Value::Shortlist(_) | Value::Filtered(_)) => v.ids().unwrap_or_default(),
1614 _ => {
1615 return Err(fault(
1616 Fault::Invalid("weighted_rank over a non-shortlist".into()),
1617 cx,
1618 ));
1619 }
1620 };
1621 let n = candidates.len();
1622 let mut entries: Vec<RankEntry> = Vec::new();
1623 let mut excluded: Vec<Excluded> = Vec::new();
1624 'candidate: for (i, c) in candidates.iter().enumerate() {
1625 let mut components = BTreeMap::new();
1626 let mut score = 0.0f64;
1627 for comp in &a.components {
1628 let value = match &comp.source {
1629 Source::ShortlistPosition => {
1630 if n <= 1 {
1631 1.0
1632 } else {
1633 1.0 - (i as f64) / ((n - 1) as f64)
1634 }
1635 }
1636 Source::RubricExpectation { step } => {
1637 let inst = semantic_of(step, cx)?;
1638 match inst.get(std::slice::from_ref(c)) {
1639 Some(Ok(SemValue::Ordinal(o))) => {
1640 let top = (o.levels().len().saturating_sub(1)).max(1) as f64;
1641 o.expectation() / top
1642 }
1643 Some(Err(u)) => {
1644 excluded.push(Excluded {
1645 id: c.clone(),
1646 reasons: vec![format!("{}:{u}", comp.name)],
1647 });
1648 continue 'candidate;
1649 }
1650 _ => {
1651 excluded.push(Excluded {
1652 id: c.clone(),
1653 reasons: vec![format!("{}:no value", comp.name)],
1654 });
1655 continue 'candidate;
1656 }
1657 }
1658 }
1659 Source::PropositionMean {
1660 step,
1661 candidate_bind,
1662 weights,
1663 } => {
1664 let inst = semantic_of(step, cx)?;
1665 let pos = cx
1666 .binds
1667 .get(step_name(step))
1668 .and_then(|b| b.iter().position(|b| b == candidate_bind))
1669 .unwrap_or(0);
1670 let other = 1 - pos.min(1);
1671 let wmap = weights_by_key(weights, cx)?;
1672 let (mut num, mut den) = (0.0f64, 0.0f64);
1673 for (key, v) in inst.iter().filter(|(k, _)| k.get(pos) == Some(c)) {
1674 let claim = key.get(other).cloned().unwrap_or_default();
1675 let w = match wmap.get(&claim) {
1676 Some(Some(w)) => *w,
1677 _ => {
1678 excluded.push(Excluded {
1679 id: c.clone(),
1680 reasons: vec![format!("{}:unweighted {claim}", comp.name)],
1681 });
1682 continue 'candidate;
1683 }
1684 };
1685 match v {
1686 Ok(sv) => match sv.mass("true") {
1687 Some(p) => {
1688 num += w * p;
1689 den += w;
1690 }
1691 None => {
1692 excluded.push(Excluded {
1693 id: c.clone(),
1694 reasons: vec![format!("{}:no mass", comp.name)],
1695 });
1696 continue 'candidate;
1697 }
1698 },
1699 Err(u) => {
1700 excluded.push(Excluded {
1701 id: c.clone(),
1702 reasons: vec![format!("{}:{u}", comp.name)],
1703 });
1704 continue 'candidate;
1705 }
1706 }
1707 }
1708 if den > 0.0 { num / den } else { 0.0 }
1709 }
1710 };
1711 components.insert(comp.name.clone(), value);
1712 score += comp.weight.to_f64_nearest() * value;
1713 }
1714 entries.push(RankEntry {
1715 candidate: c.clone(),
1716 position: 0,
1717 score,
1718 components,
1719 });
1720 }
1721 entries.sort_by(|x, y| {
1722 y.score
1723 .total_cmp(&x.score)
1724 .then_with(|| x.candidate.as_bytes().cmp(y.candidate.as_bytes()))
1725 });
1726 for (i, e) in entries.iter_mut().enumerate() {
1727 e.position = i as u64 + 1;
1728 }
1729 Ok(Value::Ranking(Ranking { entries, excluded }))
1730}
1731
1732fn step_name(r: &str) -> &str {
1733 r.strip_prefix("step:").unwrap_or(r)
1734}
1735
1736fn semantic_of<'c>(r: &str, cx: &'c OpContext<'_>) -> Result<&'c SemInstances, Unresolved> {
1737 cx.semantic.get(step_name(r)).ok_or_else(|| {
1738 Unresolved::invalid(
1739 cx.lineage_inputs.iter().cloned(),
1740 format!("{r} has no value"),
1741 )
1742 })
1743}
1744
1745#[cfg(test)]
1746mod tests {
1747 use super::*;
1748 use serde_json::json;
1749
1750 #[test]
1751 fn joins_and_merges_are_only_in_registry_2() {
1752 for (op, v) in REGISTRY_V2_ADDS {
1753 assert!(!is_registered(op, *v), "{op} is not in rustev.exact/1");
1754 assert!(is_registered_v2(op, *v), "{op} is in rustev.exact/2");
1755 assert!(decode(op, *v, &json!({})).is_none());
1756 }
1757 assert!(REGISTRY.iter().all(|(op, v)| is_registered_v2(op, *v)));
1758 assert!(!is_registered_v2("join_keyed", 2));
1759 }
1760
1761 #[test]
1762 fn a_merge_presence_predicate_names_merged_fields_only() {
1763 let nullable_decimal = Ty::Nullable(Box::new(Ty::Decimal));
1764 let inputs = BTreeMap::from([
1765 (
1766 "base".to_string(),
1767 Ty::Record(vec![
1768 ("id".into(), Ty::Text { max_bytes: 8 }),
1769 ("price".into(), nullable_decimal.clone()),
1770 ]),
1771 ),
1772 (
1773 "over".to_string(),
1774 Ty::Record(vec![("price".into(), nullable_decimal)]),
1775 ),
1776 ]);
1777 let steps = BTreeMap::new();
1778 let env = TyEnv {
1779 inputs: &inputs,
1780 steps: &steps,
1781 item: None,
1782 v2: true,
1783 };
1784 let merge = |present: &str| {
1785 decode_v2(
1786 "merge_fields",
1787 1,
1788 &json!({"base": "input:base", "overlay": "input:over", "rules": [
1789 {"field": "id", "rule": "base_only"},
1790 {"field": "price", "rule": "overlay_only"}],
1791 "present_when": {"any_non_null": [present]}}),
1792 )
1793 .unwrap()
1794 .unwrap()
1795 };
1796 assert!(merge("price").type_check(&env).is_ok());
1797 let bare = decode_v2(
1799 "merge_fields",
1800 1,
1801 &json!({"base": "input:base", "overlay": "input:over", "rules": []}),
1802 )
1803 .unwrap()
1804 .unwrap();
1805 assert_eq!(
1806 bare.merge_rule_error(&env).as_deref(),
1807 Some(
1808 "field \"id\" has no merge rule; field \"price\" has no merge rule, \
1809 and there is no default"
1810 )
1811 );
1812 let e = merge("total").type_check(&env).unwrap_err();
1813 assert!(e.contains("not a merged field"), "{e}");
1814 }
1815
1816 #[test]
1817 fn a_join_key_is_a_plain_field_name() {
1818 let join = |key: &str| {
1819 decode_v2(
1820 "join_keyed",
1821 1,
1822 &json!({"left": "input:hotels", "key": key, "right": "input:quotes"}),
1823 )
1824 .unwrap()
1825 .unwrap()
1826 };
1827 assert!(join("id").structure().is_ok());
1828 for bad in ["", "price/net", "price.net", "*"] {
1829 assert!(join(bad).structure().is_err(), "{bad:?}");
1830 }
1831 let merge = |field: &str| {
1832 decode_v2(
1833 "merge_fields",
1834 1,
1835 &json!({"base": "input:base", "overlay": "input:over",
1836 "rules": [{"field": field, "rule": "base_only"}]}),
1837 )
1838 .unwrap()
1839 .unwrap()
1840 };
1841 assert!(merge("id").structure().is_ok());
1842 for bad in ["", "price/net", "price.net", "*"] {
1843 assert!(merge(bad).structure().is_err(), "{bad:?}");
1844 }
1845 }
1846
1847 #[test]
1848 fn registry_2_decodes_strictly() {
1849 assert!(matches!(
1850 decode_v2("join_keyed", 1, &json!({})),
1851 Some(Err(_))
1852 ));
1853 assert!(matches!(
1854 decode_v2("merge_fields", 1, &json!({"surprise": 1})),
1855 Some(Err(_))
1856 ));
1857 assert!(decode_v2("join_keyed", 2, &json!({})).is_none());
1858 }
1859}