DataFrame as a Row-Parameterized Type Constructor (+ bool)
Status: Implemented (2026-07-09)
Goal: make df::* functions polymorphic over the row (record) type of the
data they process, and add a bool type so quoted predicates have a proper
result type:
fn df::fromcsv(path: string) -> DataFrame<'a>
fn df::filter(frame: DataFrame<'a>, predicate: Quote<'a -> bool>) -> DataFrame<'a>
fn df::show(frame: DataFrame<'a>) -> i64
The row type is learned from the quoted predicate: :( fn (r) => r.a > r.b )
has inner type fn {a: i64, b: i64} -> bool, which unifies with the frame's
row, so df::filter(df::fromcsv(p), :( fn (r) => r.a > r.b )) producesDataFrame<{a: i64, b: i64}>.
Companions: knowledge/design/list-types.md (node lists cross the boundary),knowledge/design/sum-types.md (row machinery used by the quote's record
selects).
Decisions (confirmed with user, 2026-07-09)
- Naming:
Frameis the boundary resource,DataFrame<'a>is the type
constructor. The WIT resourcedata-frameis namedFramein blr
source.DataFrameis always written with a type argument — there is no
bareDataFrame. This avoids a bare/parameterized duality inTypeand a
LALR conflict in the type grammar. - WASM boundaries cannot carry the row parameter, so typed wrappers call
underscored externs that take/returnFrame. Two compile-time cast
builtins bridge the two types (mantle-rewritten to identity, liketo_nodes):to_frame_resource : fn DataFrame<'a> -> Frame(erases the row)to_dataframe : fn Frame -> DataFrame<'a>(asserts a row; the row is a
promise about the data, verified by nothing — confined tostd::df)
User code cannot cast; only thestd::dfwrappers do.
- Quote literals are lowered eagerly.
:( expr )becomes a node-list
value at its definition site (the quote's only physical form is already
the node list; today it is just built lazely underto_nodes).to_nodesbecomes an identity rewrite that works on anyQuote-typed
argument (literal or parameter). This is what makesto_nodes(predicate)
valid insidestd::df'sfilterbody, where the quote arrives as a
parameter and its source payload is not visible.
In emitted (mantle→core) types aQuote<T>parameter is physicallylist<node>(one i32 region pointer, same facts asList), because every
quote value is a node list. The quote reports its real solved inner type (not the sealedQUOTE_INNER_VAR). The isolated inner context defaults its leftover
unbound unifiers before the type escapes: type unifier →i64, row
unifier → empty closed row. This is what tiesDataFrame<'a>to the
predicate's record type.
Superseded (2026-07-10,knowledge/plans/quote-row-superset.md):
quote bodies are inferred in the enclosing context; a quoted
predicate's parameter row is the fields it references plus an
unknown remainder (row superset), body-local rows close to the
observed fields at item end, and chaineddf::filtercalls accumulate
(DataFrame<{a,b,c,d}>for predicates over{a,b}then{c,d}).fromcsvmay be left unconstrained today (e.g.df::show(df::fromcsv(p))). No defaulting/source-function syntax in
this plan — that syntax is a later item. Guardrail so the pipeline doesn't
silently break: the row parameter is phantom for codegen and
monomorphization —is_mono_type(DataFrame(_)) => true,emit_val_typ(DataFrame(_)) => I32— a leftoverVarinside theDataFrameparameter does not poisonmain.boolscope:booltype,true/falseliterals, and comparison
operators>/</=get schemeT -> T -> bool(currentlyT -> T -> 'b
with unconstrained result). Deferred:&&/||/not, and aboolean(bool)node case indf.wit(a quoted predicate that is a bare
boolean literal gets a clean "not encodable in the v1 node set" diagnostic).showtakes a frame (show(path: string)in the request was a typo
forfromcsv).- Monomorph mangling of instantiated wrappers uses the existing
field[(row-sexpr)]scheme (verbose now, terse later).
Type model
Type::Frame // boundary resource: WIT `own <data-frame>`, monomorphic
Type::DataFrame(Box<Type>) // typed frame; parameter = row type (a record type in practice)
Type::Bool // new primitive
- Unification:
DataFrame<A> ~ DataFrame<B>iffA ~ B(strict).Framenever unifies withDataFrame<_>— only the cast builtins bridge. - The row parameter is phantom in codegen: a frame value is an i32 resource
handle regardless of the parameter. Quote(Box<Type>)is unchanged structurally; its inner type is now real
(solved, closed) instead ofVar(QUOTE_INNER_VAR).QUOTE_INNER_VARis
deleted.
Syntax
Parser (lang/src/compiler/air/parser.lalrpop), TypeExpr region (~:279):
"Frame" => TypeExpr::Frame(replaces the bare"DataFrame"production)."DataFrame" < TypeExpr > => TypeExpr::DataFrame(Box::new(<>))."bool" => TypeExpr::Bool.- Arrow types — no function-type syntax exists in type position today
(TypeExpr::Funis only produced byfn-decl signatures). Needed forQuote<'a -> bool>. Add a dedicated right-recursive nonterminal to avoid
the LALR shift/reduce ona -> b -> c:TypeExpr: TypeExpr = { ..., ArrowType, ... } ArrowType: TypeExpr = { <param:TypeExpr> "->" <ret:ArrowType> => TypeExpr::Fun(FunctionType { parameter_names: vec!["_"], parameter_typs: vec![param], ret: Box::new(ret) }), <t:TypeExpr> => t, }TypeExpr::Funalready lowers to nestedAbs(_lower_typ), so no crust
change beyond the newTypeExprvariants. - Expression literals:
"true" => Expr::Bool(true),"false" => Expr::Bool(false)in theTermrule.
Quote value model (the core change)
Today: a quote literal lowers to a Unit hole; to_nodes(quote-lit) is
rewritten in the mantle to a node-list literal built from the air payload;
the quote's reported type is sealed (Quote(Var(QUOTE_INNER_VAR))).
New:
inferarm forcrust::Expr::Quote(infer.rs:268): the isolated inner
check runs as today (check_quote_inner, fresh innerTypeInference), but
afterinner.unificationthe inner context defaults unbound unifiers
(type →Int, row → emptyClosedRow) and the solved inner type is
substituted out and reported:Type::Quote(solved_inner).checkarm forQuote(infer.rs:460): the inner check runs against the
translated expected inner type; on success the arm re-emitsConstraint::TypeEqual(id, solved_inner, *expected_inner)to the outer
context. Without this, outer variables (the shared frame-row'a
instantiated at thefiltercall) would never learn the row, because the
inner table's solutions don't cross the isolation boundary. (The primary
call flow — quote literal as an inferred application argument — already
works via (1), since the literal's solved type is closed; this arm covers
quote literals in check position, e.g. under an annotated binding.)- Mantle
lower_expr(crust::Expr::Quotearm, mantle/mod.rs:~1303)::( air )→Expr::list(node_ty, quote::encode(&node_ty, air))— always,
not only underto_nodes.node_tyis extracted once from the registeredto_nodesitem type (same peel pattern as the existing rewrite,
mantle/mod.rs:~1235). The v1-node-set panic inquote::encodebecomes a
proper diagnostic carrying the quote's node id (covers the deferred
boolean-literal case). - Mantle
to_nodesrewrite:to_nodes(x)→xfor any argument
(previously required a quote literal). - Mantle
lower_ty:crust::Type::Quote(_) => Type::list(node_ty)— the
physical type of any quote is the node list. Consequences:mantle::Type::Quotebecomes unconstructible and is removed (adjust,subst_ty, sexpr arms go away);to_nodes's stored type lowers tolist<node> -> list<node>(physically an identity); the monomorphizedfilter[row]wrapper'spredicateparameter is emitted as one i32 (list
region pointer) and the node-list value crosses the local/remote call
exactly like aListvalue (single i32 on the blr stack; the
pointer-pair canonical ABI only matters atAppExternal, where the extern
signature islist<node>and already codegen-complete per
list-types.md). - core: the mantle→core arm (core/mod.rs:341) becomes
mantle::Type::List(..) => ...as usual — noQuotecase needed since
the mantle no longer produces it. Update the stale comment.
Why this is sound enough: a Quote<T> value is always a node list
(built from the quoted source); nothing else can produce one. Typing the
physical value as list<node> erases only the (uninhabited-at-runtime)T tag. The row-typing benefit lives entirely at the type level, whereQuote<fn R -> bool> still unifies against the frame row.
Cast builtins
Registered in an ItemSource (new lang/src/runtime/df.rs, called fromrun() in lib.rs alongside register_quote_functions), module std::df:
to_frame_resource : ∀'a. DataFrame<'a> -> Frame
to_dataframe : ∀'a. Frame -> DataFrame<'a>
Mantle rewrite (next to to_nodes in lower_expr's Application arm, keyed
on symbol.field): the application lowers to its argument. Both directions
are the identity as values (an i32 handle).
New std::df surface
stdlib/wit/df.wit — rename the host functions (the typed wrappers need the
unmangled names for themselves); resource and node types unchanged:
fromcsv -> _fromcsv : func(path: string) -> data-frame
filter -> _filter : func(frame: data-frame, predicate: list<node>) -> data-frame
show -> _show : func(frame: data-frame) -> s64
stdlib/src/df.blr:
pub extern type binaryoperator = [ ... ] // unchanged
pub extern type binary = { ... } // unchanged
pub extern type function = { ... } // unchanged
pub extern type node = [ ... ] // unchanged
pub extern fn _fromcsv(path: string) -> Frame;
pub extern fn _filter(frame: Frame, predicate: list<node>) -> Frame;
pub extern fn _show(frame: Frame) -> i64;
pub fn fromcsv(path: string) -> DataFrame<'a> { to_dataframe(_fromcsv(path)) }
pub fn filter(frame: DataFrame<'a>, predicate: Quote<'a -> bool>) -> DataFrame<'a> {
to_dataframe(_filter(to_frame_resource(frame), to_nodes(predicate)))
}
pub fn show(frame: DataFrame<'a>) -> i64 { _show(to_frame_resource(frame)) }
stdlib/src/df.rs — bindgen!/Host impls renamed to _fromcsv/_filter/_show; build_node_expr unchanged.
Flow for df::filter(df::fromcsv(p), :( fn (r) => r.a > r.b )):
quote literal infers Quote<fn {a: i64, b: i64} -> bool> (inner context:r's row solves to {a: U1, b: U2} via the existing row-combination
machinery, > unifies U1 ~ U2, both default to i64); filter's shared
row unifier 'u := {a: i64, b: i64} from the predicate parameter and
flows from the frame parameter; main monomorphizes requestingfilter[{a:i64,b:i64}] and fromcsv[{a:i64,b:i64}] in std::df via the
existing cross-module instances machinery (same as +[(float)]). The
emitted call passes (i32 frame handle, i32 node-list region) to thefilter[row] function, which calls host _filter — the host code is
byte-identical to today's df::filter extern call.
bool
- Comparisons (
runtime/binary.rsregister_comparison_functions): schemeT -> T -> bool(drop the'bvar). No existing test uses comparisons
outside quotes (runtime impls aretodo!()stubs;binary_ops.blruses
only+ - * /), so this is test-safe. Type::Boolplumbing is mechanical, patterned onIntat everyType::DataFrame/Type::Listmatch site below, with these physical
facts: core wasmi32(ValType::I32), componentbool
(PrimitiveValType::Bool),size_align = (4, 4), flat[I32],
literal emissioni32.const 0/1.Expr::Bool(NodeId, bool)in air/crust; mantle/core get a matching
literal variant (do not fold intoInteger— different valtype).- Not added in v1:
ExternalType::Bool/boolean(bool)node case /&&||not(see decision 6).
Touch-point map
air
parser.lalrpop:Frame/DataFrame <TypeExpr>/bool/ arrowArrowTypeproductions;true/falseTerm tokens;TypeExpr::Frame,TypeExpr::DataFrame(Box<TypeExpr>),TypeExpr::Bool;Expr::Bool.
Runparser_tests.rsearly (arrow rule is the main parser risk).air/mod.rs(143-169),air/sexpr.rs(To 270, From 881, test 1187):
variants +(frame),(dataframe <inner>),(bool),(b <literal>)
forms.
crust
ty.rs:Type::Frame,Type::DataFrame(Box<Type>)(replacesType::DataFrame),Type::Bool;occurs_check,mentions.unification.rs:normalize_ty(:148-171),unify_ty_tyarms
(DataFrame(a)~DataFrame(b)recursive;Frame~Frame;Bool~Bool).inst.rs:Instantiate::ty(~:94-127).subst.rs:substitute_ty(121-150) + expr traversal.infer.rs:Expr::Boolinfer + check arms;translate_tyarms; quote
changes per "Quote value model" (1)-(2); deleteQUOTE_INNER_VARuse.mod.rs:_lower_typarms (DataFrame(Box)lowers its parameter;Frame;Bool);convert_to_ext_typ:Frame => ExternalType::Resource("data-frame"),DataFrame(_) => panic!("DataFrame row types cannot cross the component boundary; declare externs with Frame");Expr::Boolin enum/id()/lowering.sexpr.rs: type To/From + allow-lists.
runtime registrations
runtime/quote.rs: unchanged scheme; (doc updates).runtime/binary.rs: comparison scheme →T -> T -> bool.runtime/df.rs(new):to_frame_resource/to_dataframeschemes.lib.rsrun(): register the new module.
mantle
mod.rs:Type::Frame,Type::DataFrame(Box<Type>),Type::Bool
(subst/shift/adjust/lower_ty/sexpr);lower_tyquote →list<node>; removeType::Quote;Expr::Boollowering; eager
quote-literal arm;to_nodes/cast identity rewrites;node_ty()helper.monomorph.rs:is_mono_type—Frame | Bool => true,DataFrame(_) => true(phantom; decision 5).simplify.rs:subst_typtype arms; expr arms forExpr::Bool.quote.rs:encodepanic → diagnostic; tests updated for eager
lowering/identity.sexpr.rs: arms + allow-lists.
core
mod.rs:Type::Frame,Type::DataFrame(Box<Type>),Type::Bool
(295);lower_typ(341-357, incl. the quote comment);convert_external_type(701):Resource(_) => Type::Frame;Expr::Bool(+free_vars_aux/rename/type_of); mantle→core expr arm.sexpr.rs: arms + allow-lists (42-81, 410-514, 842-881).
nucleus
mod.rs:emit_val_typ(111:Frame | Bool | DataFrame(_) => I32);Expr::Boolliteral emission (i32.const);size_align(Bool(4,4));flatten_type/core_flat_tys(Bool →[I32]); the list-element arm at
392 and record/sum slot arms at 536/645/936 gain the new variants
(DataFrame staystodo!()-parity in slots: no record/sum field may carry
a frame in v1);emit_load/emit_store_exprBool arms (i32).component.rs:convert_ext_ty_to_ctyp—Resourcearm unchanged;EmitComponent::convert_ty_to_ctypcore-Typearms updated
(Frame/DataFrame(_)staytodo!()-parity: frames never cross the
root component boundary —mainreturnsi64).
external_type.rs
- v1: no new variant (see decision 6).
Resource("data-frame")unchanged.
stdlib
wit/df.wit: function renames only.src/df.blr: per "New std::df surface".src/df.rs:Hostimpl renames;build_node_exprunchanged.
Test strategy
- Integration (
lang/tests/integration/):df.blrunchanged source;.outunchanged (0? — verify: currently
returns the csv row count viashow); WAT changes are expected.df_filter.blr→df::filter(df::fromcsv("test.csv"), :( fn (r) => r.a > r.b ))(dropto_nodes);.outunchanged.- New
df_chain.blr:df::show(df::filter(df::fromcsv("test.csv"), :( fn (r) => r.a > r.b )))— row flows through two wrappers.
- Type check (
lang/tests/type_check/):- New
frame_row_mismatch.blr:df::filter(df::filter(df::fromcsv("test.csv"), :( fn (r) => r.a > r.b )), :( fn (r) => r.c < r.d ))→ error (rows{a,b}vs{c,d}do not
unify) — the first real negative test for row tracking. - New
bool_type.blrstyle positive/negative checks (true : bool,1 > 2 : bool,boolvsi64mismatch).
- New
- Unit: parser tests (arrow type,
DataFrame<'a>,Frame, literals);
sexpr roundtrips per layer;quote::encodediagnostic test;is_mono_typeDataFrame facts. - Snapshot WAT audit (
BLR_SNAPSHOT+print_wasmexample):mainpasses (i32 frame, i32 node-list region) to the mangledfilter[(prd ...)]function in thestd::dfcore module.std::dfmodule calls the lowered host_filterimport with the
pointer-pair list lift (existing machinery).to_dataframe/to_frame_resourceleave no trace (identity rewrites).
- Gates: full workspace green,
cargo fmt,cargo clippy --workspace -- -D warnings; existing expect-test snapshots
(crust/mantle sexpr, monomorph test in monomorph.rs:315+,
quote.rs:149+) get bulk-updated — review diffs for sanity, not
byte-for-byte.
Work items
| # | Item | Phase |
|---|---|---|
| D1 | air: Frame/DataFrame<T>/bool/arrow TypeExpr, true/false Expr::Bool, AST + sexpr + parser tests | 1 |
| D2 | crust: Type::{Frame, DataFrame(Box), Bool} through ty/unify/inst/subst/sexpr; Expr::Bool; _lower_typ; convert_to_ext_typ | 1 |
| D3 | mantle: new type arms, remove Type::Quote, lower_ty quote→list<node>, is_mono_type facts, Expr::Bool, simplify/sexpr | 1 |
| D4 | core + nucleus + external_type: type/expr arms, physical facts (i32), literal emission; Resource => Frame | 1 |
| D5 | Quote value model: real inner type + inner-context defaulting (type→i64, row→∅), check-arm TypeEqual re-emit, delete QUOTE_INNER_VAR; eager literal lowering + encode diagnostic; to_nodes identity; runtime/df.rs casts + registration + mantle rewrites | 2 |
| D6 | bool completions: comparison schemes, literal plumbing in all layers (fold into D1-D4 arms where they touch the same matches), unit tests | 2 |
| D7 | stdlib df: WIT renames, df.blr wrappers, host df.rs; run() wiring | 3 |
| D8 | Tests: df_filter update, df_chain, frame_row_mismatch, bool type-check cases; snapshot WAT audit; plan status updates | 4 |
D1-D4 are mechanical (the list-types.md arm census is the template); land
per layer as today. D5 is the only genuinely new mechanism. D7/D8 depend on
D5.
Risks
- Arrow-type grammar: new
->inTypeExprposition next to existing->in fn-decl position and the>>forward token — the dedicated
right-recursiveArrowTyperule is the mitigation; land D1 and run
parser + precedence tests first. - Quote check-arm information flow: if the
TypeEqual(solved, expected)
re-emit is missed, rows silently fail to unify in check-position quotes —
covered by thedf_chainandframe_row_mismatchtests. DataFrame(Var)inmain: the phantom guard (decision 5) keeps
unconstrainedfromcsvalive; without itmainwould be classified poly
and dropped as dead code (monomorph.rs:76-80). The real fix (source
function syntax) is deferred.- Node list crossing local/remote item calls: single-i32 region values
throughLocalItem/RemoteItemcalls are untested territory (lists
today only crossAppExternal). The WAT audit in D8 is the gate; fallback
if broken: emitfilter[row]bodies with the list argument stack-allocated
like other i32 params (expected, sinceemit_val_typ(List) = I32already). - Snapshot churn: every layer's expect-test snapshots touching
DataFrame/Quotechange; budget review time.
Progress log
- 2026-07-09: Plan written from repo survey (crust/mantle/core/nucleus
touch points, quote isolation mechanics, monomorph instance machinery,
list/quote physical facts). Decisions 1-8 confirmed with user. - 2026-07-09: Implemented across five commits on the
quotebranch:booltype,true/falseliterals, comparisonsT -> T -> bool
(D1/D2/D3/D4/D6).Frameboundary resource +DataFrame<row>constructor, arrow type
syntax (D1-D4).- Quote value model: real solved inner types, eager node-list lowering,
to_nodes/cast identity rewrites,runtime/df.rscast registration
(D5). - Typed
std::dfwrappers (D7) plus the mechanisms that proved
necessary (see deviations below). - Tests:
df_chainintegration,frame_row_mismatch/bool_type/bool_mismatchtype-check cases (D8), WAT audit.
Deviations from the written plan
- Boundary WIT names are
readcsv/applyfilter/count, not_fromcsv/_filter/_show: WIT identifiers cannot start with_, and BLR identifiers cannot contain-(WIT is kebab-case), so the
host functions must be single words. The blr externs match them
verbatim (no name mapping at the boundary). mantle::Type::DataFramewas deleted rather than kept: with the
identity cast rewrites, aDataFrame-typed value passed whereFrame
is expected tripped the mantle's application type-check. Lowering bothcrust::Type::Frameandcrust::Type::DataFrame(_)to the single
mantleFrametype is simpler and consistent with the row being
phantom; the row still flows through monomorphization via the wrapperTypApp(mangled names carry the row sexpr).- Quote row-closing needed a new mechanism: the row-combination solver
does not merge complementary single-field projections (r.aandr.b
produce two partial combinations on the same goal row that are not
unifiable under the existing two-out-of-three rule), so a quote
parameter's row stayed an open variable.TypeInference::close_unbound_rows
(isolated quote contexts only) closes pending combinations whose goal is
still unbound: goal := union of the closed left rows, each right :=
goal minus its left.
Superseded (2026-07-10):close_unbound_rowsnow runs in the main
context at item end with a reachability filter — only body-local goals
close; signature-reachable rows stay open (row-polymorphic items keep
their row and carry the predicate's row equations as evidence).
Quote-context isolation itself is gone (quote bodies infer in the
enclosing context); liveness of monomorphic items is guaranteed by
scheme hygiene (only interface-named variables quantify an item), not by
closing. - The phantom guard needed a second half:
is_mono_type(DataFrame(_)) => true
alone does not keep an unconstrainedshow(fromcsv(p))alive — the free
row variable leaks into the item scheme via the item wrappers, makingmainvacuously polymorphic (∀a. () -> i64) and dropped. AddedTypeInference::default_body_local_ty_unifiers(main context): unbound
type unifiers not reachable from the signature type (following solved
unifiers) are defaulted toIntafter unification. Reachable unifiers
are preserved (the S combinator's inferredbis reachable through a
solved parameter unifier). - Monomorph instance dedup: the same poly item instantiated at the
same type from several call sites (e.g. twofilters at the same row)
produced duplicate exports; instance recording now dedups by type.