The MVP showcase set (curried functions + |>, ADTs + exhaustive matching, computation expressions,
units of measure) and Phase 2 file-based modules are complete — the language is feature-complete for
its intended scope, and nothing below blocks normal use.
This is the single forward-looking list of what's not built, so nothing is drip-fed. Design mechanics
and rationale live in DESIGN.md; what shipped and when is in git history. Effort is
rough: S ≈ a sitting, M ≈ a focused day, L ≈ multi-day.
Keep this a forward-looking backlog — do not let it grow back into a changelog of shipped work.
A dogfooded interactive terminal game (private repo) hit six gaps that a compiler test suite does not reach: the first Pyfun program to be interactive, stateful across turns, and written to a module layout chosen before the compiler had an opinion. Each root cause below is verified in the source, not inferred from the symptom. All six are accepted work, and the two that carried an open decision (the size of the standard-library sweep, and which of three shapes answers the recursion gap) were settled on 2026-07-31; each entry records what was chosen and what was turned down with it.
-
Imported types cannot appear in a
typedeclaration (S) —types::runcallsbuild_decls(which resolves every record field and ADT variant againsttype_arity) beforemerge_imported_types, so an imported type is not registered yet when local type bodies resolve.type Holder = { item: Placed }fails with "unknown type" even though the bare name is exactly how imported records register; the qualified spellingShapes.Placedadditionally does not parse (parse_type_atomaccepts a bareIdent). Fix: register imported names and arities in a pre-pass, then allow dotted names in type position. Highest language impact of the six: it forces every record mentioning another module's type into a single file, which collapsed a board module and a rules module into one 340-line engine in the dogfooded program. This is the only finding that changed a program's architecture rather than its phrasing. Follow-up it creates (S, LSP): a type name can now be written in a file other than the one declaring it, so rename and find-references for type names, which are in-file only, can leave stale references behind. Cross-file type nav was never built because qualified type syntax did not exist; it does now, andresolve::type_atneeds the cross-file dimension the value and constructor paths already have (symbol_occurrences). -
Field access picks its record before the base type is known (S) —
Infer::infer_fieldcallsrecord_of_field(the name-only multimap) and infers the base one line later, so two records sharing a field name collide at every use site even where the base's type is already solved. The dogfooded program paid for it in Hungarian prefixes (cRow,cCol,cLetter, plus nineg-prefixed fields on the game state), which is a wart in an ML-family language, and the suggested workaround (pattern-match to disambiguate) means destructuring at every use site. Fix: infer the base first and use its solvedTy::Conwhen that record declares the field, keeping the multimap as the fallback for a still-unsolved base. That is F#'s own rule, and it needs no new syntax. -
Single-file
pyfun runcannot feed the program stdin (S) —main::runpipes the emitted source topython -, so the program's stdin is its own source text and the first read raisesEOFError. Interactive programs are therefore un-runnable by the tool whose job is running programs. The project path (main::run_project) already materializes to a temp directory and inherits stdio, so a multi-module interactive program works today: the fix is making the single-file path do what the project path does. Tooling, not language design. -
No tuple patterns in function or lambda parameters (M) —
parse_paramisparse_ident, sofun (t, sq) -> …does not parse and anything folding over pairs needs a named helper wrapping amatch(five such one-line functions in the dogfooded program). WideningParamto a pattern reaches the LSP, whereParam{name,span}feeds hover, go-to-definition and rename. Follow-ups (S each, both wanted 2026-07-31): record patterns in parameters (fun (Cell { letter }) -> …), which are irrefutable and so belong in the admitted set, but need attribute-reading lowering rather than tuple unpacking; and narrowing the self-tail-call capture guard below to true free variables (see #6). -
Standard library completion (L, sliced per module) — the dogfooded program wrote 11 scaffolding functions before it could start on the game, and defined
takeN/dropNindex-based over zip-with-indices because the natural recursive definitions are stack-unsafe (item 6). Confirmed missing across the prelude:fst/snd(no tuple accessors at all),List.take/drop/head/tail/last/map2/indexed/exists/forall/sortBy/distinct/max/min/partition/updateAtand more;Seqcarries 7 members againstList's 16;MapandSethave nomap/filter/fold;OptionandResulthave nomap2/orElse/iter. The standing "prelude functions on demand" policy (under Deferred) is what produced this backlog one program at a time, so the decision is to complete the surface in one sweep instead. Decided 2026-07-31: the full sweep, about 90 members, F# core as the reference, then a member-by-memberFSharp.Coreaudit to catch what this list missed; excluded is anything needing type classes or an un-Pythonic lowering. Five PRs split by module. Three conventions settled with it, and the first two were already the house style rather than new rules: total functions (String.sliceclamps, sotake/dropclamp), bare names returningOptionfor accessors that can fail (head : List a -> Option a, following the existingList.get/List.find, diverging from F# whereheadraises), andtake/droprather than F#'stake/skip(theconcatdivergence already set that precedent).String.tryIndexOfis the one member out of step with the accessor convention; it stays as-is (the audit turned up no others). The positional-update family (updateAt/insertAt/removeAt) is the most directly game-shaped gap: a board update has no vocabulary at all today. Sweep COMPLETE 2026-07-31 —List(+fst/snd),Seq,Set/Map,String,Option/Result, then theFSharp.Coreaudit, which added 21 more members. Nearly all of those came from internal asymmetry rather than F# parity:takeWhile/dropWhileonSeqbut notList, nineListmembers with noSeqcounterpart,iteron every module exceptSetandMap,Option.existswith noforall, andsignas the one missing F# global. The audit's more valuable half was a bug: every multi-argument callback's scheme put the effect variable on every arrow, so an impure two-argument callback could never unify andList.foldcould not print (DESIGN.md, "Effects through a multi-argument callback"). -
Unbounded recursion has no stack-safe form (M, decided) — an interactive turn loop is not a collection traversal, so the Non-goals answer below ("iteration is the
List/Seqcombinators plus recursion") does not cover it: every turn and every rejected input is a frame that never returns until the game ends, and the dogfooded program callssetRecursionLimit 20000at startup to survive a long game. This is the first genuine evidence against that non-goal, and it is narrower than the non-goal's scope: the shape at issue is a self tail call driving a loop, not general TCO and notwhile. Decided 2026-07-31: lower a direct, saturated self tail call towhile Truewith parameter rebinding. It is contained insrc/lowering, needs no surface change, emits thewhileloop a Python programmer would have written by hand (so it costs nothing in readability), and it also makes the natural recursivetake/dropstack-safe, which pays part of item 5. The precision requirement is the work: the tail-position walk covers match arms,ifbranches, block tails andletbodies, and must not fire where the function is partially applied or captured. Rejected alongside it: a state-in/state-outLoopcombinator (stack-safe for free and S effort, but a second loop idiom that goes redundant the moment this lands, and it does nothing for recursive list functions), and reopeningwhile(it needslet mutto be useful and fights the expression orientation, and this covers the actual complaint without it). Mutual-recursion trampolining stays out: it costs the readable output that lowering exists to protect. Known over-rejection (S to fix): the capture precondition compares every name mentioned inside a nested function against the names this frame binds, so it also rejects two shapes that are in fact safe — a lambda whose own parameter merely shares a name with one of ours (fun n -> n + 1inside a function whose parameter isn: no capture at all), and a closure that genuinely captures but is consumed within the iteration and never escapes (List.fold (fun acc x -> acc + x * n) 0 xs). Subtracting the names bound inside the nested function fixes the first outright and is strictly sound; the second needs escape analysis and is not worth it. Rejections are silent, which is the real cost: a program keeps its recursion without saying why. -
A dottedCLOSED 2026-07-31 (was S–M, reported the same day) — fixed with option (b): the undecidable shape is now a compile error naming theexterntarget imports the wrong prefix when a lowercase segment is not a moduleextern importto add (types::undecidable_extern_segment, checked inbuild_declssopyfun checkreports it, not onlycompile). Two shipped externs inexamples/interop/http_fetch.pyfunneeded the declaration the diagnostic asks for, which is the expected cost of the trade. Original report below. —extern flush : unit -> unit = sys.stdout.flushemitsimport sys.stdout, which raisesImportError. The call is always right; only the import line is wrong.lowering::extern_importtakes the maximal leading run of lowercase-initial segments before the final name, following PEP 8 (packages lowercase, classes capitalised), so it succeeds exactly when that run happens to be a real submodule:target emitted outcome pathlib.Path.write_textimport pathlibworks, Pathis capitalised so the run stopsos.path.joinimport os.pathworks, os.pathreally is a submodulesys.stdout.flushimport sys.stdoutfails, stdoutis an objectWorkaround today, and it does work: declare the module (
extern import sys), whichextern_import_specconsults before the heuristic and which emitsimport sys. The function's own doc comment already names this case, so it is a known limit rather than a surprise — but emitting a line that cannot import is still the wrong failure mode, and the fix should stop at a compile error at worst.The obvious fix does not work as stated. "Import the longest importable prefix" is not statically decidable: whether
urllib.requestorsys.stdoutis a module is a property of the target environment, not of the text. Nor does "always import only the top-level package", which trades one broken case for another — verified on CPython 3.14:import urllibleavesurllib.requestunbound (AttributeError), whileimport osdoes bindos.pathandimport sysdoes bindsys.stdout. So the candidates are: (a) emit the top-level import plus the deeper one guarded bytry/except ImportError, which is correct everywhere and mildly ugly, once per module; (b) keep the heuristic but reject the ambiguous shape at compile time with a fix-it naming theextern importto add, which fits "the compiler is the gatekeeper, no runtime surprises" and costs nothing at runtime; or (c) both — (b) by default, with (a) where the shape is unambiguous. Decided 2026-07-31: (b). A compile error naming the one line to add beats an import that may or may not work in the target environment, and it keeps the emitted Python free of defensivetry/exceptaround something the author can state exactly. The check fires only on the shape the heuristic cannot see through — a target with a lowercase segment after the first, before the final name — and the message carries the fix (addextern import sys``). Theextern importescape hatch already exists and already wins over the heuristic, so this turns a silent runtime failure into a diagnostic pointing at the existing answer.
A second pass over the same private game found two gaps, both about the shape of code rather than a
missing capability: each had a working spelling already, and each cost the readable Python that lowering
exists to protect. Both shipped on 2026-08-02, in the order below reversed (destructuring first, so
the option example landed in its final form). Together they turn a chained-Option function into the
statements a Python programmer would have written:
let parseCoord tok =
option {
let! c0 = List.get 0 (String.toList tok)
let! ci = List.findIndex ((==) (String.upper c0)) Render.colLabels
let! rn = String.toInt (tok |> String.toList |> List.drop 1 |> String.join "")
return! if 1 <= rn and rn <= Board.size then Some (rn - 1, ci) else None
}
-
CLOSED 2026-08-02 (was M, reported the same day) —Optionis the one short-circuit type with no computation expressionoption { }is the fourth built-in. The stdlib's accessor convention (item 5: bare names returningOption) makesOptionthe type it pushes you to chain, and a chain of them meant one nestedmatchper step with an identicalcase None: Nonearm at every level. A user-defined builder could not close it, which was the point:src/desugar.rsis an expression transform, soOpt { … }compiled to nestedbindlambdas on one line, where the bespoke lowering emits flatmatchstatements with early returns.What keeps a fourth built-in from becoming a fifth, now recorded in
DESIGN.md§8.1 and onCeBuilder: one per built-in short-circuit type (Option,Result) and one per Python control-flow form (async,seq). There is no fifth candidate, so the set closes at four.resultandoptionturned out to be one lowering, not two:lowering::ShortCircuitnames the success and failure constructors and whether the failure carries a payload, so neither can drift from the other.option's is the simpler half —Nonecarries nothing, so its failure arm binds nothing and returns a fresh one (case None_(): return None_()). Also shipped: the near-miss diagnostic (uppercaseOption { let! … }is rejected with a message naming the lowercase form, instead of resolving against the prelude module and failing onOption.bind's pipe-first argument order), thea/anfix in the CE diagnostics this exposed (asynchad it too), and the builder keyword across every editor target. Lesson 13 gains theoptionsection and the two-or-more-binds threshold; lesson 20 keeps theOptionbuilder as its worked example, now introduced as a rebuild of a battery you have, with the lowering difference as the reason the built-in exists. -
ACLOSED 2026-08-02 (was M, reported the same day) —letbinding cannot destructurelet (r, c) = parseCoord tok,let Point { x, y } = originandlet (a, (b, c)) = nestednow parse at top level, in blocks, in an in-filemodule, and on a computation expression'slet/let!.LetBindingandCeItem::Let/LetBangcarry aPatternwhere they carried aString, withbound_names/bound_varson the AST as the one place every phase asks what a binding introduces. The irrefutability rule turned out to already exist —parser::refutable_in_param, renamedrefutable_shapeand now shared — so alettarget admits exactly what a parameter does (a name,_, tuples, records, nested) and rejects the rest with a message naming what was written and pointing atmatch. A function binding and alet mutkeep their single name, each with its own message. Lowering reusesunpack_into, solet (r, c) = eemitsr, c = ewith no temp, a nested target reads through a reserved base (_pf_t0_1, never a name derived from the user's), a record target reads one attribute per field named, and a destructuringlet!inresultrides inside theOkpattern it already matches (case Ok((r, c)):) for no extra statement. Each bound name generalizes on its own, matching the single-name case. CanonicalDESIGN.md§7; lesson 8 covers it; the tree-sitter grammar accepts the new targets (and destructuring parameters, which the language had but the grammar did not).
A third pass over the Scrabble program was about runtime cost rather than missing capability: the program was complete and correct, and the question was how far the emitted Python sat from what a person would write in the hot paths. Six issues came out of it (#84 to #89), each measured by hand-editing the emitted module and timing the result, and all shipped the same day in PRs #90, #91, #93 and #94; a seventh (#92), found while fixing #84, and its sibling #96 shipped in the PR that adds this entry.
-
Two miscompiles the checker could not seeCLOSED 2026-08-30 (#84 in #91, #92 and #96 in the PR carrying this entry). A block-locallet pair a bapplied to one argument was emitted as a full call with one argument, because lowering took arity from a top-level table only; the checker had typed it as a partial application. Now a block-scoped arity table sits beside the fold pass's local-folder registry with the same save/restore/shadow discipline, soList.map (pair 10)with a block-localpairprints[11, 12], and two lambda workarounds in the program came out. The second was the mirror image: a match-arm capture named like a block-localdef(case Some pair: pair, thenpair r 1) is arm-scoped in Pyfun but a function-wide local in Python, so the later call found an integer, and aletin a nested block (#96, anifbranch rebindingx) had the same shape. A capture or nestedletthat reuses a name the function uses elsewhere is now emitted as_name(DESIGN.md§5, "Arm-scoped captures and nested-blocklets"). Downstream review found the first rule too eager (#97, closed the same day): it renamed a name reused across sequential matches, where nothing is live across the rename, so the rule was tightened to liveness andcase Error why:in three matches of one function keeps its plain name. The same review turned up the last member of the family (#99, closed the same day): a root-levellet xin a function that had already read an enclosingxmadexlocal to the wholedefand the earlier read raisedUnboundLocalError; such aletnow renames exactly when a read in the function means a binding outside it. -
A destructuring folder went quadraticCLOSED 2026-08-30 (#85 in #90). The in-place fold pass rejectedfun m (p, l) -> Map.add p l mbecause the element parameter had no single name, and thefunctools.reducefallback copies the accumulator on every step. The element parameter may now be any irrefutable pattern (Python's ownfor (p, l) in steps:header). On the program's automaton build: 2,000 words 478ms to 15ms, and the full 267,751-word list builds in 1.85s, the same as thefst/sndspelling it used to need. -
Option was a tax on every accessorCLOSED 2026-08-30 (#86 to #89 in #93 and #94). Four costs in the stdlib'sOptionconvention, each cheap alone and paid millions of times per position:List.findIndex ((==) x)ran a Python-level scan with a fresh lambda;match Map.tryFind k m:built aSomeonly to take it apart; every miss constructed a newNone_(); and everyOptionmatch went through CPython's class-pattern dispatch plus an unreachablecase _. NowList.findIndex/find/exists ((==) x)route tolist.index/in; a match that consumesMap.tryFind,List.headorList.geton the spot lowers toif k in m: v = m[k]with noOptionbuilt; nullary constructors are module-level singletons (_None_,_Across); andOption/Resultmatches areif isinstance(o, Some): x = o._0ladders. Downstream:takeLetteremits_pf_index_of(l, rack)at 3 sites andallowsLetteremitsif rc in checks: s = checks[rc]; return l in s, output identical (46,271 candidates); generation on the Mega position went ERSTAING 1.26s to 1.01s, ERSTAIN? 9.84s to 7.63s, QU??AING 13.3s to 10.3s, 1.29x overall against the 1.27x predicted from the hand-patched output; and scoring throughRules.validategained about 8% (4.5s to 4.1s) without being the target, because it usesOptionandMap.tryFindtoo. 45 engine tests and 4 golden replays byte-identical, no source changes needed.Three decisions made this round, all now in
DESIGN.md§5: theisinstanceladder is the default emitter's output forOption/Result(the rider in the performance section below that kept it native-mode-only is retired; user ADTs still emitmatch/case);slots=Trueon every emitted class, sovars(instance)no longer works from Python, the one visible interop change and one to name in the next release notes; and nullary singletons are spelled_Ctor, with a fallback toCtor()when a program binds that name.
A fourth pass over the Scrabble program scoped its two remaining features, network play (its roadmap item 4) and a browser interface (item 3), against Pyfun 0.7.0 and filed nine issues (#103 to #111). Nothing here is a miscompile of code the program already runs; every item is a place the language ran out when the program reached for concurrency, a wire format, or a page. The decisions below were made on 2026-08-30 and each entry records what was chosen and what was turned down with it. Network play comes first; the browser target is last because it depends on the async decisions.
-
AnCLOSED 2026-08-30 (#104, in the PR carrying this entry). The lowering had no hook for the->{async}extern is checked but never awaitedasynceffect label: an extern typedfloat ->{async} unitwas emitted as a plain call, the coroutine dropped with aRuntimeWarning, and lesson 18 plusexamples/interop/http_fetch.pyfuntaught exactly that spelling. Decided and done: theAsynctype is the only thing the lowering awaits, and an->{async}extern whose result is notAsync _is rejected at the declaration with the working spelling named (-> Async a, bound withlet!); lesson 18,hello.pyfunandhttp_fetchmoved to it (DESIGN.md§4). Effect-directed lowering (emitawaitwherever the label says so) was turned down: anasync-effect function handed toList.mapor any effect-polymorphic higher-order function would propagate the label onto a call that cannot await, so the result is silently a list of coroutines, and the rule needed to forbid that is a new checker concept for the gain of onelet!. F# chose a type for the same reason. -
CE bodies reject expression andCLOSED 2026-08-30 (#105, in the PR carrying this section). A unit-typed expression on its own line (matchitems, andasyncrejects a trailingdo!print reply) was a parse error inside any CE, so was amatchorif, and anasyncblock had to end withreturn. The parser now reads a bare expression item aslet _ = e(what the checker requires of it and what the canonical pretty-print spells), which admitsmatchandifas items; a trailingdo! eends aresult/option/asyncblock as its value (M unit, anawaitas the last statement of theasync def; aresultforwards the step as it is, no ladder), as the user-builder table already did.DESIGN.md§8.1. Still open: CE items inside match arms (F# allowslet!in an arm), a separate desugaring change that waits for a program to need it. -
CLOSED 2026-08-30 (#103, in the PR carrying this entry; its two small siblings shipped in #112).forinside computation expressionsseq { }had nofor, so "one per element" wasyield! (List.map f xs), which allocates a list to feed a generator; a user builder had nofor_row either, andDESIGN.md§8.1 listsForas part of the protocol being followed. Decided and done: the Python spelling,for x in xs:with an offside body, consistent withmatch e:/case;foris a keyword only in item position inside CE braces. The source is aListor aSeq, the target any irrefutable pattern, the body a nested level of the same block (anything butreturn). Nativeseq/async/result/optionlower to Python'sforstatement (a failedlet!inside aresultloop still short-circuits the block); a user builder desugars toB.for_ e (fun x -> body), combined with what follows like ayield. One Python-shaped rule keeps a one-line block unambiguous: a body on the same line as the:is exactly one item, a longer body goes on indented lines, and the pretty-printer lays such a block out on lines. Turned down: custom operations (F#'s[<CustomOperation>],class' "board"as a bare item); they need a per-builder name table, and a list-shaped markup DSL (div [attrs] [kids]) is where F#'s own community settled. -
ACLOSED 2026-08-30 (#107, in the PR carrying this entry).unit -> athunk handed to Python is miscalledfun _ -> 41lowered tolambda _: 41andasyncio.to_threadcalled it with no arguments. Done: an argument to an extern whose declared parameter type isunit -> ais wrapped aslambda: f(None)at the call site (a literalfun _ -> bodycollapses tolambda: body), at every extern call path (plain, kwargs, receiver). The issue's second rule, spreading a tuple parameter ((a, b) -> cwrapped aslambda a, b: f((a, b))), was turned down: Python callbacks that receive one tuple are everywhere (sorted(pairs, key=f),map(f, d.items())) and the boundary cannot tell the two conventions apart from the Pyfun type. The rule is "curry your callbacks", and lesson 12 now has a section on handing Python a function, with the callback's effects on the parameter arrow (DESIGN.md§6). Still to check: a partially applied function (serve (handler cfg)) crossing the same boundary. -
AnCLOSED 2026-08-30 (#106, #108, #109; the module andAsyncmodule,Async.catch, and structured concurrencycatchin #113, the scope in the PR carrying this entry).Asyncwas a type withasync { }and nothing else. NowAsync.sleep/timeout/toThread/parallel/race/catchare prelude members (DESIGN.md§6, "Async combinators"), and structured concurrency is the library form:Task.scope : (Scope ->{e} Async a) ->{e} Async aoverasyncio.TaskGroup(a newPyStmt::AsyncWithIR node) andTask.start : Scope -> Async unit ->{io} unit, so a start outside a scope is a missing argument at compile time. Decided: notask { }spelling. As a user builder it cannot open the scope around the whole block (the protocol has norunmember), andTask.scope (fun scope -> async { … })reads well enough that arunmember waits for demand; the §8.1 rule stays at four built-ins untouched. A failed child'sExceptionGroupreachesAsync.catchas oneException(kindExceptionGroup); typing the scope asAsync (Result a (List Exception))stays open until a program needs the members. Deliberately absent:Async.startandAsync.cancel. Thespawneffect label that only a scope discharges, Pyfun's first effect handler, is in Deferred. A trailing unit expression now ends aresult/option/asyncblock (F#'s implicitZero), which the fire-and-forget producer in the cookbook example needed. -
A mailboxCLOSED 2026-08-30 (#108, in the PR carrying this entry) as a cookbook example,Agentexamples/interop/structured_concurrency.pyfun: anasyncio.Queuebehind two externs (post=.put_nowait,take=.get()returningAsync a) and a recursiveasyncloop that is amatchover aMsgADT, started withTask.startinside aTask.scopeso it dies with the session. It is promoted to the prelude once its signature stops moving; the game is its first consumer. -
CLOSED 2026-08-30 (#110, in the PR carrying this entry). A program that speaks to itself over a wire wrote its values out with f-strings and read them back withEncodeto mirrorDecode, with derived codecsDecode.fieldby hand, and the two drifted. NowEncode.auto : a -> stringandDecode.auto : Decoder aare derived from the type (DESIGN.md§6, "Derived codecs"):Encode.autois a run-time helper reading the value's shape from the emitted classes;Decode.autois type-directed lowering after inference, the checker resolving each site'sDecoder ainto aCodec(records, sum types, tuples,List/Set/Map/Option/Result, newtypes read through, recursion via a per-module table) that lowering turns into a descriptor the emitted_pf_dec_autointerprets. A site whose type is still open is an error naming the ways to pin it, and its variable stays weak atlet-generalization so a later use pins it. The round-trip property holds on a record holding aMap (int, int) Placed, a recursiveTree, aSet, a tuple and nested cases, in one file and across a project import. Decided shape: internally tagged objects, the convention serde (tag = "type"), Pydantic discriminated unions and System.Text.Json share:{"type": "Move", "fields": ["K11 a QUIZ"]},{"type": "Resign"},Optionasnullor the value, tuples as arrays, aMapwith string keys as an object and any other key type as a list of[k, v]pairs. F#'s{"Case", "Fields"}is the outlier and was not copied. The game's.replayfiles are free to adopt the same encoding. Not done: a composableEncode(Encode.object,Encode.list, aJsonvalue type) for shapes that are not a Pyfun type's own; a hand-written decoder remains the tool at a boundary you do not control. -
A browser targetCLOSED 2026-08-30 (#111, in the PR carrying this entry). Three pieces, all landed:pyfun bundle <entry> -o <dir> [--asset f]... [--page fragment.html]emits a static page (the compiled Python, one file or a project's tree, the assets, a loader that boots Pyodide from the CDN the playground pins and runs the entry with stdout/stderr on the page), so a program is a shareable link with no server; a typedDomfaçade in the interop cookbook (examples/interop/browser/dom.pyfun, the first real consumer of the "publish a façade, import many" axis:extern import js, opaqueElement/Event/Callback,create_proxybehindDom.proxy) with a counter page as its consumer; and thePromisetoAsyncbridge, which needed nothing built, since a JS promise is awaitable under Pyodide and an extern over a JS async API is typed-> Async a(DESIGN.md§6, "Browser target"). What a browser cannot give the cookbook harness is ajsmodule, so the example is compile-checked, not run, in the tests. Signalling for a peer-to-peer transport is the game's problem, not Pyfun's.
-
An async self tail call walked the stackCLOSED 2026-08-30 (found by the game agent in the cookbook's agent loop:return! agentLoop inbox (count + 1)awaited a fresh coroutine per message on the same stack, so ~1,000 messages was aRecursionError— a clock postingTickevery second died in under twenty minutes, and an agent is precisely the thing written to live indefinitely). The §5.4 rewrite now has an async form: when a body is thedef f(a): async def g(): …; return g()wrapper, the awaited tail calls ingrebind the outer parameters through anonlocaland loop awhile True:inside the one coroutine, under the sync pass's preconditions (same rejection notes). To make the tail call visible,return!of amatch/ifin anasync { }block now returns per arm instead of assigning a temp and awaiting it once. The cookbook agent is reworked to the loop-safe shape (a syncheardhelper computes the next state, so the recursive call stays direct); 100,000 messages complete in the e2e test. Mutual recursion stays out of scope, as in the sync pass. -
CLOSED 2026-08-30 (same review): racing nothing would wait forever, so the empty list now raisesAsync.race []raised asyncio's errorValueError("Async.race needs at least one value")at the await — the module's one partial member, documented on hover and inDESIGN.md§6 — instead ofasyncio.wait's message about an empty set.Async.catchreports it like any await-time failure.
-
Assigning a moduleCLOSED 2026-08-31 (#122, found when a spectator acceptor polling on a modulemutfrom anasync { }block silently bound a localmut acceptDoneflag never saw it flip and a finished host idled forever): a built-in CE body lowers to its own nesteddef/async def, and the closure-capture declarations (DESIGN.md§3) were only computed for function bodies — soflag <- trueinside a coroutine bound a fresh local and the module binding never changed (result { }'s read-modify-write form hit the louderUnboundLocalErrorinstead). All four built-in CE frames now get the sameglobal/nonlocalprelude a function body gets, and the CE's own binders join the frame stack, so a lambda nested in the block classifies its captures against them too. -
A leadingCLOSED 2026-08-31 (#123, found wiring the keyboard pump:unitparameter split the extern callunit -> int -> int = absemittedabs()(-5)— the nullary zero-argument rule fired and the remaining arguments applied to its result — while a mid-list unit travelled asNone(max(3, None, 5)): three shapes, three meanings). Now one rule: aunitparameter contributes no argument, wherever it sits —abs(-5),max(3, 5), and the nullary call unchanged. Partial application closes over a lambda (a future unit is accepted and ignored, whichfunctools.partialcannot do), receiver methods and bare references drop units the same way, and the parser's...-slot arithmetic counts only non-unit arguments — so the extern the finding wanted,unit -> (unit ->{io} unit) -> Thread = threading.Thread(daemon = true, target = ...), now parses and emits the single call. The mid-listNonechange is breaking in principle; nothing in the repo or lessons relied on it.
- Fold-pass residual shapes (S per slice, demand-driven) — Tier B shipped 2026-07-13 (local named
folders incl.
dedupLegs, chained updates, fresh-reset slots with the store-then-reset idiom,Map.remove/Set.remove, defensive-copy/aliasVarinits —DESIGN.md§5.1), so the known rejecting shapes are covered. What still falls back, honestly: ordered inserts (network-rail'sinsertByDep— list slicing/splicing, not an append), folds inside in-filemodules (P8 mangling), and anything the occurrence discipline can't prove. Pick one up only when a real hot fold rejects on it. (A persistent-map/HAMTMapwould kill the O(n²) generally but still loses to a baredicton this pattern.) The ceiling framing stands and caps all emitted-code perf work: Pyfun targets un-JIT'd CPython, so the goal is "as fast as idiomatic hand-written Python," and a genuinely hot inner loop still belongs behind anextern— the further lowering tiers (general inlining, fusion, micro-opts) remain non-goals (below). What runs the output is a separate axis — see Performance beyond CPython. - A
spawneffect label discharged only by a concurrency scope (fourth dogfooding session, item 17):Task.startwould performspawnand only aTask.scopehandles it, so a start outside a scope is "performsspawn" with nothing to discharge it. Pyfun's first effect handler, and the reason structured concurrency belongs in the language rather than a library; the value form (Scopeas a capability argument) ships first and covers the use. - Larger prelude / package manager — the prelude half is superseded by Dogfooding findings #5
(complete the surface in one sweep; "on demand" is what accumulated that backlog). The package/façade story (publish typed extern façades once,
importmany) is a whole axis that waits for actual users. A future Python-side runtime package could default touv. (Macros are a non-goal, below — not part of this bucket.) (Decode specialization shipped 2026-07-13 —DESIGN.md§5.3: statically-known decoders deforest to direct dict/list access, byte-identicalResults, 2.8x measured on a decode-dominated workload; dynamic shapes (andThen, decoder-as-value) keep the interpreter.) Module-alias shadowingCLOSED 2026-07-27 —import Ids+ any same-named binder (top-levellet, parameter, blockletanywhere in the function, lambda parameter, match-pattern capture at any level, native-CE binder) now emitsimport ids as _pf_idsat the affected sites (lowering::py_module_refconsultsuser_defs+module_binders+ thefn_local_stackscope frames; plain and aliased imports coexist, so un-collided sites keep readable output). No known residual; the per-shape regression test istests/project.rs::local_binders_colliding_with_a_module_alias_also_get_the_mangled_import.
The lowering work above closed the emitted-code axis: output within ~1.3× of hand-written Python,
further tiers measured out (non-goals below). This section is the other axis — changing what runs the
output. Ordered by effort; each entry carries its own gate. Draft write-up:
local/article-draft-how-fast-could-it-get.md. Measurement infrastructure: bench/ (added
2026-07-18) — three compute-bound benchmarks (expr_eval / collatz / map_build), each paired with a
hand-written Python baseline as the ceiling reference, bench/run.py wall-clock runner
(median-of-N, output-equivalence-checked, --python selects the interpreter — the same harness
measures every option below). CPython 3.14.6 status quo: expr_eval 2.37×, collatz 1.18×,
map_build 1.64× vs hand-written.
- Faster host runtimes (S for the PyPy switch) — GraalPy VERIFIED 2026-07-18 (3.12.8 /
GraalVM CE 25.1.3, container; artifacts
local/graalpy-verification/): emitted output runs unchanged — PEP 701 nested-quote f-strings, class-patternmatch, dataclass ADTs/records, full bench suite byte-identical. Performance is workload-dependent, not a blanket win: collatz 1.7× faster than CPython 3.14, map_build ~1.6× slower, expr_eval ~4× slower. Warmup probes show why: the hand-written tuple-based baseline JIT-warms to 2× faster than CPython, while every ADT-as-classes variant (match or isinstance, dataclass or__slots__) stays flat or degrades — GraalPy currently punishes allocation-heavy trees of small class instances, which is Pyfun's core data shape. Docs line: GraalPy runs Pyfun unchanged; try it for long-running arithmetic-heavy work; measure withbench/run.py --python graalpy, don't assume. CPython 3.14 is the best all-round stock host. PyPy TESTED 2026-07-18 — the best host measured for emitted Pyfun (7.3.23/3.11.15, dockerpypy:3.11; artifactslocal/pypy-verification/): the--target 3.11switch SHIPPED the same day (src/python_emitter/py311.rs— PEP 701-dependent f-strings rewrite to"…".format(…)calls, exact check on rendered holes, everything else Pyfun emits is 3.10-compatible;bench/run.py --target 3.11compiles intobench/out-3.11/). Results, cold: emitted code runs 1.5–3.6× faster than CPython 3.14 (expr_eval 3.6×, map_build 2.7×, collatz 1.5×), outputs byte-identical; the GraalPy ADT pathology does not transfer (steady ~0.4s/iter on the probe), and cold PyPy even beats the mypyc-compiled figure on expr_eval (0.525s vs 0.824s) with zero user toolchain. Weak spot: recursion (collatz 7.36× vs PyPy's own iterative baseline — absolute time still beats CPython). Docs line earned: "compute-bound?--target 3.11+ PyPy." CPython's own JIT (experimental since 3.13) accrues to every program for free. - Typed-emit + mypyc AOT (
--native) (M to measure, L to ship; gated on the measurement) — the checker knows every binding's inferred type, so the emitter could produce fully annotated Python whose annotations cannot lie, then compile it with mypyc into a C extension — native speed with the interop story intact (the result is still an ordinary extension module). Real blockers make this a feature, not a flag: mypyc does not yet compilematchstatements (python/mypy#12362) and every Pyfun pattern match lowers to one, so native mode needs an alternateif/elifmatch lowering; nested closures (partial application), generators (seq), and_pyfun_rt.pyall need a compatibility audit; and mypyc needs a C toolchain on the user's machine, so this is opt-in only —pip install pyfunstays toolchain-free. Gate MEASURED 2026-07-18 (hand-made--nativemock-up ofbench/expr_eval— annotations +if/isinstancematch lowering + monomorphized fold; mypyc 1.19 in a python:3.12 container, gcc; artifacts inlocal/mypyc-experiment/): vs the hand-written baseline, emitted 4.25× → rewrite-only (interpreted) 2.16× → mypyc-compiled 1.26×. Net: ~3.4× faster than today's emitted output on the ADT-heavy workload, landing near hand-written speed — the L is justified on these numbers. Two riders: (1) roughly half the gap closed before compilation — CPython's class-patternmatchdispatch is expensive (Windows 3.14 ablation: 2.31× → 1.44× from the rewrite alone), so theif/isinstancelowering mypyc forces is also a lever on its own. It shipped in the default emitter forOption/Resultscrutinees on 2026-08-30 (DESIGN.md§5.5, issues #87/#89): a real workload (a Scrabble move generator askingMap.tryFind/List.findIndexmillions of times per position) demanded it, and for a two-case type with one payload the ladder is the readable form. User ADTs keepmatch/case, so native mode still needs the generalif/eliflowering; (2) frozen-dataclass ADTs compiled fine — mypyc's remaining headroom (native classes vs dataclasses, boxed union fields) is upside not yet claimed. - Native backend (not planned — recorded as a design-space note so the property it rests on
stays deliberate) — the semantics are AOT-compilable: static HM types (no dynamic dispatch),
default immutability (aggressive optimization is sound), tracked effects (pure code may be
reordered), exhaustive ADTs (matches become jump tables), units already erase. That is OCaml's
profile; nothing in the language requires a dynamic runtime, and that stays true by design. The
cost center is the boundary: a native Pyfun embeds CPython and every
externcrosses worlds, where cost = crossing frequency × data marshalling, not callee speed (bulk data can share zero-copy via the buffer protocol; chatty per-element crossings are fatal). Pyfun's edge if ever built: externs are typed and effect-tracked, so every crossing is statically known — the compiler could warn on chatty boundaries inside hot loops, or batch them. Two-tier precedent: Codon, Mojo — both multi-year funded-team efforts. Rewriting Python libraries in Pyfun to remove the boundary is rejected outright (the ecosystem is the asset). Reopen only with a funded reason.
Sweep completed 2026-07-14: Neovim 0.12 (5/5 headless checks: filetype/syntax/LSP attach/hover/
diagnostics), Helix 25.07 (health + the [[grammar]] git+subpath fetch AND build + highlights),
Emacs 30.2 (eglot attach + hover; note eglot-ensure needs interactive Emacs — batch tests must
call eglot--connect directly), Tree-sitter (40 corpus goldens + themed render audit), and the
Jupyter kernel — interrupt (CPU-bound cell aborts in ~50ms; a cell blocked in a C call does not
interrupt promptly on Windows, verified identical in the stock python3 kernel), engine-death
replay, and macOS/Linux/Windows via the kernel.yml CI matrix running tests/kernel_e2e.py on
every push (all green). JupyterLab UI session user-confirmed (if cells show empty [ ] with no
output, restart the Jupyter server before suspecting the kernel). Wheel/install/discovery chain
verified against the released v0.1.0 in a clean venv.
Zed user-confirmed 2026-07-14 (dev-extension install; needs rustup target add wasm32-wasip2 —
documented in editors/zed/README.md). PyCharm user-confirmed 2026-07-14 (LSP4IJ + TextMate
bundle; hover is noticeably slower than in VS Code — LSP4IJ behavior, not the server). No open
gaps. Post-launch follow-ups that came out of the sweep: publish the Zed extension to the
registry (PR to zed-industries/extensions), and consider shipping Helix indent/textobject queries
(hx --health reports them missing; highlights ship today).
-
PyPI —
pyfun-langat 0.8.1 (2026-08-31, published by the tag and verified by installingpyfun-lang[jupyter]==0.8.1into a clean venv and running the two fixed shapes from that release — a leading/mid-listunitextern parameter and amutassigned inside a CE block — against it; the index lags the tag build by a few minutes, so a firstNo matching distributionis a retry, not a failure). Rides the tag; no manual step. -
Open VSX — DONE, accepted:
pyfun.pyfuncovers VSCodium/code-server/Gitpod/Theia. At 0.8.1 (2026-08-31, verified against the registry API; the first check after publishing can read "published but not active" for a couple of minutes before the API reflects the new version, which is indexing lag, not a failed publish). Every release:ovsx publish <vsix> -p <token>(scriptable, no moderation). -
JetBrains Marketplace — DONE, accepted: plugin
com.github.simontreanor.pyfun(id 32915) is live (editors/jetbrains/, thin: file type + TextMate grammar + LSP4IJ wiring, free mode + legacy CE, 2024.2+). At 0.8.1, uploaded 2026-08-31 and awaiting moderation; the plugins API reports 0.8.0, so that one cleared (verified against the plugins API — check the API, not this line).editors/jetbrains/has a committed Gradle wrapper:./gradlew publishPlugin(JDK 21 +JETBRAINS_PERMANENT_TOKEN) needs nothing installed, because Gradle is not on this machine and two releases running lost time rediscovering that before reaching for a baregradle. Approval is not instant: the plugins API lists approved versions only, so it reads the previous one until moderation clears. -
VS Code Marketplace — accepted and live as
pyfun.pyfun, at 0.8.1 (vsix uploaded through the publisher web UI 2026-08-31, verified against the gallery API). The only surface that cannot be scripted: the vsix is uploaded by hand through the publisher web UI athttps://marketplace.visualstudio.com/manage/publishers/pyfun(the CLI auth path is broken — seeeditors/vscode/DEVELOPMENT.mdand RELEASING.md). It is therefore the one that silently falls behind; check it whenever a release goes out. -
Third-party registries — PARKED until there is adoption evidence (decided 2026-07-31). The surfaces Pyfun controls (PyPI, VS Code Marketplace, Open VSX, JetBrains) are the ones that get kept current every release; these do not, and their status is deliberately not re-checked each time. Two of them already told us the same thing in different words, which is what makes the rule rather than the exception. The rule is about adoption gates: a registry that closed on a process rule with a stated way back in is worth finishing when that condition is met, which is why MELPA below was resubmitted while the star-gated ones stay parked.
- nvim-lspconfig (#4476, closed) and Mason (#16012, withdrawn — its path was lspconfig approval): new languages need adoption evidence, informally ~100 stars.
- MELPA
pyfun-mode(melpa/melpa#10094, closed 2026-07-19): not a rejection of the recipe, which they had already signed off; they require the Emacs package to live in a public repository for one month or more andpyfun-mode.elwas five days old. The one-month gate passed on 2026-08-14 (pyfun-mode.elpublic since 2026-07-14), and resubmitted 2026-08-29 as melpa/melpa#10189, recipe unchanged. GitHub refuses to reopen #10094, so a fresh PR from a branch off currentmasteris the way back in. Their closing note also carried a preference, not a requirement: MELPA would rather a package not live in a monorepo, because their build machinery has to pull the whole thing. Decided 2026-08-29 to stay in the monorepo and say so in the PR: a full bare clone is 3.3 MB, the mode's version is bumped in lockstep with the compiler byRELEASING.md, and a separate repository would mean two places to keep in step for one file plus a reset of the very soak time the submission was waiting on. Split it out only if MELPA asks. - Zed (zed-industries/extensions#6814 — the main repo as a submodule at
editors/zed, with the LICENSE the registry wanted inside the extension dir) had changes requested on 2026-08-10: the submodule pointed at a branch commit that stopped being reachable once that branch was squash-merged and deleted. Repointed at the v0.6.0 commit on 2026-08-29; checks green, awaiting re-review. Two traps a future pin bump walks straight into, both learned the hard way that day: squash-merging deletes the commit a submodule pin names, so a pin must always be a commit onmainand wants rechecking whenever it is bumped; and moving the pin moves every file inside it, includingeditors/zed/extension.toml, whoseversiontheirpackage-extensionsscript checks against theversionin theirextensions.toml. The repoint carried the extension from 0.1.0 to 0.2.0 and failed withIncorrect version for extension pyfun, fixed by bumping their entry to match. Bump both numbers together. Note the Zed extension is versioned on the grammar's schedule, not the compiler's (RELEASING.md), so the two are not the same number and the pairing has to be checked rather than assumed. When reading that failure, the loudcould not find Cargo.tomlerror in the log is benign: it appears identically in the July run that passed, and the real error sits ten lines below it. Helix (helix-editor/helix#16036 — languages.toml, git/rev/subpath grammar, Helix-scope queries, their checks clean locally) is still open and needs nothing from us; if it merges, it merges. - nvim-treesitter: upstream ARCHIVED 2026-04 with no successor (candidates: the
neovim-treesitter fork org, or parser management in Neovim core — neovim/neovim#39006). A fully
validated branch is parked at
simontreanor/nvim-treesitter(add-pyfun), ready to retarget when the ecosystem settles. - Sublime Text Package Control and a Pygments lexer on PyPI (the kernel declares the
fsharplexer as an approximation) were always demand-gated and stay that way.
The documented fallback for every one of these already exists in
editors/README.md, so a user on any of those editors is not blocked — they install by hand instead of by registry. Revisit the whole list when download or install numbers give the maintainers something to say yes to, rather than re-litigating each one per release.
The mdBook site shipped 2026-07-15 (learner track, educator pack, internals tour, in-page runnable
code blocks; the playground moved to /playground/ with #code= permalinks). Teaching prose is
CC BY 4.0. When lessons change, re-verify with python docs/verify_lessons.py (checks every deep
link decodes to its displayed starter and every solution's output matches); ci.yml runs it on
every PR, and it refuses a target/debug binary older than Cargo.toml rather than reporting
green against a stale compiler. Still open:
- Notebook-format lessons (M, demand-gated) — the same lessons as
.ipynbfiles riding the shipped Jupyter kernel, so instructors can distribute them through existing course infrastructure. Wait for an educator to ask. - CONTRIBUTING.md + curated good-first-issues (S) — point new contributors at the internals tour's "Where you would add..." notes; label a handful of well-scoped issues.
- Printable educator pack (S, demand-gated) — a PDF export of the five session docs for departments that circulate paper.
Both surfaced while checking what the docs claim against what the compiler does, and neither is a documentation problem, so they are recorded here rather than papered over in prose.
- An uppercase
letbinding silently defines a function (S) —let Some x = Some 1type-checks.parser::parse_binding_targetenters the pattern grammar only after(orIdent {, so a bare constructor name is read as the function name oflet f x = …, and the program defines a function calledSomethat shadows the constructor. The irrefutability rule that exists for exactly this case (refutable_shape, which does rejectlet (Some x) = …) never sees it. Nothing downstream can use such a name as a constructor, so the shape is a mistake every time it is written. Fix: reject an uppercase-initial binding name, with a message pointing at the parenthesized pattern form when a constructor pattern was plainly intended. Lesson 8 had to work around it (it quotes the parenthesized spelling), which is how it was found. - The hole-fit shortlist can hide the answer it exists to name (S–M) —
hole_fitsranks by generality, then by qualified-vs-bare, then by name, and truncates atHOLE_FIT_CAP = 6. For a common shape likestring -> stringthe stdlib sweep left far more than six equally specific fits, so the tail is decided alphabetically:String.uppernow falls off the end of astring -> stringhole whileString.trimStartstays. That is arbitrary from the reader's side, and it cost lesson 9 its worked example. Options, in increasing effort: rank the remaining tier by shortest name or by prelude-declaration order rather than alphabetically; say "and N more" when the list is truncated; or filter by the hole's own name against candidate names (?upperplainly wantsupper), which is the one that would have kept the lesson working.
- Type annotations (
let x : T,(x: T), return types) — annotation-free code is a selling point, not a gap: HM inference is complete so the compiler needs none, types are already surfaced by LSP hover /pyfun check/ REPL:type, andexternis the one place Pyfun asks for types on purpose (the boundary contract). The one concrete unlock they once offered — lifting field-name uniqueness — shipped without them (use-site multimap), and the syntax fights a load-bearing decision: a depth-0:is thematch/caseblock opener. Sole revisit trigger: error localization under pure inference becomes a real, recurring pain — and even then the first answer is better HM diagnostics (provenance / expected-vs- found notes), with param annotations(x: T)alone (inside brackets:is free) as the fallback slice, not fullletannotations.DESIGN.md§3, §8.3. - Visibility (
pub) — all-public is the Python-natural model; enforced privacy fights the ethos. - Tail-call optimization — CPython has none; the stack-safe path is the
List/Seqcombinators. Partially reopened 2026-07-31: the combinators answer holds for collection traversal and does not cover an unbounded interactive loop (Dogfooding findings #6). General and mutual TCO stay out; a direct, saturated self tail call is accepted work as a lowering-only transform (while Trueplus parameter rebinding), which is a change to emitted code, not to the language. Arraytype — redundant:Listalready is a Python list (O(1) index/len).- User-extensible type classes / SRTP —
numandcomparisonare deliberately closed constraints; Python dispatches operators at runtime. - Row polymorphism — a whole type-system axis (row variables, open records, presence constraints) for
structural records Pyfun deliberately doesn't have — its records are nominal. Field-name ambiguity was
solved instead with a lazy use-site multimap (a bare
p.xerrors only when two visible records genuinely sharex, never at declaration/import).DESIGN.md§8.3. - Effect subsumption (pure ≤ io subtyping) — the wrong tool for the gap it would close. Declared
effects are exact (two closed sets unify only when equal), which only ever bites at declared arrows —
ordinary code is inference-first, and inferred higher-order functions are already effect-polymorphic, so
pure and impure arguments both flow everywhere annotations aren't written. Sound subsumption is
directional (safe only at contravariant positions), so it means threading polarity through a
symmetric HM unifier — an invasive, permanent complication — and a variance slip lets an effect past
let pure, the flagship guarantee. Where a declared arrow genuinely must accept any effect, the HM-native fix is an effect variable in the extern signature — implemented (->{e}, extern-only, 2026-07-13), not subtyping.DESIGN.md§4. - Active-pattern nesting & export — three cutoffs keeping the feature honest to its lowering (an AP is
a function call, not a structural test): (1) nesting an AP under structural patterns — under
constructors (
case Some (Positive p):), tuple scrutinees (case (Positive p, Positive q):), or as-patterns — needs recognizer application at projection paths plus Maranget usefulness recursing into hidden case sets at depth; the workaround is a nestedmatchon the bound value. (2) Nested destructuring case arguments (case Small (x, y):) — the same soundness-sensitive usefulness recursion into the case's monomorphic field types, for ergonomics-only payoff: a nested literal iscase Small s if s == 0:(guards, shipped), and a tuple payload is bound whole and destructured in the body. (3) Cross-module export — the hidden case-set type and its mono field vars can't cross a module boundary soundly. Re-open only on a concrete driver; F#-parity alone doesn't qualify.DESIGN.md§7.2.1. - Singly-linked
list+cons/head/tailpatterns (F#'slist) — Pyfun'sListis F#'s array (a Pythonlist). A cons-cell type would lower to un-Pythonic linked nodes, and its recursivex :: xsidiom is stack-unsafe without TCO. Sequence patterns on the existingList(case [x, *rest], done) are the Python-native, big-O-honest answer. - Imperative loops (
while/for … in) — iteration is theList/Seqcombinators plus recursion;let mutis for local accumulation inside an expression, not to drive a loop. (The interactive-loop gap this leaves is Dogfooding findings #6, where reopeningwhileis option (c) of three.) - Else-less
if—ifis an expression, so both branches are required; a conditional side effect isif c then eff else (). - Imperative
raise/finally/ exception hierarchy — Pyfun signals failure withError; thetry e : Result a Exceptionexpression catches at the FFI boundary andresult {}+ theResultmodule compose the rest. Araise/finallyform would duplicateResultand import a class hierarchy Pyfun has no types for. - f-string format specifiers (
{x:.2f},{v!r}) — an unchecked, stringly-typed sublanguage smuggled inside a string literal: the compiler can't see into it, so.2f→.f2misformats only at runtime and nothing enforces consistency. The Pyfun way is centralized formatting functions (the shippedFormatmodule,DESIGN.md§6). Plainf"{expr}"interpolation stays; only the:spec/!rmini-language is excluded. - Further lowering tiers: general inlining, stream fusion, micro-opts (old perf tiers 2–4) — measured
out on the flagship workload; each also pressures the readable-output promise. (2) General
folder/call inlining: the landed fold pass already splices the folder into the loop for every
qualifying fold, and the residual per-element call overhead is wall-clock-small — inlining the hottest
wrapper (1.87M calls) saved ~3%, after the cProfile line claiming 87% proved to be the profiler's own
per-call overhead (
DESIGN.md§5.2). (3) Stream fusion / deforestation: rests on a false premise here —Seqpipelines are already lazy iterators, nothing intermediate materializes — so fusion only removes per-element indirection (the same small bucket: network-rail's entire interpreter residual is ~0.6s of ~14s), while costing one of the hardest passes there is (effect ordering across fused stages) and replacing a visible source pipeline with a fused loop the source doesn't show. (4) Micro-opts (hoisting method lookups out of loops): noise-level wins, pure erosion of line-to-line correspondence. Reopen (3) only on a profiled real workload where combinator indirection itself — not IO or costs shared with native Python — dominates and anexternis inappropriate.DESIGN.md§5.1–5.2. externstub generator (pyfun stub <module.pyi>emitting draft extern files) — it would optimize the part of the design that is deliberately small. The interop model is a thin, curated boundary — wrap the handful of functions you call and sign each effect deliberately; the largest boundary any shipped example needs is 10 externs (http_fetch). Bulk generation invites wide, untightened,io-by-default surfaces nobody really signed, automating the step that was never the bottleneck while diluting the one that matters (the trusted contract, §4). The mechanical drafting it offered is better done by an LLM assistant from docs/stubs (same human-signs step after); a dependency-free.pyi-subset parser is an L to build and a permanent second frontend to maintain, for inputs that are often absent, inline-only, orAny-ridden. Reopen only if a façade/package ecosystem emerges with demonstrated churn hand-writing large boundary files.DESIGN.md§6.- Built-in date type /
Format.formatDate— doubly against the design. A native date type means reimplementing calendar logic Python'sdatetimealready has (the boundary-vs-engine thesis says call it, don't rebuild it), and a generalformatDatetakes a strftime pattern —"%Y-%m-%d"is exactly the stringly-typed mini-language the f-string-specifier non-goal rejects and theFormatmodule exists to replace; a typed date-format DSL is out of scope. Dates belong at the boundary:extern type Datetime- instance-method externs, where the programmer signs the contract — shipped as
examples/interop/datetime.pyfun(a fully pure FFI pipeline).
- instance-method externs, where the programmer signs the contract — shipped as
- Unicode / symbol measure names (
<Ω>,<μ>, superscriptm²) — measure names are ordinary identifiers, so this can't be scoped to units; it's language-wide Unicode identifiers (which would leak into Python names). Safe homoglyph handling (µ U+00B5 vs μ U+03BC) needs Unicode normalization, which isn't in std — violating the dependency-free constraint. Use ASCII names (ohm,deg,celsius). Explored + dropped 2026-07-04. - Higher unit-aware roots beyond
sqrt/cbrt— a generalroot n xneeds dependent types (runtimen, the same wall asx<'u> ** y). √ and ∛ map to physical area/volume and are the principled cutoff;**stays dimensionless, and integer powers-with-units are covered by*. - Macros — out of scope for the compiler.
- Truly incremental LSP reparse — whole-file lex + parse + check is milliseconds at realistic sizes, and the fingerprint-validated caches already remove redundant whole-file work; region reparse would complicate the offside lexer + recovering parser for no perceptible win.
A 2026-07-02 table-stakes gap audit found 12 overlooked essentials (silent non-ASCII string double-encoding,
%, List completeness ops, scientific notation, numeric conversions, Option.bind, **, String
slice/tryIndexOf, mutual recursion, as-patterns, let _ = discard, literal ergonomics) — all cleared.
Everything across the MVP showcase, effects, records, mutability, numerics, the standard library, file-based
modules, and the LSP has shipped. See DESIGN.md for mechanics and git history for the timeline.