Skip to content

Latest commit

 

History

History
784 lines (707 loc) · 63.9 KB

INTERNALS.md

File metadata and controls

784 lines (707 loc) · 63.9 KB

Pyfun — Internals

How this Rust compiler is built: crate layout, the lowering passes, and the language server. It is the map from a semantic rule to the code that implements it. DESIGN.md is the source of truth for what the language does and why; this file is the source of truth for how this compiler does it. Read them together for any compiler change: DESIGN for the rule, this file for where it lives. The exact current code is always the final authority; when this file and the code disagree, fix one of them.

Crate layout

Keep modules small and single-purpose — exhaustiveness, type+effect inference, and codegen each grow large and must not bleed together.

src/
  lexer/           tokenizer, token types, lex errors, the offside rule
  parser/          recursive-descent + precedence climbing; ast.rs = span-carrying AST
  ast/             traversal + visitor utilities, pretty-printer
  desugar.rs       computation-expression desugaring (DESIGN §8.1): builder{} → bind/return/…
  types/           HM inference + effect inference/checking, exhaustiveness, units (DESIGN §8.2)
  lowering/        Pyfun AST → Python-AST IR; scope/name-binding analysis; unit erasure
    fold_loop.rs     hot folds become loops (DESIGN §5.1)
    decode_spec.rs   statically-known decoders deforest (DESIGN §5.3)
    self_tail_call.rs a saturated self tail call becomes `while True` (DESIGN §5.4)
  python_emitter/  Python-AST IR → readable source (py311.rs = the --target 3.11 rewrite)
  diagnostics/     rustc-style errors: codes (E001…), levels, spans, notes
  project/         module graph: acyclic import resolution, cross-module checking
    manifest.rs      pyfun.toml reader/editor, project root and vendored façades (DESIGN §6.2)
  lsp/             front-end-first language server (stdio JSON-RPC)
    json.rs        hand-rolled, dependency-free JSON value + parser + serializer
    resolve.rs     AST resolver: symbol_at / find_references / definitions
  repl.rs          read-eval-print loop over one long-lived Python worker
  kernel.rs        the REPL session inverted, for the Jupyter kernel
  packages.rs      `pyfun add` / `install` / `remove`: façade packages (DESIGN §6.2)
  native_build.rs  `pyfun build --native`: compile, then mypyc (DESIGN §5.6)
  main.rs          the CLI: check/compile/run/build/add/install/remove/parse/lsp/repl/kernel-engine
editors/vscode/    minimal VS Code client that launches `pyfun lsp`
tests/             parser tests, compile tests, .pyfun fixtures (favor snapshot/golden tests)

The shared Python runtime support (the Option/Result classes, and the _pf_scope context variable Task.scope publishes its TaskGroup through) is not a directory: it is generated by lowering::runtime_module() into a _pyfun_rt.py beside the emitted output. There are no dependencies — Cargo.toml's [dependencies] is empty, so the CLI is hand-rolled argument parsing rather than clap.

Build order: lexer + parser + ast → desugar → types → lowering + python_emitter → diagnostics + main → project → lsp.

Opaque types (newtype erasure) — implements DESIGN §7.3

Where each rule lives. Parse: parse_opaque_type (src/parser/mod.rs; opaque is a contextual keyword — the item dispatcher matches Ident("opaque") + Tok::Type lookahead), producing TypeDeclKind::Newtype(TypeExpr) (src/parser/ast.rs). Check: build_decls pass 2 (src/types/mod.rs) registers the constructor as a single-ctor sum sharing the type's name (ctors/type_ctors), so inference, pattern checking, and Maranget exhaustiveness need no new rules; collect_exported_types includes newtypes so they cross file modules. Lower: the Lowerer.newtype_ctors set (src/lowering/mod.rs, seeded in Lowerer::new; project mode extends it with imported qualified names via ImportContext.newtype_ctors ← project::export_newtypes). Erasure sites: fully-applied wrap → the bare argument (early branch in application lowering, Var and qualified Field heads); first-class reference → _pf_id (in lower_var and lower_module_member); Pattern::Ctor → its payload pattern (lower_pattern); no class emitted. Because an erased pattern can become irrefutable, seal_cases finalizes every lowered match: truncate at the first unconditional lowered case, else append the defensive raise — Python rejects any case after an irrefutable one as a SyntaxError, so this is judged on PyPattern, not the source arm.

Arity for the currying lowering — implements DESIGN §5

Lowering does not yet consume inferred types, so the arity that build_call uses to collapse a full application, wrap a partial one in functools.partial, or apply an over-application's remainder one argument at a time comes from syntax. Three sources, consulted in lower_application in this order: the block-scoped local_arities table (a let pair a b = … or let pair = fun a b -> … in an enclosing block, issue #84), the module-level arities/ctor_arity tables (top-level functions, imported members, data constructors), and a fun literal's own parameter count when it is applied in place. A parameter has no entry anywhere and stays arity-unknown, so its application is emitted n-ary as written.

local_arities is kept in step with the fold pass's local_fn_defs registry so an entry can only mean "this name resolves to that local function here": both are snapshotted and restored per block (save_local_scope/restore_local_scope, also around a computation expression, whose body is its own Python function), both are displaced by parameters and match-arm binders for the binder's extent (shadow_local_fns/unshadow_local_fns), and both are evicted when a non-function let or a CE binder rebinds the name (note_block_let/bind_ce_target). One ordering detail: a function let registers its arity in enter_block_let before its value is lowered, so a recursive local function can partially apply itself, while a non-function let evicts after its value is lowered, since let pair = pair 1 reads the previous binding.

Lowering passes

The lowering strategy and its observable contracts are in DESIGN.md §5. The performance-directed passes below are pure implementation: each is default-on, gated by an env-var kill switch for differential testing, and proven value-identical to the unoptimized lowering by a differential gate that byte-compares both variants' output on the network-rail example.

In-place linear accumulation (Seq.fold/List.fold) — implements DESIGN §5.1

The immutable-style collections rebuild a fresh container on every step, so a fold that builds a collection by repeated Map.add/List.concat/Set.add is quadratic (each step copies the whole accumulator). The pass (src/lowering/fold_loop.rs, hooked in lower_application, defaulting on; PYFUN_NO_FOLD_OPT is the kill switch) recognizes the common case and rewrites functools.reduce(f, xs, acc) into a for-loop over a mutable accumulator, turning the copy-returning ops into in-place mutations (Map.add→m[k]=v, List.concat→.append/.extend, Set.add→.add), collapsing the build to linear. This adds two Python-IR nodes: PyStmt::For (whose target is a PyForTarget: a name or a nested tuple of names) and PyStmt::SubscriptAssign.

