Skip to main content

lemon/dsl/
ops.rs

1//! Op vocabulary: DSL surface names ⇄ JSON `Expr` op tags + field layout.
2//! Field declaration order MUST match `spec.rs` so positional args line up.
3
4#[derive(Debug, Clone, Copy)]
5pub enum Field {
6    Expr(&'static str),
7    ExprOpt(&'static str),
8    ExprList(&'static str),
9    Num(&'static str),
10    NumOpt(&'static str),
11    BoolOpt(&'static str),
12    Str(&'static str),
13    StrOpt(&'static str),
14    StrListOpt(&'static str),
15}
16
17pub fn field_name(f: &Field) -> &'static str {
18    match f {
19        Field::Expr(n)
20        | Field::ExprOpt(n)
21        | Field::ExprList(n)
22        | Field::Num(n)
23        | Field::NumOpt(n)
24        | Field::BoolOpt(n)
25        | Field::Str(n)
26        | Field::StrOpt(n)
27        | Field::StrListOpt(n) => n,
28    }
29}
30
31/// JSON-schema kind for a field: `expr` (an `Expr` subtree), `number`, `string`,
32/// `bool`, or `list` (a list of `Expr`) / `list-string`.
33pub fn field_kind(f: &Field) -> &'static str {
34    match f {
35        Field::Expr(_) | Field::ExprOpt(_) => "expr",
36        Field::ExprList(_) => "list",
37        Field::Num(_) | Field::NumOpt(_) => "number",
38        Field::BoolOpt(_) => "bool",
39        Field::Str(_) | Field::StrOpt(_) => "string",
40        Field::StrListOpt(_) => "list-string",
41    }
42}
43
44/// Whether the parser requires this field (required scalar/Expr/list) or accepts
45/// it as optional (`*Opt` variants).
46pub fn field_required(f: &Field) -> bool {
47    matches!(
48        f,
49        Field::Expr(_) | Field::Num(_) | Field::Str(_) | Field::ExprList(_)
50    )
51}
52
53pub struct OpSig {
54    pub tag: &'static str,
55    pub fields: &'static [Field],
56}
57
58/// One row per function-style op. `names[0]` is the canonical DSL name (used by the
59/// printer); the rest are accepted aliases. Operator-style ops (Gt/And/Add/Neg/…)
60/// are NOT here — see `binop_tag`/`prefix_tag`.
61///
62/// `desc` is a one-line, machine-readable description consumed by the schema
63/// generator (`cargo run -p lemon-lang --example gen-schema`); keep it terse and
64/// in sync with the `Expr` doc-comment in `spec.rs`.
65struct Row {
66    names: &'static [&'static str],
67    sig: OpSig,
68    desc: &'static str,
69}
70
71use Field::*;
72
73static ROWS: &[Row] = &[
74    Row {
75        names: &["sma", "average"],
76        sig: OpSig {
77            tag: "Average",
78            fields: &[Expr("of"), Num("n")],
79        },
80        desc: "Simple moving average of `of` over `n` days.",
81    },
82    Row {
83        names: &["ema"],
84        sig: OpSig {
85            tag: "Ema",
86            fields: &[Expr("of"), Num("n")],
87        },
88        desc: "Exponential moving average of `of` over `n` days.",
89    },
90    Row {
91        names: &["std"],
92        sig: OpSig {
93            tag: "Std",
94            fields: &[Expr("of"), Num("n")],
95        },
96        desc: "Rolling standard deviation of `of` over `n` days.",
97    },
98    Row {
99        names: &["rsi"],
100        sig: OpSig {
101            tag: "Rsi",
102            fields: &[Expr("of"), Num("n")],
103        },
104        desc: "Relative Strength Index of `of` over `n` days.",
105    },
106    Row {
107        names: &["pct_change"],
108        sig: OpSig {
109            tag: "PctChange",
110            fields: &[Expr("of"), Num("n")],
111        },
112        desc: "Percentage change of `of` over `n` days.",
113    },
114    Row {
115        names: &["rise"],
116        sig: OpSig {
117            tag: "Rise",
118            fields: &[Expr("of"), Num("n")],
119        },
120        desc: "1 where `of` rose for `n` consecutive days, else 0.",
121    },
122    Row {
123        names: &["fall"],
124        sig: OpSig {
125            tag: "Fall",
126            fields: &[Expr("of"), Num("n")],
127        },
128        desc: "1 where `of` fell for `n` consecutive days, else 0.",
129    },
130    Row {
131        names: &["shift"],
132        sig: OpSig {
133            tag: "Shift",
134            fields: &[Expr("of"), Num("n")],
135        },
136        desc: "`of` lagged forward by `n` days.",
137    },
138    Row {
139        names: &["rolling_max"],
140        sig: OpSig {
141            tag: "RollingMax",
142            fields: &[Expr("of"), Num("n")],
143        },
144        desc: "Rolling maximum of `of` over `n` days.",
145    },
146    Row {
147        names: &["rolling_min"],
148        sig: OpSig {
149            tag: "RollingMin",
150            fields: &[Expr("of"), Num("n")],
151        },
152        desc: "Rolling minimum of `of` over `n` days.",
153    },
154    Row {
155        names: &["bollinger_mid"],
156        sig: OpSig {
157            tag: "BollingerMid",
158            fields: &[Expr("of"), Num("n")],
159        },
160        desc: "Bollinger mid band: the `n`-day simple moving average of `of`.",
161    },
162    Row {
163        names: &["bollinger_upper"],
164        sig: OpSig {
165            tag: "BollingerUpper",
166            fields: &[Expr("of"), Num("n"), NumOpt("k")],
167        },
168        desc: "Bollinger upper band: sma(of, n) + k * std(of, n) (k defaults to 2).",
169    },
170    Row {
171        names: &["bollinger_lower"],
172        sig: OpSig {
173            tag: "BollingerLower",
174            fields: &[Expr("of"), Num("n"), NumOpt("k")],
175        },
176        desc: "Bollinger lower band: sma(of, n) - k * std(of, n) (k defaults to 2).",
177    },
178    Row {
179        names: &["macd"],
180        sig: OpSig {
181            tag: "Macd",
182            fields: &[Expr("of"), NumOpt("fast"), NumOpt("slow")],
183        },
184        desc: "MACD line: ema(of, fast) - ema(of, slow) (fast/slow default 12/26).",
185    },
186    Row {
187        names: &["macd_signal"],
188        sig: OpSig {
189            tag: "MacdSignal",
190            fields: &[Expr("of"), NumOpt("fast"), NumOpt("slow"), NumOpt("signal")],
191        },
192        desc: "MACD signal line: `signal`-day EMA of the MACD line (defaults 12/26/9).",
193    },
194    Row {
195        names: &["macd_hist"],
196        sig: OpSig {
197            tag: "MacdHist",
198            fields: &[Expr("of"), NumOpt("fast"), NumOpt("slow"), NumOpt("signal")],
199        },
200        desc: "MACD histogram: MACD line minus its signal line (defaults 12/26/9).",
201    },
202    Row {
203        names: &["donchian_high"],
204        sig: OpSig {
205            tag: "DonchianHigh",
206            fields: &[Expr("of"), Num("n")],
207        },
208        desc: "Donchian channel upper band: rolling `n`-day high of `of`.",
209    },
210    Row {
211        names: &["donchian_low"],
212        sig: OpSig {
213            tag: "DonchianLow",
214            fields: &[Expr("of"), Num("n")],
215        },
216        desc: "Donchian channel lower band: rolling `n`-day low of `of`.",
217    },
218    Row {
219        names: &["donchian_mid"],
220        sig: OpSig {
221            tag: "DonchianMid",
222            fields: &[Expr("of"), Num("n")],
223        },
224        desc: "Donchian channel mid-line: (rolling_max + rolling_min) / 2 over `n` days.",
225    },
226    Row {
227        names: &["atr"],
228        sig: OpSig {
229            tag: "Atr",
230            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
231        },
232        desc: "Average True Range over `n` days from high/low/close.",
233    },
234    Row {
235        names: &["natr"],
236        sig: OpSig {
237            tag: "Natr",
238            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
239        },
240        desc: "Normalized ATR (percent) over `n` days.",
241    },
242    Row {
243        names: &["willr"],
244        sig: OpSig {
245            tag: "WillR",
246            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
247        },
248        desc: "Williams %R over `n` days.",
249    },
250    Row {
251        names: &["cci"],
252        sig: OpSig {
253            tag: "Cci",
254            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
255        },
256        desc: "Commodity Channel Index over `n` days.",
257    },
258    Row {
259        names: &["stoch_k"],
260        sig: OpSig {
261            tag: "StochK",
262            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
263        },
264        desc: "Stochastic %K over `n` days.",
265    },
266    Row {
267        names: &["stoch_d"],
268        sig: OpSig {
269            tag: "StochD",
270            fields: &[
271                Expr("high"),
272                Expr("low"),
273                Expr("close"),
274                Num("n"),
275                NumOpt("d"),
276            ],
277        },
278        desc: "Stochastic %D: `d`-day average of %K over `n` days (d defaults to 3).",
279    },
280    Row {
281        names: &["aroon_up"],
282        sig: OpSig {
283            tag: "AroonUp",
284            fields: &[Expr("high"), Num("n")],
285        },
286        desc: "Aroon Up over `n` days from high.",
287    },
288    Row {
289        names: &["aroon_down"],
290        sig: OpSig {
291            tag: "AroonDown",
292            fields: &[Expr("low"), Num("n")],
293        },
294        desc: "Aroon Down over `n` days from low.",
295    },
296    Row {
297        names: &["adx"],
298        sig: OpSig {
299            tag: "Adx",
300            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
301        },
302        desc: "Average Directional Index over `n` days.",
303    },
304    Row {
305        names: &["plus_di"],
306        sig: OpSig {
307            tag: "PlusDi",
308            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
309        },
310        desc: "Plus Directional Indicator (+DI) over `n` days.",
311    },
312    Row {
313        names: &["minus_di"],
314        sig: OpSig {
315            tag: "MinusDi",
316            fields: &[Expr("high"), Expr("low"), Expr("close"), Num("n")],
317        },
318        desc: "Minus Directional Indicator (-DI) over `n` days.",
319    },
320    Row {
321        names: &["obv"],
322        sig: OpSig {
323            tag: "Obv",
324            fields: &[Expr("close"), Expr("volume")],
325        },
326        desc: "On-Balance Volume from close and volume.",
327    },
328    Row {
329        names: &["mfi"],
330        sig: OpSig {
331            tag: "Mfi",
332            fields: &[
333                Expr("high"),
334                Expr("low"),
335                Expr("close"),
336                Expr("volume"),
337                Num("n"),
338            ],
339        },
340        desc: "Money Flow Index over `n` days.",
341    },
342    Row {
343        names: &["vwap"],
344        sig: OpSig {
345            tag: "Vwap",
346            fields: &[
347                Expr("high"),
348                Expr("low"),
349                Expr("close"),
350                Expr("volume"),
351                Num("n"),
352            ],
353        },
354        desc: "Volume-Weighted Average Price over `n` days from high/low/close/volume.",
355    },
356    Row {
357        names: &["is_largest"],
358        sig: OpSig {
359            tag: "IsLargest",
360            fields: &[Expr("of"), Num("n")],
361        },
362        desc: "1 for the `n` highest values per row (cross-section), else 0.",
363    },
364    Row {
365        names: &["is_smallest"],
366        sig: OpSig {
367            tag: "IsSmallest",
368            fields: &[Expr("of"), Num("n")],
369        },
370        desc: "1 for the `n` lowest values per row (cross-section), else 0.",
371    },
372    Row {
373        names: &["sustain"],
374        sig: OpSig {
375            tag: "Sustain",
376            fields: &[Expr("of"), Num("nwindow"), NumOpt("nsatisfy")],
377        },
378        desc: "1 where `of` held true at least `nsatisfy` times within the last `nwindow` rows.",
379    },
380    Row {
381        names: &["is_entry"],
382        sig: OpSig {
383            tag: "IsEntry",
384            fields: &[Expr("of")],
385        },
386        desc: "1 on the row where `of` turns false->true (rising edge).",
387    },
388    Row {
389        names: &["is_exit"],
390        sig: OpSig {
391            tag: "IsExit",
392            fields: &[Expr("of")],
393        },
394        desc: "1 on the row where `of` turns true->false (falling edge).",
395    },
396    Row {
397        names: &["exit_when"],
398        sig: OpSig {
399            tag: "ExitWhen",
400            fields: &[Expr("entry"), Expr("exit")],
401        },
402        desc: "Hold true from an entry edge of `entry` until an exit edge (or `exit` is true).",
403    },
404    Row {
405        names: &["quantile_row"],
406        sig: OpSig {
407            tag: "QuantileRow",
408            fields: &[Expr("of"), Num("c")],
409        },
410        desc: "Per-row quantile of `of` across symbols at level `c` (e.g. 0.5 = median); one-column result.",
411    },
412    Row {
413        names: &["winsorize"],
414        sig: OpSig {
415            tag: "Winsorize",
416            fields: &[Expr("of"), Num("lower"), Num("upper")],
417        },
418        desc: "Per-row winsorize: clip values to empirical quantiles `lower`/`upper` (in 0..1).",
419    },
420    Row {
421        names: &["zscore"],
422        sig: OpSig {
423            tag: "Zscore",
424            fields: &[Expr("of")],
425        },
426        desc: "Per-row z-score (population std); NaN preserved; constant rows become 0.",
427    },
428    Row {
429        names: &["bucket"],
430        sig: OpSig {
431            tag: "Bucket",
432            fields: &[Expr("of"), Num("n")],
433        },
434        desc: "Per-row quantile buckets labeled 1..=n (ties share average rank).",
435    },
436    Row {
437        names: &["demean"],
438        sig: OpSig {
439            tag: "Demean",
440            fields: &[Expr("of")],
441        },
442        desc: "Per-row demean: subtract the cross-sectional mean of non-NaN cells.",
443    },
444    Row {
445        names: &["ceil"],
446        sig: OpSig {
447            tag: "Ceil",
448            fields: &[Expr("of")],
449        },
450        desc: "Ceiling of `of`.",
451    },
452    Row {
453        names: &["rank"],
454        sig: OpSig {
455            tag: "Rank",
456            fields: &[Expr("of"), BoolOpt("pct"), BoolOpt("ascending")],
457        },
458        desc: "Cross-sectional rank of `of` per row; `pct` for 0..1 percentile (default true), `ascending` sets direction (default true).",
459    },
460    Row {
461        names: &["mask"],
462        sig: OpSig {
463            tag: "Mask",
464            fields: &[Expr("of"), Expr("by")],
465        },
466        desc: "`of` kept only where `by` is true; elsewhere dropped.",
467    },
468    Row {
469        names: &["normalize_row"],
470        sig: OpSig {
471            tag: "NormalizeRow",
472            fields: &[Expr("of")],
473        },
474        desc: "Scale each row so gross weight (sum of |w|) is 1 — turns a raw signal into explicit portfolio weights. NaN preserved; zero rows unchanged.",
475    },
476    Row {
477        names: &["hold_until"],
478        sig: OpSig {
479            tag: "HoldUntil",
480            fields: &[
481                Expr("entry"),
482                Expr("exit"),
483                NumOpt("nstocks_limit"),
484                ExprOpt("rank"),
485                NumOpt("stop_loss"),
486                NumOpt("take_profit"),
487                NumOpt("trail_stop"),
488                NumOpt("trail_stop_activation"),
489            ],
490        },
491        desc: "Stateful rotation: enter on `entry`, exit on `exit`, hold up to `nstocks_limit` (prioritised by `rank`), with optional stop_loss/take_profit/trail_stop/trail_stop_activation.",
492    },
493    Row {
494        names: &["rebalance"],
495        sig: OpSig {
496            tag: "Rebalance",
497            fields: &[Expr("of"), StrOpt("freq"), ExprOpt("on")],
498        },
499        desc: "Hold `of`, refreshing on calendar `freq` (W/ME/QE) or on dates where `on` is true.",
500    },
501    Row {
502        names: &["neutralize"],
503        sig: OpSig {
504            tag: "Neutralize",
505            fields: &[Expr("of"), ExprList("by"), BoolOpt("add_const")],
506        },
507        desc: "Cross-sectionally regress `of` against the `by` factors, optionally adding a constant (default true).",
508    },
509    Row {
510        names: &["neutralize_industry"],
511        sig: OpSig {
512            tag: "NeutralizeIndustry",
513            fields: &[Expr("of"), BoolOpt("add_const")],
514        },
515        desc: "Neutralize `of` within each industry/sector (add_const defaults to true).",
516    },
517    Row {
518        names: &["industry_rank"],
519        sig: OpSig {
520            tag: "IndustryRank",
521            fields: &[Expr("of"), StrListOpt("categories")],
522        },
523        desc: "Rank `of` within each industry, optionally limited to `categories`.",
524    },
525    Row {
526        names: &["groupby_category"],
527        sig: OpSig {
528            tag: "GroupbyCategory",
529            fields: &[Expr("of"), Str("agg")],
530        },
531        desc: "Aggregate `of` within each industry using `agg` (e.g. mean).",
532    },
533    Row {
534        names: &["in_sector"],
535        sig: OpSig {
536            tag: "InSector",
537            fields: &[Expr("of"), Str("name")],
538        },
539        desc: "Boolean mask (1/0) where the symbol's industry equals `name` (exact, case-sensitive); shape follows `of`.",
540    },
541];
542
543// ---------------------------------------------------------------------------
544// Public catalog API — the single source of truth consumed by the schema
545// generator (`cargo run -p lemon-lang --example gen-schema`). Everything below
546// is derived from `ROWS`, `BINOPS`, and `prefix_tag`, so the emitted JSON
547// artifacts can never drift from the parser.
548// ---------------------------------------------------------------------------
549
550/// A field's serde default, when it has one. `serde(default)` on an `Option`
551/// field means "absent" (no default value to emit); those return `None` here.
552/// Kept co-located with the field declarations so it stays in sync with
553/// `spec.rs`.
554pub fn field_default(tag: &str, field_name: &str) -> Option<serde_json::Value> {
555    use serde_json::json;
556    match (tag, field_name) {
557        ("StochD", "d") => Some(json!(3)),
558        ("BollingerUpper", "k") | ("BollingerLower", "k") => Some(json!(2.0)),
559        ("Macd", "fast") | ("MacdSignal", "fast") | ("MacdHist", "fast") => Some(json!(12)),
560        ("Macd", "slow") | ("MacdSignal", "slow") | ("MacdHist", "slow") => Some(json!(26)),
561        ("MacdSignal", "signal") | ("MacdHist", "signal") => Some(json!(9)),
562        ("Rank", "pct") => Some(json!(true)),
563        ("Rank", "ascending") => Some(json!(true)),
564        ("Neutralize", "add_const") => Some(json!(true)),
565        ("NeutralizeIndustry", "add_const") => Some(json!(true)),
566        _ => None,
567    }
568}
569
570/// One field of a callable op, in schema-friendly form.
571pub struct FieldInfo {
572    pub name: &'static str,
573    /// One of: `expr`, `number`, `string`, `bool`, `list`, `list-string`.
574    pub kind: &'static str,
575    pub required: bool,
576    pub default: Option<serde_json::Value>,
577}
578
579/// One callable op: its canonical name, aliases, op tag, ordered fields, and a
580/// one-line description.
581pub struct OpInfo {
582    pub name: &'static str,
583    pub aliases: &'static [&'static str],
584    pub tag: &'static str,
585    pub description: &'static str,
586    pub fields: Vec<FieldInfo>,
587}
588
589/// Every function-style op in `ROWS`, in declaration order.
590pub fn function_ops() -> Vec<OpInfo> {
591    ROWS.iter()
592        .map(|r| OpInfo {
593            name: r.names[0],
594            aliases: &r.names[1..],
595            tag: r.sig.tag,
596            description: r.desc,
597            fields: r
598                .sig
599                .fields
600                .iter()
601                .map(|f| {
602                    let name = field_name(f);
603                    FieldInfo {
604                        name,
605                        kind: field_kind(f),
606                        required: field_required(f),
607                        default: field_default(r.sig.tag, name),
608                    }
609                })
610                .collect(),
611        })
612        .collect()
613}
614
615/// The binary operator ops: `(symbol, op tag)`. Both operands are `l`/`r` exprs.
616pub fn binary_operators() -> &'static [(&'static str, &'static str)] {
617    BINOPS
618}
619
620pub fn op_by_name(name: &str) -> Option<&'static OpSig> {
621    ROWS.iter()
622        .find(|r| r.names.contains(&name))
623        .map(|r| &r.sig)
624}
625
626pub fn sig_by_tag(tag: &str) -> Option<&'static OpSig> {
627    ROWS.iter().find(|r| r.sig.tag == tag).map(|r| &r.sig)
628}
629
630/// Canonical DSL name for a function-style op tag (printer).
631pub fn dsl_name_for_tag(tag: &str) -> &'static str {
632    ROWS.iter()
633        .find(|r| r.sig.tag == tag)
634        .map(|r| r.names[0])
635        .unwrap_or_else(|| {
636            // Tag not in the function table — callers should not reach this for
637            // operator-style ops; return the tag itself via a leaked copy so the
638            // return type stays `&'static str`.
639            ALL_OP_TAGS
640                .iter()
641                .copied()
642                .find(|t| *t == tag)
643                .unwrap_or("")
644        })
645}
646
647static BINOPS: &[(&str, &str)] = &[
648    (">", "Gt"),
649    ("<", "Lt"),
650    (">=", "Ge"),
651    ("<=", "Le"),
652    ("and", "And"),
653    ("or", "Or"),
654    ("+", "Add"),
655    ("-", "Sub"),
656    ("*", "Mul"),
657    ("/", "Div"),
658];
659
660pub fn binop_tag(op: &str) -> Option<&'static str> {
661    BINOPS.iter().find(|(s, _)| *s == op).map(|(_, t)| *t)
662}
663
664pub fn binop_symbol_for_tag(tag: &str) -> Option<&'static str> {
665    BINOPS.iter().find(|(_, t)| *t == tag).map(|(s, _)| *s)
666}
667
668pub fn prefix_tag(op: &str) -> Option<&'static str> {
669    if op == "-" {
670        Some("Neg")
671    } else {
672        None
673    }
674}
675
676/// All op tags in `spec.rs` — the completeness gate (see ops::tests).
677pub static ALL_OP_TAGS: &[&str] = &[
678    "Data",
679    "Const",
680    "Average",
681    "Ema",
682    "Std",
683    "Rsi",
684    "PctChange",
685    "Rise",
686    "Shift",
687    "RollingMax",
688    "RollingMin",
689    "BollingerMid",
690    "BollingerUpper",
691    "BollingerLower",
692    "Macd",
693    "MacdSignal",
694    "MacdHist",
695    "DonchianHigh",
696    "DonchianLow",
697    "DonchianMid",
698    "Atr",
699    "Natr",
700    "WillR",
701    "Cci",
702    "StochK",
703    "StochD",
704    "AroonUp",
705    "AroonDown",
706    "Adx",
707    "PlusDi",
708    "MinusDi",
709    "Obv",
710    "Mfi",
711    "Vwap",
712    "Fall",
713    "IsLargest",
714    "IsSmallest",
715    "Sustain",
716    "IsEntry",
717    "IsExit",
718    "ExitWhen",
719    "QuantileRow",
720    "Winsorize",
721    "Zscore",
722    "Bucket",
723    "Demean",
724    "Gt",
725    "Lt",
726    "Ge",
727    "Le",
728    "And",
729    "Or",
730    "Not",
731    "Add",
732    "Sub",
733    "Mul",
734    "Div",
735    "Neg",
736    "Ceil",
737    "Rank",
738    "Mask",
739    "NormalizeRow",
740    "HoldUntil",
741    "Rebalance",
742    "Neutralize",
743    "NeutralizeIndustry",
744    "IndustryRank",
745    "GroupbyCategory",
746    "InSector",
747];
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752
753    #[test]
754    fn looks_up_alias_and_snake_case() {
755        assert_eq!(op_by_name("sma").unwrap().tag, "Average");
756        assert_eq!(op_by_name("average").unwrap().tag, "Average");
757        assert_eq!(op_by_name("rolling_max").unwrap().tag, "RollingMax");
758        assert!(op_by_name("not_an_op").is_none());
759    }
760
761    #[test]
762    fn maps_operators() {
763        assert_eq!(binop_tag(">"), Some("Gt"));
764        assert_eq!(binop_tag("and"), Some("And"));
765        assert_eq!(binop_tag("+"), Some("Add"));
766        assert_eq!(prefix_tag("-"), Some("Neg"));
767    }
768
769    #[test]
770    fn round_trip_names_for_printer() {
771        assert_eq!(dsl_name_for_tag("Average"), "sma");
772        assert_eq!(binop_symbol_for_tag("Gt"), Some(">"));
773        assert_eq!(binop_symbol_for_tag("Average"), None);
774    }
775
776    #[test]
777    fn function_ops_expose_every_row_with_schema_metadata() {
778        let ops = function_ops();
779        // One OpInfo per row in the table.
780        assert_eq!(ops.len(), ROWS.len());
781
782        // sma is the canonical name for Average and carries `average` as an alias.
783        let sma = ops.iter().find(|o| o.tag == "Average").unwrap();
784        assert_eq!(sma.name, "sma");
785        assert!(sma.aliases.contains(&"average"));
786        assert!(!sma.description.is_empty());
787
788        // Every field carries a name, a known kind, and a required flag; the field
789        // kinds collectively cover the full `field_kind` match.
790        let mut kinds = std::collections::BTreeSet::new();
791        for op in &ops {
792            for f in &op.fields {
793                assert!(!f.name.is_empty());
794                assert!(matches!(
795                    f.kind,
796                    "expr" | "number" | "string" | "bool" | "list" | "list-string"
797                ));
798                kinds.insert(f.kind);
799            }
800        }
801        // The table exercises expr and number kinds at minimum.
802        assert!(kinds.contains("expr"));
803        assert!(kinds.contains("number"));
804    }
805
806    #[test]
807    fn field_defaults_match_serde() {
808        // Documented serde defaults surface through field_default…
809        assert_eq!(field_default("StochD", "d"), Some(serde_json::json!(3)));
810        assert_eq!(field_default("Rank", "pct"), Some(serde_json::json!(true)));
811        assert_eq!(
812            field_default("Neutralize", "add_const"),
813            Some(serde_json::json!(true))
814        );
815        // …and everything else has no default.
816        assert_eq!(field_default("Average", "n"), None);
817        assert_eq!(field_default("Nope", "nope"), None);
818
819        // field_default is threaded through function_ops for the defaulted fields.
820        let stoch_d = function_ops()
821            .into_iter()
822            .find(|o| o.tag == "StochD")
823            .unwrap();
824        let d_field = stoch_d.fields.iter().find(|f| f.name == "d").unwrap();
825        assert_eq!(d_field.default, Some(serde_json::json!(3)));
826    }
827
828    #[test]
829    fn field_kind_and_required_cover_every_variant() {
830        use Field::*;
831        for (f, kind, required) in [
832            (Expr("x"), "expr", true),
833            (ExprOpt("x"), "expr", false),
834            (ExprList("x"), "list", true),
835            (Num("x"), "number", true),
836            (NumOpt("x"), "number", false),
837            (BoolOpt("x"), "bool", false),
838            (Str("x"), "string", true),
839            (StrOpt("x"), "string", false),
840            (StrListOpt("x"), "list-string", false),
841        ] {
842            assert_eq!(field_name(&f), "x");
843            assert_eq!(field_kind(&f), kind);
844            assert_eq!(field_required(&f), required);
845        }
846    }
847
848    #[test]
849    fn tag_and_operator_lookups() {
850        // sig_by_tag round-trips against op_by_name.
851        assert_eq!(sig_by_tag("Average").unwrap().tag, "Average");
852        assert!(sig_by_tag("NotATag").is_none());
853
854        // binary_operators exposes the BINOPS table.
855        let ops = binary_operators();
856        assert!(ops.contains(&(">", "Gt")));
857        assert!(ops.contains(&("/", "Div")));
858        assert_eq!(ops.len(), 10);
859
860        // Unknown / operator-style tags return "" from dsl_name_for_tag (fallback arm).
861        assert_eq!(dsl_name_for_tag("TotallyUnknownTag"), "");
862        assert_eq!(prefix_tag("+"), None);
863    }
864
865    #[test]
866    fn every_spec_op_has_a_signature_or_operator() {
867        // Every op tag from spec.rs. If a new op is added, add it here AND to the
868        // table/operator maps — this test is the completeness gate for this plan.
869        for tag in ALL_OP_TAGS {
870            let known = ROWS.iter().any(|r| r.sig.tag == *tag)
871                || binop_symbol_for_tag(tag).is_some()
872                || *tag == "Neg"
873                || *tag == "Not"
874                || *tag == "Const"
875                || *tag == "Data";
876            assert!(known, "op `{tag}` has no DSL handler");
877        }
878    }
879}