Quartz v5.25

Handoff: impl Trait sprint

For: the next Claude Code session, starting fresh Last session ended: Apr 13 2026, after delivering the compiler-hole sprint (file attribution, MULTI-CLAUSE-1, MAP-NEW-DECLARE-MISSING, UNKNOWN-VARIANT-MATCH, all committed and smoked). State at handoff: tree clean; HEAD at the UNKNOWN-VARIANT-MATCH commit (39a984ca + the design/handoff docs); fixpoint 2260 functions; smoke baseline at the full 22/22 expr_eval pass. Purpose: close the impl Trait feature holes, redesign Iterable<T>, and modernize std/iter.qz to a Rust-class standard.


Read this first

impl Trait return types are already partially implemented in the Quartz compiler. I discovered this via a codebase audit and six probe programs during planning. The design document at docs/IMPL_TRAIT_DESIGN.md has the full analysis — read that first, then come back here for the phases. The design doc is self-contained and cites primary sources (Rust RFCs 1522/1951/3425/2515, Swift SE-0244, rustc dev guide, Haskell rank-N).

The short version:

  • Parser, AST storage, registry data model, and inference already exist
  • Simple cases work end-to-end (def f(): impl Trait + caller + method call)
  • Two silent miscompile holes + one symbol-mangling bug block real use
  • Iterable<T> in std/traits.qz:133-137 is a dead stub — zero impls exist
  • std/iter.qz is closure-based throughout — the whole adapter API works around the missing feature

This sprint closes the holes, makes Iterable<T> real, and rewrites std/iter.qz without closures. Stretch: RPITIT (trait-method-return impl Trait).

Probe results you must reproduce before you trust me

Write these six files to /tmp/, run them against the committed compiler, and confirm the same results before starting any implementation work. If any probe behaves differently, stop and re-plan — the landscape has shifted.

#FileExpected result
1Return impl MyIter, caller binds, calls .next()✅ compiles, runs
2Return impl MyIter, passed to fn take_three<T: MyIter>(src: T)❌ llc error: @take_three$1$impl MyIter — literal space in symbol
3impl Trait arg position, one impl✅ compiles, runs
4impl Trait arg position, one impl✅ compiles, runs
5impl Trait arg position, two impls, two callers❌ returns 202 instead of 302 — silent miscompile, drain dispatches to first impl for both callers
6Inline chain make_counter().next()✅ compiles, runs

The exact probe source code is reproduced in §2.10 of the design doc.

State at handoff — verification checklist

Before you start, run:

git status                              # should be clean
git log --oneline -6                    # 39a984ca at top + design/handoff commits
./self-hosted/bin/quake guard:check     # fixpoint stamp valid
./self-hosted/bin/quake smoke           # brainfuck 4/4, expr_eval 45 lines baseline

Backups in self-hosted/bin/backups/:

  • quartz-golden — rolling, managed by quake guard, do not touch
  • quartz-001, quartz-002, quartz-prev — deep fossils, do not delete

Take a fix-specific backup before the first edit of any compiler source:

cp self-hosted/bin/quartz self-hosted/bin/backups/quartz-pre-impl-trait-sprint-golden

Files to read first, in order

  1. docs/IMPL_TRAIT_DESIGN.md — the full design doc, including research synthesis and semantic choices
  2. self-hosted/frontend/parser.qz:444-450ps_parse_type already handles impl prefix
  3. self-hosted/middle/typecheck_util.qz:266impl_return_concrete_types field
  4. self-hosted/middle/typecheck_registry.qz:2600-2635tc_register_impl_return, tc_lookup_impl_return, tc_is_impl_return
  5. self-hosted/middle/typecheck_walk.qz:2860-2922tc_collect_impl_returns, tc_infer_impl_concrete_type
  6. self-hosted/middle/typecheck_walk.qz:3100-3121 — where tc_function validates and registers the concrete type
  7. self-hosted/middle/typecheck_generics.qz:126-161tc_infer_expr_type_annotation for NODE_CALL, handles impl Trait substitution
  8. self-hosted/middle/typecheck_generics.qz:274-403tc_infer_type_param_mapping and tc_substitute_annotation, the monomorphization machinery
  9. self-hosted/middle/typecheck_registry.qz:1041-1129tc_validate_trait_bounds
  10. std/traits.qz:133-137 — the dead Iterable<T> stub to delete
  11. std/iter.qz — the whole file gets rewritten
  12. std/collections/{stack,queue,deque,linked_list,priority_queue,sorted_map}.qz — six collections to add impl Iterable<Int> for X end

Phases — in order, each one a commit

Each phase ends with quake guard + quake smoke. Commit after each phase passes. Do not skip either verification step. The no-holes-left-behind prime directive applies to every phase: if you discover something unexpected that doesn’t belong to the current phase, file it in the ROADMAP or fix it, never silently move past.

Probe findings already landed from the planning session (no need to re-run):

  • Simple return-position impl Trait + let binding + method call works (probe 1, probe 6).
  • Return impl Trait passed to bounded generic <T: Trait> hard error — mangler bakes "impl MyIter" (space) into the symbol name. Probe 2.
  • impl Trait in argument position with two impls in scope silent miscompile — caller dispatches statically to the first impl, returns wrong answer with no error. Probe 5.
  • Unbounded generic struct field + UFCS dispatch on that field works. struct W<T> { inner: T } + impl Counter for W<T> + self.inner.tick() compiles and runs.
  • Bounded generic struct field (struct W<C: Counter> { inner: C }) OOMs/hangs the typechecker. Same for impl<T: Counter> Counter for W<T>. Pre-existing bug discovered during planning.