Soundness is what the pass must protect. The rewrite is observable only through a retained reference to a mutated container, so the pass is a set of conservative syntactic proof obligations on the AST, checked with no side effects on the lowerer (a rejected fold falls through to the byte-identical _pf_fold lowering). A fold qualifies only when: it is a fully-applied Seq.fold/List.fold (exactly 3 args); the folder is a 2-ary lambda literal or a top-level named let (an inlinable body, not a mut/extern/imported member/parameter); the accumulator parameter is a plain name (the pass substitutes it by name), while the element parameter may be any irrefutable pattern, since it is only ever the loop target: a name, a wildcard, or a tuple of those becomes Python's own for (p, l) in steps: header (PyForTarget), and any other shape binds a temp and unpacks on the first line of the body, so fun m (p, l) -> Map.add p l m stays linear like its fst/snd spelling and the pattern's bound names count as body binders for the P8 collision check; the accumulator is a fresh literal collection or a flat tuple of them (a Var init is rejected — it may be read after the fold); every slot is threaded position-preservingly (no swap, no duplication, no cross-slot storage, no closure capture, no escape to a user function — retention is unknowable, so reject); reads of a slot use a whitelist of ops that return scalars or fresh copies; and inlining is capture-safe (the folder's free variables and introduced binders are disjoint from every enclosing Python frame, and at module scope, where the loop's names are globals, from every top-level name the program binds other than the binding being defined, tracked in cur_top_names; inside an in-file module only a lambda or block-local folder inlines, since a bare name there may be a mangled sibling member). Read-before-mutate and effect order are preserved by lowering every op argument (hoisting non-atomic ones to temps) before emitting the mutations. When in doubt, reject. The folder is always pure (an effectful folder does not typecheck against fold's scheme), so the only ordering hazard is a value dependency between slots, which the hoisting handles. Full preconditions (P1–P11) live in the design memo the pass was built from.

Tier B extends the qualifying shapes, each behind the same differential gate: block-local named folders (dedupLegs's inner step — a scope-coherent registry (local_fn_defs) records 2-ary block lets, evicts on rebinding, and is shadow-guarded at every binder introduction (function parameters, match-arm patterns), so a hit is always the innermost binding; a local folder's frees resolve in the call site's own frame, so the top-level-folder free-variable check is skipped, like a lambda's); chained updates in one slot (Map.add k2 v2 (Map.add k1 v1 m) → in-place ops innermost-first — sound because the reduce form never mutates, so hoisting every read before every mutation preserves what each read sees); fresh-reset slots (([], List.concat done [cur]) — the slot rebinds to a fresh init; the store-then-reset exception licenses storing a reset slot's whole object into another slot, force-hoisting the old reference before the rebind); Map.remove/Set.remove (→ m.pop(k, None) / s.discard(x), mirroring the copy helpers exactly); and Var inits (a mutated slot binds a defensive shallow copy — dict(seed), constructor inferred from the slot's op family — so the original reads unchanged after the fold; an unmutated slot binds as an alias, preserving reduce's object identity). For soundness, a parameterized local let in a folder body is a deferred closure and is held to the closure rule (no sensitive mention), not the immediate-read rule.

Inlining fully-applied pure stdlib predicates — implements DESIGN §5.2

Most stdlib members lower to an emitted _pf_* helper (a single callable so partial application still works). But a handful are pure, total, one-liner wrappers over a Python idiom, and when such a member is fully applied there is no reason to route through the helper at all — the same fully-applied-collapse instinct as the currying rule. try_inline_stdlib (in lower_application, gated on the head being a module-qualified Field and the arg count matching the member's exact arity) emits the idiom directly: String.contains n s→n in s, String.startsWith p s→s.startswith(p), String.endsWith→s.endswith(…), List/Set/Map.contains→x in xs (a PyExpr::Compare with the In op, so precedence/parenthesization is free), and List.isEmpty xs→not xs. The argument order is taken verbatim from each helper body — an inverted operand would be a silent miscompile — so the inline form is value-identical to the helper. Anything short of full arity (a partial application like String.contains "x", or a bare value reference) is not matched and falls through to the helper unchanged, so List.map (String.contains "x") xs still works. The win is mostly readability: "CHIPNHM" in line reads better than _pf_str_contains("CHIPNHM", line), and the call-elimination is small on wall-clock (~3% on the network-rail example; a cProfile line that looked like ~6s was mostly the profiler's own per-call overhead on a ~1.87M-call trivial function, not real runtime — see ROADMAP.md). (List/Set/Map.len are not in this set: they already lower to a bare len, so a fully-applied call is already len(xs) with no helper in between.)

Two further call-site shapes ride on the same lever (found on a Scrabble move generator that asked List.findIndex ((==) l) rack two million times and match Map.tryFind rc checks: four million times per position).

Equality predicates (try_inline_eq_predicate, right after try_inline_stdlib in lower_application): a fully-applied List.findIndex/List.find/List.exists whose first argument eq_predicate_value recognises as (==) x (an App of OpFunc(Eq) to one argument) or as a one-parameter lambda whose body is x == y / y == x with y not free in x (checked with fold_loop::collect_free) routes to _pf_index_of(x, xs) (try: return Some(xs.index(x)) except ValueError: return None_()), _pf_find_eq(x, xs) (the same with xs[xs.index(x)], because list.index finds an element that is == to x and the generic helper returns that element, which 0.0/-0.0 can tell apart), or the Compare … In expression x in xs. The two helpers live in list_prelude under the collection-helper registry, flagged like _pf_find_index so the Option prelude is emitted. x lowers first and xs second, the argument order of the call they replace. The arity gate is exact, so List.findIndex ((==) l) as a value still becomes functools.partial(_pf_find_index, lambda b: l == b). Semantics are those of list.index/in: an identity shortcut before ==, the same trade List.contains→x in xs already made.

Match-consumed lookups (try_lower_lookup_match, an early return at the top of both the return-position and the value-position Match arms; is_lookup_match is the pure shape check the value-position caller runs before allocating its temp, so a rejected shape leaves temp numbering untouched): a match whose scrutinee flattens to a fully-applied Map.tryFind k m, List.head xs, or List.get i xs, and whose arms option_arms accepts (exactly two, unguarded, None and Some v in either order, v a name, _, or a tuple of those) lowers to a PyStmt::If: the test and payload are taken verbatim from the helper body (k in m/m[k]; xs/xs[0]; 0 <= i < len(xs)/xs[i]), each operand bound to a fresh temp unless is_atomic, v bound from the payload through bind_ce_target (skipped for _, which is never read), and the two arm bodies lowered through lower_arm_body (return position returns, value position assigns the match temp), with the usual shadow_local_fns discipline around the Some arm. No Option is constructed, so a program whose only Option was this one emits no Some/None_ classes at all. Anything else falls through to the match, unchanged.

Option/Result matches as isinstance ladders — implements DESIGN §5.5

Lowerer::try_lower_ladder_match runs ahead of the generic match lowering in both positions (lower_return and lower_value). ladder_shape is the gate, and it is purely syntactic: every arm must be a constructor of one built-in family (LadderFamily::of_ctor — Some/None or Ok/Error) whose payload pattern is simple_irrefutable (a variable, _, or a tuple of those, nested), or a catch-all (_/a variable, which binds the whole scrutinee); arms after an unguarded catch-all are dropped as dead. Anything else returns None and the arms lower to match/case as before. The scrutinee is lowered once and bound to a temp unless it is already a name. Each arm becomes a Piece (its isinstance test, the payload binding via bind_irrefutable, then the body under its guard if any), and the chain is assembled back to front: an unguarded arm nests the rest as its orelse (the emitter renders that as elif/else), a guarded arm cannot (its guard failing must reach the later arms), so the rest follows it as statements, which is only sound in return position where every body returns, hence a guarded value-position match is rejected before any lowering happens. Exhaustiveness is the ladder's own rule (both constructors unguarded, or an unguarded catch-all, judged on the source arms): then the last arm is emitted with no test and no defensive raise follows. In value position the result temp is allocated before the shape check so the ladder can name it, and handed back (tmp_counter restored) on rejection, so the match path numbers its temps exactly as it did. short_circuit_bind gives result {}/option {} the same shape for each let!/do! (bind_ce_target unpacks the payload; the failure is return <subject>).

Two things the pass deliberately does not do: it does not test with is against the singleton (a hand-built Some/None_ from Python must still match), and it does not extend to user ADTs.

Nullary constructor singletons — implements DESIGN §5.5

Lowerer::new computes nullary_singletons: every zero-field constructor in ctor_arity (user variants plus the prelude's None, minus hidden active-pattern cases) whose singleton name (nullary_singleton_name: _ + the emitted class name) is not in binder_names, the whole-module binder set (module_binder_names, shared with the module-alias shadow check). lower_module emits _Red = Red() (singleton_assign) right after each such class; option_prelude does the same for None_ (always, in _pyfun_rt.py). nullary_value is the one place a constructor-as-value is spelled (lower_var, the decode specializer's None), and the prelude helpers reach it through none_value/none() (only collection_prelude constructs None values). Cross-module: the project driver computes each module's singleton set with the same two functions and passes it as ImportContext::nullary_singletons, so an importer emits palette._Red exactly when palette.py defines it and palette.Red() otherwise; a project module that needs Option imports _None_ from the runtime alongside Some/None_ unless it binds that name itself.

Arm-scoped captures and nested-block lets — implements DESIGN §5

A match arm's capture, and a let in a nested block, both become function-wide Python locals, so one that another binding of the same name is live across is renamed (issues #92, #96, #97 and #99). The rule, in src/lowering/captures.rs, is liveness: binder B of name n (an arm capture or a nested-block let) is freshened when some reference to n outside B resolves, under Pyfun scoping, to a binding that is live across B: the frame root (a parameter, a root-level let, a global or builtin, a binding of an enclosing frame) or an arm/nested-block binder whose extent encloses B. A reference bound by a disjoint arm or block (a sibling arm, an arm of a different match, another block's let) reads its own binding and does not count; a reference inside a nested closure always counts, since the closure can outlive its arm; and B's own references never count for B, closure or not. So case Error why: in three sequential matches keeps why, while the same three arms under a root-level why read at the end all rename, and a lambda made in one arm that reads why forces a later arm capturing why to rename.

The census is one walk of the frame's body (Census) that resolves every Var reference and <- target to what it reads at that point, recorded as an Occurrence { target, in_closure }: the target is Outside (a binding outside the frame: an enclosing function's, a module-level one, a builtin), Root (a parameter or a root-level let of this frame), or Binder (a registered arm/nested-block binder); must_rename counts the first two alike. Identity is the address of the AST node (binder_key, the MatchArm or the LetBinding), which is what the lowering holds when it decides that binder, so no spans are involved and a desugared or cloned tree can never be mistaken for the original. Each registered binder also records its ancestors, the registered binders enclosing it, so Frame::must_rename(n, key) can tell an enclosing binder from a disjoint one. A binder the census never saw gets the conservative answer (every foreign occurrence counts). On entering a nested Python scope (an ExprKind::Fn, a parameterised block let, a computation expression) the walk marks that scope's parameters and everything it binds as Inner, which produces no occurrence at all (a lambda's own x is its own slot), while a free reference in it still resolves through the enclosing environment and is recorded with in_closure. A root-level let binds to the root; a nested-block let registers itself and binds for the rest of its block, its value having been walked first (so let x = x + 1 reads the outer x); a parameterised let binds its name before its body, so a recursive reference is its own. Binders never produce an occurrence. The walk mirrors the lowering's block depth, so a block is root exactly when Frame::block_is_nested says so at that point.

