The checker writes its mode verdict into the IR
Landed in three commits: d4aa75be adds the ground annotation slots
(ByteMode / Wire in core/src/mode.rs, CompKind::Pipeline a struct variant
{ stages, wires: Option<Vec<Wire>> }, CompKind::Bind.rhs_output: Option<ByteMode>); e3c23b4b writes them (the checker records per-node verdicts
during inference, annotate grounds them into a rebuilt IR); edf47cd9 reads
them at the two consumers, with the dual-run debug_assert! live. The deletion
inventory below was parked for ADR 4 and has since landed there
(unconditional-mode-pass, 602218d3):
no engine is removed here — this page only adds the slots and proves the dual-run
agrees.
ral keeps two mode-inference engines: the static Hindley–Milner checker
(core/src/typecheck/) and a runtime engine (core/src/ty.rs) that re-infers
each bind’s modes at evaluation time. The checker writes its resolved mode
verdict into the IR, and the evaluator reads modes off the node rather than
inferring them. This makes the compilation ladder’s typed-IR rung
(compilation-ladder names “IR → typed IR” but
today emits a CompileOutcome that drops the modes back out) carry the modes it
already claims to compute, and it deletes the runtime engine wholesale.
This is the third of four decisions whose end state is a single engine. The arc:
session-scheme-continuity
(session-scheme-continuity) →
handler/alias mode preservation
(handler-alias-mode-preservation)
→ IR mode annotation (here) → the mode pass made unconditional, --no-typecheck
retired (unconditional-mode-pass). The
through-line is net code removal: this is the page where the second engine
dies, and it adds only the annotation slots that justify the deletion.
What we decide
-
Annotation is elaboration into a rebuilt IR, not interior mutation. IR nodes are
Arc-shared and serde-derived (Comp = Spanned<CompKind>,core/src/ir.rs;Arc<Comp>thunk bodies serialised bycore/src/serial.rs). AnOnceCellslot would not serialise; a side table keyed by node identity would not travel with a thunk body acrossSerialValueor the baked prelude. So the checker becomes a transformation: it returns an IR with mode slots filled, leaving the unannotated IR untouched for the--no-typecheckpaths that still exist until ADR 4. The prelude bake follows suit:ral/build.rsandexarch/build.rsserialise the annotated IR (parse → elaborate → annotate), not the bare elaborated comp —bake_preludealready runs the inferencer at bake time, so the comp blob and the schemes blob come out of one checked pass instead of one unchecked and one checked walk. The transformation isannotate(comp, ctx, spine)(core/src/typecheck.rs) — a full structural rebuild over both theCompandValcategories, descending into thunk bodies and the values nested in lists, maps, and variants. Thespineflag carries the scheme discipline of session-scheme-continuity: it covers exactly the top-level Seq parts and aBind’srest, so only a top-level name-bind takes a generalised scheme, while wires and theBindRHS output mode are written wherever inference visited, at any depth (aPipelinestage inside a lambda body carries its wires). The two slots differ in weight: aWireis aCopytwo-valued pair and rides unboxed in the node, where theBindscheme slot isOption<Box<Scheme>>because a scheme is large (clippylarge_enum_variant).The checker records its per-node verdicts during inference and the rebuild reads them back:
InferCtx.stage_specs, keyed by each stage’sCompaddress, written at the end ofinfer_pipelineonce every adjacency is unified;InferCtx.bind_outputs, keyed by theBindnode, written by the Bind rule for every pattern — aFunRHS records∅, because evaluating a lambda builds a closure and emits no bytes, not the body spec aFun-traversing projection would report. Grounding happens once, inannotate: resolve each recorded mode through the final unifier, thenVar → Empty(InferCtx::ground). -
Annotations are ground — and the type makes that a compile-time fact. The slot does not hold a
PipeSpec, whosePipeMode::Vararm would let a residual variable ride into the evaluator. It holds a new two-valued ground pair — call itWire { input: ByteMode, output: ByteMode }withenum ByteMode { Bytes, Empty }— so “annotations are ground” is enforced by the type, not by an invariant comment (ir). Polymorphism lives only in schemes on the checker side; the IR carries the instantiated, ground wire at each use site. Residual mode variables — the 260601 ADR’s parked branch-union fresh vars, and uninstantiated polymorphic outputs — are grounded by one defaulting rule applied at annotation time:Var → Empty. This is exactly today’s behaviour, in two places that must agree:resolve_pipelinealready defaults an unresolved stage mode toPipeMode::None(analysis.rs, theif m.is_var()loop), andInferCtx::output_modealready returnsPipeMode::Nonefor aVar-resolved bind-RHS output (core/src/ty.rs). The annotation pass performs that defaulting once, at the unification frontier, rather than at each read site.
-
Minimum slots, maximum deletion. A
Wireis added only where a consumer reads one:- Per-stage on
Pipeline.analyze_stagereadsStageSpec.comp_typeto fillBoundary::from_mode,PipelineKind::from_specs, and the last-stagelast_output(analysis.rs,route.rs).resolve_pipeline(stages, wires, shell)now takes the node’s wires:specs_from_wiresreads eachStageSpec.comp_typeoff the ground wire (Wire::spec) and feeds it toanalyze_stage, which no longer infers its own signature. The runtime engine survives at this site only as the unchecked fallback (specs_from_runtime, the--no-typecheckpath, byte-for-byte) and as the debug-only cross-check (runtime_specs) — its deletion is ADR 4’s. - The RHS output mode on
Bind.eval_bind_rhsreads exactly one bit — “is the RHS byte-output?” — to decide stdout capture (evaluator/comp.rs, SPEC §4.3). It branches on theBindnode’srhs_output: whenSome, the groundByteModeis the verdict; whenNone(unchecked IR) it falls back toInferCtx::new().output_mode. In a debug build the annotated branch also asserts the ground mode against that runtime call — the same dual-run guard. - No body wire at thunk roots — dropped unconditionally. ADR 2’s
install-time check has two halves, and ADR 2 landed as
ed043029serving both with ADR 1’s mechanism:WithinScope::parse(core/src/evaluator/scope.rs) callstypecheck::alias_arm_scheme(now head-aware,Result<Scheme, ModeMismatch>,core/src/typecheck.rs) seeded fromshell.session_schemes(), exactly asinstall_aliasdoes (core/src/types/shell/scope.rs), and the pin (pin_arm_to_head,core/src/typecheck/infer.rs) runs inside that engine pass; the catch-allhandler:arm is exempt. Both halves run ADR 1’s install-time engine and read no IR node, so the maintainer’s choice to keep computedwithinopts legal and mode-check them at install (ADR 2, resolved) does not resurrect a thunk-root consumer — the computed-withinarm is mode-checked by the same engine run, after which its scheme is discarded (HandlerEntry.schemestaysNone) where the alias half stores it on the frame. The stronger reason is that a ground wire cannot serve this consumer at all, by this page’s own grounding rule: the install check unifies the arm’sPipeSpecagainst the head’s, and that unification exploits polymorphism — a divergent arm carries an unconstrained output mode var that pins toBytesand passes, where theVar → Emptydefaulting would freeze the wire toEmptyand the wire-vs-head comparison would over-reject every mode-polymorphic arm. The check needs the arm’s polymorphic spec, which only a scheme carries — “annotations are ground” is the very reason a wire cannot do this job. No other node is annotated. The minimum slot set is two — the per-stagePipelinewire and theBindRHS output mode — with no thunk-root slot and no conditionality. Every slot is justified by the consumer it lets read the node instead of re-walking it.
- Per-stage on
The deletion inventory
This is the page’s reason to exist — though the deletion itself lands in ADR 4
(602218d3), not here. With the IR carrying ground wires, the whole runtime engine
core/src/ty.rs is dead:
- The structural judgment —
constrain_comp,first_byte_input,seq_byte_output,infer_chain,infer_branches,infer_app,infer_force,constrain_val_thunk,command_sig,reducer_pipe,thunk_body_type,instantiate_from_args,named_head/last_effective— and theenv_binding_typewalk (already shorn of itslookup_handlerprecondition by ADR 2). Each had a static twin intypecheck/infer.rs(comp_output_mode/comp_input_mode/lift_seq_output/ the pipeline stage loop); the twin survives, the runtime copy goes. - The
ModeUnifierunion-find (core/src/ty.rs) andInferCtxitself. - The prelude byte-mode residue
classify::prelude_command_spec/PRELUDE_BYTE_FNS(core/src/classify.rs) — a third copy of mode knowledge, the hand-maintained list["map-lines", "filter-lines", "each-line", "view"]that the shell-free fallback consults because it cannot walk a prelude body. The baked prelude’s schemes already carry the real inferred modes for these, and the IR annotation supersedes the residue.core/src/classify.rsis deleted in full (syntax). validate_pipeline’s runtime unification and thepipeline_mismatchdiagnostic (analysis.rs). A∅-into-Bytesadjacency is rejected by the static checker (260601 madepipeline_mismatchreachable statically, T0012); on annotated IR the pipeline’s wires are already ground and consistent, so the runtime unification is unreachable. It is demoted to adebug_assert!that the adjacent wires agree, or deleted outright once ADR 4 makes the check unconditional.- The
ModeStoretrait and the genericity ofunify_mode(core/src/mode.rs). The trait existed for exactly one reason: to drive the equality rule through both engines’ variable stores so the two could not drift (260601’s “neither can drift again”). With one engine left, the sharing mechanism has done its job and retires —unify_modefolds into the checker’s unifier as a plain method, andModeStore/ the runtimeModeSlot/ModeVarunion-find go with it. The lattice itself (PipeMode,PipeSpec) stays: it is what schemes serialise and what the annotation pass grounds into aWire.
What survives is honest about itself. The brief asks whether
CommandTypeResolution.internal / command_is_internal is classification rather
than inference. A source read says: the internal field is dead — it is
written at three sites in core/src/ty.rs and read nowhere. The genuine head
classification that pipeline staging consumes is the separate
command_call::resolve_command_word → Resolution and classify_stage
(analysis.rs), which decide Ral vs External vs Uutils dispatch and do not touch
modes. So CommandTypeResolution and command_is_internal are deleted with the
rest of the engine — not because they are inference, but because the only thing
they classified into is unused. The surviving classifier already lives where the
launcher needs it (pipeline-execution). Nothing
else in ty.rs is classification.
Transition and dependencies
- Dual-run, then delete. The annotation pass landed first (
e3c23b4b); the consumers then switched to reading theWireoff the node (edf47cd9), with adebug_assert!comparing each annotation against the old runtime engine’s verdict over the test suite —runtime_specsfor the pipeline stages (no argv re-evaluated),rhs_is_byte_output’s assert for the bind RHS. The dual-run is green across the full suite — 33 binaries, 0 failures, debug build with the asserts live; clippy clean; the-D warningsbuild holds. The deletions in the inventory above land only when ADR 4 lands, because the fallback for unchecked IR (--no-typecheck,ral/src/main.rs) is the old engine, and ADR 4 is what removes that fallback. This page’s job is to make the annotation real and prove it agrees; ADR 4 collects the corpses. - What the dual-run caught. The page predicted “exactly the kind of
disagreement the dual-run
debug_assert!exists to catch.” A seventh, the earliest, predates the dual-run — found while landing ADR 1 (commit03a3fee):consumes_value_arg(core/src/typecheck/infer.rs) misread a ground-Bytes-input stage whose return type was polymorphic (from-json : F[Bytes,∅] α) as a value-arg consumer; the static twin was aligned to make that edge a static T0012. The dual-run itself surfaced six more, each fixed at the root — on whichever engine was wrong — rather than by weakening the assert:- An external head’s input is open. The static
external_exec_comp_tyalways saidF[var, Bytes]; the runtimeext()default disagreed withF[Bytes, Bytes]. The runtime was aligned (newexternal_specincore/src/ty.rs, andcommand_sig’s unknown-external branch). Consequence:'abc' | blahis well-typed through the open input and fails atblah-not-found, not as a channel mismatch. named_headpeers throughForce(Thunk(..))and aBind’srest. So!{ f x }and a hoisted-interpolation bind resolve their head through scope instead of defaulting a local name to external bytes (core/src/ty.rs).try/guard/auditown no byte output. They return a value, so their own output channel is∅while the body’s bytes flush through — matching the staticinfer_try/infer_guard/infer_audit, eachCompTy::pure(α)(core/src/typecheck/scope.rs). The alignment exposed wrong goldens:tests/builtins/audit.outandaudit-tree.outwere missing body lines that the old bind-capture swallowed — bytes captured for a non-Unitresult, then discarded, printed nowhere and bound nowhere. The goldens were re-blessed with the complete output; this is a user-visible bug the alignment fixed. The check exists to catch engine disagreement, but here it caught the tests encoding the buggy engine’s behaviour.env_binding_typeresolves lexically and breaks recursion. It resolves a closure body’s free names against its captured environment (lexical, as the checker does), treats a bound non-invocable value as∅rather than external bytes, and breaks recursion with aseenset so a self-referential binding is mode-neutral (core/src/ty.rs,env_binding_type_seen).- A value-arg-consumed stage reports its own channels.
infer_pipelinerecords such a stage onF[∅,∅](theconsumed_as_valueflag,stage_own_spec) rather than its application body’s — matching how staging launches a{ |x| … }block stage (core/src/typecheck/infer.rs). - The pipeline’s tail output reads the same own-channel spec. When the last
stage is value-arg-consumed,
infer_pipelinereads the pipeline’s output mode offstage_own_specof that stage (F[∅,∅]) rather than its applied body’s, so the wire on the final stage matches the runtime’s reading of the block-stage launch.
- An external head’s input is open. The static
- Depends on ADR 1. The checker carries each session binding’s scheme
forward, so a session-bound call site is annotated with a real wire, not a
fresh-variable-defaulted-to-
Emptyone. - Depends on ADR 2. No handler or alias may change a head’s modes after the check, so an annotation written at compile time stays valid through any dynamic rebinding — the precondition for a verdict that travels with the node.
Verify: the dual-run debug_assert! holds across the full test suite,
including the pipeline tests, with no annotation diverging from the old engine’s
verdict; a representative pipeline (printf "a\nb\n" | map-lines { |x| echo $x } | from-lines) wires its stages from the annotated Wires, and the
--no-typecheck fallback runs byte-for-byte through the old engine (compiled out
only in ADR 4); a Lambda whose body carries interior wires (on its
Pipeline stages and Bind RHSes — there is no thunk-root wire) round-trips
through SerialValue and a sandbox re-exec with those annotations intact
(core/src/serial.rs, core/src/sandbox); and the prelude re-bake picks up the
CompKind/Wire schema change automatically via the cargo:rerun-if-changed
lines the build-dep hazard note mandates (core/src/lib.rs, ral/build.rs,
exarch/build.rs) — a stale blob must not deserialise into the new shape.
See also session-scheme-continuity, handler-alias-mode-preservation, modes-equality-constrained-shared, compilation-ladder, pipeline-execution, ir, evaluator.