Re-verification check at the start of your session:

git status                              # should be clean
git log --oneline -6                    # 39a984ca at top plus design/handoff commits
./self-hosted/bin/quake guard:check     # fixpoint stamp valid
./self-hosted/bin/quake smoke           # brainfuck 4/4, expr_eval 45 lines baseline
cp self-hosted/bin/quartz self-hosted/bin/backups/quartz-pre-impl-trait-sprint-golden

Phase 1 — Fix generic-monomorphization of impl Trait return values (1-2h)

Goal: close probe 2. A call to fn take_three<T: MyIter>(src: T) with a value from a impl MyIter–returning function must monomorphize correctly.

Work:

  1. In typecheck_generics.qz:tc_infer_type_param_mapping (line 274), when inferring the concrete type for a generic param T from an argument expression, check if the argument is a CALL whose callee’s return annotation starts with "impl ". If so, resolve through tc_lookup_impl_return before recording the mapping.
  2. In tc_substitute_annotation (line 349), confirm that substitution uses the resolved concrete type (not the literal "impl Trait" string). Add an assertion or explicit check.
  3. In the symbol mangler (string_intern.qz:mangle), add a defensive assertion that symbol components contain no whitespace. This catches future analogous bugs loudly.

Tests:

  • Probe 2 compiles, runs, returns the correct answer.
  • Create spec/qspec/impl_trait_spec.qz, seed with probe-2 case plus basic return-position tests.

Verification: quake guard + quake smoke + spec compile/run.

Commit.

Phase 2 — Desugar argument-position impl Trait to implicit generic param (2-3h)

Goal: close probe 5 (silent miscompile). def drain(src: impl MyIter) must desugar to def drain<_impl_0: MyIter>(src: _impl_0) before typechecking.

Work:

  1. Add a new pass resolve_desugar_impl_trait_args in self-hosted/resolver.qz, inserted before resolve_desugar_multiclause. For each NODE_FUNCTION:
    • Walk the parameter list. For each param whose annotation starts with "impl ":
      • Generate synthetic type-param name _impl_N where N is the occurrence index
      • Strip "impl ", extract the trait name
      • Rewrite the parameter annotation to _impl_N
      • Append _impl_N: TraitName to the function’s type_params and type_param_bounds strings
  2. Handle coexistence with existing type parameters: prepend synthetic params to the existing list, don’t replace.
  3. Preserve line/col spans so errors on the synthetic param point back to the original impl Trait annotation.

Tests: probe 5 returns 302, single-impl still works (probes 3/4), coexistence with explicit <T> works. Add cases to impl_trait_spec.qz.

Verification: full suite + regression compile of collection_stubs_spec.qz and match_unknown_variant_spec.qz (touches the resolver, regressions can cascade).

Commit.

Phase 3 — Trait bound satisfaction through impl Trait (1h)

Goal: fn sum<T: Iterator<Int>>(src: T) accepts impl Iterator<Int>–typed arguments, with auto-trait propagation.

Work:

  1. In tc_validate_trait_bounds (typecheck_registry.qz:1041), when the argument annotation starts with "impl ", resolve through tc_lookup_impl_return before checking the bound.
  2. Auto-trait propagation: if the bound is Send and the concrete type is Send, accept. Re-use typecheck_concurrency.qz’s existing Send/Sync check on the resolved concrete.
  3. Error messages name both the declared impl Trait signature and the resolved concrete type.

Tests: positive/negative bound, Send auto-trait case.

Verification: full suite.

Commit.

Phase 4 — Iterable<T> redesign (1h)

Goal: replace the dead stub with a real impl Trait–based trait and add impls for every user collection.

Work:

  1. Rewrite trait Iterable<T> in std/traits.qz:133-137:
    trait Iterable<T>
      def iter(self): impl Iterator<T>
    end
  2. Add impl Iterable<Int> for X end to each of std/collections/{stack,queue,deque,linked_list,priority_queue,sorted_map}.qz.
  3. Add def sum_all<C: Iterable<Int>>(collection: C): Int qspec test that calls each collection.

Verification: full suite.

Commit.

Phase 5 — Fix bounded generic struct/impl hang (1-8h, REQUIRES INVESTIGATION)

Goal: struct MapIter<I: Iterator<Int>> { source: I, f: Fn(Int): Int } must typecheck without hanging. Same for impl<T: Counter> Counter for Wrapper<T>.

Reproducer (canonical):

trait Counter
  def tick(self): Int
end
struct Wrapper<C: Counter>
  inner: C
end
def main(): Int = 0

Compile with ./self-hosted/bin/quartz --no-cache /tmp/repro.qz. Today this hangs / OOMs.

Investigation steps (not implementation):

  1. First: file the bug in the ROADMAP with the reproducer. Name it BOUNDED-STRUCT-FIELD-HANG. This guarantees the hole is recorded even if the fix is harder than expected.
  2. Run with a short timeout and a very verbose eputs trace on tc_check_struct_def and tc_register_struct to see where the loop is. The hang is almost certainly in the recursion between bound checking and type-param resolution — a missing memoization or cycle guard.
  3. Compare against the working path: bounded generic functions (def f<T: Counter>(...) — probe 4 shows this works). Find what that path does differently and why the struct path doesn’t.
  4. Hypothesis to test first: the struct’s bound check re-enters tc_parse_type on the param annotation in a way that re-evaluates the bound recursively, with no cycle guard. Quartz already has cycle guards for Send/Sync (g_send_checking/g_sync_checking in typecheck_registry.qz). The pattern is known — the fix may be as simple as adding a similar guard for bounded struct field evaluation.

