Skip to main content

rustev_core/
ops.rs

1//! The closed exact-operator registries `rustev.exact/1` (spec 002, 3.8) and
2//! `rustev.exact/2` (spec 032, 3.5 and 3.6), which adds `join_keyed@1` and
3//! `merge_fields@1` with every `rustev.exact/1` operator unchanged.
4//!
5//! An operator is named with a version. Changing what an operator means is a
6//! new version, never an edit (E-06). Each operator's arguments are decoded
7//! strictly against its own schema.
8
9use 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
25/// Every registered `(name, version)`.
26pub 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
41/// Operators `rustev.exact/2` adds (spec 032, 3.1.1).
42pub 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
49/// Whether `(op, version)` is in `rustev.exact/2`.
50pub 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/// A bounded constructed key (spec 036, 3.7).
58#[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            // Spec 036 3.7.2: trim whitespace only and collapse runs of
86            // whitespace, `-` and `_` into one `-`; a leading or trailing `-`
87            // is in `[a-z0-9-]` and stays, since nothing is stripped silently.
88            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/// A typed keyed join (spec 032, 3.5).
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct JoinKeyed {
127    /// A list of records, or a list of ids.
128    pub left: String,
129    /// `id` for a list of ids, or a text or enum item field.
130    pub key: String,
131    /// A map.
132    pub right: String,
133}
134
135/// A typed per-field merge (spec 032, 3.6). There is no default rule.
136#[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    /// Evaluated over the merged record's fields (spec 032, 3.6.3).
143    #[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    /// `input:<name>` references, in the order they are reported.
256    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    /// `step:<id>` of a filter result; only its eligible ids are ranked.
287    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    /// `input:<name>`: a list of records.
297    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    /// Weighted mean of a two-index proposition's `true` mass over the other
314    /// index, weights by class; zero items give 0.
315    PropositionMean {
316        step: String,
317        candidate_bind: String,
318        weights: WeightTable,
319    },
320    /// A one-index rubric's expectation divided by its highest level index.
321    RubricExpectation { step: String },
322    /// `1 - i / (n - 1)` over the candidate order; 1 when `n = 1`.
323    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    /// `step:<id>` of a shortlist or filter result, in the order ranked.
338    pub candidates: String,
339    pub components: Vec<Component>,
340}
341
342/// Decoded operator arguments.
343#[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    /// Registry name and version.
368    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    /// The arguments as the definition document carries them.
389    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        // Every argument type serializes to a JSON object.
408        v.unwrap_or(Json::Null)
409    }
410}
411
412/// A field name an operator argument carries, which in version 2 is
413/// non-empty and holds no '/', '.' or '*' (spec 032, 3.4.4).
414fn 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
426/// Decode the arguments of a registered operator; `None` when `(op,
427/// version)` is not registered (refusal category 2, not a parse failure).
428pub 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
435/// Decode the arguments of an operator of `rustev.exact/2`.
436pub 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
488/// Keep only `input:` and `step:` references (item references and `now` are
489/// not dependencies).
490fn 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    /// References this step reads, in argument order (spec 002, 3.10.4).
501    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    /// Every raw reference string, including item references and `now`,
569    /// for the structure phase.
570    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    /// Whether the arguments use a predicate or reference form of
598    /// `rustev.exact/2` (spec 032, 3.1.3).
599    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    /// Structural checks (compile phase b).
619    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    /// Reasons this operator itself can produce, beyond its dependencies'.
716    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            // `join_keyed@1` included: duplicate left ids are
734            // `invalid_input` (spec 032, 3.5.3).
735            _ => {
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    /// The output type, or a kind mismatch (compile phase e).
747    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                    // A merged field can be absent, so reading it yields its
958                    // type or `missing_evidence`.
959                    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                // The output's presence predicate names only its own fields,
967                // all nullable after merging (spec 032, 3.6.3 and 3.4.2).
968                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    /// The category 11 refusals of `merge_fields@1` (spec 032, 3.6.1): a
990    /// field of either side with no rule, or a rule naming a field missing
991    /// from a side it reads.
992    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        // Every field of either side without a rule, each once, in name order.
1021        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
1043/// The base and overlay record types of a merge, and the list bound when
1044/// it merges per item.
1045fn 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        // A join result's items, as per-item operators read them.
1087        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        // A merged id can be absent; an item whose id cannot be read is
1115        // refused at evaluation as any item without an id.
1116        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
1124/// What an operator needs at evaluation time besides expression scope.
1125pub struct OpContext<'a> {
1126    pub scope: Scope<'a>,
1127    pub semantic: &'a BTreeMap<String, SemInstances>,
1128    /// Status of every declared input.
1129    pub inputs_status: &'a BTreeMap<String, Result<(), Unresolved>>,
1130    /// Input fields in this step's lineage, named by `invalid_input`.
1131    pub lineage_inputs: &'a [String],
1132    /// Each semantic step's fan-out binds, in instance-key order.
1133    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        // An operator that does not exclude absent items reads them as
1148        // missing evidence for their path (spec 032, 3.4.4).
1149        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
1191/// Items with unique ids, or `invalid_input` on a duplicate or missing id.
1192fn 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
1217/// Missing evidence for the items of `r`, named as an input or step is
1218/// named in a missing reason: without its `input:` or `step:` kind.
1219fn missing_items(r: &str) -> Unresolved {
1220    let path = r.split_once(':').map_or(r, |(_, p)| p);
1221    Unresolved::missing([format!("{path}/*")])
1222}
1223
1224/// A row a version 2 filter judges: an id and the item, or the reason it
1225/// is excluded before any rule.
1226type Row = (String, Result<Value, &'static str>);
1227
1228/// The rows a version 2 filter judges (spec 032, 3.4.4 and 3.5.4). An
1229/// absent item has no readable id; it is named by its position.
1230fn 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                    // `item` is `Some` exactly for a matched entry; the reason
1238                    // names why any other entry has none.
1239                    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
1267/// `filter_with_reasons@1` in `rustev.exact/2`: absent and unmatched items
1268/// are excluded before the rules, and a rule reading an absent value or a
1269/// `null` field excludes the item `missing:<path>`.
1270fn 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
1309/// `top_k@1` in `rustev.exact/2`: a key reading an absent value or a `null`
1310/// field excludes the item `missing:<path>`.
1311fn 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    /// Evaluate with every dependency resolved (the evaluator checks that
1378    /// first, except for `field_presence`, which reads statuses).
1379    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            // Plan 3 evaluation is increment 3 of spec 032; `Evaluation::start`
1384            // refuses a plan 3 until then, so these are never reached.
1385            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                // The result is `maybe` exactly when an operand's type is.
1450                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        // Every field without a rule is named, each once.
1801        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}