Lowerer::frames is a stack of Frames, pushed in lower_fn_body (every function and every statement-bodied lambda), in lower_ce (a built-in CE body is its own function; a user builder desugars into the enclosing frame, so its frame is the enclosing census with the CE body's occurrences added on top), and once in lower_module (top-level values evaluate at module scope; a parameterised top-level let and an active-pattern recognizer are their own defs). The fold-loop pass inlines a folder body into the enclosing frame, whose census hid that body as a nested scope, so emit_fold_loop pushes the enclosing frame merged with the body's occurrences for the duration. A def-emission site without its own push inherits the enclosing census, and every merge over-counts; both only ever freshen more, never less.

Lowerer::enter_arm(arms, idx, locals) is the one place an arm is entered, used by every arm-binding site (the return- and value-position match, the active-pattern chain and fall-through sequence, the Option/Result ladder, the lookup peephole, the fold pass's inlined match). It extends the locals, shadows same-named registry entries, then for each capture asks Frame::must_rename(n, key(arm)) and, when it says so, installs n → fresh in Lowerer::renames, where fresh_capture_name is _

  • the name, bumped with a counter while the frame uses it, it was already handed out in the frame, it is a binder anywhere in the module, or it would land in the reserved _pf_ space. exit_arm removes the arm's renames and unshadows. renames follows the block-scoped registries' discipline (LocalScope/Shadowed): saved and restored per block, displaced by parameters, lambda parameters and inner captures (shadow_local_fns), evicted by a block let or CE binder of the same name. Every place a binder or reference is spelled consults it: lower_var (first thing, before any routing), lower_pattern (captures, list rests, as-names), bind_irrefutable/unpack_into_as through their rename hook, the lookup peephole's bind_arm_payload, the ladder's whole-value binding and the active-pattern chain's binder assignments.

Nested-block lets. Each Frame carries a block depth, bumped by enter_block/exit_block around the three block lowerings (lower_block_return, lower_block_value, the fold pass's inlined block) and reset by each frame push, plus root_is_body: whether the first block entered is the frame's own body (lower_fn_body sets it when the body is a block; a computation expression's items are not a block, so any block inside a CE is nested; the module frame treats a block-valued top-level binding as root, since a collision with a module-level binding is already isolated by lower_module's frame wrap). In a block that is not the root (Frame::block_is_nested, reported by enter_block), lower_block_let decides each bound name by Frame::must_rename(n, key(let)). A root-level let asks Frame::must_rename_root(n) instead (issue #99): rename iff any occurrence of n in the frame is Outside. Python makes n local to the whole def as soon as anything in it assigns n, so a read of an outer n before the let (or in its value, or in a closure made before it) would raise UnboundLocalError; a read after the let resolves to Root(let) and a read of a parameter or an earlier root-level let resolves to Root(that), neither of which is Outside, so rebinding in sequence stays as Python does it. Parameters are bound as Root when the frame is built (Frame::of_body takes them), and the module frame never renames a root-level let (Frame::module), since those are globals. Ordering: a value let lowers its value first (references there mean the outer binding), then installs n → _n in renames for the rest of the block, where it dies with the block's restore_local_scope; a parameterised let installs it before its body so a recursive call resolves to the renamed def, and the def is emitted under the fresh name. The emitted target is passed down explicitly (lower_let's targets map, through lower_binding_as and unpack_binding's rename hook for a destructuring let), never read from renames, since the rename is not yet active while the value lowers. A let mut is covered: an <- target spells through py_binder_name, and the global/nonlocal declarations lower_fn_body computes from scan_scope (Pyfun names) map through the renames active when the nested function is lowered, which is while the outer rename is in force.

Deliberately not counted: binders (a later let x in the frame assigns before it reads) and a reference bound by a disjoint arm or block (its own slot in Pyfun and, by the time it runs, its own value in Python).

Computation-expression binders. A built-in CE body (option/result/seq/async) is its own Python def and its own frame (Frame::of_ce), and a let/let! target in it is a root-level binder of that frame, so it takes the root-level rule: enter_ce_binder asks must_rename_root for each bound name, evicts the name from the local-function registries, and installs any rename in renames for the rest of the CE body (the CE's save_local_scope/restore_local_scope wrap drops it after); bind_ce_target_as then emits the assignment under the chosen spelling, through unpack_binding's targets for a destructuring target. The let! of result/option lowers its continuation before its ladder is built, so lower_short_circuit_items registers the binder first and hands the spelling to short_circuit_bind; the failure branch still returns the subject untouched. seq and async bind after lowering the value and before the next item, so bind_ce_target does both steps in one.

Specializing statically-known Decode decoders — implements DESIGN §5.3

Decode.decodeString dec s normally builds a runtime decoder value — a tree of raising closures — and interprets it over json.loads output. When dec is a syntactically-known composition of the simple combinators (string/int/float/bool/field/list/nullable/map–map4/ succeed/fail/oneOf, including through top-level let decoder names), the pass (src/lowering/decode_spec.rs, hooked in lower_application, default-on with PYFUN_NO_DECODE_OPT as the kill switch) deforests the interpreter into direct dict/list access with inline error handling: one try whose body reads like a hand-written validating parser (t = v["name"], if not isinstance(t, str): raise ValueError(...)), with the handler folding any raise into Error(_Exception(kind, msg)) exactly like _pf_dec_decode_string. The Result is byte-identical to the interpreter's on every input — same Ok payload, same error kind and message (a missing field is KeyError with the key's quoted repr). Faithfulness mechanics: configuration expressions (fan-in functions, field names, succeed values) hoist outside the try in construction order, matching the interpreter's build-time evaluation; every node decodes from a bound temp so a subscript evaluates once; oneOf nests try/except per alternative, exhausting to the interpreter's exact message. Anything dynamic — andThen, a decoder passed as a value, a shadowed or cyclic named decoder, a non-literal oneOf list, an in-file module — rejects and falls back to the interpreter unchanged. Measured on a decode-dominated workload (400k-object JSON array into records, in-process best-of-5): 2.8x end-to-end including the shared json.loads, ~4x on the decode portion itself.

Self tail calls as loops — implements DESIGN §5.4

src/lowering/self_tail_call.rs, called from lower_binding_as (the one site that emits a named FuncDef, so the function has a name to call itself by) after lower_fn_body returns. It runs on the lowered body, not the Pyfun AST, which is what keeps it small: lower_return emits a PyStmt::Return only in tail position, so "tail call" is just "a Return whose value is PyExpr::Call on our own emitted name with our own arity", and the tail-position walk is a walk over statements that already exist. Leading Global/Nonlocal split off first and stay outside the loop (Python requires them at the top of the block); everything after them is wrapped in PyStmt::WhileTrue, with each self tail call replaced by a rebinding (PyStmt::Assign for one parameter, PyStmt::UnpackAssign from a PyExpr::Tuple for several, so the rebinding is simultaneous) plus PyStmt::Continue. Both statements are new IR nodes, as is UnpackAssign.

The four preconditions (DESIGN §5.4) are binds (the body rebinds the function's own name — assign, unpack, for target, nested def/class, or a match capture), has_yield (generator), the descent rule in rewrite_stmts (into If branches and Match case bodies only — never For, FuncDef, ClassDef or Try), and the capture check: frame_names (parameters plus everything assigned in this frame) intersected with nested_refs_stmt, which collects each nested function's free names (free_in_function/free_stmts/free_expr subtract the function's own parameters, locals and match captures, and add back anything it declares nonlocal/global, since those name the enclosing cell by definition). A non-empty intersection rejects, because the loop shares one cell per name where recursion gave each frame its own. Any rejection returns the body untouched, which is always correct and merely stack-bound.

rewrite returns an Outcome { body, note }. has_self_tail_call runs first, so a function with no self tail call is never mentioned; when there is one and a precondition rejects, the note names the function and the reason. Notes ride out through lowering::lower_collecting / LoweredModule.notes → lib::compile_collecting / project::CompiledProject.notes → the CLI's report_notes, which prints them to stderr for compile and run. lower and compile keep their old signatures by delegating and dropping the notes. Regression tests live in tests/compile.rs under "self tail calls become loops", including two end-to-end: 50k/100k-deep recursion that only completes as a loop, and the closure case whose per-frame values the capture check exists to preserve.

Native lowering (--native) — implements DESIGN §5.6

A Lowerer.native flag, set by lowering::lower_native_typed (single file, called from pyfun::compile_with) or lower_in_project (from project::compile_with). Three pieces:

  • Match ladders. try_lower_native_match runs before the Option/Result ladder at both match call sites (return and value position). native_pattern_ok decides whether every arm can be compiled to plain tests; native_pattern compiles one pattern against a subject expression into conds (and-ed) and binds (assignments run once the arm is chosen). A top-level or-pattern is expanded into one piece per alternative (the body lowered per alternative); a nested one is joined with or when no alternative binds a name. The assembly (guards falling through in return position, the last unguarded arm as a plain else) mirrors try_lower_ladder_match.
  • Annotations. lower_module post-processes the module: any_for_object_fields turns object field annotations into typing.Any (mypyc cannot build an object field), and Lowerer::annotate_functions adds a union alias per non-generic user sum type after its classes, spells data class fields that name a user type ("Expr"), and rewrites each top-level PyStmt::FuncDef whose binding type is known into a PyStmt::TypedFuncDef using py_type_annotation over Lowerer.binding_types (span of the binding target → types::Ty, from TypeSpan::raw). An unspellable type leaves its position unannotated. The same pass annotates module-level values, which mypyc cannot type when another module reads them: a nullary singleton _X: X = X(), and the first assignment of a top-level value bound once.
  • Comparisons and closures. native_classes sets PyStmt::ClassDef::native, so an ordered variant emits __eq__ and all four orderings instead of @total_ordering (mypyc compiled == as identity on a class with __lt__ alone).
  • Discarded matches. Lowerer::push_discarded emits a statement-position expression. In native mode, when its value is a temp set by an if ladder, discard_temp turns each assignment of the temp into a bare evaluation, since mypyc rejects a local whose every assignment is a unit call.
  • Build. src/native_build.rs writes the native output to a directory, runs python -m mypyc --allow-redefinition-new --local-partial-types over every module but _pyfun_rt.py, removes the sources and writes __main__.py. bench/mypyc_run.py and .github/workflows/native-bench.yml measure it, and tests/native_parity.py (the native CI job) diffs every runnable lesson, offline example and tests/native/ program against pyfun run. A file Python cannot import (01-intro.pyfun) builds as _pf_01_intro.

The --target 3.11 emission pass

Emitted code targets Python 3.12+ for exactly one construct: an f-string hole may carry a nested string literal reusing the outer " quote, or a backslash escape — both PEP 701-only. pyfun compile --target 3.11 (src/python_emitter/py311.rs, applied by emit_for on a clone of the IR, single-file and project paths both) rewrites each f-string a 3.11 parser would reject into an equivalent "template".format(args…) call: literal chunks with {/} doubled, one positional {} per hole, hole expressions moved out of the string where no quoting rules apply. The unsafe check renders each hole through the emitter itself (emit_expr), so it is exact by construction: unsafe iff the rendered text contains " or \. Clean f-strings stay f-strings; default-target output is untouched. case patterns are not walked (an f-string cannot occur there, and a call would not be a valid pattern). This exists to unlock PyPy (3.11 is its ceiling as of v7.3.22); match/case is 3.10 and fine.

The language server (pyfun lsp) — implements DESIGN §9 tooling

pyfun lsp runs a small language server over stdio. It speaks LSP/JSON-RPC with Content-Length framing; to keep the crate dependency-free (no serde/lsp-types), the JSON value type, parser, and serializer are hand-rolled in src/lsp/json.rs — the same choice as the hand-rolled lexer/parser. The message-handling core (Server::handle) is pure (JSON in → JSON out) so it is unit-tested without spawning a process; a separate integration test (tests/lsp.rs) drives the real binary over piped stdio. All features reuse the existing front end:

  • Diagnostics — the existing type/effect/unit/exhaustiveness errors, streamed as textDocument/publishDiagnostics on open/change (full document sync).
  • Hover-for-type — the inferred type of the narrowest expression, binding name, parameter, or pattern variable under the cursor, with latent effects shown on arrows (e.g. string ->{io} unit) plus a dedicated Effect: line summarizing the concrete effect performed on full application (the union of the type's result-spine arrows — io/async; argument arrows are a callback's effect, not the value's, and pure values omit the line — types::effect_summary). (The same pass hands lowering the derived codecs: types::check_collecting returns a types::Codecs, one Codec per Decode.auto site keyed by span plus the named table, and lowering::lower_collecting / lower_in_project take it beside the float-literal spans — DESIGN.md §6, "Derived codecs".) It works because the checker, in a record-enabled pass (types::check_collecting, surfaced via analyze), accumulates a (span, ty) table for every expression node, binding name, function parameter, and pattern variable, then resolves each entry against the final substitution and renders it. Bindings carry a target_span, and parameters / pattern variables carry their own spans, so a function name hovers to its full inferred signature and a parameter hovers to its element type. A destructuring binding records both: the whole target (hover the tuple, see the tuple's type) and each name inside it, at its own span. A ## doc attached to a top-level let/type/extern (DESIGN §7) is appended below the type when hovering the declaration name or any reference resolving to it (resolve::symbol_at → the item's doc); a documented symbol with no recorded type hovers to the doc text alone.
  • Go-to-definition — jump from a reference to its definition, module-level or local. Backed by a dependency-free name resolver (src/lsp/resolve.rs) that walks the parsed AST (independent of the type checker, so it works on any program that parses): definitions collects module-level symbols (top-level lets with their precise name span; constructors / type / record decls / externs at their declaration), and references resolves every identifier occurrence to a Target — either a Local binder (function parameter, block-local let, pattern variable, or computation-expression let/let!, resolved to the binder's own span) or a Module symbol (resolved by name against definitions). The walk tracks lexical scopes so an inner binding correctly shadows an outer one.
  • Find-references — every occurrence of the symbol under the cursor (the inverse of go-to-definition, reusing the same resolver). The cursor may sit on a use or the definition/binder itself: symbol_at maps the offset to its occurrence span and a Target (the narrowest enclosing reference / local-binder / definition span wins), then find_references returns all references with that target plus, when context.includeDeclaration is set, the declaration(s).
  • Rename — rewrite every occurrence (declaration included) of the symbol under the cursor to a new name, returned as a WorkspaceEdit. Built on symbol_at + find_references; prepareRename validates first and returns the identifier's range. Only locals and top-level let values are renameable — their every occurrence is a precise span; constructors / types / externs are refused, because their declaration span covers the whole declaration and their type-annotation uses aren't tracked as references, so a rename would be unsound. The new name must be a valid lowercase value identifier. No capture-avoidance check is done (the editor shows the diff for review).
  • Completion — in-scope module symbols (from whatever the recovering parser produced, so even a partially-typed file contributes its symbols) plus the always-available prelude (PRELUDE + LIST_PRELUDE), builtins (Ok/Error, the builtin/reserved type names), and keywords, each tagged with a CompletionItemKind. The static set is the fallback when nothing parses.
  • Document symbols — the editor outline: every module-level definition as a flat DocumentSymbol[], reusing resolve::definitions (each with a precise range/selectionRange and an LSP SymbolKind icon). Works on whatever parsed.

Cross-file navigation (with file-based modules, DESIGN §6.1): go-to-definition on a qualified reference to an imported file module (Geometry.area, Geometry.Circle) jumps to the definition in that module's .pyfun (resolve::qualified_at records expression-position qualified refs with spans; the server resolves the sibling URI and locates the member via resolve::definitions); workspace/symbol searches every definition across the project directory's .pyfun files; and find-references + rename for top-level let values and constructors span the whole project (Server::symbol_occurrences scans the directory's files and collects the definition, its bare uses in the defining file, and every qualified use elsewhere, rewriting only the member identifier via member_subspan so the Geometry. qualifier is preserved). A constructor's uses include both construction expressions and patterns (Pattern::Ctor and the type variant declaration each carry a name span). Rename fires only for a top-level value or constructor and a strict scan refuses rather than do a partial rewrite if any project file fails to parse. Type names navigate and rename across files the same way: Server::type_target resolves the occurrence under the cursor (an annotation, bare or Shapes.Placed; a record tag; the declaration name) to its declaring module, and Server::type_occurrences scans the project (declaration and bare uses at home; qualified uses, unshadowed bare uses, and a record's qualified tags in importers). resolve::type_references exposes the raw occurrences; a qualified expression counts as a type only when it is a record's tag, since a sum type's same-named constructor is a value. A type renames to an uppercase type name; builtins are refused.

Resilient & incremental analysis. A half-typed file still yields results. The parser has an error-recovering entry point (parser::parse_recover → (Module, Vec<ParseError>)) used by the editor (the compiler keeps the strict parse, as it must reject any broken program): on a failed item it records the error, guarantees forward progress, then synchronizes to the next item boundary (a statement separator at block depth 0, tracking Indent/Dedent so a separator inside a broken block isn't mistaken for it). So one broken let no longer hides the rest of the file — the items that parse still drive hover and navigation, and only the syntax errors are reported until the file is clean, at which point the type errors take over. analyze returns an Analysis { module, diagnostics, types, parse_ok } bundle; lexing errors remain fatal (no AST) and rename requires parse_ok. The "incremental" half is a per-document analysis cache keyed on a monotonic version stamp: repeated requests on an unchanged document reuse one parse + type-check.

Project-wide cache + import invalidation. Import-aware analysis is cached at two levels, both validated by content fingerprints (a DefaultHasher of the source text; an analysis is a pure function of the entry text plus every imported source it consulted, so equal fingerprints prove an equal result). (1) Each per-document cache entry (CachedAnalysis) records the imported module files its analysis consulted — deps: (uri, Option<fingerprint>), with None recording the file's absence so that creating it later also invalidates. A cache hit requires the document version and every dep fingerprint to match, so editing an imported file — in an open buffer or on disk — re-analyzes its dependents on their next request. (2) A project-wide exports cache (Server.exports, CachedExports keyed by module-file URI) memoizes each imported module's checked interface (ModuleExports) together with its own dep list, so two open documents importing Geometry share one parse + check of geometry.pyfun. Imported sources are read from the open buffer when the file is open (else disk). The resolver (Server::resolve_exports_cached, driven through lib::analyze_with_imports) mirrors the forgiving project::resolve_imports semantics — missing/ broken/cyclic imports are omitted — with one care point: an interface computed in an import-cycle context is context-dependent, so such "tainted" results live only in a per-pass memo and never enter the project-wide cache. Diagnostics for a dependent are still published only when that document is next analyzed; proactively re-publishing dependents' diagnostics on an import edit stays deferred.

The AST changes that enable local navigation: function/binding parameters are Param { name, span } (was Vec<String>), Pattern::Var { name, span } (was Var(String)), and LetBinding and the CeItem::Let/LetBang variants carry a target: Pattern plus its target_span (each was a String name and its span, before a binding could destructure — LetBinding::name() is the plain-name shortcut every phase keeps for the common case, and bound_names / bound_vars are how a phase asks for all of them). The spans are NodeSpan (which compares equal unconditionally), so roundtrip/structural equality is unaffected; lowering erases them (param_names).

Deferred: truly incremental reparsing — an edit still re-analyzes the whole document — and deliberately so: a whole-file lex + parse + check is milliseconds at realistic Pyfun file sizes, the two caches above already eliminate all redundant whole-file work, and region-based reparse would complicate the offside-rule lexer and the recovering parser for no perceptible latency win at this scale. The editors/vscode/ client is intentionally thin — all language smarts live in the Rust server.

Names Python already owns — implements DESIGN §5

Two mechanisms, both in src/lowering/mod.rs, for the two shapes of collision between user names and the namespace the emitter shares with them.

Mangling (py_value_name, and py_field_name for record attributes) is a pure function over three fixed lists — PY_KEYWORDS, PY_EMITTED_BUILTINS (the non-type builtins the emitter calls: len, map, filter, isinstance, …) and PY_BUILTIN_TYPES (also the legal roots of a dotted extern target) — appending _ to any name they claim. Being a pure function is what lets a definition and a cross-module reference agree without coordination, so it must stay one. Two names are deliberately absent: print/abs/min/max/round lower name-for-name from identically spelled Pyfun prelude bindings, so a user binding shadows the Pyfun name too and the checker settles it; and id never reaches the output (Pyfun's id is _pf_id), so mangling let id x = x would be a tax with no collision behind it.

It is applied only where a name is written into the Python IR — parameters (py_param_names), lower_binding_as, lower_var, pattern captures, <- targets, nonlocal/global lists, CE binders, qualified member references, the in-file Module_member spelling, and the fold pass's slot and loop variables. Scope tracking (locals, scan_scope, fn_local_stack, binder_names, the fold pass's plan) stays in Pyfun name space: mangling a set those passes consult would silently break shadowing.

Aliasing (Lowerer::extern_path) covers imported Python modules, which are arbitrary (math, numpy, httpx) and so cannot be a fixed list. It consults the same whole-module binder_names set py_module_ref uses for imported Pyfun modules: on a collision the import becomes import math as _pf_math and the reference is re-rooted, leaving the user's binding alone. A dotted import binds only its first segment (import os.path binds os), so the alias replaces as many segments as the module spec covers.

The spawn handler (Task.scope) — implements DESIGN §4, §8

EffLabel::Spawn is performed by Task.start (its scheme, seed_task_prelude). The discharge is a rule in Infer::infer_apply, not in unification: for a saturated application whose head is the prelude's Task.scope (qualified_name, and no user binding named Task), the argument is inferred in a fresh effect context and spawn is removed from what it performed before the rest joins the enclosing effect (infer_apply_plain is the ordinary path). types::run collects each top-level evaluation's performed effect (value bindings, expressions, module value members) in top_effects and reports any that still contain spawn once inference is done. Lowering routes Task.scope/Task.start to _pf_task_scope/_pf_task_start (async_prelude) and note_task_scope_var defines or imports _pf_scope.

Cold async (DESIGN §8). lower_async returns Lowerer::cold_async([g]), a _pf_Async(g) call, and records _pf_Async in needed_async_helpers; async_prelude emits the class as a PyStmt::Class (the plain-class IR node) and adds _pf_Async/_pf_run whenever a helper that needs them is used. Every Async/Task helper is a plain def returning _pf_Async(run) around an inner async def run(). At the boundary, Lowerer::new records each extern's async_param_slots (AsyncSlot::Value/Callback/Thunk) and whether it returns_async; wrap_async_args runs after wrap_thunk_args at every extern call site, and lower_application wraps a saturated Async-returning extern call with cold_extern_result (_pf_Async(f, *args), or a functools.partial for pinned keywords). In a project, async_extern_binding gives such an extern an adapting lambda, since a dependent knows only its arity; lower_var_value uses the same lambda for a bare reference, and lower_application a functools.partial over it for a partial application. _pf_Async.__call__ gives Python code a fresh coroutine (asyncio.run(job())). The emitter prints await _pf_Async(f, *args) as await f(*args). The async self-tail-call rewrite (rewrite_async_wrapper) matches return _pf_Async(g), gives g the parameters and returns _pf_Async(g, *params).

Solving effect equations (Infer::unify_eff) — implements DESIGN §4

An Effect is a set of concrete labels plus a set of effect variables, so unification is over an idempotent union semilattice, not a free algebra. unify_eff resolves both sides through the effect substitution and then tries, in order:

  1. equal — nothing to do;
  2. a bare variable absent from the other side (X ~ S, X ∉ S) — bind X := S, the most-general solution;
  3. a bare variable that occurs in the other side (X ~ S ∪ X) — bind X := S, i.e. the other side with the recursive occurrence removed. Idempotence makes this exact, and every other variable in S stays open;
  4. two closed effects with different labels — fail (declared effects are exact, DESIGN §4);
  5. anything else — widen conservatively: close every variable involved to the joined label set.

Case 3 exists because tying a recursive knot produces exactly that equation. Each application in a body performs a fresh latent variable into cur_eff (infer_apply), so a recursive call of arity n contributes n of them, and the binding's own type puts the accumulated body effect on the innermost arrow with the currying arrows pure (infer_binding). unify recurses into the codomain before unifying an arrow's own effect, so the innermost equation — which mentions the outer arrows' variables — is solved first. Widening it (case 5) closed those outer variables to {io} as well, contradicting the pure outer arrow and rejecting every effectful recursive function of arity ≥ 2: the shape of every loop in a language with no while.

Typed holes — implements DESIGN §7 (typed-hole semantics)

The surface semantics (a ?/?name hole is a typed blank the checker reports and that blocks compile/run) are in DESIGN. Implementation: ? is lexed as Tok::Hole(Option<String>) (a name is lexed adjacently, like f"/r"); the parser produces ExprKind::Hole { name }, which round-trips. A hole infers as a fresh type variable that unifies freely, so it never causes a spurious error and takes whatever type the context demands (?body + 1 ⇒ int, List.map ? xs ⇒ a function type); it is recorded (Infer::holes) and, once the substitution is final, resolved and rendered (types::Hole { name, ty, span }). It is reported informatively: pyfun check prints each as a note (hole `?body` has type `int`) and the LSP publishes it at Information severity (3) with hover showing the type. A hole blocks compile/run with a "cannot compile: unfilled hole" error, and check exits non-zero.

Valid hole fits. Each note lists in-scope bindings that could fill the hole — the compiler searches the environment snapshotted at the hole and reports every binding whose type unifies with the hole's. The test is a real trial unification rolled back afterward (Infer::hole_fits snapshots the substitution maps, instantiates each candidate scheme, unifies against the resolved hole type, and restores). Fits are ranked by the hole's own name first (name_affinity: ?upper puts a member named upper ahead, then names containing it), then most-specific (fewest generalized variables), unqualified names before qualified module members, and prelude declaration order (prelude_rank) rather than the alphabet. The list is capped at 6 and says and N more when it cuts; a fully-unconstrained hole ('a) lists none. Refinement fits go further: a function binding whose result — after applying one or two arguments — unifies with the hole's type is reported applied to that many further holes (String.upper ?, String.concat ? ?). Infer::hole_refinements peels leading arrows off each candidate (up to MAX_REFINE_DEPTH = 2) and trial-unifies the tail, skipping a peeled result that is a bare variable — a structural filter that keeps out trivially-general combinators (id, const). Name match, then fewest-holes-first, capped at 4, never duplicating a direct fit.

Syntax highlighting (TextMate grammar)

Separate from the LSP's semantic smarts, editors/vscode/pyfun.tmLanguage.json gives static, parse-free highlighting (keywords, declarations, types/constructors, numbers + adjacent unit annotations, operators, strings/comments). One deliberate design choice: the escape-hatch tokens are flagged in a caution colour to signal the opt-outs from Pyfun's immutable-by-default / effect-checked defaults — mut (the mutability opt-out), <- (the act of mutation), and extern (the untyped, effectful-by-default Python FFI boundary). pure deliberately stays a neutral storage.modifier (it is an encouraged assertion, the opposite of an escape hatch), and -> is scoped apart from <- so only the reassignment arrow is flagged. The colour is applied via TextMate scopes plus a pinned foreground, not by borrowing a "warning" scope: mut → storage.modifier.mutable.pyfun, <- → keyword.operator.mutation.pyfun, extern → keyword.other.extern.pyfun (each names what the token is), and the extension pins all three to an amber #CC5E00 (no italic) via contributes.configurationDefaults.editor.tokenColorCustomizations. Pinning the colour rather than relying on a theme's rendering of invalid keeps the hue consistent across themes and light/dark auto-switching, and avoids the semantic lie that these valid keywords are errors. Users can override the colour in their own editor.tokenColorCustomizations.

Façade packages — implements DESIGN §6.2

project::locate_module(dir, name) is the one resolution rule, used by build_from_path (the CLI graph) and resolve_exports (the LSP): the sibling name.pyfun, else the same file in each directory under <root>/.pyfun/facades/ for the nearest pyfun.toml (manifest::find_root, manifest::facade_dirs). src/packages.rs implements the commands: the installer is uv pip when uv is on PATH, else python -m pip; the façade's .pyfun files are found by asking the installed distribution for its file list (importlib.metadata), and manifest::set/remove edit pyfun.toml line by line.

File-based modules — implements DESIGN §6.1

The module system's semantics (one module per file, qualified use, an acyclic import graph, cross-module ADTs/records/externs/measures, all-public) are in DESIGN.md §6.1. The driver, cross-module checking, and project emit are built as follows.

Resolution & ordering (src/project). Implementation: project::build(entry, load) walks the graph depth-first with an injected load: Fn(&str) -> Option<String> loader, so the graph/cycle/topo logic is filesystem-free and unit-testable; a back-edge to a module on the DFS path is a ProjectError::Cycle (reported as the path A -> B -> A), a None from the loader is a ProjectError::Missing (naming the importer), a lex/parse failure is a ProjectError::Compile (naming the module), and the DFS post-order is the returned topological order (dependencies first, entry last). project::build_from_path(entry) is the thin .pyfun-file wrapper (module name = stem with first letter uppercased; import Geometry → geometry.pyfun in the entry's directory). Cross-module checking and emit consume this Project.

Cross-module checking (types::check_module + project::check). Implementation: the single-file run was generalized to take the imports map and return the module's ModuleExports interface: its exported value schemes (which include extern names), its exported sum types, its exported records, its opaque handle types (collect_exported_opaques / ExportedOpaque: name + arity, issue #72), and its measures + measure-aliases; check_module(module, imports) seeds imported values under qualified keys and imported sum types into the decls under qualified constructor keys. The type merge happens in two halves, because a local type declaration may name an imported type (type Holder = { item: Shapes.Placed }, DESIGN §6.1): merge_imported_type_names runs inside build_decls, between pass 1 (local type names, which therefore win a clash) and pass 2 (local type bodies, which can now resolve an imported name), registering each imported type under its bare identity name and a qualified key at the same arity, and returning the accepted (module, name) pairs; merge_imported_types then fills in the bodies for those pairs after build_decls returns, which is when the local field registry already exists so local records lead the field multimap. A written qualifier is validated against the qualified key and folded back to the identity name in resolve, so Shapes.Placed and a bare Placed are one Ty::Con. Body merge: (merge_imported_types: decls.ctors["Geometry.Circle"], type_ctors["Shape"] = ["Geometry.Circle", …]), plus imported records under their bare identity name with a qualified surface alias (decls.record_aliases["Geometry.Point"] = "Point") and their fields appended to the field multimap, plus imported measures merged unqualified into decls.measures/measure_aliases (with the alias-conflict check), so construction (the Record/Field arms), qualified ctor/record patterns (bind_pattern), exhaustiveness (ctor_signature), and <…> unit resolution all resolve with no special cases. Transplanting a scheme across modules is sound because a top-level binding (and a constructor) generalizes against an env of closed schemes, so its own scheme is closed — but closed is not enough: every module allocates variable ids from its own counter starting at RESERVED_VARS, so an imported scheme arrives holding ids the consumer will hand out again. instantiate alone survives that (it refreshes the quantified vars before substituting), yet env_free_vars applies the local substitution to every env scheme, so a single id collision rewrote the imported scheme and leaked local variables into the "free in the env" set — blocking generalization of the consumer's own bindings, which then exported un-quantified and cascaded onward (#26). Every imported scheme is therefore alpha-renamed into the consumer's id space as it is seeded (Infer::refresh_scheme, values and constructors alike, in sorted order so the ids do not depend on HashMap iteration). Transitive interface closure (issues #72/#75): before run hands back its ModuleExports, close_over_references walks every exported scheme, constructor scheme, and record field for Ty::Con names (collect_con_names) and pulls any name satisfied by an import's record/opaque tables into this module's own exports, tagged origin: Some(declaring module) (own declarations carry origin: None); newly pulled records are walked in turn, and a seen set terminates the walk on mutually referencing records. Each import's interface is itself closed, so one pull per hop reaches any depth. On the consumer side merge_imported_type_names registers carried entries last and under the bare identity name only (no qualified key, no record_aliases entry, so constructing or pattern-matching a carried record still requires the direct import); a taken bare name skips the carried entry silently (record_home recognizes the same record arriving along two paths). By-name field lookup applies the same precedence in tiers (Infer::field_owner_tier, consulted by record_of_field and the pending-field fallback): local and directly imported records decide first, and a carried record owns a field only when no direct one declares it, so a carried record can never make an existing access ambiguous; a tie between carried records with no direct owner is still the ambiguity error, naming the tier that decided. A genuinely shadowed record's fields land in Decls::field_hints, which record_of_field / record_of_field_on render as "the record Config in module Inner declares this field" instead of a bare unknown. Constructing or matching a carried record bare is refused with the fix spelled out (resolve_record_tag via Decls::carried_record_home: "the record Config is declared in module Inner; import Inner to construct its values here", the pattern site says "to match"), while a name that is no record at all keeps "not a record type". The lowering side: ModuleExports::carried_records feeds project::compile's per-module ImportContext::record_class_modules plus field data keyed by the declaring module's tag, so a record update in a consumer that never imports the declaring module still reconstructs via the right class (inner.Config(...), Lowerer::record_class_name accepts those modules and hoists their imports). project::check threads the ModuleExports map through the topological order, seeding each module from only the modules it actually imports (so an unimported module's members/constructors stay invisible), and returns errors grouped by module. Lowering routes a qualified constructor — in expression or pattern position — to the imported module's class (geometry.Circle, dotted class pattern case geometry.Circle(r):, with import geometry hoisted); a nullary imported constructor used as a value is called (palette.Red()), and imported constructor arities are threaded so a partial application still curries.

Output & the shared runtime (lowering::lower_in_project + project::compile). Implementation: the Lowerer gained an imported_modules set (drives the geometry.area routing) and a use_runtime flag (emit from _pyfun_rt import … vs inline); lower_in_project(module, ctx) sets them and threads ctx.member_arities (the imported functions' arities) into the arity table so a cross-module partial application still lowers to functools.partial. project::compile builds each module's ImportContext from its imports, emits <name>.py per module, and appends _pyfun_rt.py (via runtime_module()) iff any module used the nominal classes.