Work (after root cause is understood):

  1. Add the appropriate cycle guard or memoization to the typecheck path that evaluates struct W<C: Bound> field references.
  2. Do NOT short-circuit the bound check itself — we still need the bound enforced, we just need the enforcement to terminate.
  3. Verify the bound is actually enforced: write a negative test where struct W<C: Counter> { inner: C } is instantiated with a W<NonCounterStruct> and confirm QZ1xxx bound-violation fires.
  4. Verify probe 5 still returns 302 (no resolver desugar regression).
  5. Verify impl<T: Counter> Counter for Wrapper<T> also works.

Hard constraint: if the investigation reveals the fix is genuinely a multi-session overhaul (>8h of work), stop and decide with the user whether to (a) continue and accept the sprint will span an extra session, (b) fall back to unbounded-struct + bounded-function form for std/iter.qz and file the hang fix as a separate sprint. Do not silently pick. User has pre-approved option (a) if the fix is ≤8h. If it blows past 8h, it’s a separate conversation.

Tests:

  • Canonical reproducer compiles cleanly in <1s.
  • Positive: struct W<C: Counter> { inner: C } compiles and w.inner.tick() dispatches correctly.
  • Negative: W<NonCounter> fails with a bound violation error at the instantiation site.
  • Probe 5 still returns 302.
  • Full qspec no-regression sweep: compile every spec/qspec/*.qz and confirm no new failures.

Verification: full suite + guard + smoke + the no-regression qspec sweep.

Commit. Expect a meaningful diff in typecheck — this is the most unknown phase.

Phase 6 — std/iter.qz modernization (3-5h)

Goal: rewrite std/iter.qz using the correct struct MapIter<I: Iterator<Int>> form now that Phase 5 unblocks it.

Work:

  1. Rewrite each adapter struct:
    struct MapIter<I: Iterator<Int>>
      source: I
      f: Fn(Int): Int
    end
    
    impl Iterator<Int> for MapIter<I>
      def next(self): Option<Int>
        var v = $try(self.source.next())
        var f: Fn(Int): Int = self.f
        return Option::Some(f(v))
      end
    end
  2. Rewrite each adapter constructor to return impl Iterator<Int>:
    def iter_map<I: Iterator<Int>>(src: I, f: Fn(Int): Int): impl Iterator<Int>
      return MapIter { source: src, f: f }
    end
  3. Rewrite each terminal to take impl Iterator<Int> directly:
    def iter_sum<I: Iterator<Int>>(src: I): Int
      var it = src
      var total = 0
      while true
        match it.next()
          Some(v) => total = total + v
          None => return total
        end
      end
      return total
    end
  4. Update the file header doc comment — delete the “accept Fn(): Option<Int> because existential erasure” explanation. The workaround no longer applies.
  5. Adapter list (all must be rewritten): iter_map, iter_filter, iter_take, iter_skip, iter_take_while, iter_enumerate, iter_zip, iter_chain, iter_flat_map, iter_scan.
  6. Terminal list (all must be rewritten): iter_collect, iter_sum, iter_count, iter_fold, iter_any, iter_all, iter_find, iter_for_each, iter_min, iter_max.

Tests: create/update spec/qspec/iter_spec.qz to exercise every adapter and terminal via the new no-closure API. Include at least one 3-deep combinator chain.

Verification: full suite.

Commit. Big diff — expect ~500 lines changed in std/iter.qz.

Phase 7 — Opacity enforcement (2-4h)

Goal: make the opaque-type identity a hard abstraction boundary. Callers cannot name the concrete type of an impl Trait return in a source-level annotation. Refactoring a function’s concrete return type becomes a non-breaking change.

Design (from docs/IMPL_TRAIT_DESIGN.md §4.2.1): each impl Trait–returning function has an opaque identity impl Trait@module$funcname. Two calls to the same function yield the same identity; calls to different functions yield distinct identities. Method dispatch still resolves to the concrete type through tc_lookup_impl_return_concrete, but annotation matching treats opaque identities as distinct from concrete types.

Work:

  1. Add tc_lookup_impl_return_opaque(func_name) in typecheck_registry.qz — returns "impl TraitName@func_name" as the opaque marker string.
  2. Rename existing tc_lookup_impl_return to tc_lookup_impl_return_concrete (it already returns the concrete type string). Audit all callers and classify each as “method dispatch path” (keep using concrete) or “annotation-propagation path” (switch to opaque).
  3. Update tc_infer_expr_type_annotation (typecheck_generics.qz:126-161): when a CALL’s callee returns impl Trait, return the opaque marker as the annotation, not the concrete type. This propagates the opaque identity through var bindings.
  4. Update tc_types_match / tc_parse_type — add handling for "impl Trait@F" marker strings:
    • Two opaque markers match iff they have the same origin function (exact string match after resolving module prefixes).
    • A concrete type does NOT match an opaque marker (rejects var x: Counter = make_counter()).
    • An opaque marker passed to a bound-typed parameter (<T: Iterator<Int>>) satisfies the bound iff the underlying concrete type satisfies it — resolve through tc_lookup_impl_return_concrete for the bound check only.
  5. UFCS method dispatch (typecheck_expr_handlers.qz lines 2010-2200): when the receiver has an opaque-marker annotation, resolve to concrete via tc_lookup_impl_return_concrete and dispatch normally. The concrete type is used for method lookup only, never leaks back as the binding annotation.
  6. Add QZ0167 (opaque cannot be named in explicit annotation). Error message: “cannot assign opaque type impl Iterator<Int>@iter to explicit type VecIter. Remove the type annotation: var x = iter(v).”
  7. Add QZ0168 (opaque types from different origins unified). Error message: “two functions returning impl Trait produce incompatible opaque types even when the underlying concrete types match. Hoist the shared concrete type explicitly if you need unification.”

Refactor-safety check: write a test where std/collections/stack.qz:iter()’s concrete return type is changed (e.g. from StackIter to a hypothetical IndexedStackIter) and confirm that no existing caller needs to change. This is the entire payoff.

Tests:

  • Positive: var x = iter(v); x.next() — opaque type, method dispatches correctly.
  • Positive: passing an opaque-typed value to a bounded generic satisfies the bound.
  • Positive: two calls to the same function produce unifying types.
  • Negative: var x: VecIter = iter(v) → QZ0167.
  • Negative: if cond then make_a() else make_b() end where both return impl Trait → QZ0168.
  • Negative: Vec<impl Iterator<Int>> with pushes from two different origins → QZ0168 at push site.
  • Refactor-safety: change a concrete return type, confirm no caller breaks.

Verification: full suite.

Commit. This is the opacity phase — audit carefully that method dispatch still resolves correctly through the opaque marker path.

Phase 8 — Error diagnostics polish (1h)

Goal: round-trip the full error code set (QZ0160-QZ0168) with clear help messages and explain entries.

Work:

  1. Add QZ0163 (recursive impl Trait inference) detection at inference time by tracking in-progress functions.
  2. Add QZ0165 (concrete type unresolvable — no return to infer from).
  3. Add QZ0166 (reserved for future dyn Trait incompatibility; emit a forward-compat stub if anyone writes Vec<Box<dyn TraitWithImplReturn>>).
  4. Ensure QZ0167 and QZ0168 (from Phase 7) have clear help messages.
  5. Add explain entries for every new error code via self-hosted/error/explain.qz.
  6. Each error includes a help line suggesting the concrete rewrite.

Tests: negative qspec tests triggering each error code.

Verification: full suite.

Commit.

Phase 9 — Comprehensive test coverage (1h)

Goal: consolidate the 22-case test matrix from design doc §4.8 into spec/qspec/impl_trait_spec.qz.

Work:

  1. Audit the spec against the 22 cases listed in §4.8 (+2 for opacity: refactor-safety, QZ0167/8 negatives).
  2. Add any cases missing from earlier phases.
  3. Each case has a one-line comment explaining what it tests and which probe/bug it closes.
  4. Full compile + run: should be 24+ cases all green.

Commit.

Phase 10 — Documentation (30m)

Goal: impl Trait (and opacity) fully documented.

Work:

  1. New section in docs/QUARTZ_REFERENCE.md: “Opaque Return Types (impl Trait)”. Cover return position, argument position (as generic sugar), opacity semantics (you can’t name the concrete type), trait bound propagation, refactor safety, error codes.
  2. Anti-hallucination table in CLAUDE.md: add a row stating impl Trait exists and is opaque.
  3. Add examples/impl_trait_demo.qz showing a closure-free iterator pipeline:
    def main(): Int
      var nums = vec_new<Int>()
      nums.push(1)
      nums.push(2)
      nums.push(3)
      nums.push(4)
      nums.push(5)
      var sum = iter_sum(iter_filter(iter_map(vec_iter(nums), x -> x * 10), x -> x > 20))
      return sum  # 30 + 40 + 50 = 120
    end
  4. Update docs/ROADMAP.md:
    • Mark any impl Trait work items as resolved with commit refs.
    • File IMPL-TRAIT-TAIT (type-alias impl Trait) and IMPL-TRAIT-STRUCTURAL (tuples of opaque types) as open follow-ups.
    • File IMPL-TRAIT-RPITIT as open unless Phase 11 landed.
    • Ensure BOUNDED-STRUCT-FIELD-HANG is marked resolved.

Commit.

Phase 11 (stretch) — RPITIT (2-4h)

Goal: impl Trait return type on trait methods. trait Iterable<T> { def iter(self): impl Iterator<T> } — each impl Iterable<Int> for Collection provides a method whose concrete return type is known at the impl site.

Only start this if Phases 1-10 are committed and guard+smoke are green. If you’re out of time, file it as IMPL-TRAIT-RPITIT in the roadmap and stop.

Work:

  1. Extend tc_register_impl to track per-impl-method concrete return types when the trait method declares impl Trait.
  2. At impl-method typecheck, infer concrete return type from the method body (tc_infer_impl_concrete_type machinery exists).
  3. Register the concrete type keyed by (impl_idx, method_name) in addition to func_name.
  4. At call-site dispatch, when resolving a trait method that returns impl Trait, look up the per-impl concrete type via the receiver’s inferred type.
  5. Emit QZ0166 forward-compat stub if anyone tries to store such a value in a dyn-trait-like position (Quartz has no dyn Trait today so this is defensive).

Tests: add to impl_trait_spec.qz. Bounded generic over Iterable<Int>, caller invokes c.iter(), expects the per-impl concrete type for each collection.

Commit.

What NOT to do

  • Don’t skip quake guard or quake smoke between phases. The impl Trait work touches typecheck, resolver, potentially the mangler — exactly the layers where subtle regressions hide behind fixpoint.
  • Don’t skip Phase 5 (bounded-struct fix) and go straight to Phase 6 (iter.qz). The user has explicitly pre-approved bundling Phase 5 into this sprint. Using unbounded structs as a workaround is a silent compromise. If Phase 5’s root cause turns out to require >8h, stop and talk to the user — don’t silently fall back.
  • Don’t skip Phase 7 (opacity). The user has explicitly approved bundling opacity into the MVP. Transparent mode is a silent compromise that ships the wrong API. If Phase 7 is harder than estimated, stop and talk to the user.
  • Don’t let errors be silent. Every edge case either (a) compiles correctly, (b) emits a clear error code with a help message, or (c) is filed with a reproducer. No silent miscompiles, no “works on my machine.”
  • Don’t leave compat shims. std/traits.qz:133-137 gets deleted and replaced in the same commit as Phase 4. std/iter.qz’s closure API gets deleted and replaced in the same commit as Phase 6. No “legacy” forms.
  • Don’t attempt RPITIT before the MVP is green. Phase 11 is genuinely optional. A correct, committed, documented, opaque MVP beats a half-finished RPITIT.
  • Don’t trust the Phase 5 estimate. 1-8h is the honest range because I haven’t root-caused the hang. Run the investigation steps in Phase 5 before writing any fix code — understand the bug, then fix it.

Estimation

Total quartz-time: 15-23 hours (3 sessions likely, 2 possible if Phase 5 is quick).

PhaseEstimateConfidence
1 — Mangler + monomorphization fix1-2hMedium
2 — Arg-position desugar2-3hMedium
3 — Trait bound propagation1hHigh
4 — Iterable<T> redesign1hHigh
5 — Bounded-struct hang fix1-8hLow — needs investigation
6 — iter.qz modernization3-5hMedium
7 — Opacity enforcement2-4hMedium — new design surface
8 — Error diagnostics1hHigh
9 — Test coverage1hHigh
10 — Documentation0.5hHigh
11 — RPITIT (stretch)2-4hLow — only if time remains
MVP total (phases 1-10)13.5-26.5h
Realistic mid-range~18h
With stretch15.5-30.5h

The Phase 5 estimate is the widest because I didn’t root-cause the hang — it could be a missing cycle guard (1-2h) or something deeper in the inference recursion (5-8h). The handoff instructs the next session to investigate first, then fix.

When the sprint is done

Deliverables:

  • Clean tree, all phases committed.
  • Guard at ≥2260 functions (count may shift ±20 from the new trait/impl registrations).
  • Smoke baseline still the full 22/22 expr_eval pass.
  • spec/qspec/impl_trait_spec.qz with 24+ passing cases including opacity refactor-safety and all new error codes.
  • std/iter.qz with zero Fn(): Option<Int> signatures in the public API — rewritten against impl Iterator<Int> throughout.
  • std/traits.qz’s Iterable<T> trait is real (no default body) with impl Iterable<Int> on all six user collections.
  • std/collections/*.qz unchanged at the inherent-method level — the impl Iterable<Int> for X end lines are additive.
  • docs/QUARTZ_REFERENCE.md has a complete “Opaque Return Types” section including opacity semantics.
  • examples/impl_trait_demo.qz exists, compiles, runs, returns 120.
  • ROADMAP updated: BOUNDED-STRUCT-FIELD-HANG marked resolved, IMPL-TRAIT-TAIT and IMPL-TRAIT-STRUCTURAL filed as open, IMPL-TRAIT-RPITIT marked resolved if Phase 11 landed else open.
  • The four probes (2, 5) that currently fail are now passing; the two new silent miscompile negatives (QZ0167, QZ0168) fire correctly; refactor-safety test demonstrates the opacity payoff.
  • Bounded generic struct fields work correctly — the canonical Wrapper<C: Counter> reproducer compiles cleanly in <1s.
  • Opacity demonstrably prevents var x: VecIter = vec_iter(v) from compiling, with a QZ0167 help message pointing at the fix.

Update this handoff doc with a “Results” section before ending the sprint, so the next session sees what actually landed versus what was planned. Include the Phase 5 actual-time if it diverged meaningfully from estimate — that calibration data is valuable for future sprints.

User-confirmed decisions

Two decisions made during planning and pre-approved by the user on Apr 13 2026:

  1. Bundle the bounded-generic-struct OOM fix into this sprint as Phase 5. iter.qz ships with the correct struct MapIter<I: Iterator<Int>> form, not the unbounded-struct workaround. If the fix is harder than 8h, stop and re-scope with the user.

  2. Ship opacity in the MVP as Phase 7. The transparent model was a silent compromise rooted in the wrong framing (“we don’t have package versioning yet” misses that opacity also protects intra-project refactoring). Cost estimate ~2-4h, worth it to ship the correct API once.

No other open questions. The next session can proceed straight from Phase 1 without needing further user input unless Phase 5 diverges from estimate.


Results (Apr 13, 2026 — end of first sprint session)

Summary: Phases 1, 2, 3, 5, and 10 landed cleanly, plus a deep fix for GENERIC-IMPL-ON-GENERIC-STRUCT-MONO (pre-existing pre-sprint bug discovered while investigating Phase 6). Phase 6 partially landed at the infrastructure level — tc_lookup_impl now matches on base struct names, mir_flatten_type_for_symbol escapes generic type args for LLVM symbols, and PTYPE base kind resolves in UFCS dispatch — but the full std/iter.qz rewrite is blocked on a NESTED-MONO-UFCS-TYPE-PARAM issue that needs its own sub-sprint. Phases 4, 7, 8, 9, 11 deferred. The impl Trait return-position and argument-position holes (probes 2 and 5) are closed, BOUNDED-STRUCT-FIELD-HANG is fixed with real bound enforcement, generic-impl-on-generic-struct dispatch works end-to-end, and 24 impl Trait qspec cases are green.

Landed

PhaseCommitSummary
1 — monomorphization + mangler73ed7a73Stamp func.str2 with concrete type after impl-return inference so MIR’s ast_get_str2 reads concrete instead of "impl Trait". Defensive whitespace-in-spec-name assertion in mir_lower_specialized_function. Closes probe 2.
2 — arg-position desugar34f39b99New resolve_desugar_impl_trait_args pass in self-hosted/resolver.qz, runs before resolve_desugar_multiclause in both the import-time path and the main-module path. Rewrites impl TraitName arg annotations to synthetic _impl_N type params with bounds appended to the function’s type-param list. Closes probe 5 silent miscompile.
3 — trait bound propagation79317243tc_validate_trait_bounds now handles NODE_CALL arg shapes, resolving callee’s impl Trait return through tc_lookup_impl_return before the bound check. Previously only NODE_IDENT and NODE_STRUCT_INIT were handled, so direct-call patterns silently bypassed bound validation.
5 — BOUNDED-STRUCT-FIELD-HANGff02f1bb, ace28b69Parser fix (ps_parse_optional_type_params consumes :Trait+Trait bounds and preserves them inline), typechecker propagation (tc_infer_struct_type_args / tc_substitute_field_type strip bounds before name-matching), new tc_check_struct_type_param_bounds validator called from tc_expr_struct_init. Follow-up: skip bound enforcement when the concrete is a type parameter or primitive (non-struct, non-enum) so MapIter<I> inside a bounded generic function doesn’t false-alarm.
Bonus: GENERIC-IMPL-ON-GENERIC-STRUCT-MONOd8eb56e9Discovered while investigating Phase 6 blockers. UFCS dispatch on a receiver typed as a parameterized struct (PTYPE, e.g. Lift<Base> stored via var l = make_lift(b)) was silently picking the wrong method. Root cause: tc_expr_call compared the raw PTYPE id against TYPE_STRUCT constant rather than resolving through tc_base_kind. One-line fix.
Phase 6 infrastructure4af390bfThree prerequisites for the full iter.qz rewrite: (1) tc_lookup_impl matches on base struct name so impl X for Foo<T> satisfies a bound check against Foo; (2) mir_lower_specialized_function + the call-site queue path flatten Foo<Bar>Foo$Bar in spec symbol names since </>/, are invalid in LLVM identifiers; (3) init_base_kind_struct == TYPE_STRUCT check widens the let-binding struct-name tracking to PTYPE-based struct returns. Together these let def iter_map<I: Iterator<Int>>(src: I, f: ...): MapIter<I> compile and call-site monomorphize, but the caller’s binding still carries the unsubstituted MapIter<I> annotation — see NESTED-MONO-UFCS-TYPE-PARAM below.
Phase 10 — Documentation + demo(this commit)docs/QUARTZ_REFERENCE.mdimpl Trait (LF2.7)” section rewritten to cover return + argument positions, trait-bound propagation, bounded generic struct fields, error codes, and the three open limitations. New examples/impl_trait_demo.qz exercises probes 1/2/5/6 end-to-end and returns 126.

Impact tally:

  • Probes 1, 2, 3, 4, 5, 6: all ✅ (were ❌ ❌ ✅ ✅ ❌ ✅ at sprint start)
  • spec/qspec/impl_trait_spec.qz: 24/24 green (was 11/11 — 13 new cases added)
  • examples/impl_trait_demo.qz: 126/126
  • quake guard: 2265 functions, fixpoint verified
  • smoke: brainfuck 4/4, expr_eval 45-line baseline (22/22 expr tests)
  • Regression sweep (abstract_trait_spec, traits_spec, hybrid_ufcs_traits_spec, arity_overload_spec, collection_stubs_spec, match_unknown_variant_spec): all green
  • BOUNDED-STRUCT-FIELD-HANG was ~1h actual vs 1-8h estimated — parser issue, not the typechecker-recursion hypothesis in the handoff. See Phase 5 commit body for the root cause.
  • GENERIC-IMPL-ON-GENERIC-STRUCT-MONO was a completely pre-existing dispatch bug that the sprint serendipitously discovered. 1-line fix.

Deferred

PhaseWhyBlocker
4 — Iterable<T> redesignNeeds RPITIT (Phase 11) semantics. The trait method def iter(self): impl Iterator<T> is RPITIT by definition — there’s no transparent way to declare “returns some concrete implementing Iterator<T>” without Phase 11’s per-impl concrete return tracking.Phase 11 (RPITIT) has to land before Phase 4 can. Or: ship a different API shape for Iterable<T> that doesn’t require RPITIT.
6 — std/iter.qz modernizationPartial: infrastructure (commits d8eb56e9, 4af390bf) closes two of the three known blockers. Still open: NESTED-MONO-UFCS-TYPE-PARAM — inside a bounded generic body like def sink<I: Iter>(src: I), UFCS dispatch on it.i_next() during monomorphization reuses the wrong impl (caller’s concrete leaks into the spec body). End-to-end prototype returns garbage even though each isolated layer compiles.Need per-monomorphization substitution of type-parameter receivers to their concrete types AND re-dispatch through the resolved impl. Substantial typechecker/MIR work, ~2-3 quartz-days. Schedule as its own sub-sprint before reopening Phase 6.
7 — opacity enforcementAttempt surfaced a cascade through UFCS method dispatch + scope binding annotations + source-level type matching. The handoff’s 2-4h estimate assumed a small, localized change but the scope binding currently unifies struct_name (for dispatch) and type_ann (for source-level matching) — splitting them into “dispatch type” vs “display type” is the right shape but bigger than 4h. Concrete path forward captured in the task tracker.Not blocked on any infrastructure gap; just needs a dedicated sub-sprint with room for the cascade.
8, 9, 10Polish phases; skipped because there’s no opacity (QZ0167/QZ0168 have nothing to test yet) and because phases 4 and 6 haven’t landed to document.Should run alongside whichever follow-up sprint reopens Phase 6 or Phase 7.
11 — RPITIT stretchNot attempted.Same follow-up sprint.

Follow-up filings in ROADMAP

  • IMPL-TRAIT-SEND-AUTO — Send/Sync auto-trait does not propagate through impl Trait args without an explicit impl Send for X (filed at Phase 3 commit).
  • BOUNDED-STRUCT-FIELD-HANG — marked resolved (filed at Phase 5 commit).
  • STRUCT-GENERIC-ARG-INFERENCEWrapper { inner: Cell { ... } } with implicit type args returns "Struct" as the inferred type (pre-existing, filed during Phase 5 investigation).
  • To file in next session: GENERIC-IMPL-ON-GENERIC-STRUCT-MONOimpl Iterator<Int> for MapIter<I> symbol mangling is broken; lift(Base { val: 4 }).g_next() dispatches to @MapIter$g_next which is never defined (monomorphization expects @MapIter$Base$g_next).
  • To file in next session: IMPL-TRAIT-OPACITY — Phase 7 work blocked on split of scope binding’s struct_name/type_ann into separate dispatch-type and display-type slots.
  • Existing open: IMPL-TRAIT-TAIT, IMPL-TRAIT-STRUCTURAL, IMPL-TRAIT-RPITIT — not yet formally filed, should be added when Phase 4/6/7/11 sprint opens.
  • New blocker discovered Apr 14 Phase 6 reattempt → RESOLVED Apr 14: GENERIC-TRAIT-BOUND-HANG — a function signature like def f<I: Iterator<Int>>(src: I) hung the typechecker at ~40 GB RSS forever. Any parameterized trait used as a bound triggered the hang; non-generic traits as bounds worked fine. Root cause: not the typechecker — the parser. ps_parse_function’s type-param loop consumed only a bare TOK_IDENT for the trait name (line 5234), then bailed back to the outer while ps_check(TOK_GT) == 0 with the cursor still parked on the unconsumed < of Iter<Int>. ps_expect on a mismatch does not advance, so the loop spun forever, allocating a fresh string each iteration. (Same shape as Phase 5’s BOUNDED-STRUCT-FIELD-HANG, which fixed the struct version of the same parser site.) Fix: new ps_parse_trait_bound_name helper consumes the optional <...> after the trait IDENT (handling >> like ps_parse_type), called from both the function-bound and where-clause sites, plus ps_parse_optional_type_params for struct bounds. Added a safety break in the function-bound outer loop. Downstream: tc_validate_trait_bounds in typecheck_registry.qz strips generic args from the trait name before lookup; mir_has_only_iterator_bounds and mir_get_iterator_param_names in mir.qz do the same. Regression tests: test_gtb_parameterized_bound_compiles and test_gtb_two_arg_parameterized_bound in spec/qspec/impl_trait_spec.qz (47/47 green, was 45/45). Sample analysis of the hung process showed all time in parser$ps_parse_function +2204qz_alloc_str_platform_memmove, confirming string-explosion in the parser loop. Total fix: ~3 quartz-hours, 4 source files touched (parser.qz, typecheck_registry.qz, mir.qz, impl_trait_spec.qz). Phase 6 std/iter.qz modernization is now unblocked — the first thing it can demo is a bounded generic taking Iterator<Int>.
  • NEW (Apr 14 Phase 6 partial): NESTED-MONO-UFCS-TYPE-PARAM cross-module resolved. Commit extends mir_lower_specialized_function to extract the module prefix from base_name into MirGenericState.spec_current_module_prefix, and A8 trait-rewrite in mir_lower_expr_handlers.qz now tries {module}${Type}${method} candidates when the unprefixed lookup misses. Applies to both the per-param-type-override path and the spec_current_type fallback. Regression test in impl_trait_spec.qz via new fixture spec/qspec/fixtures/nmutp_xmod_module.qz: test_nmutp_xmod_wrapped_generic_call — bounded generic wrapper + terminal defined in the fixture module, called from the main spec file, must return 30 (2+4+6+8+10). This was the single-file NMUTP fix from commit 0bda4a66 extended to cross-module dispatch. 44/44 → 45/45 impl_trait tests.

Next session priorities

  1. Fix GENERIC-IMPL-ON-GENERIC-STRUCT-MONO first — it’s the hardest blocker and blocks Phase 6 completely.
  2. Fix STRUCT-GENERIC-ARG-INFERENCE so iter.qz adapter call sites can omit explicit type args.
  3. Land Phase 6 full rewrite.
  4. Land Phase 7 opacity with the split-type-ann approach.
  5. Land Phase 4 with Phase 11 RPITIT support.
  6. Polish phases 8, 9, 10.

The sprint ran ~4 hours this session for phases 1, 2, 3, 5 plus investigation on 6 and 7. Total sprint budget was 15-23h; remaining work is ~10-15h split across a dedicated generic-impl infrastructure sprint and then the Phase 4/6/7/11 sprint.

Phase 11 (RPITIT) — partial landing

Status: direct-receiver dispatch lands. Bounded-generic dispatch deferred (NESTED-MONO-UFCS-TYPE-PARAM blocker).

What landed

The single-impl and multi-impl direct-receiver cases now work end-to-end. A trait can declare def m(self): impl OtherTrait, multiple impls can each return a different concrete type, and recv.m().method_on_concrete() dispatches correctly through the per-impl concrete return.

Two compiler bugs were fixed to make this work:

  1. Impl method func_name collision in the typecheck registry (self-hosted/quartz.qz): the resolver flattens impl methods into all_funcs with the mangled name (BoxB$make), but the AST node’s str1 still held the bare source name (make). tc_function reads func_name from str1 and passes it to tc_register_impl_return. With multiple impls of the same trait method, the bare name collided in the registry and only the first impl won — call sites then dispatched through the wrong concrete return type. Fix: a new “Phase 4.3.5” pass writes entry.name into the function AST node’s str1 after all impl blocks have been registered (Phase 4.0c + tc_program Phase 1.6) and before the function body type-check loop. The pass is unconditional for tag-0 entries; it’s a no-op for top-level functions whose mangled name already matches str1, and a real rename for impl methods.

  2. Duplicate function registration clobbers return annotation (self-hosted/middle/typecheck.qz): tc_register_impl_block is called twice for main-module impl methods — once at quartz.qz Phase 4.0c (or directly via tc_program Phase 1.6 for the main module) and again from tc_program Phase 1.6 when tc_program walks the main module’s items. The second call re-pushes a function entry with no return-annotation slot set, and the registry’s last-wins lookup picks up the empty entry. Fix: in tc_register_impl_block, before registering a method, check tc_lookup_function_return_annotation — if it’s already populated by a prior tc_register_function_signature_named call (Phase 4.1), skip the duplicate registration entirely.

Tests added

Three positive tests in spec/qspec/impl_trait_spec.qz, gated on a new describe("RPITIT — impl Trait in trait method returns") block:

  1. test_rpitit_single_impl — single impl, direct receiver, baseline.
  2. test_rpitit_multi_impl_distinct_dispatch — TWO impls of the same RPITIT trait return different concrete types. This is the load-bearing regression test for the registry-collision fix. Without the fix this returned 9 (5+4) instead of 45 (5+40).
  3. test_rpitit_generic_trait — generic RPITIT trait Container<T> { def fetch(self): impl Wrapper<T> }.

Total impl_trait_spec count: 39 → 42 passing.

What did NOT land

IssueReproWhy deferred
Bounded-generic dispatch through RPITITdef use_src<S: Source>(s: S): Int = s.make().g() returns 9 instead of 45 with two distinct Source impls.NESTED-MONO-UFCS-TYPE-PARAM territory. The receiver inside the bounded generic body is typed as the type parameter S, and UFCS dispatch on s.make() during monomorphization needs to re-resolve through each specialization’s concrete impl. Already filed in this handoff (Deferred row “6 — std/iter.qz modernization”) — same blocker as the Phase 6 full rewrite.
return None / return Option::Some(v) in trait impl method bodiesA trait impl method def next(self): Option<Int> { ... return None } produces broken IR — %None is referenced as a local variable and llc errors with “use of undefined value %None”. Same with Option::None qualified. The typechecker silently misses the unbound None lookup that would fire as QZ0403 in a non-impl-method context. The custom_iterator_spec.qz test file works around this by importing traits first, which presumably brings the constructor names into a different scope path.Pre-existing bug, surfaced while writing the RPITIT Iterator test. Not RPITIT-specific. File as OPTION-CTOR-IN-IMPL-BODY in roadmap. Not blocking Phase 4 by itself, but blocks any RPITIT Iterator test that exercises the canonical return None path until fixed.
QZ0166 enforcementdyn Trait does not exist in Quartz.Phase 11 (and Phase 8) leave QZ0166 as a pure stub. There’s nothing to enforce because there’s no dyn-trait position to flag. The error code can stay reserved.

Estimated remaining work for full RPITIT

  • NESTED-MONO-UFCS-TYPE-PARAM fix (~2-3 quartz-days): per-monomorphization substitution of type-parameter receivers + re-dispatch through resolved impl. This is the same fix Phase 6 needs and the Iterable redesign needs. One sub-sprint covers all three.
  • OPTION-CTOR-IN-IMPL-BODY fix (~1-2h, low confidence): trace why return None resolves correctly under one import path but not another. Likely a scope-binding hole specific to impl method bodies, possibly related to the import traits side effect.
  • Once those two land, the remaining RPITIT work is purely test/example/doc.

Phase 11 actual time

~3 quartz-hours including the bounded-generic investigation that decided to file rather than fix.