Boxed any with shape descriptors, structural and tuple patterns - #212
Merged
Conversation
Codegen: treat LType::Any as managed at every ownership site via a single
LType::is_managed_ptr(), replacing seven hand-written `Str | Ptr` tests that
each answered "unmanaged" for an erased box — flat list-literal elements,
runtime container element flags, captured-mut rebinds, fiber result slots,
fold accumulators, and Result<any, _> being marked payload-free. Preserve
LType::Any through flat-list element selection and the `[]any` owner tag so a
round-tripped element renders through its descriptor instead of printing the
box address.
Types: reject a structural pattern that names the same field twice, and check
closed rows by distinct name set rather than vector length. The two measures
disagreed, so `{ x, x }` selected a concrete record and then missed the same
value once erased to `any`.
Grammar: replace token.immediate('(') with a zero-width same-line token from a
new external scanner, so `f (x)` parses again while a next-line `(` still opens
the following arm's tuple pattern. Indexing keeps byte-adjacency: list-arm
matches are written on one line, so a same-line `[` would read `0 [head` as an
index. Spec updated to state the asymmetry it actually enforces.
Repo: regroup .gitignore, drop two duplicate rules, anchor the three
corpus-written artifacts to the repo root, and remove four generated files
committed against those rules.
Three spec sections showed syntax that does not parse, each sitting above a status block that correctly said so — a reader hits the example first. Replace them with spellings verified against the compiler: 0007's nested-row and `field: binder` example, 0004's `let pair: (int, string) = (1, "a")`, and 0024's `pair : int * string -> string`. State plainly in each that the aspirational spelling is unimplemented. Delete docs/tuples-branch-review.md: a scratch review artifact whose verdict was stale and whose union_owner claim was not backed by the diff.
Two CI gates were red, both self-inflicted by the previous commit's tests. wasm32: adding a `spawn` to any_type_comprehensive made the whole file unlinkable on wasm32 (`__osprey_coro_suspend`), growing the reviewed skip set by two and dropping the golden count to 124 against a floor of 126. Move the erased-fiber-result case to fiber_showcase, which already cannot link there and is already pinned in WASM_UNPORTABLE.txt, so both files keep their goldens and the skip set is unchanged. The gate was right; the fix is the code, not the floor. Coverage: osprey-codegen 94.4% and osprey-types 97.9% were under their 95/98 thresholds. anybox.rs shipped with 90 uncovered lines — the float, union, Result and opaque descriptor kinds had no test at all, so an erased float would have rendered its IEEE bits unnoticed. Cover every descriptor kind through the corpus in both flavors, and add the unary-operator and index reads of an erased value to the must-reject fixture; both were rejected but untested. Two checker tests cover the non-row scrutinee refusal naming its type and binder collection skipping unbound tuple slots. osprey-codegen 94.41 -> 95.22, osprey-types 97.91 -> 98.01.
A value-carrying `resume` compiles to a captured continuation and pulls in `__osprey_coro_suspend`, which wasm32 has no port of. The `Audit` effect added to any_type_comprehensive resumed a value, so that file stopped linking there — costing it both wasm goldens and pushing the reviewed skip set from 53 to 55. The earlier attempt blamed `spawn` and removed it; the file still could not link, because `spawn` was never the cause. The real rule, checked against the corpus: every effects suite that links on wasm32 documents using no `resume`, and every one that does not is pinned for this exact symbol. No rewrite keeps value-resume semantics and portability, so the case moves rather than shrinks. handler_scoping already carries `__osprey_coro_suspend` and already owns the resume-under-scoping section, so the erased operand joins it there: both performs continue past a suspend, with a runtime-built heap payload so ARC counts it. any_type_comprehensive keeps the mailbox coverage through the Unit-typed `Swap` handler, which uses no `resume` and stays portable. Verified locally rather than inferred: both any_type_comprehensive flavors now link for wasm32-wasip1, and handler_scoping still fails to, so the skip set returns to its pinned 53 and the golden floor to 126. Corpus is 179/179 suites and 179/179 goldens under default, gc and arc, with TEST_CORPUS_ARC_LEAKY=0. Coverage holds: osprey-codegen 95.2%, osprey-types 98.0%.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR
anystops being a bare machine word — it becomes a heap box carrying a shape descriptor, readable only through new structural ({ x, .. }) and tuple ((a, b)) match patterns, with every recovery-by-annotation path now a truthful compile error instead of a printed heap address or a segfault.Details
New representation for
any(crates/osprey-codegen/src/anybox.rs, new, 552 lines){ i8* desc, i64 payload }.descpoints at a per-shape module global@osp.any.desc.<slug>(private constant { i64 kind, i8* render }). Eight descriptor kinds keyed by aDescKeyenum (int, bool, float, string, row, union, result, opaque); two erasure sites with the same shape share one global, and that shared pointer identity is what narrowing compares. Slugs are deterministic, so both flavors emit byte-identical IR.i8* @osp.any.boxrow.<owner>(i8* src)— one child box per field — so every row with the same field names erases identically regardless of source layout. A union keeps its tagged block (variant decided at run time); aResulterases whole asDescKey::ResultOf(inner); lists, maps, GPU buffers,HttpResponseand any owner with no record/union layout become named opaques (<list>/<map>/<gpu>/<handle>). A record whose row is not a declared record is a hardCodegenError— the backend backstop for the checker's anonymous-record rejection.LType::Anyis added as a distinct LLVM-i8*type;ltype_of_conmapsnames::ANYto it instead ofLType::I64. NewLType::is_managed_ptr()replaces hand-writtenStr | Ptrmatches at eight ownership sites (arc::managed,listlitelement kind andto_runtime_list,collections::managed_flag,fiber::gen_spawnresult slot,iter::acc_stepfold accumulator,result::make_result,lower::gen_cell_store). NewANY_TAG_SPELLING = "any"so container owner tags name an erased element unambiguously.Ownership fixes (both were live defects, #208/#209)
build_boxtransfers the payload's+1into the box (or retains a borrowed one) and masks the payload slotPtrManagedexactly when it holds a pointer, so each backend's drop walk releases through the box. This is the ARC: returning a heap value asanyreleases it in the producing frame — caller reads a dangling pointer, two calls underflow the live-object counter #208 root cause — an erased return could previously neither move its owner out nor be retained.gen_listno longer foldsLType::Anyinto the uniformI64element word (which turned the box pointer into an integer and marked the slots unmanaged);lit_ownertags an erased-element literal[]anyandlit_elemdecodesanybeforei8*.Anyoperand slots managed on both the declared and the resolved/erased parameter paths; a capturedmut anyrebind now releases the box it displaces.Erasure is one-way (new rejections, user-visible)
unify::unify_assignablebecomes directional: ananyactual against a non-variable, non-anyexpected iscannot recover `T` from an erased `any`: match its structure instead. Anonymous records getcannot erase an anonymous record into `any`: declare its row as a named type first(narrowing selects only among declared rows).Checker::reject_erased_operandrejects binary operators, unary operators, field access and indexing on anany, substituting a plausible result type so errors don't cascade.reject_reading_erasedrejects every pattern form that carries no runtime row test over ananyscrutinee — literal, list, type-annotated (s: string =>), constructor (exceptSuccess/Error, whose auto-wrap binds the value whole) and nullary-variant-as-binding arms — with<form> cannot read an erased `any`: match its structure instead. Rejected arms still bind their names asanyso one mistake doesn't cascade into unknown-identifier errors.let s: string = erased(),fn f() -> string = erased(),erased() == "ab",erased().name, and anany-typed parameter used with+.Structural and tuple patterns
Pattern::Structuralchanges fromfields: Vec<String>tofields: Vec<(field, binder)>plusopen: bool; newosprey_ast::tuple_pattern(binders)builds the positional row (slot i → decimal field name"0","1", …), so ML(a, b)and Default(a, b)produce identical AST. Consumers updated:freevars,osprey-projectrewrite.rs/state_support.rs,effect_rows::bind_pattern(projects by field name, records under the binder). Internal API break for crate consumers.'{' field_pattern '}'alternative becomes a namedstructural_patternrule with an optional trailing, ..; a newtuple_patternrequires two or more slots, each anidentifieror_, so(x)never reaches pattern position. The..marker lives onstructural_pattern, notfield_pattern, so the structural ternary andCtor { … }/p: { x, y }cannot spell an open row. The structural ternary loses its staticPREC.ternaryin favour of nine newly declared GLR conflicts; the token after}(?vs=>) decides.{/}become real lexemes (TokKind::LBrace/RBrace) and count toward layout bracket depth; a newstructural_patternparser reads{ a, b }/{ a, .. }, andgroup_patternno longer rejects commas — a single element still erases to grouping, a comma list becomesMlPattern::Tuplewith each slot required to be a binder or_. Consequence: a brace in ML expression position now reportsunexpected token LBrace in expression(a parser error) instead ofunexpected character '{'(a lexer error); braces still have no ML expression form.bind_structural+structural_row+check_row_selectsdo real row selection. Over aType::Recordor aConnaming a record constructor, binders take their declared field types; overanyevery binder is itselfany(narrowing is not recovery). New errors:structural pattern names `z`, but T has no such field,a closed structural pattern must name the whole row of T; missing y: add `..` to open it, anda structural pattern needs a record or `any` scrutinee(previously such a scrutinee silently bound fresh variables).a match over `any` is never exhaustive: add a catch-all arm;check_redundant_armsnow takes the discriminant and reportsan earlier `{ x, .. }` arm already covers this rowplus identical closed rows compared as sets (re-spelling field order does not evade it); over a concrete record a structural arm ends the reachable arms.gen_matchroutes any match containing a structural arm to a newgen_structural_match, ahead of the union and literal backends (which previously rejected structural arms). AnLType::Anyscrutinee opens the box once and tests each arm's compile-time candidate rows withicmp eq i8*— no run-time name comparison — binding each field to the row block's child box asAny; a concrete record binds statically off the block; any other representation skips field-naming arms so the catch-all runs. The candidate table is seeded from declared record constructors incompile_program_with_options.finish_phiboxes non-Anyarm results when any arm yields a box, so a catch-all's concrete value can't be read as a descriptor.Rendering
runtime::to_string_valuedispatchesLType::Anyto a once-emitted@osp.any.to_string(i8* box)that loads the descriptor and calls its render slot. Rows render{ x: 1, y: 2 }, unions renderLeaf/Node(1, 2)/Circle { radius: 1.0 },Resultrenders through its block, opaques render<list>/<map>/<gpu>/<handle>, and a null box rendersnullinstead of faulting.print/toString/interpolation of ananynow print the value, not a pointer-sized integer.New external scanner (
tree-sitter-osprey/src/scanner.c, new, 74 lines)(. The scanner emits one zero-width_call_open_gaptoken (it callsmark_endbefore consuming anything) that skips only' 'and'\t'and succeeds only when the next(is on the callee's line. The call suffix becomesseq($._call_open_gap, '(', …), keeping calls atPREC.memberso1 + id (2)is still1 + id(2), while a(opening the next line belongs to that arm's tuple pattern. Stateless (createreturns NULL,serializereturns 0).build.rscompiles it via the existingccinvocation — no new dependency.(opens the next line is no longer a call. The index[rule is untouched and still requires strict adjacency.Docs
docs/plans/0027-any-erasure-and-recovery.mddeleted as done (229 lines) and its README row removed. Spec 0004[TYPE-ANY]rewritten from "carries no runtime type tag / renders its raw pointer-sized representation" to the boxed, descriptor-rendered, structurally-narrowed model with a status block;[TYPE-ROW]generalised to open/closed rows with a revised unification rule; new[TYPE-RECORD-ANON]and[TYPE-TUPLE]. Spec 0007 gains[PATTERN-STRUCTURAL]and[PATTERN-TUPLE]and drops "runtime narrowing of ananyby type is not implemented" / "standalone structural record patterns are not implemented"; spec 0003 rewrites the pattern inventory; spec 0024 replaces "(a, b)is not a tuple pattern and is rejected" with[FLAVOR-ML-TUPLE]and adds[FLAVOR-ML-RECORD-ANON](documented not implemented); spec 0017 reworks one rationale paragraph only.No new dependencies. No
Cargo.tomlorpackage.jsonchange anywhere in the diff.tree-sitter-osprey/src/parser.candsrc/grammar.jsonare regenerated tree-sitter output and were excluded from review for size — nothing more;src/node-types.jsonis the same kind of generated output and is included.Follow-up fixes in this PR (found by auditing the diff against the compiler)
{ x, x }selected a concretePair { x, y }and then MISSED that same value once erased toany. Erasure changing whether an arm matches is the one thing[TYPE-ANY]must never do.bind_structuralrejects duplicates before any scrutinee-shaped reasoning, so concrete and erased behave identically, andcheck_row_selectscounts distinct names.docs/specs/0003-Syntax.mdnow states the rule the parser actually enforces: same-line for a call's(, strict adjacency for an index's[. Extending the same-line rule to[was tried and reverted — list-arm matches are written on one line (match xs { [] => 0 [head, ...tail] => … }), so a same-line[reads0 [headas an index and breaksbenchmarks/cases/listops/listops.osp. Tuple arms have no single-line usage, which is why(can afford the looser rule.0007's nested-row/rename example,0004'slet pair: (int, string) = (1, "a"), and0024'spair : int * string -> string. Every code block in those sections was run through the compiler.docs/tuples-branch-review.mddeleted — a scratch review artifact whose verdict was stale and whoseunion_ownerclaim was not backed by the diff.Repo hygiene
.gitignoreregrouped under section headers, two exact duplicates removed (*.wasm,__pycache__/), andtest_output.txt/test_stale_reason.txt/osprey_http_state_levels.dbanchored to/so they match only the repo root the corpus writes to. All 86,307 repo paths were run throughgit check-ignoreunder both rule sets: exactly one decision changes (tests/regressions/basics/files/test_output.txtstops being ignored, which is the point) and nothing becomes newly hidden.examples/wasm/build/*.stdout.txtcaptures (byte-identical to the trackedexamples/wasm/*.expectedoutputgoldens they duplicate, sole writerscripts/wasm-smoke.mjs, zero readers) andtests/regressions/basics/files/test_output.txt(written by the test that reads it). Eightscratchpad/files untracked but left on disk.How Do The Automated Tests Prove It Works?
Codegen IR assertions (
crates/osprey-codegen/src/lib.rs)erasure_boxes_with_one_way_ownership(replacesrecovering_a_pointer_from_an_erased_word_takes_no_ownership) — an erasing return allocates via@osp_alloc_tagged_noinitwith meta513(payload word masked managed), storesnullinto its ARC slot (the referent MOVES in, no second owner), and references@osp.any.desc.string; anany -> anypass-through emits no descriptor reference and retains its borrowed return. Pins ARC: returning a heap value asanyreleases it in the producing frame — caller reads a dangling pointer, two calls underflow the live-object counter #208 shut from both directions.erased_values_render_through_their_descriptor—print("${dynamic()}")emitscall i8* @osp.any.to_string(i8* …)and that entry dispatches through a register loaded from the descriptor's render slot, rather than formatting the raw word.structural_narrowing_compares_descriptors— a{ x, y }arm over an erasedPointlowers toicmp eq i8*against@osp.any.desc.row.x.ywith nostrcmp/osp_string_equalsanywhere, and the erasure deep-boxes through@osp.any.boxrow.Point.structural_match_on_a_concrete_record_is_static— a(n, _)arm overtype Pair = Pair(int, string)compiles to plainload i64, i64*field loads with no@osp.any.descreference at all: typed code does not pay for the erasure machinery.effect_result_parameters_preserve_shape_in_direct_and_resuming_abis(amended) — handler arm registers are now positional%__arm0/%__arm1instead of source binder names, because a binder spelledentrycollided with theentry:block label and clang rejected the module; the Result parameter still travels as its block pointer.Type-checker unit tests (
crates/osprey-types/src/pattern.rs,expr.rs)shape_reading_patterns_are_rejected_over_an_erased_scrutinee— literal (0), type-annotated (s: string), list ([a, b]) and variant (Red) arms over ananyeach produce their own namedcannot read an erased \any`` diagnostic.structural_fields_of_an_erased_scrutinee_are_erased_themselves— a field bound from{ n }overanyis itselfany, son + 1is rejected; narrowing cannot be used as recovery.a_match_over_any_requires_a_catch_all— a structural-only match overanyreports the never-exhaustive error.an_open_row_arm_shadows_every_extension_after_it—{ x, .. }before{ x, y }is rejected, and{ x, y }before{ y, x }is rejected as the same row (field order does not make a new row).a_closed_structural_pattern_must_name_the_whole_row— overPoint,{ x }names the missingy,{ x, .. }is accepted,{ z, .. }reports "has no such field".tuple_patterns_bind_positional_record_slots—(n, s)overPair(int, string)gives each binder its declared slot type, son + length(s) ?: 0type-checks.structural_pattern_binds_record_fields(rewritten) — an unresolved scrutinee now errors withneeds a record or `any` scrutineewhere it previously bound fresh variables; the concrete-record case still binds field types.an_abandoning_arm_may_not_answer_an_erased_word— converts a previously-passingok(...)case into a rejection: anany-valued abandoning handler arm in astringregion now errors instead of silently handing the caller an erased word.Parser tests
crates/osprey-syntax/src/default/expr.rs::a_call_accepts_horizontal_space_before_its_argument_list—id(1),id (1),id (1)all lower to the sameExpr::Call; this is the source compatibilitytoken.immediate('(')would have broken.…::a_spaced_call_binds_tighter_than_an_operator—1 + id (2)isBinary(Integer(1), Call), never(1 + id)(2); a GLR fork could not guarantee this because both readings tie on dynamic precedence.…::a_newline_paren_opens_a_tuple_arm_not_a_call— in{ held, .. } => render (held)followed by(n, s) => n, both arms survive: arm 0 keeps its spaced call, arm 1 is a closedPattern::Structuralwith fields("0","n"),("1","s").…::an_index_bracket_must_touch_its_target_unlike_a_call— pins the asymmetry AND the single-line list-arm match it protects, so a future attempt to unify the two rules fails here rather than in the benchmark corpus.crates/osprey-syntax/src/default/lower.rs::lowers_assignment_effects_structural_and_list_patterns(assertion updated) —{ name, age }lowers to the pair-shaped closed structural pattern.crates/osprey-syntax/tests/ml_elegance.rs::a_tuple_pattern_lowers_to_the_positional_row/structural_patterns_parse_closed_and_open/a_grouped_pattern_holds_one_pattern(rewritten) — ML(a, _)produces the same canonical row[("0","a"),("1","")]the Default spelling does;{ heading, .. }is open and{ name }closed;(Both a, b)now fails witha tuple pattern slot binds a name or `_`instead of the removed "Osprey has no tuple patterns".Must-reject fixtures (
examples/failscompilation/, byte-exact goldens)any_recovery_by_annotation.ospo— 4 line:col-anchored errors at11:3,12:3,14:0,15:0, allcannot recover `T` from an erased `any`, covering a return annotation over a heap payload, a return annotation over a scalar,let viaLet: stringandlet viaInt: int. These are exactly the forms that previously printed a heap address as a decimal integer (let x: string = <any-typed expr>drops the annotation and prints the pointer as a decimal integer — the same recovery through a function return works #209) or segfaulted with no message.any_narrowing_rules.ospo— 10 exact diagnostics in one file: literal arm,s: stringarm, variant arm, missing catch-all,unreachable match arm: an earlier `{ x, .. }` arm already covers this row, duplicate structural field in BOTH erased and direct position,cannot apply `==` to an erased `any`, field access on an erased value, and61:0: let `viaAnon`: cannot erase an anonymous record into `any`.ml_any_recovery_by_annotation.ospo— 3 errors at12:0,15:0,18:0: an ML signature line is an annotation and cannot recover a value out ofanyeither.ml_any_narrowing_rules.ospo— 7 errors (the duplicate-field pair included); the ML twin reports the variant arm asa constructor pattern(same arm, different frontend lowering), and the annotation-pattern, field-access and anonymous-record cases have no ML surface.ml_brace_record_and_question_sigil.ospore-pinned — the golden's lexer errors12:8: unexpected character '{'/12:23: unexpected character '}'become parser errors13:8: unexpected token LBrace in expression/13:23: unexpected token RBrace in expression;crates/osprey-cli/tests/examples_compile.rsML_NEGATIVESis updated to the new substring.End-to-end corpus (
tests/regressions/basics/types/any_type_comprehensive.test.osp/.ospml, one shared golden, run under every memory backend and again on wasm32)describeAnyselects{ held, .. }/{ value }/(n, s)/ catch-all correctly for a named record, a positional record, a scalar and an erased string (held=dynamic,value=via row,pair=7:seven,other=42,other=stillerased), and reads a runtime-built string back out of an erased row — the recovery annotations lost.other=boxed|other=7), a runtimeListappend/get (Success(appended)), a fiber result slot (other=fiber), afoldaccumulator (other=seed), an effect mailbox operand (auditTally() == 18), and a capturedmut anyrebind inside a handler (swapped() == "other=11"— the leak the branch review filed as P1). A built-but-never-read erasure is discarded as a statement so the ARC live-object oracle must return to zero.describeTwiceproves narrowing is repeatable on the same box (held=twin/held=twin);toString(erasedPoint()) == "{ x: 1, y: 2 }"proves descriptor-driven rendering. 13 new assertions in the existingcheckAllcase; the shared golden gains exactly two stdout lines,narrowed: held=printedandrendered: { x: 1, y: 2 }.forwarded() -> stringbecameforwardedHeap()forwarding into anotheranysink, and the ML twin'sprocessAnyValue : any -> anybecameint -> any, because ananyparameter can no longer be an operand of+.Verification run on this branch
cargo fmt --all --check: clean.cargo clippy --workspace --all-targets -- -D warnings: clean.cargo test --workspace: passes, excludingwasm::tests::build_and_run_end_to_end_when_toolchain_present, which fails only because the installed Node is 22.22.2 and it requires Node 24+.crates/run_test_corpus.shunderdefault,gcandarc: 179/179 suites, 179/179 byte-exact goldens, 18/18 alt GPU lowering, andTEST_CORPUS_ARC_LEAKY=0underarc.npx tree-sitter test: passes.Closes #175. Closes #208. Closes #209.