Skip to main content

rustyfi_lang/
prim_types.rs

1//! Type signatures for every primitive registered in `primitives.rs`'s
2//! `prims!` table (plus the `inline-fil` constant), transcribed from
3//! v0.0.6's `tools/gencode/vminst.ml` `~type_:` fields (cited by line
4//! number at each entry below) and from `src/frontend/primitives.cppo.ml`
5//! for the handful of names vminst.ml doesn't define directly (`::`, `!`,
6//! the comparison trio derived in `general_table`).
7//!
8//! `document`, `+p` and `\emph` have no entry here: they are ordinary
9//! `stdja-mini` stdlib bindings
10//! (`lib-rustyfi/dist/packages/stdja-mini.satyh`), built out of *other*
11//! primitives below and typechecked like any other `.satyh` library.
12//!
13//! Also provides `builtin_variants_with_version`, the seed set of variant
14//! type declarations ([`VariantDecl`]) primitives.cppo.ml registers before
15//! any user code runs.
16
17use crate::types::{self, BaseType, CmdArgType, MonoType, PolyType, TyVarRef};
18use rustyfi_syntax::RustyfiVersion;
19use std::collections::HashMap;
20
21// ============================================================================
22// Constructor helpers — read the table below like vminst.ml's own `tI`,
23// `tB`, `@->`, etc.
24// ============================================================================
25
26pub(crate) fn t_unit() -> MonoType {
27    MonoType::Base(BaseType::Unit)
28}
29pub fn t_bool() -> MonoType {
30    MonoType::Base(BaseType::Bool)
31}
32pub fn t_int() -> MonoType {
33    MonoType::Base(BaseType::Int)
34}
35pub(crate) fn t_float() -> MonoType {
36    MonoType::Base(BaseType::Float)
37}
38pub(crate) fn t_length() -> MonoType {
39    MonoType::Base(BaseType::Length)
40}
41pub fn t_string() -> MonoType {
42    MonoType::Base(BaseType::String)
43}
44pub(crate) fn t_inline_text() -> MonoType {
45    MonoType::Base(BaseType::InlineText)
46}
47pub(crate) fn t_block_text() -> MonoType {
48    MonoType::Base(BaseType::BlockText)
49}
50/// `math` (v0.0.6, vminst.ml's `tMATH`) / `math-text` (V0_1, upstream's
51/// literal rename of 0.0.6's `math`, dev-0-1-0 `tMT`) — same `MonoType`
52/// (`BaseType::MathText`) under both versions; only the surface NAME differs
53/// (`typecheck.rs`'s `name_to_mono`, version-gated). `${…}`'s unparsed
54/// source, in both generations.
55pub(crate) fn t_math_text() -> MonoType {
56    MonoType::Base(BaseType::MathText)
57}
58/// `math` — v0.0.6-only alias of [`t_math_text`] (both name the same
59/// `BaseType::MathText`). V0_1 call sites should read `t_math_text()`
60/// instead; this alias exists only for 0.0.6 signatures written against it.
61fn t_math() -> MonoType {
62    t_math_text()
63}
64/// `math-boxes` (V0_1 only; dev-0-1-0 vminst.ml's `tMB`) — the evaluated
65/// math tree `read-math` produces. 0.0.6 has no name
66/// for this type (its `math` conflates both halves).
67pub(crate) fn t_math_boxes() -> MonoType {
68    MonoType::Base(BaseType::MathBoxes)
69}
70/// `context -> math-boxes` (V0_1 only) — the script-callback type
71/// `math-sup`/`math-sub`/`math-upper`/`math-lower` take in 0.1
72/// (vminst.ml:208-353), evaluated under `enter_script`.
73fn t_math_script_fn() -> MonoType {
74    arrow(t_context(), t_math_boxes())
75}
76/// `math-class` (`primitives.cppo.ml:162-170`) — a built-in
77/// **variant** (same shape as `t_color()`/`t_script()` above), registered by
78/// `builtin_variants_with_version`. `math-char`/`math-group`/… all take this as an
79/// argument. **Distinct** from `t_math_char_class()` below (the
80/// `MathItalic`/… styling variant) — do not conflate the two.
81fn t_math_class() -> MonoType {
82    MonoType::Variant("math-class".to_string(), Vec::new())
83}
84/// `math-char-class` (`horzBox.ml:147`) — a built-in variant, registered by
85/// `builtin_variants_with_version`. Needed for `math.satyh`'s `sig` (`\math-style :
86/// [math-char-class; math] math-cmd`) and its
87/// `\mathrm`/`\mathbf`/`\mathcal`/… definitions to type-check —
88/// the actual Unicode-math-block restyling this variant names is resolved
89/// (`rustyfi_backend:: MathCharClass`/`resolve_variant_char`) once
90/// evaluation reaches a value of this type. This TYPE itself
91/// (`math-char-class`, nominal, no parameters) is version-blind; its
92/// CONSTRUCTOR SET is not — see [`builtin_variants_with_version`]'s
93/// `math_char_class_decl` (V0_1 registers 14
94/// ctors, V0_0 exactly 9).
95fn t_math_char_class() -> MonoType {
96    MonoType::Variant("math-char-class".to_string(), Vec::new())
97}
98/// `paren` — version-forked, the same shape as
99/// `t_deco(version)`/`t_graphics_output(version)`/`t_decoset(version)`:
100/// - `V0_0` (`pervasives.satyh`'s `type paren`) — structural, like
101///   `t_point()`/`t_dash()`/`t_deco()` above: directly the expanded function
102///   type, not a nominal reference.
103/// - `V0_1` (`primitives.cppo.ml:91`'s `tPAREN`) — args are (inner height,
104///   inner depth SIGNED, the context) → (the delimiter boxes, the
105///   script-kern function). The 0.0.6→0.1 delta: the closure now extracts
106///   fontsize / axis-ratio (via `get-math-axis-height-ratio`) / color FROM
107///   the context instead of receiving them as separate explicit arguments —
108///   see `make_paren_run` (`primitives.rs`) for the corresponding runtime
109///   protocol fork.
110///
111/// `math-paren`'s first two arguments (`math.satyh`'s
112/// `paren-left`/`paren-right`, `\paren`/`\brace`/`\abs`/…) are typed against
113/// this shape directly; this port's type-synonym expansion resolves a
114/// `paren`-named annotation to the same shape (V0_0: pervasives synonym
115/// expansion; V0_1: the `name_to_mono("paren", …)` nominal case,
116/// `typecheck.rs`), so what must match is `paren-left`/`paren-right`'s own
117/// INFERRED type, which it does by construction. Gated on `math_is_split()`,
118/// the same predicate that forks `math-paren` itself, so type-env and
119/// runtime stay keyed on one capability.
120pub(crate) fn t_paren(version: RustyfiVersion) -> MonoType {
121    if version.math_is_split() {
122        arrows(
123            vec![t_length(), t_length(), t_context()],
124            product(vec![t_inline_boxes(), arrow(t_length(), t_length())]),
125        )
126    } else {
127        arrows(
128            vec![t_length(), t_length(), t_length(), t_length(), t_color()],
129            product(vec![t_inline_boxes(), arrow(t_length(), t_length())]),
130        )
131    }
132}
133/// A math-char kern function (fontsize, y-position -> kern amount).
134/// `math-char-with-kern`/`math-big-char-with-kern`'s 3rd/4th arguments.
135fn t_math_kern_func() -> MonoType {
136    arrows(vec![t_length(), t_length()], t_length())
137}
138/// `math-variant-char`'s 9-field per-style codepoint record (`value.rs`'s
139/// `MathVariantStyle`) — a closed record row, structural like
140/// `t_pbinfo()`/`t_page_content_scheme()` above. Field order doesn't matter
141/// (records are structural), only the label set; matches `math.satyh`'s
142/// `greek-lowercase`/`greek-uppercase` record literals field-for-field.
143fn t_math_variant_style() -> MonoType {
144    const LABELS: [&str; 9] = [
145        "italic",
146        "bold-italic",
147        "roman",
148        "bold-roman",
149        "script",
150        "bold-script",
151        "fraktur",
152        "bold-fraktur",
153        "double-struck",
154    ];
155    let mut row = types::Row::Empty;
156    for label in LABELS.iter().rev() {
157        row = types::Row::Cons(label.to_string(), Box::new(t_string()), Box::new(row));
158    }
159    MonoType::Record(row)
160}
161/// `image` (vminst.ml's `tIMG`) — `load-image`'s result;
162pub(crate) fn t_image() -> MonoType {
163    MonoType::Base(BaseType::Image)
164}
165pub(crate) fn t_inline_boxes() -> MonoType {
166    MonoType::Base(BaseType::InlineBoxes)
167}
168pub(crate) fn t_block_boxes() -> MonoType {
169    MonoType::Base(BaseType::BlockBoxes)
170}
171pub(crate) fn t_context() -> MonoType {
172    MonoType::Base(BaseType::Context)
173}
174pub(crate) fn t_document() -> MonoType {
175    MonoType::Base(BaseType::Document)
176}
177/// `color` (`primitives.cppo.ml:187-190`'s `Gray of float | RGB of
178/// (float*float*float) | CMYK of (float*float*float*float)`) — a built-in
179/// **variant**, not a `BaseType`: it costs a `VariantDecl` (registered by
180/// `builtin_variants_with_version` below) and no `BaseType::Color`. `Gray`/`RGB`/`CMYK`
181/// typecheck and evaluate as ordinary `Ast::Ctor`/`Value::Ctor` values;
182/// `fill`/`stroke` are its first consumers.
183fn t_color() -> MonoType {
184    MonoType::Variant("color".to_string(), Vec::new())
185}
186/// `hyphenation` (dev-0-1-0 `types.cppo.ml`'s opaque `HyphenationType`) —
187/// stand-in: NOT a declared `BaseType` or
188/// `VariantDecl`, just the nominal-`Variant` fallthrough `name_to_mono`
189/// already gives any unrecognized sig type name (`typecheck.rs:500`), the
190/// same shape the already-vendored `color.satyh` seals through.
191/// `load-hyphenation-dictionary`'s return type and
192/// `set-hyphenation-dictionary`'s domain both spell this helper, so sealing
193/// subsumption unifies them structurally — no `types.rs` touch needed.
194fn t_hyphenation() -> MonoType {
195    MonoType::Variant("hyphenation".to_string(), Vec::new())
196}
197/// `unicode-char-database` (dev-0-1-0 `types.cppo.ml`'s opaque
198/// `UnidataType`) — a stand-in, same nominal-`Variant` shape as
199/// [`t_hyphenation`] just above.
200fn t_unicode_char_database() -> MonoType {
201    MonoType::Variant("unicode-char-database".to_string(), Vec::new())
202}
203/// `pre-path` (vminst.ml's `tPRP`; v0.0.6 `PrePathType`).
204pub(crate) fn t_prepath() -> MonoType {
205    MonoType::Base(BaseType::PrePath)
206}
207/// `path` (vminst.ml's `tPATH`; v0.0.6 `PathType`).
208pub(crate) fn t_path() -> MonoType {
209    MonoType::Base(BaseType::Path)
210}
211/// `graphics` (vminst.ml's `tGR`; v0.0.6 `GraphicsType`).
212pub(crate) fn t_graphics() -> MonoType {
213    MonoType::Base(BaseType::Graphics)
214}
215/// `point = length * length` (vminst.ml's `tPT = tPROD[tLN;tLN]`) —
216/// structural, not a `BaseType`: a point is just a 2-tuple of lengths,
217/// matching the runtime representation (`Value::Tuple([Length, Length])`,
218/// see `primitives.rs`'s `as_point`/`make_point_value`).
219fn t_point() -> MonoType {
220    product(vec![t_length(), t_length()])
221}
222/// `page-break-info` (vminst.ml's `tPBINFO`) — a `hook-page-break` closure's
223/// first argument. The port has first-class row-typed records
224/// (`types::MonoType::Record`) and `#field` access, so this type-checks
225/// structurally with no nominal `tPBINFO` variant needed.
226/// Runtime: `Value::Record`, built by `fire_hooks` (`lib.rs`).
227fn t_pbinfo() -> MonoType {
228    MonoType::Record(types::Row::Cons(
229        "page-number".to_string(),
230        Box::new(t_int()),
231        Box::new(types::Row::Empty),
232    ))
233}
234/// `tDOCINFODIC` (dev-0-1-0 `src/frontend/primitives.cppo.ml:98-107`):
235/// `register-document-information`'s argument, upstream's named record type
236/// `document-information-dictionary`. Structural here, the same `t_pbinfo`
237/// precedent above: upstream registers a `SynonymType` name for the
238/// identical closed row, which this port deliberately doesn't mirror
239/// nominally (cosmetic deviation — revisit only if a 0.1 package names the
240/// type in a signature).
241fn t_doc_info_dictionary() -> MonoType {
242    MonoType::Record(types::Row::Cons(
243        "title".to_string(),
244        Box::new(t_option(t_string())),
245        Box::new(types::Row::Cons(
246            "subject".to_string(),
247            Box::new(t_option(t_string())),
248            Box::new(types::Row::Cons(
249                "author".to_string(),
250                Box::new(t_option(t_string())),
251                Box::new(types::Row::Cons(
252                    "keywords".to_string(),
253                    Box::new(list(t_string())),
254                    Box::new(types::Row::Empty),
255                )),
256            )),
257        )),
258    ))
259}
260/// `page` (vminst.ml's `tPG`) — a nominal variant, `primitives.cppo.ml:203-212`,
261/// registered by `builtin_variants_with_version`. `page-break`'s first argument
262/// selects the whole document's paper size.
263fn t_page() -> MonoType {
264    MonoType::Variant("page".to_string(), Vec::new())
265}
266/// `page-break`/`page-break-multicolumn`/`page-break-two-column`'s
267/// first-argument type, forked: `t_page()` (the v0.0.6 9-ctor ADT)
268/// under `has_page_adt()`, a plain `length * length` tuple otherwise.
269/// `RustyfiVersion::has_page_adt()` is the single source of truth for
270/// which shape a given version's `page-break*` family admits —
271/// `builtin_variants_with_version` below gates the ADT's own *registration* on the
272/// exact same method, so the two can never disagree (a `V0_1` program can
273/// never see a type that calls `t_page()` while `page`'s `VariantDecl` is
274/// absent from its `builtin_variants_with_version` result).
275fn t_page_or_geometry(version: RustyfiVersion) -> MonoType {
276    if version.has_page_adt() {
277        t_page()
278    } else {
279        product(vec![t_length(), t_length()])
280    }
281}
282/// `page-content-scheme` (vminst.ml's `tPAGECONT`) — what a `page-break`
283/// content-scheme closure returns, applied once per page with that page's
284/// `pbinfo`. Structural, like `t_pbinfo` above — no nominal type needed.
285fn t_page_content_scheme() -> MonoType {
286    MonoType::Record(types::Row::Cons(
287        "text-origin".to_string(),
288        Box::new(t_point()),
289        Box::new(types::Row::Cons(
290            "text-height".to_string(),
291            Box::new(t_length()),
292            Box::new(types::Row::Empty),
293        )),
294    ))
295}
296/// `page-parts` (vminst.ml's `tPAGEPARTS`) — what a `page-break`
297/// parts-scheme closure returns, applied once per page with that page's
298/// `pbinfo`.
299fn t_page_parts() -> MonoType {
300    MonoType::Record(types::Row::Cons(
301        "header-origin".to_string(),
302        Box::new(t_point()),
303        Box::new(types::Row::Cons(
304            "header-content".to_string(),
305            Box::new(t_block_boxes()),
306            Box::new(types::Row::Cons(
307                "footer-origin".to_string(),
308                Box::new(t_point()),
309                Box::new(types::Row::Cons(
310                    "footer-content".to_string(),
311                    Box::new(t_block_boxes()),
312                    Box::new(types::Row::Empty),
313                )),
314            )),
315        )),
316    ))
317}
318/// `'a option` (vminst.ml's `tOPT`) — the built-in `option` variant
319/// (`builtin_variants_with_version`'s `option_decl`) applied to `ty`.
320pub(crate) fn t_option(ty: MonoType) -> MonoType {
321    MonoType::Variant("option".to_string(), vec![ty])
322}
323/// `script` (vminst.ml's `tSCR`) — a built-in **variant** (same shape as
324/// `t_color()` above): `HanIdeographic | Kana | Latin | OtherScript`
325/// (upstream's real surface constructor set, `primitives.cppo.ml:192-196`),
326/// registered by `builtin_variants_with_version`. `script-guard` (pervasives.satyh's
327/// `\SATySFi`/`\LaTeX`/`\TeX`) is its first consumer.
328fn t_script() -> MonoType {
329    MonoType::Variant("script".to_string(), Vec::new())
330}
331/// `language` (vminst.ml's `tLANG`; `charBasis.ml`'s `language_system =
332/// Japanese | English | NoLanguageSystem`) — a nullary built-in variant,
333/// same shape as [`t_script`]. `set-language`'s 2nd argument
334/// (`stdja.satyh`'s `set-language Kana Japanese`).
335fn t_language() -> MonoType {
336    MonoType::Variant("language".to_string(), Vec::new())
337}
338/// `text-info` (vminst.ml's `tTCTX`; v0.0.6 `TextInfoType`) — the text-mode
339/// context: only the three pure prims below produce/consume it.
340fn t_text_info() -> MonoType {
341    MonoType::Base(BaseType::TextInfo)
342}
343/// `paddings` (vminst.ml's `tPADS = tPROD [tLN;tLN;tLN;tLN]`) — a plain
344/// 4-tuple `(paddingL, paddingR, paddingT, paddingB)`, matching the runtime
345/// shape `primitives.rs`'s `as_paddings` reads (mirrors `evalUtil.ml`'s
346/// `get_paddings` field order).
347fn t_paddings() -> MonoType {
348    product(vec![t_length(), t_length(), t_length(), t_length()])
349}
350/// `cell` (`primitives.cppo.ml:214-217`) — a built-in **variant** (same
351/// shape as `t_color()` above), registered by `builtin_variants_with_version`.
352fn t_cell() -> MonoType {
353    MonoType::Variant("cell".to_string(), Vec::new())
354}
355/// `dash` (`graphicD.ml`'s `type dash = length * length * length`) —
356/// `dashed-stroke`'s 2nd argument, `(d1, d2, d0)` = on-length, off-length,
357/// phase.
358fn t_dash() -> MonoType {
359    product(vec![t_length(), t_length(), t_length()])
360}
361/// The result type of a graphics-producing callback (`inline-graphics`'s
362/// `tIGR`, `inline-graphics-outer`'s `tIGRO`, `tabular`'s `tRULESF`,
363/// `t_deco`'s own result): `list graphics` (`tL tGR`) under `V0_0`, one
364/// `graphics` collection (`tGR`) under `V0_1` — the hidden alias-redefinition
365/// retype surfaces across every carrier primitive that returns this shape. Runtime counterpart: `coerce_graphics_result`
366/// (`primitives.rs`), keyed on the same
367/// `RustyfiVersion::graphics_is_collection()` capability so the env and
368/// type-env agree by construction.
369fn t_graphics_output(version: RustyfiVersion) -> MonoType {
370    if version.graphics_is_collection() {
371        t_graphics()
372    } else {
373        list(t_graphics())
374    }
375}
376/// `deco` (vminst.ml's `tDECO_raw` under `V0_0`; dev-0-1-0 redefines the
377/// same alias with a bare `tGR` result), invoked (once placed) with
378/// its own position and resolved width/height/depth. `inline-frame-outer`'s
379/// stand-in body (`primitives.rs`) never actually calls it (see that
380/// primitive's doc comment), but it is typed faithfully so callers still
381/// type-check exactly as they would upstream.
382pub(crate) fn t_deco(version: RustyfiVersion) -> MonoType {
383    arrows(
384        vec![t_point(), t_length(), t_length(), t_length()],
385        t_graphics_output(version),
386    )
387}
388/// `deco-set = deco * deco * deco * deco` (vminst.ml's `tDECOSET`) —
389/// `block-frame-breakable`'s third argument (the four edge/corner
390/// decoration closures a frame would fire at placement time). STAND-IN
391/// body (`primitives.rs`'s `prim_block_frame_breakable`) pops and drops it
392/// entirely (like `t_deco()`'s own callers above), but it is typed
393/// faithfully here so callers still type-check exactly as they would
394/// upstream.
395pub(crate) fn t_decoset(version: RustyfiVersion) -> MonoType {
396    product(vec![t_deco(version); 4])
397}
398/// `font` — the V0_1-only OPAQUE face handle (upstream `saphe-split`
399/// `primitives.cppo.ml:45`'s `tFONTKEY = (~! "font", BaseType(FontType))`).
400/// Value: [`Value::Font`](crate::value::Value::Font), a resolved
401/// `rustyfi_backend::FontKey`, exactly upstream's `BCFontKey of FontKey.t`.
402///
403/// There is deliberately no `V0_0` counterpart: upstream 0.0.6 registers no
404/// `font` base type and declares no `type font` in its bundled library, so
405/// under `V0_0` the name falls through to the nominal `Variant("font", [])`
406/// — see [`BaseType::Font`] for the citations.
407pub(crate) fn t_font_key() -> MonoType {
408    MonoType::Base(BaseType::Font)
409}
410
411/// `font-with-ratio`, i.e. what `set-font`'s second argument actually is,
412/// **version-forked at the head component**:
413///
414/// - `V0_0` — `string * float * float`, upstream `v0.0.6
415///   primitives.cppo.ml:69`'s `tFONT = tPROD [tS; tFL; tFL]`. The head is a
416///   font ABBREV naming a row of `dist/hash/fonts.satysfi-hash`.
417/// - `V0_1` — `font * float * float`, upstream `saphe-split
418///   primitives.cppo.ml:74`'s `tFONTWR = tPROD [tFONTKEY; tFL; tFL]`. The
419///   head is the opaque [`t_font_key`] handle; the 0.0.6 name is GONE from
420///   the surface, and no primitive converts between the two.
421///
422/// The trailing `(size_ratio, rising_ratio)` pair is identical in both.
423fn t_font_with_ratio(version: RustyfiVersion) -> MonoType {
424    let head = match version {
425        RustyfiVersion::V0_1 => t_font_key(),
426        _ => t_string(),
427    };
428    product(vec![head, t_float(), t_float()])
429}
430
431/// `dom -> cod` (vminst.ml's `@->`) — a function taking no labeled optional
432/// arguments (`Row::Empty`), so every 0.0.6 primitive/inference site
433/// building a `Func` produces the empty-row shape by construction.
434pub fn arrow(dom: MonoType, cod: MonoType) -> MonoType {
435    MonoType::Func(
436        Box::new(crate::types::Row::Empty),
437        Box::new(dom),
438        Box::new(cod),
439    )
440}
441
442/// Right-folds [`arrow`] over `doms`, ending in `cod` — for chaining
443/// several arguments the way vminst.ml chains `@->`, e.g.
444/// `arrows(vec![t_bool(), t_bool(), t_context(), t_inline_boxes()], t_block_boxes())`
445/// for `bool -> bool -> context -> inline-boxes -> block-boxes`.
446fn arrows(doms: Vec<MonoType>, cod: MonoType) -> MonoType {
447    doms.into_iter().rev().fold(cod, |acc, dom| arrow(dom, acc))
448}
449
450/// `tL` — list type.
451pub fn list(t: MonoType) -> MonoType {
452    MonoType::List(Box::new(t))
453}
454
455/// `tR` — mutable reference type.
456pub fn reff(t: MonoType) -> MonoType {
457    MonoType::Ref(Box::new(t))
458}
459
460/// `tPROD` — tuple type.
461pub fn product(ts: Vec<MonoType>) -> MonoType {
462    MonoType::Product(ts)
463}
464
465/// `[...] inline-cmd` (vminst.ml's `tICMD ty` = `HorzCommandType([Mandatory
466/// ty])`: an inline command taking exactly one mandatory argument of type
467/// `ty`).
468fn inline_cmd(args: Vec<CmdArgType>) -> MonoType {
469    MonoType::InlineCmd(args)
470}
471
472/// `mandatory` command-argument entry (0.0.6 positional model; also the
473/// V0_1 shape for a slot with no `?(…)` bundle at all — `opt_labels` empty
474/// either way).
475pub(crate) fn mandatory(ty: MonoType) -> CmdArgType {
476    CmdArgType {
477        optional: false,
478        opt_labels: Vec::new(),
479        ty,
480    }
481}
482
483/// `optional` (`?`) command-argument entry (0.0.6 positional model only —
484/// `opt_labels` stays empty; V0_1 never sets `optional: true`, see
485/// [`labeled`]).
486pub(crate) fn optional(ty: MonoType) -> CmdArgType {
487    CmdArgType {
488        optional: true,
489        opt_labels: Vec::new(),
490        ty,
491    }
492}
493
494/// A SATySFi 0.1 labeled command-argument entry (upstream
495/// `CommandArgType(LabelMap.t, typ)`, `types.cppo.ml:214-215`):
496/// `ty` is the slot's mandatory argument, `opt_labels` its CLOSED `?(l:τ,…)`
497/// bundle (kept sorted by label by the caller — `command_scheme`'s harvest,
498/// `lower_type_atom`'s sig lowering). `optional` is always `false`: V0_1 has
499/// no whole-slot `ty?` positional-optional model at all.
500pub(crate) fn labeled(opt_labels: Vec<(String, MonoType)>, ty: MonoType) -> CmdArgType {
501    CmdArgType {
502        optional: false,
503        opt_labels,
504        ty,
505    }
506}
507
508/// A type scheme with no quantified variables (vminst.ml's `~%` wraps a
509/// closed monomorphic body the same way; primitives that actually need
510/// polymorphism, like `::`/`!`, use [`poly1`] instead).
511fn poly0(ty: MonoType) -> PolyType {
512    PolyType::mono(ty)
513}
514
515/// A type scheme quantified over exactly one fresh type variable, e.g.
516/// `poly1(|a| arrow(reff(a.clone()), a))` for `!`'s `'a ref -> 'a`
517/// (vminst.ml's `~@` marks such a per-scheme fresh variable; `~%` then
518/// closes the whole thing into a scheme, matching `ptyderef`/`ptycons` in
519/// `primitives.cppo.ml:546-547`).
520fn poly1<F: FnOnce(MonoType) -> MonoType>(f: F) -> PolyType {
521    let v = types::new_ty_var(0);
522    let body = f(MonoType::Var(v.clone()));
523    PolyType::from_vars(vec![v], Vec::new(), body)
524}
525
526// ============================================================================
527// The primitive type table.
528// ============================================================================
529
530/// Look up the type scheme of a v0.0.6 primitive registered in
531/// `primitives.rs`'s `prims!` table (or the separately-defined
532/// `inline-fil` constant), by its *source* name (sigil included, e.g.
533/// `"\\emph"`, `"+'"`, `"::"`). Back-compat wrapper — see
534/// `primitive_type_with_version`'s doc comment.
535pub fn primitive_type(name: &str) -> Option<PolyType> {
536    primitive_type_with_version(name, RustyfiVersion::V0_0)
537}
538
539/// Look up the type scheme of a primitive registered in `primitives.rs`'s
540/// `prims!` table, for a given target `version`. Mirrors
541/// `primitives::base_env`/`base_env_with_version`'s split (the
542/// `lex`/`lex_with_version` idiom).
543pub fn primitive_type_with_version(name: &str, version: RustyfiVersion) -> Option<PolyType> {
544    Some(match name {
545        // ==== removed in 0.1 — guard these OUT of the type table under V0_1
546        // before falling through to their ordinary (0.0.6-only-meaningful)
547        // arms further below. Runtime availability is the `prims!` table's
548        // `v006` tag on the same six names; this guard keeps the two
549        // mechanisms in agreement. ====
550        "get-axis-height"
551        | "math-pull-in-scripts"
552        | "math-color"
553        | "math-char-class"
554        | "math-variant-char"
555        | "text-in-math"
556            if version.math_is_split() =>
557        {
558            return None
559        }
560
561        // ==== added in 0.1 — unbound under V0_0
562        // (falls through this guard to the catch-all `_ => return None`
563        // below, since none of these names have a v0.0.6 arm at all). ====
564        //
565        // dev-0-1-0 vminst.ml:790-793 — REAL, see `primitives.rs`'s `prim_read_math`.
566        "read-math" if version.math_is_split() => {
567            poly0(arrows(vec![t_context(), t_math_text()], t_math_boxes()))
568        }
569        // vminst.ml:858 — STAND-IN (out-of-scope text backend, same scoping
570        // note as `primitives.rs`'s `prim_convert_string_for_math`);
571        // registered so 0.1 packages typecheck.
572        "stringify-math" if version.math_is_split() => {
573            poly0(arrows(vec![t_text_info(), t_math_text()], t_string()))
574        }
575        // vminst.ml:59 — REAL: inserts into `Context::math_class_map`.
576        "set-math-char" if version.math_is_split() => poly0(arrows(
577            vec![t_int(), t_int(), t_math_class(), t_context()],
578            t_context(),
579        )),
580        // vminst.ml:445 — REAL: sets `Context::math_char_class`.
581        "set-math-char-class" if version.math_is_split() => {
582            poly0(arrows(vec![t_math_char_class(), t_context()], t_context()))
583        }
584        // vminst.ml:459 — REAL: inverse of `as_math_char_class`.
585        "get-math-char-class" if version.math_is_split() => {
586            poly0(arrow(t_context(), t_math_char_class()))
587        }
588        // vminst.ml:432 — REAL data, stand-in render (`MathElement::EmbeddedBoxes`).
589        "embed-inline-to-math" if version.math_is_split() => poly0(arrows(
590            vec![t_math_class(), t_inline_boxes()],
591            t_math_boxes(),
592        )),
593        // vminst.ml:1305 — REAL: the axis-height ratio `MathC` already scales by.
594        "get-math-axis-height-ratio" if version.math_is_split() => {
595            poly0(arrow(t_context(), t_float()))
596        }
597        // `%math-attach-scripts` — hidden (unlexable name, `%` starts a
598        // comment), the synthesized script-attacher `val math` commands
599        // without `with sub sup` lower to.
600        "%math-attach-scripts" if version.math_is_split() => poly0(arrows(
601            vec![
602                t_context(),
603                t_math_boxes(),
604                t_option(t_math_text()),
605                t_option(t_math_text()),
606            ],
607            t_math_boxes(),
608        )),
609
610        // ==== hyphenation/unidata loader
611        // + setter stand-ins, and the `here` lex-time-constant stand-in —
612        // all V0_1-only (genuinely absent from 0.0.6 upstream, so these
613        // fall through to the catch-all `_ => return None` under V0_0).
614        // Types are FAITHFUL to upstream (`vminst.ml`/`primitives.cppo.ml`);
615        // bodies (`primitives.rs`) are ACCEPT-AND-RETURN stand-ins, not
616        // hard errors like `stringify-math` above, because std-ja
617        // *evaluates* `load-unicode-char-database`/`load-hyphenation-
618        // dictionary` at module load time. ====
619        //
620        // `vminst.ml`'s `LoadHyphenationDictionary`.
621        "load-hyphenation-dictionary" if version == RustyfiVersion::V0_1 => {
622            poly0(arrow(t_string(), t_hyphenation()))
623        }
624        // `vminst.ml`'s `LoadUnicodeCharDatabase` — args are
625        // Scripts.txt/EastAsianWidth.txt/LineBreak.txt paths.
626        "load-unicode-char-database" if version == RustyfiVersion::V0_1 => poly0(arrows(
627            vec![t_string(), t_string(), t_string()],
628            t_unicode_char_database(),
629        )),
630        // STAND-IN no-op (see `primitives.rs`'s
631        // `prim_set_hyphenation_dictionary`); closes a scout-identified gap.
632        "set-hyphenation-dictionary" if version == RustyfiVersion::V0_1 => {
633            poly0(arrows(vec![t_hyphenation(), t_context()], t_context()))
634        }
635        // STAND-IN no-op (see `primitives.rs`'s
636        // `prim_set_unicode_char_database`); closes a scout-identified gap.
637        "set-unicode-char-database" if version == RustyfiVersion::V0_1 => poly0(arrows(
638            vec![t_unicode_char_database(), t_context()],
639            t_context(),
640        )),
641        // upstream is a lex-time constant (the source file's directory);
642        // the port models it as a V0_1-only nullary constant bound to
643        // `Value::Str(String::new())` (`primitives.rs`'s `base_env_with_version`).
644        "here" if version == RustyfiVersion::V0_1 => poly0(t_string()),
645
646        // ---- this port's own natives (no vminst.ml entry — local signatures) ----
647        //
648        // vminst.ml:834, `HorzLex`.
649        "read-inline" => poly0(arrow(t_context(), arrow(t_inline_text(), t_inline_boxes()))),
650        // vminst.ml:857, `VertLex`.
651        "read-block" => poly0(arrow(t_context(), arrow(t_block_text(), t_block_boxes()))),
652        // vminst.ml:1003, `BackendLineBreaking`.
653        "line-break" => poly0(arrows(
654            vec![t_bool(), t_bool(), t_context(), t_inline_boxes()],
655            t_block_boxes(),
656        )),
657        // vminst.ml:1024, `BackendPageBreaking` — the real 4-arg primitive.
658        "page-break" => poly0(arrows(
659            vec![
660                t_page_or_geometry(version),
661                arrow(t_pbinfo(), t_page_content_scheme()),
662                arrow(t_pbinfo(), t_page_parts()),
663                t_block_boxes(),
664            ],
665            t_document(),
666        )),
667        // vminst.ml:1065 `BackendPageBreakingMultiColumn` — FAITHFUL, see
668        // `primitives.rs`'s `prim_page_break_multicolumn` / `page_break_core`.
669        "page-break-multicolumn" => poly0(arrows(
670            vec![
671                t_page_or_geometry(version),
672                list(t_length()),
673                arrow(t_unit(), t_block_boxes()),
674                arrow(t_unit(), t_block_boxes()),
675                arrow(t_pbinfo(), t_page_content_scheme()),
676                arrow(t_pbinfo(), t_page_parts()),
677                t_block_boxes(),
678            ],
679            t_document(),
680        )),
681        // vminst.ml:1041 `BackendPageBreakingTwoColumn`.
682        "page-break-two-column" => poly0(arrows(
683            vec![
684                t_page_or_geometry(version),
685                t_length(),
686                arrow(t_unit(), t_block_boxes()),
687                arrow(t_pbinfo(), t_page_content_scheme()),
688                arrow(t_pbinfo(), t_page_parts()),
689                t_block_boxes(),
690            ],
691            t_document(),
692        )),
693        // ---- int arithmetic ----
694        // vminst.ml:2537 `Plus`.
695        "+" => poly0(arrows(vec![t_int(), t_int()], t_int())),
696        // vminst.ml:2553 `Minus`.
697        "-" => poly0(arrows(vec![t_int(), t_int()], t_int())),
698        // vminst.ml:2487 `Times`.
699        "*" => poly0(arrows(vec![t_int(), t_int()], t_int())),
700        // vminst.ml:2503 `Divides`.
701        "/" => poly0(arrows(vec![t_int(), t_int()], t_int())),
702        // vminst.ml:2520 `Mod`.
703        "mod" => poly0(arrows(vec![t_int(), t_int()], t_int())),
704
705        // ---- int comparisons ----
706        // vminst.ml:2569 `EqualTo`.
707        "==" => poly0(arrows(vec![t_int(), t_int()], t_bool())),
708        // LOCAL (primitives.cppo.ml's `general_table` derives this as
709        // `LogicalNot (EqualTo ..)`, not its own vminst.ml instruction).
710        "<>" => poly0(arrows(vec![t_int(), t_int()], t_bool())),
711        // vminst.ml:2601 `LessThan`.
712        "<" => poly0(arrows(vec![t_int(), t_int()], t_bool())),
713        // vminst.ml:2585 `GreaterThan`.
714        ">" => poly0(arrows(vec![t_int(), t_int()], t_bool())),
715        // LOCAL (derives as `LogicalNot (GreaterThan ..)`).
716        "<=" => poly0(arrows(vec![t_int(), t_int()], t_bool())),
717        // LOCAL (derives as `LogicalNot (LessThan ..)`).
718        ">=" => poly0(arrows(vec![t_int(), t_int()], t_bool())),
719
720        // ---- bool ----
721        // vminst.ml:2617 `LogicalAnd`.
722        "&&" => poly0(arrows(vec![t_bool(), t_bool()], t_bool())),
723        // vminst.ml:2633 `LogicalOr`.
724        "||" => poly0(arrows(vec![t_bool(), t_bool()], t_bool())),
725        // vminst.ml:2649 `LogicalNot`.
726        "not" => poly0(arrow(t_bool(), t_bool())),
727
728        // ---- float ----
729        // vminst.ml:2664 `FloatPlus`.
730        "+." => poly0(arrows(vec![t_float(), t_float()], t_float())),
731        // vminst.ml:2680 `FloatMinus`.
732        "-." => poly0(arrows(vec![t_float(), t_float()], t_float())),
733        // vminst.ml:2696 `FloatTimes`.
734        "*." => poly0(arrows(vec![t_float(), t_float()], t_float())),
735        // vminst.ml:2712 `FloatDivides`.
736        "/." => poly0(arrows(vec![t_float(), t_float()], t_float())),
737        // vminst.ml:2333 `PrimitiveFloat`.
738        "float" => poly0(arrow(t_int(), t_float())),
739        // vminst.ml:2348 `PrimitiveRound` — despite the name this truncates
740        // toward zero (see primitives.rs's `prim_round`).
741        "round" => poly0(arrow(t_float(), t_int())),
742
743        // ---- length ----
744        // vminst.ml:2894 `LengthPlus`.
745        "+'" => poly0(arrows(vec![t_length(), t_length()], t_length())),
746        // vminst.ml:2910 `LengthMinus`.
747        "-'" => poly0(arrows(vec![t_length(), t_length()], t_length())),
748        // vminst.ml:2926 `LengthTimes`.
749        "*'" => poly0(arrows(vec![t_length(), t_float()], t_length())),
750        // vminst.ml:2942 `LengthDivides`.
751        "/'" => poly0(arrows(vec![t_length(), t_length()], t_float())),
752        // vminst.ml:2958 `LengthLessThan`.
753        "<'" => poly0(arrows(vec![t_length(), t_length()], t_bool())),
754        // vminst.ml:2974 `LengthGreaterThan`.
755        ">'" => poly0(arrows(vec![t_length(), t_length()], t_bool())),
756
757        // ---- string ----
758        // vminst.ml:22 `Concat`.
759        "^" => poly0(arrows(vec![t_string(), t_string()], t_string())),
760        // vminst.ml:2303 `PrimitiveArabic`.
761        "arabic" => poly0(arrow(t_int(), t_string())),
762        // vminst.ml:2085 `PrimitiveSame`.
763        "string-same" => poly0(arrows(vec![t_string(), t_string()], t_bool())),
764        // vminst.ml:2143 `PrimitiveStringLength`.
765        "string-length" => poly0(arrow(t_string(), t_int())),
766        // vminst.ml:2101 `PrimitiveStringSub`.
767        "string-sub" => poly0(arrows(vec![t_string(), t_int(), t_int()], t_string())),
768        // vminst.ml:2212 `PrimitiveStringExplode`.
769        "string-explode" => poly0(arrow(t_string(), list(t_int()))),
770        // vminst.ml `PrimitiveRegExpOfString`/`PrimitiveStringMatch`. The
771        // port models `regexp` as its underlying pattern `string` (see
772        // `primitives.rs`); `satysfi-base`'s `char.satyg` only ever builds
773        // character-class patterns (`[0-9]`, `[A-Za-z]`, …).
774        "regexp-of-string" => poly0(arrow(t_string(), t_string())),
775        "string-match" => poly0(arrows(vec![t_string(), t_string()], t_bool())),
776        // vminstdef.yaml:1961 `PrimitiveStringScan`:
777        // `~% (tRE @-> tS @-> tOPT (tPROD [tS; tS]))` — the matched prefix and
778        // the remainder. `satysfi-code-printer`'s lexer is built on it.
779        "string-scan" => poly0(arrows(
780            vec![t_string(), t_string()],
781            t_option(product(vec![t_string(), t_string()])),
782        )),
783        // vminst.ml `PrimitiveSplitOnRegExp` — split points paired with the
784        // segment between them (`satysfi-base`/figbox split a path on `\.`).
785        "split-on-regexp" => poly0(arrows(
786            vec![t_string(), t_string()],
787            list(product(vec![t_int(), t_string()])),
788        )),
789
790        // ---- list cons ----
791        // primitives.cppo.ml:547's `ptycons`. Not its own vminst.ml
792        // instruction upstream (`::` desugars to `ListCons` at parse time
793        // there); here it's a first-class primitive (see the `prims!`
794        // table's comment on `"::"` in primitives.rs).
795        "::" => poly1(|a| arrow(a.clone(), arrow(list(a.clone()), list(a)))),
796
797        // ---- mutable-cell dereference ----
798        // primitives.cppo.ml:546's `ptyderef`.
799        "!" => poly1(|a| arrow(reff(a.clone()), a)),
800
801        // ---- text embedding ----
802        // vminst.ml:1706 `PrimitiveEmbed`.
803        "embed-string" => poly0(arrow(t_string(), t_inline_text())),
804
805        // ---- context ops ----
806        // vminst.ml:1434 `PrimitiveSetFontSize`.
807        "set-font-size" => poly0(arrow(t_length(), arrow(t_context(), t_context()))),
808        // vminst.ml:1449 `PrimitiveGetFontSize`.
809        "get-font-size" => poly0(arrow(t_context(), t_length())),
810        // vminst.ml:1633 `PrimitiveSetLeading` (see primitives.rs's `prims!`
811        // table comment on why this, and not `set-min-gap-of-lines`, is the
812        // baseline-distance setter).
813        "set-leading" => poly0(arrow(t_length(), arrow(t_context(), t_context()))),
814        // vminst.ml:1396 `PrimitiveSetParagraphMargin`.
815        "set-paragraph-margin" => poly0(arrows(
816            vec![t_length(), t_length(), t_context()],
817            t_context(),
818        )),
819        // vminst.ml:1648 `PrimitiveGetTextWidth`.
820        "get-text-width" => poly0(arrow(t_context(), t_length())),
821        // vminst.ml:1247 `PrimitiveGetInitialContext` — the second argument
822        // is a `[math] inline-cmd` (`MonoType::InlineCmd` with one mandatory
823        // `math` argument), NOT `MathCmd` (`MathCommandType`, the different
824        // v0.0.6 type used for math-mode commands like `\sqrt`). Call sites
825        // pass `(command \math)` — or a local stub command — to build the
826        // first-class command reference this needs; the runtime
827        // side is FAITHFUL, see `primitives.rs`'s
828        // `prim_get_initial_context`.
829        "get-initial-context" => poly0(arrow(
830            t_length(),
831            arrow(inline_cmd(vec![mandatory(t_math_text())]), t_context()),
832        )),
833
834        // ---- context ops, continued (a LOCAL, non-upstream primitive; see primitives.rs's `prims!` table
835        // comment on `"set-font-key"` for why it exists) --------------------
836        "set-font-key" => poly0(arrow(t_int(), arrow(t_context(), t_context()))),
837
838        // ---- box combinators ----
839        // vminst.ml:803 `HorzConcat`.
840        "++" => poly0(arrows(
841            vec![t_inline_boxes(), t_inline_boxes()],
842            t_inline_boxes(),
843        )),
844        // vminst.ml:818 `VertConcat`.
845        "+++" => poly0(arrows(
846            vec![t_block_boxes(), t_block_boxes()],
847            t_block_boxes(),
848        )),
849        // No vminst.ml entry — see `base_env`'s comment on `inline-nil`/
850        // `block-nil` in primitives.rs (the empty-boxes value that v0.0.6
851        // gets for free from `{}`/`<>` literal syntax).
852        "inline-nil" => poly0(t_inline_boxes()),
853        "block-nil" => poly0(t_block_boxes()),
854        // vminst.ml:1757 `BackendFixedEmpty`.
855        "inline-skip" => poly0(arrow(t_length(), t_inline_boxes())),
856        // vminst.ml:1771 `BackendOuterEmpty`.
857        "inline-glue" => poly0(arrows(
858            vec![t_length(), t_length(), t_length()],
859            t_inline_boxes(),
860        )),
861        // vminst.ml:1171 `BackendVertSkip`.
862        "block-skip" => poly0(arrow(t_length(), t_block_boxes())),
863
864        // ---- the reflow
865        // marker-box constructors — no vminst.ml entry (NEW, not a port);
866        // see `primitives.rs`'s `prim_list_mark`/`prim_inline_mark` doc
867        // comments for the `int` tag encoding. Both produce an INERT marker
868        // box, stripped with zero contribution before PDF/faithful-HTML
869        // placement — read only by the reflow HTML walker. ----
870        "list-mark" => poly0(arrow(t_int(), t_block_boxes())),
871        "inline-mark" => poly0(arrow(t_int(), t_inline_boxes())),
872
873        // ---- images (raster images, mirroring v0.0.6 vminstdef.yaml:540/:554) ----
874        "load-image" => poly0(arrow(t_string(), t_image())),
875        "use-image-by-width" => poly0(arrows(vec![t_image(), t_length()], t_inline_boxes())),
876        // v0.0.6 vminstdef.yaml:525 — path + 1-based page number.
877        "load-pdf-image" => poly0(arrows(vec![t_string(), t_int()], t_image())),
878
879        // ---- inline-fil ----
880        // Not a primitive *function* at all (`base_env` binds it directly
881        // to a constant `Value::InlineBoxes`, primitives.rs), so there is
882        // no vminst.ml `~type_:` to cite; its type is simply that of the
883        // value it names.
884        "inline-fil" => poly0(t_inline_boxes()),
885        // primitives.cppo.ml:567 — same shape as `inline-fil` above (a bare
886        // constant, STAND-IN body; see `primitives.rs`'s `base_env` comment).
887        "omit-skip-after" => poly0(t_inline_boxes()),
888        // primitives.cppo.ml:569 — same shape as `inline-fil`/`omit-skip-
889        // after` above: a bare constant (`base_env` binds it to
890        // `Value::BlockBoxes(vec![VertBox::ClearPage])`), FAITHFUL —
891        // `mitou-report.satyh`'s `document` unblocker.
892        "clear-page" => poly0(t_block_boxes()),
893
894        // ---- the ~18 pure primitives ----------------------------
895        // (`|>` is excluded here on purpose — it is elaborated directly to
896        // ordinary `Apply`, never a `scope`/env-bound name, so it has no
897        // primitive type scheme at all; see `elaborate.rs`'s `climb`.)
898
899        // vminst.ml:2729/2744/2759/2774/2789/2804 `FloatSine`/`FloatArcSine`/
900        // `FloatCosine`/`FloatArcCosine`/`FloatTangent`/`FloatArcTangent`.
901        "sin" => poly0(arrow(t_float(), t_float())),
902        "asin" => poly0(arrow(t_float(), t_float())),
903        "cos" => poly0(arrow(t_float(), t_float())),
904        "acos" => poly0(arrow(t_float(), t_float())),
905        "tan" => poly0(arrow(t_float(), t_float())),
906        "atan" => poly0(arrow(t_float(), t_float())),
907        // vminst.ml:2819 `FloatArcTangent2`, params `(flt1, flt2)` in that
908        // order, so `flt1.atan2(flt2)`.
909        "atan2" => poly0(arrows(vec![t_float(), t_float()], t_float())),
910        // vminst.ml:2835 `FloatLogarithm`: natural log, not log10.
911        "log" => poly0(arrow(t_float(), t_float())),
912        // vminst.ml:2850 `FloatExponential`.
913        "exp" => poly0(arrow(t_float(), t_float())),
914        // vminst.ml:2865/2880 `PrimitiveCeil`/`PrimitiveFloor` — result is
915        // `float`, not `int` (contrast `round`, above).
916        "ceil" => poly0(arrow(t_float(), t_float())),
917        "floor" => poly0(arrow(t_float(), t_float())),
918        // vminst.ml:2319 `PrimitiveShowFloat`.
919        "show-float" => poly0(arrow(t_float(), t_string())),
920
921        // vminst.ml:2159 `PrimitiveStringByteLength`.
922        "string-byte-length" => poly0(arrow(t_string(), t_int())),
923        // vminst.ml:2123 `PrimitiveStringSubBytes`.
924        "string-sub-bytes" => poly0(arrows(vec![t_string(), t_int(), t_int()], t_string())),
925        // vminst.ml:2196 `PrimitiveStringUnexplode`.
926        "string-unexplode" => poly0(arrow(list(t_int()), t_string())),
927
928        // vminst.ml:2056 `PrimitiveDisplayMessage`.
929        "display-message" => poly0(arrow(t_string(), t_unit())),
930        // vminst.ml:3133 `AbortWithMessage` — a fresh-per-instantiation type
931        // variable (same pattern as `!`/`::`'s `poly1` above). ZERO-EDIT
932        // row: dev-0-1-0's vminst.ml entry differs only in notation (`let
933        // bid = …` vs `forall "a"`), not in the type — both generations are
934        // the identical `∀a. string -> a`, so this arm serves both versions
935        // unchanged.
936        "abort-with-message" => poly1(|a| arrow(t_string(), a)),
937
938        // ==== graphics primitives — paths, fill/stroke, and the `inline-graphics` on-page sink.
939        // Argument order transcribed from `tools/gencode/vminst.ml`:
940        // `start-path` :713, `line-to` :727, `terminate-path` :759,
941        // `close-with-line` :773, `fill` :2398, `stroke` :2381,
942        // `inline-graphics` :1872. ====================================
943        //
944        "start-path" => poly0(arrow(t_point(), t_prepath())),
945        "line-to" => poly0(arrows(vec![t_point(), t_prepath()], t_prepath())),
946        // finishes an OPEN subpath.
947        "terminate-path" => poly0(arrow(t_prepath(), t_path())),
948        // closes with a straight segment back to the subpath's start.
949        "close-with-line" => poly0(arrow(t_prepath(), t_path())),
950        // even-odd filled region.
951        "fill" => poly0(arrows(vec![t_color(), t_path()], t_graphics())),
952        "stroke" => poly0(arrows(vec![t_length(), t_color(), t_path()], t_graphics())),
953        // a box of size (w, h, d) carrying the callback's graphics, the
954        // minimal on-page sink for `graphics` values (`primitives.rs`'s body
955        // notes an eager-callback-at-origin caveat this signature doesn't
956        // capture). The callback's RESULT forks per version via
957        // `t_graphics_output`; row stays untagged (`Both`) — see
958        // `coerce_graphics_result`'s doc comment for why.
959        "inline-graphics" => poly0(arrows(
960            vec![
961                t_length(),
962                t_length(),
963                t_length(),
964                arrow(t_point(), t_graphics_output(version)),
965            ],
966            t_inline_boxes(),
967        )),
968
969        // v0.0.6 vminst.ml:539 (`tRULESF` at primitives.cppo.ml:141);
970        // dev-0-1-0 inlines the same shape with a bare `tGR` result,
971        // vminst.ml:487-489 — the ruled-grid primitive; same
972        // per-version callback-result fork as `inline-graphics`.
973        "tabular" => poly0(arrows(
974            vec![
975                list(list(t_cell())),
976                arrows(
977                    vec![list(t_length()), list(t_length())],
978                    t_graphics_output(version),
979                ),
980            ],
981            t_inline_boxes(),
982        )),
983
984        // vminst.ml:1891 `BackendInlineGraphicsOuter` — the callback's args
985        // are (the resolved width, then the placed point). Same
986        // per-version callback-result fork.
987        "inline-graphics-outer" => poly0(arrows(
988            vec![
989                t_length(),
990                t_length(),
991                arrows(vec![t_length(), t_point()], t_graphics_output(version)),
992            ],
993            t_inline_boxes(),
994        )),
995
996        // ==== gr.satyh prims — signatures from `tools/gencode/vminst.ml`:
997        // `bezier-to` :742, `close-with-bezier` :787, `shift-path` :663,
998        // `linear-transform-path` :678, `shift-graphics` :2451,
999        // `linear-transform-graphics` :2432, `get-graphics-bbox` :2466,
1000        // `dashed-stroke` :2414, `draw-text` :2363. ====================
1001        //
1002        "bezier-to" => poly0(arrows(
1003            vec![t_point(), t_point(), t_point(), t_prepath()],
1004            t_prepath(),
1005        )),
1006        "close-with-bezier" => poly0(arrows(vec![t_point(), t_point(), t_prepath()], t_path())),
1007        "shift-path" => poly0(arrows(vec![t_point(), t_path()], t_path())),
1008        "linear-transform-path" => poly0(arrows(
1009            vec![t_float(), t_float(), t_float(), t_float(), t_path()],
1010            t_path(),
1011        )),
1012        "shift-graphics" => poly0(arrows(vec![t_point(), t_graphics()], t_graphics())),
1013        // `primitives.rs`'s body notes an eager-vs-upstream's-lazy-`cm`
1014        // stroke-width caveat this signature doesn't capture.
1015        "linear-transform-graphics" => poly0(arrows(
1016            vec![t_float(), t_float(), t_float(), t_float(), t_graphics()],
1017            t_graphics(),
1018        )),
1019        // A version fork: v0.0.6 `graphics -> point * point` (vminst.ml:2466); v0.1
1020        // `graphics -> option (point * point)` (dev-0-1-0 vminst.ml:2301) —
1021        // `graphics` is a collection under 0.1, so an empty one legitimately
1022        // has no bbox.
1023        "get-graphics-bbox" => {
1024            let bbox_ty = product(vec![t_point(), t_point()]);
1025            if version.graphics_is_collection() {
1026                poly0(arrow(t_graphics(), t_option(bbox_ty)))
1027            } else {
1028                poly0(arrow(t_graphics(), bbox_ty))
1029            }
1030        }
1031        // dev-0-1-0 vminst.ml:3119 — v0.1-only, the same mirror-guard
1032        // idiom as the 0.1-only math rows above.
1033        "unite-graphics" if version.graphics_is_collection() => {
1034            poly0(arrow(list(t_graphics()), t_graphics()))
1035        }
1036        // dev-0-1-0 vminst.ml:3105 — v0.1-only.
1037        "clip-graphics-by-path" if version.graphics_is_collection() => {
1038            poly0(arrows(vec![t_path(), t_graphics()], t_graphics()))
1039        }
1040        // vminst.ml:696 `PathGetBoundingBox`.
1041        "get-path-bbox" => poly0(arrow(t_path(), product(vec![t_point(), t_point()]))),
1042        // like `stroke` (width first), with the dash pattern inserted next.
1043        "dashed-stroke" => poly0(arrows(
1044            vec![t_length(), t_dash(), t_color(), t_path()],
1045            t_graphics(),
1046        )),
1047        // FAITHFUL (`primitives.rs`'s `prim_draw_text`).
1048        "draw-text" => poly0(arrows(vec![t_point(), t_inline_boxes()], t_graphics())),
1049
1050        // ==== pervasives.satyh unblockers ====
1051        //
1052        // vminst.ml:2020 `PrimitiveGetNaturalMetrics`.
1053        "get-natural-metrics" => poly0(arrow(
1054            t_inline_boxes(),
1055            product(vec![t_length(), t_length(), t_length()]),
1056        )),
1057        // vminst.ml:1787 `BackendOuterFrame`. STAND-IN body — see
1058        // primitives.rs's `prim_inline_frame_outer` doc comment.
1059        "inline-frame-outer" => poly0(arrows(
1060            vec![t_paddings(), t_deco(version), t_inline_boxes()],
1061            t_inline_boxes(),
1062        )),
1063        // vminst.ml:1807 `BackendInnerFrame` — same signature as
1064        // `inline-frame-outer` above.
1065        "inline-frame-inner" => poly0(arrows(
1066            vec![t_paddings(), t_deco(version), t_inline_boxes()],
1067            t_inline_boxes(),
1068        )),
1069        // vminst.ml:1661 `PrimitiveSetManualRising`.
1070        "set-manual-rising" => poly0(arrow(t_length(), arrow(t_context(), t_context()))),
1071        // vminst.ml:1908 `BackendScriptGuard`. STAND-IN body (identity) —
1072        // see primitives.rs's `prim_script_guard` doc comment.
1073        "script-guard" => poly0(arrows(vec![t_script(), t_inline_boxes()], t_inline_boxes())),
1074        // vminst.ml:1969 `BackendDiscretionary`, params `(pb, hblst0,
1075        // hblst1, hblst2)`.
1076        "discretionary" => poly0(arrows(
1077            vec![
1078                t_int(),
1079                t_inline_boxes(),
1080                t_inline_boxes(),
1081                t_inline_boxes(),
1082            ],
1083            t_inline_boxes(),
1084        )),
1085
1086        // ==== Tier-2 decoration/graphics packages ====
1087        //
1088        // vminst.ml:1739 `PrimitiveGetAxisHeight`. STAND-IN body — see
1089        // `primitives.rs`'s `prim_get_axis_height` doc comment.
1090        "get-axis-height" => poly0(arrow(t_context(), t_length())),
1091
1092        // ==== hooks / annotations / cross-references ====
1093        //
1094        // vminstdef.yaml:576.
1095        "hook-page-break" => poly0(arrow(
1096            arrows(vec![t_pbinfo(), t_point()], t_unit()),
1097            t_inline_boxes(),
1098        )),
1099        // vminst.ml:632 `BackendHookPageBreakBlock` — the block-level analog
1100        // of `hook-page-break` above, FAITHFUL: see `primitives.rs`'s
1101        // `prim_hook_page_break_block`. `stdjareport.satyh`'s `document`
1102        // unblocker.
1103        "hook-page-break-block" => poly0(arrow(
1104            arrows(vec![t_pbinfo(), t_point()], t_unit()),
1105            t_block_boxes(),
1106        )),
1107        // vminstdef.yaml:1793.
1108        "register-cross-reference" => poly0(arrows(vec![t_string(), t_string()], t_unit())),
1109        // vminstdef.yaml:1808.
1110        "get-cross-reference" => poly0(arrow(t_string(), t_option(t_string()))),
1111        // vminst.ml:3043 `BackendProbeCrossReference` — `get-cross-reference`
1112        // without the recorded miss. FAITHFUL.
1113        "probe-cross-reference" => poly0(arrow(t_string(), t_option(t_string()))),
1114
1115        // ==== annot.satyh's
1116        // prim surface. STAND-IN bodies — see primitives.rs's
1117        // `prim_get_leftmost_script`/`prim_inline_frame_breakable`/
1118        // `prim_register_destination` doc comments. ====
1119        //
1120        // vminstdef.yaml:1754/1767.
1121        "get-leftmost-script" => poly0(arrow(t_inline_boxes(), t_option(t_script()))),
1122        "get-rightmost-script" => poly0(arrow(t_inline_boxes(), t_option(t_script()))),
1123        // vminstdef.yaml:1672.
1124        "inline-frame-breakable" => poly0(arrows(
1125            vec![t_paddings(), t_decoset(version), t_inline_boxes()],
1126            t_inline_boxes(),
1127        )),
1128        // vminstdef.yaml:2738.
1129        "register-destination" => poly0(arrows(vec![t_string(), t_point()], t_unit())),
1130        // vminstdef.yaml:2753/2773.
1131        "register-link-to-uri" => poly0(arrows(
1132            vec![
1133                t_string(),
1134                t_point(),
1135                t_length(),
1136                t_length(),
1137                t_length(),
1138                t_option(product(vec![t_length(), t_color()])),
1139            ],
1140            t_unit(),
1141        )),
1142        "register-link-to-location" => poly0(arrows(
1143            vec![
1144                t_string(),
1145                t_point(),
1146                t_length(),
1147                t_length(),
1148                t_length(),
1149                t_option(product(vec![t_length(), t_color()])),
1150            ],
1151            t_unit(),
1152        )),
1153
1154        // ==== the faithful `Value::Math` primitive layer `math.satyh` is
1155        // built out of. Signatures transcribed from
1156        // `tools/gencode/vminst.ml` (cited per entry). ====
1157        //
1158        // vminst.ml:388 `BackendMathChar`; v0.1 (dev-0-1-0 vminst.ml:358) —
1159        // ctx ACCEPTED, not stored on the atom.
1160        "math-char" => {
1161            if version.math_is_split() {
1162                poly0(arrows(
1163                    vec![t_context(), t_math_class(), t_string()],
1164                    t_math_boxes(),
1165                ))
1166            } else {
1167                poly0(arrows(vec![t_math_class(), t_string()], t_math()))
1168            }
1169        }
1170        // vminst.ml:405 `BackendMathBigChar` — same shape, large-operator
1171        // size class (layout does not yet upscale it; it renders the same
1172        // size as `math-char`). v0.1 (vminst.ml:374): same fork as `math-char`.
1173        "math-big-char" => {
1174            if version.math_is_split() {
1175                poly0(arrows(
1176                    vec![t_context(), t_math_class(), t_string()],
1177                    t_math_boxes(),
1178                ))
1179            } else {
1180                poly0(arrows(vec![t_math_class(), t_string()], t_math()))
1181            }
1182        }
1183        // vminst.ml:422 `BackendMathCharWithKern`; v0.1 (vminst.ml:390):
1184        // same ctx-prepended fork as `math-char`.
1185        "math-char-with-kern" => {
1186            if version.math_is_split() {
1187                poly0(arrows(
1188                    vec![
1189                        t_context(),
1190                        t_math_class(),
1191                        t_string(),
1192                        t_math_kern_func(),
1193                        t_math_kern_func(),
1194                    ],
1195                    t_math_boxes(),
1196                ))
1197            } else {
1198                poly0(arrows(
1199                    vec![
1200                        t_math_class(),
1201                        t_string(),
1202                        t_math_kern_func(),
1203                        t_math_kern_func(),
1204                    ],
1205                    t_math(),
1206                ))
1207            }
1208        }
1209        // vminst.ml:445 `BackendMathBigCharWithKern` — same shape. v0.1
1210        // (vminst.ml:411): same fork as `math-char-with-kern`.
1211        "math-big-char-with-kern" => {
1212            if version.math_is_split() {
1213                poly0(arrows(
1214                    vec![
1215                        t_context(),
1216                        t_math_class(),
1217                        t_string(),
1218                        t_math_kern_func(),
1219                        t_math_kern_func(),
1220                    ],
1221                    t_math_boxes(),
1222                ))
1223            } else {
1224                poly0(arrows(
1225                    vec![
1226                        t_math_class(),
1227                        t_string(),
1228                        t_math_kern_func(),
1229                        t_math_kern_func(),
1230                    ],
1231                    t_math(),
1232                ))
1233            }
1234        }
1235        // vminst.ml:193 `BackendMathConcat`. v0.1 (vminst.ml:181): same
1236        // shape, `mb` instead of `math`.
1237        "math-concat" => {
1238            if version.math_is_split() {
1239                poly0(arrows(vec![t_math_boxes(), t_math_boxes()], t_math_boxes()))
1240            } else {
1241                poly0(arrows(vec![t_math(), t_math()], t_math()))
1242            }
1243        }
1244        // vminst.ml:209 `BackendMathGroup`. v0.1 (vminst.ml:194): same
1245        // shape, `mb` instead of `math`.
1246        "math-group" => {
1247            if version.math_is_split() {
1248                poly0(arrows(
1249                    vec![t_math_class(), t_math_class(), t_math_boxes()],
1250                    t_math_boxes(),
1251                ))
1252            } else {
1253                poly0(arrows(
1254                    vec![t_math_class(), t_math_class(), t_math()],
1255                    t_math(),
1256                ))
1257            }
1258        }
1259        // vminst.ml:226 `BackendMathSuperscript`. v0.1 (vminst.ml:208): the
1260        // script argument is a context-taking callback, evaluated under
1261        // `enter_script`.
1262        "math-sup" => {
1263            if version.math_is_split() {
1264                poly0(arrows(
1265                    vec![t_context(), t_math_boxes(), t_math_script_fn()],
1266                    t_math_boxes(),
1267                ))
1268            } else {
1269                poly0(arrows(vec![t_math(), t_math()], t_math()))
1270            }
1271        }
1272        // vminst.ml:242 `BackendMathSubscript`. v0.1 (vminst.ml:228): same
1273        // shape as `math-sup`.
1274        "math-sub" => {
1275            if version.math_is_split() {
1276                poly0(arrows(
1277                    vec![t_context(), t_math_boxes(), t_math_script_fn()],
1278                    t_math_boxes(),
1279                ))
1280            } else {
1281                poly0(arrows(vec![t_math(), t_math()], t_math()))
1282            }
1283        }
1284        // vminst.ml:258 `BackendMathFraction`. v0.1 (vminst.ml:248):
1285        // ctx prepended, `mb` instead of `math`.
1286        "math-frac" => {
1287            if version.math_is_split() {
1288                poly0(arrows(
1289                    vec![t_context(), t_math_boxes(), t_math_boxes()],
1290                    t_math_boxes(),
1291                ))
1292            } else {
1293                poly0(arrows(vec![t_math(), t_math()], t_math()))
1294            }
1295        }
1296        // vminst.ml:274 `BackendMathRadical`. v0.1 (vminst.ml:262):
1297        // ctx prepended, `mb` instead of `math`.
1298        "math-radical" => {
1299            if version.math_is_split() {
1300                poly0(arrows(
1301                    vec![t_context(), t_option(t_math_boxes()), t_math_boxes()],
1302                    t_math_boxes(),
1303                ))
1304            } else {
1305                poly0(arrows(vec![t_option(t_math()), t_math()], t_math()))
1306            }
1307        }
1308        // vminst.ml:352 `BackendMathLowerLimit`. v0.1
1309        // (vminst.ml:338): same script-callback shape as `math-sup`.
1310        "math-lower" => {
1311            if version.math_is_split() {
1312                poly0(arrows(
1313                    vec![t_context(), t_math_boxes(), t_math_script_fn()],
1314                    t_math_boxes(),
1315                ))
1316            } else {
1317                poly0(arrows(vec![t_math(), t_math()], t_math()))
1318            }
1319        }
1320        // vminst.ml:336 `BackendMathUpperLimit`. v0.1
1321        // (vminst.ml:318): same script-callback shape as `math-sup`.
1322        "math-upper" => {
1323            if version.math_is_split() {
1324                poly0(arrows(
1325                    vec![t_context(), t_math_boxes(), t_math_script_fn()],
1326                    t_math_boxes(),
1327                ))
1328            } else {
1329                poly0(arrows(vec![t_math(), t_math()], t_math()))
1330            }
1331        }
1332        // vminst.ml:368 `BackendMathPullInScripts`.
1333        "math-pull-in-scripts" => poly0(arrows(
1334            vec![
1335                t_math_class(),
1336                t_math_class(),
1337                arrows(vec![t_option(t_math()), t_option(t_math())], t_math()),
1338            ],
1339            t_math(),
1340        )),
1341        // vminst.ml:488 `BackendMathColor`.
1342        "math-color" => poly0(arrows(vec![t_color(), t_math()], t_math())),
1343        // vminst.ml:504 `BackendMathCharClass`.
1344        "math-char-class" => poly0(arrows(vec![t_math_char_class(), t_math()], t_math())),
1345        // vminst.ml:111 `BackendMathVariantCharDirect`.
1346        "math-variant-char" => poly0(arrows(
1347            vec![t_math_class(), t_math_variant_style()],
1348            t_math(),
1349        )),
1350        // No bundled `.satyh` consumer, but v0.0.6-shaped (this arm
1351        // plus `get-left-math-class`/`get-right-math-class` below, the
1352        // boundary-class introspection pair). v0.1 (vminst.ml:36): the body
1353        // applies the selector once per each of the 9 `MathCharClass`
1354        // values and inserts into `math_variant_char_map` (eager
1355        // materialization of upstream's stored selector).
1356        "set-math-variant-char" => {
1357            if version.math_is_split() {
1358                poly0(arrows(
1359                    vec![t_int(), arrow(t_math_char_class(), t_int()), t_context()],
1360                    t_context(),
1361                ))
1362            } else {
1363                poly0(arrows(
1364                    vec![t_math_char_class(), t_int(), t_int(), t_context()],
1365                    t_context(),
1366                ))
1367            }
1368        }
1369        // v0.1 (vminst.ml:128) — ctx dropped.
1370        "get-left-math-class" => {
1371            if version.math_is_split() {
1372                poly0(arrow(t_math_boxes(), t_option(t_math_class())))
1373            } else {
1374                poly0(arrows(
1375                    vec![t_context(), t_math()],
1376                    t_option(t_math_class()),
1377                ))
1378            }
1379        }
1380        // v0.1 (vminst.ml:146): same fork as `get-left-math-class`.
1381        "get-right-math-class" => {
1382            if version.math_is_split() {
1383                poly0(arrow(t_math_boxes(), t_option(t_math_class())))
1384            } else {
1385                poly0(arrows(
1386                    vec![t_context(), t_math()],
1387                    t_option(t_math_class()),
1388                ))
1389            }
1390        }
1391        // vminst.ml:294 `BackendMathParen`. v0.1 (vminst.ml:279):
1392        // ctx prepended.
1393        "math-paren" => {
1394            if version.math_is_split() {
1395                poly0(arrows(
1396                    vec![
1397                        t_context(),
1398                        t_paren(version),
1399                        t_paren(version),
1400                        t_math_boxes(),
1401                    ],
1402                    t_math_boxes(),
1403                ))
1404            } else {
1405                poly0(arrows(
1406                    vec![t_paren(version), t_paren(version), t_math()],
1407                    t_math(),
1408                ))
1409            }
1410        }
1411        // vminst.ml:314 `BackendMathParenWithMiddle`. v0.1
1412        // (vminst.ml:297): ctx prepended.
1413        "math-paren-with-middle" => {
1414            if version.math_is_split() {
1415                poly0(arrows(
1416                    vec![
1417                        t_context(),
1418                        t_paren(version),
1419                        t_paren(version),
1420                        t_paren(version),
1421                        list(t_math_boxes()),
1422                    ],
1423                    t_math_boxes(),
1424                ))
1425            } else {
1426                poly0(arrows(
1427                    vec![
1428                        t_paren(version),
1429                        t_paren(version),
1430                        t_paren(version),
1431                        list(t_math()),
1432                    ],
1433                    t_math(),
1434                ))
1435            }
1436        }
1437        // vminst.ml:468 `BackendMathText` (named `text-in-math`).
1438        "text-in-math" => poly0(arrows(
1439            vec![t_math_class(), arrow(t_context(), t_inline_boxes())],
1440            t_math(),
1441        )),
1442        // vminst.ml:61 `PrimitiveConvertStringForMath`. STAND-IN
1443        // body (see `primitives.rs`'s `prim_convert_string_for_math`).
1444        "convert-string-for-math" => poly0(arrows(
1445            vec![t_context(), t_math_char_class(), t_string()],
1446            t_string(),
1447        )),
1448        // vminst.ml:520 `BackendEmbeddedMath` (named `embed-math`) — the
1449        // bridge to the page; `\math` (math.satyh:439) wraps this. v0.1
1450        // (vminst.ml:472): `as_math_boxes` then the SAME
1451        // `layout_math_value`, the whole MATH-engine reuse in one primitive.
1452        "embed-math" => {
1453            if version.math_is_split() {
1454                poly0(arrows(vec![t_context(), t_math_boxes()], t_inline_boxes()))
1455            } else {
1456                poly0(arrows(vec![t_context(), t_math()], t_inline_boxes()))
1457            }
1458        }
1459        // vminst.ml:77 `PrimitiveSetMathCommand` — installs the default
1460        // command a bare `${…}`-in-text dispatches to. FAITHFUL, see
1461        // `primitives.rs`'s `prim_set_math_command`.
1462        "set-math-command" => poly0(arrow(
1463            inline_cmd(vec![mandatory(t_math())]),
1464            arrow(t_context(), t_context()),
1465        )),
1466        // `PrimitiveSetMathFont`, version-forked in its FIRST argument:
1467        // 0.0.6 `vminstdef.yaml:1364` takes the math font's ABBREV (a plain
1468        // string); saphe-split `tools/gencode/vminst.ml:1462` takes the
1469        // opaque [`t_font_key`] handle (its body writes
1470        // `ctx.math_font_key = Some(mathkey)`).
1471        "set-math-font" => {
1472            let dom = match version {
1473                RustyfiVersion::V0_1 => t_font_key(),
1474                _ => t_string(),
1475            };
1476            poly0(arrow(dom, arrow(t_context(), t_context())))
1477        }
1478        // LOCAL, non-upstream, V0_1-only — the port's stand-in for
1479        // upstream's internal `LoadSingleFont{path}` node, which has no
1480        // surface name upstream. Its argument is the port's font-store key
1481        // standing in for upstream's font-file path — see `primitives.rs`'s
1482        // `prim_load_single_font` for why. Same LOCAL-primitive precedent as
1483        // `set-font-key` (`primitives.rs`'s `prims!` table).
1484        "load-single-font" if version == RustyfiVersion::V0_1 => {
1485            poly0(arrow(t_string(), t_font_key()))
1486        }
1487        // vminst.ml:173 `BackendSpaceBetweenMaths`. STAND-IN body,
1488        // used by `math.satyh`'s `+align`. v0.1 (vminst.ml:164): shared
1489        // body, only the extractor forks.
1490        "space-between-maths" => {
1491            if version.math_is_split() {
1492                poly0(arrows(
1493                    vec![t_context(), t_math_boxes(), t_math_boxes()],
1494                    t_option(t_inline_boxes()),
1495                ))
1496            } else {
1497                poly0(arrows(
1498                    vec![t_context(), t_math(), t_math()],
1499                    t_option(t_inline_boxes()),
1500                ))
1501            }
1502        }
1503        // vminst.ml:1677 `PrimitiveRaiseInline` (name inferred from usage,
1504        // not independently confirmed against a `~name:` line). STAND-IN
1505        // body — see `primitives.rs`'s `prim_raise_inline` doc comment (no
1506        // per-box vertical-offset wrapper in the line model yet outside
1507        // `PureHorzBox::Math`).
1508        "raise-inline" => poly0(arrows(vec![t_length(), t_inline_boxes()], t_inline_boxes())),
1509        // vminst.ml:973 `PrimitiveEmbeddedVertBreakable` (named
1510        // `embed-block-breakable`). STAND-IN body — no nested
1511        // page-breakable block-in-inline box yet (see
1512        // `primitives.rs`'s `prim_embed_block_breakable`).
1513        "embed-block-breakable" => {
1514            poly0(arrows(vec![t_context(), t_block_boxes()], t_inline_boxes()))
1515        }
1516        // `gr.satyh`-adjacent path combinator; `math.satyh`'s `\norm`
1517        // unions two vertical bars into one path. FAITHFUL: a real path
1518        // union (concatenation of subpaths — see `primitives.rs`'s
1519        // `prim_unite_path`).
1520        "unite-path" => poly0(arrows(vec![t_path(), t_path()], t_path())),
1521        // vminst.ml:1291 `PrimitiveSetMinGapOfLines` — a *different*
1522        // context field than `set-leading` (see that primitive's own
1523        // comment); `math.satyh`'s `+math-list` calls this. STAND-IN body:
1524        // no separate `min_gap_of_lines` field on `Context` yet, so this is
1525        // a same-shape passthrough (see `primitives.rs`'s
1526        // `prim_set_min_gap_of_lines`).
1527        "set-min-gap-of-lines" => poly0(arrow(t_length(), arrow(t_context(), t_context()))),
1528
1529        // ==== (rows 1-10): the
1530        // context-setter + box-combinator prims `code.satyh`/`itemize.satyh`
1531        // need. Signatures transcribed from `tools/gencode/vminst.ml` (cited
1532        // per entry). ====
1533        //
1534        // vminst.ml:1603 `PrimitiveSetTextColor`. FAITHFUL
1535        // (`primitives.rs`'s `prim_set_text_color`).
1536        "set-text-color" => poly0(arrow(t_color(), arrow(t_context(), t_context()))),
1537        // vminst.ml:1618 `PrimitiveGetTextColor`. FAITHFUL — `itemize.satyh`
1538        // feeds this straight into `fill`, see `primitives.rs`'s
1539        // `prim_get_text_color`/`make_color_value`.
1540        "get-text-color" => poly0(arrow(t_context(), t_color())),
1541        // vminst.ml:1692 `PrimitiveSetHyphenPenalty`. FAITHFUL store;
1542        // consumed by `flush_word`'s hyphenation injection when a
1543        // dictionary is installed.
1544        "set-hyphen-penalty" => poly0(arrow(t_int(), arrow(t_context(), t_context()))),
1545        // vminstdef.yaml:1163-1177 `PrimitiveSetHyphenMin`, params
1546        // `(left_hyphen_min, right_hyphen_min)`.
1547        "set-hyphen-min" => poly0(arrows(vec![t_int(), t_int(), t_context()], t_context())),
1548        // vminst.ml:1309 `PrimitiveSetSpaceRatio`, params `(natural, shrink,
1549        // stretch)`. FAITHFUL: read by `text_to_boxes`'s interword glue.
1550        "set-space-ratio" => poly0(arrows(
1551            vec![t_float(), t_float(), t_float(), t_context()],
1552            t_context(),
1553        )),
1554        // vminst.ml's `PrimitiveSetSpaceRatioBetweenScripts`, params
1555        // `(natural, shrink, stretch, then the two adjacent scripts)`. Used
1556        // by slydifi's arctic theme. STAND-IN — see `primitives.rs`'s
1557        // `prim_set_space_ratio_between_scripts` for why the observable
1558        // output still matches upstream.
1559        "set-space-ratio-between-scripts" => poly0(arrows(
1560            vec![
1561                t_float(),
1562                t_float(),
1563                t_float(),
1564                t_script(),
1565                t_script(),
1566                t_context(),
1567            ],
1568            t_context(),
1569        )),
1570        // vminst.ml:2269 `PrimitiveSplitIntoLines`. FAITHFUL — pure string
1571        // op, see `primitives.rs`'s `prim_split_into_lines`.
1572        "split-into-lines" => poly0(arrow(t_string(), list(product(vec![t_int(), t_string()])))),
1573        // vminst.ml:1090 `PrimitiveBlockFrameBreakable`. STAND-IN:
1574        // reduced-width + left-indent inner block, `deco-set` dropped —
1575        // see `primitives.rs`'s `prim_block_frame_breakable`.
1576        "block-frame-breakable" => poly0(arrows(
1577            vec![
1578                t_context(),
1579                t_paddings(),
1580                t_decoset(version),
1581                arrow(t_context(), t_block_boxes()),
1582            ],
1583            t_block_boxes(),
1584        )),
1585        // vminst.ml:1145 `PrimitiveEmbeddedVertTop` (named `embed-block-top`).
1586        // STAND-IN: top-aligned `PureHorzBox::EmbeddedBlock` — see
1587        // `primitives.rs`'s `prim_embed_block_top`.
1588        "embed-block-top" => poly0(arrows(
1589            vec![t_context(), t_length(), arrow(t_context(), t_block_boxes())],
1590            t_inline_boxes(),
1591        )),
1592        // vminst.ml:1185 `PrimitiveEmbeddedVertBottom` (named
1593        // `embed-block-bottom`). Same STAND-IN shape as `embed-block-top`
1594        // above — see `primitives.rs`'s `prim_embed_block_bottom`.
1595        "embed-block-bottom" => poly0(arrows(
1596            vec![t_context(), t_length(), arrow(t_context(), t_block_boxes())],
1597            t_inline_boxes(),
1598        )),
1599        // vminst.ml:1229 `PrimitiveLineStackBottom` (named
1600        // `line-stack-bottom`). FAITHFUL — see `primitives.rs`'s
1601        // `prim_line_stack_bottom`.
1602        "line-stack-bottom" => poly0(arrow(list(t_inline_boxes()), t_inline_boxes())),
1603        // vminstdef.yaml:1109 `BackendLineStackTop` — same shape as
1604        // `line-stack-bottom`, differing only in which stacked line's
1605        // baseline the result carries. FAITHFUL — see `prim_line_stack_top`.
1606        "line-stack-top" => poly0(arrow(list(t_inline_boxes()), t_inline_boxes())),
1607        // vminst.ml:1130 `PrimitiveAddFootnote`. FAITHFUL — see
1608        // primitives.rs's prim_add_footnote (footnote float accumulator).
1609        "add-footnote" => poly0(arrow(t_block_boxes(), t_inline_boxes())),
1610        // `PrimitiveSetFont`, version-forked in its SECOND argument only:
1611        // 0.0.6 `vminstdef.yaml:1335` (`tFONT = string * float * float`);
1612        // saphe-split `tools/gencode/vminst.ml:1433`
1613        // (`tFONTWR = font * float * float`). See [`t_font_with_ratio`].
1614        "set-font" => poly0(arrows(
1615            vec![t_script(), t_font_with_ratio(version), t_context()],
1616            t_context(),
1617        )),
1618        // 0.0.6 `vminstdef.yaml:1350` — the reader for the slot `set-font`
1619        // writes, so it forks at the SAME head via the same
1620        // [`t_font_with_ratio`]. FAITHFUL — see `primitives.rs`'s
1621        // `prim_get_font_v006`.
1622        "get-font" => poly0(arrows(
1623            vec![t_script(), t_context()],
1624            t_font_with_ratio(version),
1625        )),
1626        // `stdja:116`; orphan #4 — not in any vminst.ml table this port has
1627        // transcribed, so no upstream line is cited. STAND-IN: accepted and
1628        // dropped, like `set-math-command`/`set-math-font` above — see
1629        // `primitives.rs`'s `prim_set_code_text_command`.
1630        "set-code-text-command" => poly0(arrow(
1631            inline_cmd(vec![mandatory(t_string())]),
1632            arrow(t_context(), t_context()),
1633        )),
1634        // vminst.ml:2040 `PrimitiveGetNaturalLength` — `get-natural-width`'s
1635        // block sibling. FAITHFUL: block height+depth summed to one length
1636        // via `measure_block` (rustyfi-backend) — see `primitives.rs`'s
1637        // `prim_get_natural_length`.
1638        "get-natural-length" => poly0(arrow(t_block_boxes(), t_length())),
1639
1640        // ==== the remaining stdja.satyh primitives, not grouped above.
1641        // `set-dominant-wide-script`/
1642        // `set-dominant-narrow-script`/`set-language` (rows 15/17/18) are
1643        // FAITHFUL stores with real getter round-trips just
1644        // below; `set-every-word-break` is a STAND-IN (accepted, dropped) —
1645        // see its `primitives.rs` doc comment. ====
1646        //
1647        // vminst.ml:1511 `PrimitiveSetDominantWideScript`.
1648        "set-dominant-wide-script" => poly0(arrow(t_script(), arrow(t_context(), t_context()))),
1649        // vminst.ml:1539 `PrimitiveSetDominantNarrowScript`: same shape.
1650        "set-dominant-narrow-script" => poly0(arrow(t_script(), arrow(t_context(), t_context()))),
1651        // vminst.ml:1568 `PrimitiveSetLangSys`.
1652        "set-language" => poly0(arrows(
1653            vec![t_script(), t_language(), t_context()],
1654            t_context(),
1655        )),
1656        // vminst.ml:1526/1555 `PrimitiveGetDominantWideScript`/
1657        // `...NarrowScript`. FAITHFUL.
1658        "get-dominant-wide-script" => poly0(arrow(t_context(), t_script())),
1659        "get-dominant-narrow-script" => poly0(arrow(t_context(), t_script())),
1660        // vminst.ml:1587 `PrimitiveGetLangSys`.
1661        "get-language" => poly0(arrows(vec![t_script(), t_context()], t_language())),
1662        // vminst.ml:3007 `PrimitiveSetEveryWordBreak`.
1663        "set-every-word-break" => poly0(arrows(
1664            vec![t_inline_boxes(), t_inline_boxes(), t_context()],
1665            t_context(),
1666        )),
1667        // vminstdef.yaml:2794 `BackendRegisterOutline` — a list of (depth,
1668        // title, label, is-frozen) PDF-outline entries. FAITHFUL — see
1669        // `primitives.rs`'s `prim_register_outline`.
1670        "register-outline" => poly0(arrow(
1671            list(product(vec![t_int(), t_string(), t_string(), t_bool()])),
1672            t_unit(),
1673        )),
1674        // vminstdef.yaml:1565 `PrimitiveExtract`. FAITHFUL (mirrors
1675        // `horzBox.ml`'s `extract_string`); see `primitives.rs`'s
1676        // `extract_string_pure_one`.
1677        "extract-string" => poly0(arrow(t_inline_boxes(), t_string())),
1678
1679        // ==== (text-mode-context sliver): the three PURE text-info prims. The text/html backends
1680        // (`stringify-inline`/`stringify-block`, `.satyh-text` loading) are
1681        // deliberately out of scope for this PDF port — see
1682        // `primitives.rs`'s section comment. ====
1683        //
1684        // `get-initial-text-info` — a version fork (the
1685        // `t_page_or_geometry`-style version branch, inlined since it's
1686        // one row): v0.0.6 (vminst.ml:953 `TextGetInitialTextModeContext`)
1687        // takes unit; v0.1 (dev-0-1-0 vminst.ml:906) threads the text-mode
1688        // default math command (`inline [math-text]`) + a math-scripts
1689        // stringifier into `tctxsub`. Both bodies are the same STAND-IN
1690        // (`primitives.rs`'s `prim_get_initial_text_info_v01`).
1691        "get-initial-text-info" => {
1692            if version == RustyfiVersion::V0_1 {
1693                poly0(arrows(
1694                    vec![
1695                        inline_cmd(vec![mandatory(t_math_text())]),
1696                        arrows(
1697                            vec![t_string(), t_option(t_string()), t_option(t_string())],
1698                            t_string(),
1699                        ),
1700                    ],
1701                    t_text_info(),
1702                ))
1703            } else {
1704                poly0(arrow(t_unit(), t_text_info()))
1705            }
1706        }
1707        // vminst.ml:921 `TextDeepenIndent`.
1708        "deepen-indent" => poly0(arrows(vec![t_int(), t_text_info()], t_text_info())),
1709        // vminst.ml:935 `TextBreak`.
1710        "break" => poly0(arrow(t_text_info(), t_string())),
1711
1712        // ==== 10 new v0.1-only rows, unbound under V0_0 (the same
1713        // mirror-guard idiom as the 0.1-only math rows). ====
1714        //
1715        // Bitwise ops (dev-0-1-0 vminst.ml :2495/:2477/:2527/:2541/:2513/
1716        // :2556) — `<<`/`>>` lex as ordinary opsymbol-run identifiers under
1717        // BOTH versions (`primitives.rs`'s `prims!` table comment), so only
1718        // the type table's V0_1 guard decides whether they resolve.
1719        "<<" | ">>" | "band" | "bor" | "bxor" if version == RustyfiVersion::V0_1 => {
1720            poly0(arrows(vec![t_int(), t_int()], t_int()))
1721        }
1722        "bnot" if version == RustyfiVersion::V0_1 => poly0(arrow(t_int(), t_int())),
1723        // Unicode string ops (dev-0-1-0 vminst.ml :2050/:2066/:2082) — REAL,
1724        // `primitives.rs`'s `prim_normalize_string_to_nfc`/`_nfd`/
1725        // `prim_split_grapheme_cluster`.
1726        "normalize-string-to-nfc" | "normalize-string-to-nfd"
1727            if version == RustyfiVersion::V0_1 =>
1728        {
1729            poly0(arrow(t_string(), t_string()))
1730        }
1731        "split-grapheme-cluster" if version == RustyfiVersion::V0_1 => {
1732            poly0(arrow(t_string(), list(t_string())))
1733        }
1734        // dev-0-1-0 vminst.ml:3073 — REAL, `primitives.rs`'s `prim_read_file`.
1735        //
1736        // Bound under BOTH generations, unlike its neighbours here. It was
1737        // added on the 0.0.6 DEV line, not in 0.1: `satysfi-code-printer`
1738        // 1.1.1 calls it from `+file-printer` while its own opam pins
1739        // `satysfi { >= "0.0.6-53-g2867e4d9" & < "0.1" }`, which is direct
1740        // evidence that a 0.0.6-generation compiler has it. Gating it to
1741        // `V0_1` made the whole package fail to load under 0.0.6 — an
1742        // `unbound variable 'read-file'` from a module body, so not even
1743        // reachable-code-dependent.
1744        "read-file" => poly0(arrow(t_string(), list(t_string()))),
1745        // dev-0-1-0 vminst.ml:2978 — REAL, `primitives.rs`'s
1746        // `prim_register_document_information`.
1747        "register-document-information" if version == RustyfiVersion::V0_1 => {
1748            poly0(arrow(t_doc_info_dictionary(), t_unit()))
1749        }
1750
1751        // ---- language-completeness sweep: 0.1 float comparisons
1752        // (`primitives.rs`'s `prims!` table comment on ">."/"<."/">=."/
1753        // "<=." for the upstream citation + the confirmation these are
1754        // genuinely absent from 0.0.6) — unbound under V0_0.
1755        ">." | "<." | ">=." | "<=." if version == RustyfiVersion::V0_1 => {
1756            poly0(arrows(vec![t_float(), t_float()], t_bool()))
1757        }
1758
1759        _ => return None,
1760    })
1761}
1762
1763// ============================================================================
1764// Variant type declarations (primitives.cppo.ml's `~% ( ... )` registration
1765// block, lines ~150-170).
1766// ============================================================================
1767
1768/// A user (or built-in) variant type declaration.
1769///
1770/// `param_vars` names the declaration's type parameters as concrete
1771/// placeholder [`TyVarRef`]s that appear (via `MonoType::Var`) inside
1772/// `ctors`' payload types; they are never meant to be unified against
1773/// anything directly. `VariantDecl::instantiate_ctor` is the only
1774/// sanctioned way to use a declaration: given concrete argument types for
1775/// the variant's `params` type parameters, it substitutes them for the
1776/// placeholders (matched by pointer identity, the same mechanism
1777/// `types::instantiate` uses for quantified variables — see
1778/// `types::substitute`) throughout the chosen constructor's payload type,
1779/// and returns both that payload type and the resulting
1780/// `MonoType::Variant(name, args)`.
1781///
1782/// This mirrors v0.0.6's `Typeenv.Raw.register_type`/`add_constructor`
1783/// (`primitives.cppo.ml:154-217`), which pairs a `TypeID.t` with a
1784/// `Typeenv.Data(arity)` and each constructor with a `Poly(...)` scheme
1785/// quantified over the same `bid`/`typaram` placeholders declared once for
1786/// the whole type — the same shape, just spelled with this port's
1787/// `TyVarRef`/`substitute` machinery instead of v0.0.6's `BoundID`/
1788/// `PolyBound`.
1789#[derive(Clone, Debug)]
1790pub struct VariantDecl {
1791    pub name: String,
1792    pub params: usize,
1793    pub ctors: Vec<(String, Option<MonoType>)>,
1794    pub param_vars: Vec<TyVarRef>,
1795}
1796
1797impl VariantDecl {
1798    /// Instantiate `ctor` at a fresh application `Name(args[0], args[1],
1799    /// ...)`. Returns `None` if `ctor` isn't one of this declaration's
1800    /// constructors or `args.len() != self.params`. On success, returns
1801    /// `(payload_type, result_type)`, where `payload_type` is `None` for a
1802    /// nullary constructor (like `None`).
1803    pub(crate) fn instantiate_ctor(
1804        &self,
1805        ctor: &str,
1806        args: &[MonoType],
1807    ) -> Option<(Option<MonoType>, MonoType)> {
1808        if args.len() != self.params {
1809            return None;
1810        }
1811        let (_, payload_tpl) = self.ctors.iter().find(|(n, _)| n == ctor)?;
1812        let mut var_map: HashMap<usize, MonoType> = HashMap::new();
1813        for (pv, arg) in self.param_vars.iter().zip(args.iter()) {
1814            var_map.insert(types::ptr_key(pv), arg.clone());
1815        }
1816        let row_map = HashMap::new();
1817        let payload = payload_tpl
1818            .as_ref()
1819            .map(|t| types::substitute(t, &var_map, &row_map));
1820        Some((payload, MonoType::Variant(self.name.clone(), args.to_vec())))
1821    }
1822}
1823
1824/// The variant declarations `crate::eval`'s pattern matching and (later)
1825/// the inferencer need before any user `.saty` source runs, transcribed
1826/// from `primitives.cppo.ml:154-159`:
1827///
1828/// ```ocaml
1829/// |> Typeenv.Raw.register_type "option" tyid_option (Typeenv.Data(1))
1830/// |> Typeenv.Raw.add_constructor "None" ([bid], Poly(tU)) tyid_option
1831/// |> Typeenv.Raw.add_constructor "Some" ([bid], Poly(typaram)) tyid_option
1832/// |> Typeenv.Raw.register_type "itemize" tyid_itemize (Typeenv.Data(0))
1833/// |> Typeenv.Raw.add_constructor "Item" ([], Poly(tPROD [tIT; tL (tITMZ ())])) tyid_itemize
1834/// ```
1835///
1836/// v0.0.6 gives *every* constructor a payload type, using `tU` (unit)
1837/// for `None`'s "no real payload" case; this port's `Ast::Ctor`/
1838/// `Pattern::Ctor` (ast.rs) instead represent a nullary constructor as
1839/// `None` (the Rust `Option`, not the SATySFi one!) directly, so `None`'s
1840/// declared payload here is `Option::None`, not `Some(unit)`.
1841///
1842/// Takes the target `version` explicitly; mirrors the `base_env`/
1843/// `primitive_type` split above.
1844pub fn builtin_variants_with_version(version: RustyfiVersion) -> Vec<VariantDecl> {
1845    let option_param = types::new_ty_var(0);
1846    let option_decl = VariantDecl {
1847        name: "option".to_string(),
1848        params: 1,
1849        ctors: vec![
1850            ("None".to_string(), None),
1851            (
1852                "Some".to_string(),
1853                Some(MonoType::Var(option_param.clone())),
1854            ),
1855        ],
1856        param_vars: vec![option_param],
1857    };
1858
1859    let itemize_decl = VariantDecl {
1860        name: "itemize".to_string(),
1861        params: 0,
1862        ctors: vec![(
1863            "Item".to_string(),
1864            Some(product(vec![
1865                t_inline_text(),
1866                list(MonoType::Variant("itemize".to_string(), Vec::new())),
1867            ])),
1868        )],
1869        param_vars: Vec::new(),
1870    };
1871
1872    // `color` — nullary variant, `primitives.cppo.ml:187-190`.
1873    // Unblocks `color.satyh`'s `Color.rgb`/`Color.gray`/`Color.cmyk`
1874    // constructor wrappers; `fill`/`stroke` also consume it.
1875    let color_decl = VariantDecl {
1876        name: "color".to_string(),
1877        params: 0,
1878        ctors: vec![
1879            ("Gray".to_string(), Some(t_float())),
1880            (
1881                "RGB".to_string(),
1882                Some(product(vec![t_float(), t_float(), t_float()])),
1883            ),
1884            (
1885                "CMYK".to_string(),
1886                Some(product(vec![t_float(), t_float(), t_float(), t_float()])),
1887            ),
1888        ],
1889        param_vars: Vec::new(),
1890    };
1891
1892    // `script` (pervasives.satyh's `\SATySFi`/`\LaTeX`/`\TeX`, via
1893    // `script-guard`) — nullary variant, upstream's real surface constructor
1894    // set (`primitives.cppo.ml:192-196`). `script-guard`'s stand-in body
1895    // (primitives.rs) never inspects which constructor it got; the full set
1896    // is registered anyway so the TYPE is faithful even though the behavior
1897    // isn't yet.
1898    let script_decl = VariantDecl {
1899        name: "script".to_string(),
1900        params: 0,
1901        ctors: vec![
1902            ("HanIdeographic".to_string(), None),
1903            ("Kana".to_string(), None),
1904            ("Latin".to_string(), None),
1905            ("OtherScript".to_string(), None),
1906        ],
1907        param_vars: Vec::new(),
1908    };
1909
1910    // `language` (`charBasis.ml`'s `language_system`) — nullary variant,
1911    // `set-language`'s 2nd argument (`stdja.satyh`'s `set-language Kana
1912    // Japanese`), stored per script in `Context::langsys_scheme` and read
1913    // back by `get-language` (primitives.rs).
1914    let language_decl = VariantDecl {
1915        name: "language".to_string(),
1916        params: 0,
1917        ctors: vec![
1918            ("Japanese".to_string(), None),
1919            ("English".to_string(), None),
1920            ("NoLanguageSystem".to_string(), None),
1921        ],
1922        param_vars: Vec::new(),
1923    };
1924
1925    // `page` — nullary variant, the exact constructor set at
1926    // `primitives.cppo.ml:204-212`: 8 nullary paper-size constants plus
1927    // `UserDefinedPaper`. `page-break`'s first argument; `as_page`
1928    // (`primitives.rs`) maps each ctor to a backend `PaperSize`.
1929    //
1930    // GONE in v0.1 upstream (no replacement ADT — paper sizes are a
1931    // plain `length * length` tuple there, see `t_page_or_geometry`), so
1932    // this declaration is gated on
1933    // `has_page_adt()` below rather than being unconditionally registered.
1934    let page_decl = VariantDecl {
1935        name: "page".to_string(),
1936        params: 0,
1937        ctors: vec![
1938            ("A0Paper".to_string(), None),
1939            ("A1Paper".to_string(), None),
1940            ("A2Paper".to_string(), None),
1941            ("A3Paper".to_string(), None),
1942            ("A4Paper".to_string(), None),
1943            ("A5Paper".to_string(), None),
1944            ("USLetter".to_string(), None),
1945            ("USLegal".to_string(), None),
1946            (
1947                "UserDefinedPaper".to_string(),
1948                Some(product(vec![t_length(), t_length()])),
1949            ),
1950        ],
1951        param_vars: Vec::new(),
1952    };
1953
1954    // `cell` — nullary variant, transcribed from `primitives.cppo.ml:214-217`.
1955    // `EmptyCell`'s payload is `None` (this port's nullary-constructor
1956    // spelling, see this fn's doc comment), matching upstream's `Poly(tU)`
1957    // "no real payload" case.
1958    let cell_decl = VariantDecl {
1959        name: "cell".to_string(),
1960        params: 0,
1961        ctors: vec![
1962            (
1963                "NormalCell".to_string(),
1964                Some(product(vec![t_paddings(), t_inline_boxes()])),
1965            ),
1966            ("EmptyCell".to_string(), None),
1967            (
1968                "MultiCell".to_string(),
1969                Some(product(vec![
1970                    t_int(),
1971                    t_int(),
1972                    t_paddings(),
1973                    t_inline_boxes(),
1974                ])),
1975            ),
1976        ],
1977        param_vars: Vec::new(),
1978    };
1979
1980    // `math-class` — nullary variant, transcribed from
1981    // `primitives.cppo.ml:162-170`. **Distinct** from `math-char-class`
1982    // below (the styling variant) — do not conflate.
1983    let math_class_decl = VariantDecl {
1984        name: "math-class".to_string(),
1985        params: 0,
1986        ctors: vec![
1987            ("MathOrd".to_string(), None),
1988            ("MathBin".to_string(), None),
1989            ("MathRel".to_string(), None),
1990            ("MathOp".to_string(), None),
1991            ("MathPunct".to_string(), None),
1992            ("MathOpen".to_string(), None),
1993            ("MathClose".to_string(), None),
1994            ("MathPrefix".to_string(), None),
1995            ("MathInner".to_string(), None),
1996        ],
1997        param_vars: Vec::new(),
1998    };
1999
2000    // `math-char-class` — nullary variant. Constructor set is
2001    // version-dependent: `v0.0.6` upstream has
2002    // exactly these 9 (`v0.0.6:src/backend/horzBox.ml:147-158`'s exact set,
2003    // literally "TEMPORARY; should add more"); dev-0-1-0 widens
2004    // `math_char_class` 9 → 14 (`b836d512:src/backend/horzBox.ml:98-113`),
2005    // adding `MathSansSerif`/`MathBoldSansSerif`/`MathItalicSansSerif`/
2006    // `MathBoldItalicSansSerif`/`MathTypewriter`. Needed for `math.satyh`'s
2007    // `\mathrm`/`\mathbf`/`\mathcal`/`\mathfrak`/`\mathbb`/`\bm`/`\mathsf`/
2008    // `\mathtt` to type-check (each applies `math-char-class` to one of
2009    // these). Gated on `math_is_split()` so the frozen 0.0.6 surface never
2010    // learns the 5 new names (unknown-constructor error preserved,
2011    // `typecheck.rs:2257/2347`).
2012    let mut math_char_class_ctors = vec![
2013        ("MathItalic".to_string(), None),
2014        ("MathBoldItalic".to_string(), None),
2015        ("MathRoman".to_string(), None),
2016        ("MathBoldRoman".to_string(), None),
2017        ("MathScript".to_string(), None),
2018        ("MathBoldScript".to_string(), None),
2019        ("MathFraktur".to_string(), None),
2020        ("MathBoldFraktur".to_string(), None),
2021        ("MathDoubleStruck".to_string(), None),
2022    ];
2023    if version.math_is_split() {
2024        math_char_class_ctors.extend([
2025            ("MathSansSerif".to_string(), None),
2026            ("MathBoldSansSerif".to_string(), None),
2027            ("MathItalicSansSerif".to_string(), None),
2028            ("MathBoldItalicSansSerif".to_string(), None),
2029            ("MathTypewriter".to_string(), None),
2030        ]);
2031    }
2032    let math_char_class_decl = VariantDecl {
2033        name: "math-char-class".to_string(),
2034        params: 0,
2035        ctors: math_char_class_ctors,
2036        param_vars: Vec::new(),
2037    };
2038
2039    let mut decls = vec![
2040        option_decl,
2041        itemize_decl,
2042        color_decl,
2043        script_decl,
2044        language_decl,
2045        cell_decl,
2046        math_class_decl,
2047        math_char_class_decl,
2048    ];
2049    if version.has_page_adt() {
2050        decls.push(page_decl);
2051    }
2052    decls
2053}