Pipeline modes are equality-constrained, in one shared definition
A pipeline stage has a computation type F[I,O] A; connecting two stages
requires O_left = I_right, and a value cannot silently cross a byte edge
(docs/SPEC.md §4.2.1, §20.4). Modes are therefore equality-constrained:
none and bytes do not unify.
What was wrong
ral kept two mode-inference engines that had silently drifted apart on this rule.
- The static Hindley–Milner checker (
core/src/typecheck/) unified modes inunify.rs, whereNone ↔ Byteswas made to succeed (“coercion preserved by design”). That made theTypeErrorKind::ModeMismatcharm dead code: the static checker enforced nothing about modes, so SPEC §20.4’s “mismatches are caught at type-check time” was false. - The runtime engine (
core/src/ty.rs), which runs per-bind at evaluation time with the liveShelland must keep working under--no-typecheck, correctly returnedErronBytes ↔ None. It alone caught mismatches, at eval time, viavalidate_pipeline→pipeline_mismatch.
The two engines also duplicated the whole lattice: runtime Mode/CompType
(ModeVar(usize)) vs static PipeMode/PipeSpec (ModeVar(u32)) — the same
four constructors (none/ext/decode/encode) written twice, with the
runtime CompType near-colliding with the static value type CompTy.
What we decided
-
One lattice.
core/src/mode.rsownsPipeMode,ModeVar(u32),PipeSpecand the four constructors, allCopyPODs with serde derives (they ride inside aSchemeinto the postcard-baked prelude — see the schema-evolution note incore/src/lib.rs; the consumerbuild.rsscripts now re-run onmode.rs/typecheck/ty.rs/typecheck/scheme.rs). Both engines import it. The statictypecheck/ty.rsre-exports it; the runtimety.rsrenamedMode → PipeMode,CompType → PipeSpec. -
One unify rule.
mode.rsalso owns the equality rule:unify_mode<S: ModeStore>(store, a, b) -> Result<(), ModeMismatch>, whereModeStore { resolve_mode, bind_mode, union_modes }abstracts the two variable stores (runtimeVec<ModeSlot>slots, staticStore<PipeMode>union-find). Both engines implementModeStoreand call the one function; neither can drift again. The static checker now rejectsNone ↔ Byteswith a liveModeMismatch(T0012), matching the runtime. -
Modes that were only sound under the old coercion are now mode-poly, not pinned to
none. Making unification strict revealed two places that had relied onNone ↔ Bytessilently succeeding — both genuine over-constraints, not SPEC violations:- The higher-order list builtins
map/each/filter/fold/sort-list-bytyped the callback result aspure(β)=F[none,none] β, rejecting any byte-output callback (map { echo $x }). The callback result is nowF[μ₀,μ₁] βwith the modes quantified (typecheck/builtins.rs,callback_result). - The control wrappers
try/guard/auditpinned their thunk body toF[none,none]viaCompTy::pure. A control-wrapper body may itself emit bytes (try { echo x } …); the body is nowF[μ₀,μ₁] αwhile the wrapper still returns a value (F[none,none],typecheck/scope.rs::infer_thunk_body), as at runtime.
- The higher-order list builtins
The value↔byte structural boundary — enforced
The static checker enforces the §4.2.1 value↔byte boundary, and the static
output mode of streaming reducers is honest at its source. The one subtlety is
the output mode of streaming reducers map-lines/filter-lines/each-line
(prelude wrappers over fold-lines): their inner echo flushes to the stage’s
stdout, so the runtime classifies them F[Bytes,Bytes]. The static checker
agrees by making the output mode honest at its source, rather than typing them
value-output F[Bytes,∅]:
fold-linesis mode-polymorphic. Its scheme is now∀α μ. U(α → Str → F[∅,μ] α) → α → F[Bytes,μ] α(typecheck/builtins.rs), named structurally asBuiltinTypeRule::Reducerand given its boundary byreducer_specin both engines (statictypecheck/builtins.rs, runtimety.rs::reducer_pipe). The callback’s output modeμis the reducer’s output mode: a pure callback (return $acc) keepsμ = ∅(value-producing decode), while a callback thatechos per line lifts the whole stage toF[Bytes,Bytes].- A sequence’s byte-output is a join, not the tail’s.
seq_byte_output(runtimety.rs) andlift_seq_output(infer.rs) make aSeq’s output modeBytesif any statement emits bytes, not merely the last — so a reducer body thatechos thenreturns an accumulator is typed as byte-output, matching its observable behaviour. Return type and input mode stay the tail’s; aFun-tailed block keeps its shape. - The value→byte edge is checked strictly.
apply_piped_valuedoes a strictunify_comp_ty_hint, so a genuine arg-shape mismatch surfaces. The pipeline stage loop discriminates application from channel adjacency byconsumes_value_arg: a value producer feeding a value-arg function consumer (a lambda, or a block-literalReturn(_, Thunk(Fun))) iterates element-by-element through the function path, whereas a value producer feeding a concrete non-application stage (afrom-Xbyte decoder,Return(_, τ)) is a plain channel edge whose modes are unified — so a∅-into-Bytesadjacency is rejected as the §4.2.1 mismatch it is.
With this, printf "a\nb\n" | map-lines { |x| echo $x } | from-lines typechecks
because the byte-emitting reducer is honestly F[Bytes,Bytes], and the genuine
value→byte conversions (return hello | from-json, 'abc' | blah) are caught.
The static and runtime engines now agree on both the equality rule and the
output mode of streaming reducers.
Parked for maintainer review (2026-06-01): the if/case branch-mode
union (infer.rs::union_mode/merge_branches, mirroring runtime
ty::infer_branches). When two branches disagree on a mode, the conditional’s
mode becomes a fresh variable a downstream stage can pin, rather than a hard
mismatch — since only one branch runs. This is a deliberate design choice that
the maintainer wants to weigh against equality-strictness; it is implemented and
green but not yet ratified.