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::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        // A join result's items, as per-item operators read them.
1084        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        // A merged id can be absent; an item whose id cannot be read is
1112        // refused at evaluation as any item without an id.
1113        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
1121/// What an operator needs at evaluation time besides expression scope.
1122pub struct OpContext<'a> {
1123    pub scope: Scope<'a>,
1124    pub semantic: &'a BTreeMap<String, SemInstances>,
1125    /// Status of every declared input.
1126    pub inputs_status: &'a BTreeMap<String, Result<(), Unresolved>>,
1127    /// Input fields in this step's lineage, named by `invalid_input`.
1128    pub lineage_inputs: &'a [String],
1129    /// Each semantic step's fan-out binds, in instance-key order.
1130    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        // An operator that does not exclude absent items reads them as
1145        // missing evidence for their path (spec 032, 3.4.4).
1146        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
1188/// Items with unique ids, or `invalid_input` on a duplicate or missing id.
1189fn 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
1214/// Missing evidence for the items of `r`, named as an input or step is
1215/// named in a missing reason: without its `input:` or `step:` kind.
1216fn missing_items(r: &str) -> Unresolved {
1217    let path = r.split_once(':').map_or(r, |(_, p)| p);
1218    Unresolved::missing([format!("{path}/*")])
1219}
1220
1221/// A row a version 2 filter judges: an id and the item, or the reason it
1222/// is excluded before any rule.
1223type Row = (String, Result<Value, &'static str>);
1224
1225/// The rows a version 2 filter judges (spec 032, 3.4.4 and 3.5.4). An
1226/// absent item has no readable id; it is named by its position.
1227fn 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                    // `item` is `Some` exactly for a matched entry; the reason
1235                    // names why any other entry has none.
1236                    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
1264/// `filter_with_reasons@1` in `rustev.exact/2`: absent and unmatched items
1265/// are excluded before the rules, and a rule reading an absent value or a
1266/// `null` field excludes the item `missing:<path>`.
1267fn 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
1306/// `top_k@1` in `rustev.exact/2`: a key reading an absent value or a `null`
1307/// field excludes the item `missing:<path>`.
1308fn 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    /// Evaluate with every dependency resolved (the evaluator checks that
1375    /// first, except for `field_presence`, which reads statuses).
1376    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            // Plan 3 evaluation is increment 3 of spec 032; `Evaluation::start`
1381            // refuses a plan 3 until then, so these are never reached.
1382            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                // The result is `maybe` exactly when an operand's type is.
1447                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        // Every field without a rule is named, each once.
1798        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}