Quartz v5.25

Overnight Handoff — Binary DSL Phase 2 Track A landed; B and C open

Baseline: 801ed0c5 on trunk (Phase 2 Track A — computed fields — shipped). Fixpoint: 2088 functions (was 2087 before Track A). Tests: 86 binary-DSL green (80 prior + 6 new in binary_computed_spec.qz).

Design doc (canonical): docs/design/BINARY_DSL.md — still the locked 12 decisions.

Prior handoffs (read for context if anything’s unclear):


What shipped this session (1 commit, 801ed0c5)

Track A — Computed fields. name: type = <expr> inside binary {} blocks. On .encode() the expression is evaluated fresh with self bound to the user-supplied struct, and the result overrides whatever value the user passed at construction. On .decode() the field is read from the wire like any other (v1 trusts; v2 may validate).

type Pkt = binary {
  magic:    u16be = 0xcafe
  counter:  u8
  doubled:  u8 = self.counter * 2
  payload:  bytes
}

Spec: spec/qspec/binary_computed_spec.qz — 6 tests covering constant override, prior-field self.*, later-in-block self.*, multi-field arithmetic, computed + variable-width tail, repeated-encode freshness.


D16 — Clone-and-mutate architecture for computed fields

The pack path was kept unchanged. Instead of extending PACK to take per-field scalar overrides, MIR-lowering of .encode() detects computed fields and builds a fresh clone carrying overrides. Codegen sees a normal struct and emits the usual load/store prefix sequence.

Why this is the right call:

  • Zero codegen churn. cg_emit_binary_pack and its prefix helpers are unchanged.
  • Expression lowering reuses the normal MIR pipeline (function calls, arithmetic, closures — whatever the user writes Just Works).
  • self is bound via mir_ctx_bind_var("self", slot) + mir_ctx_mark_struct_var("self", type) + mir_emit_store_var("self", val), then torn down via vec-size restore on the bindings / struct_types lists. Shadowing-safe.
  • Cost is one malloc(N * 8) + N loads/stores per encode. Fine for v1; v2 optimization (elide clone when user’s value is unused) is an easy follow-up.

Registry extension: MirProgram.binary_field_compute_asts: Vec<Int> is parallel to the existing name/spec/width vectors. 0 = not computed; otherwise AST node id of the expression. Populated from ast_get_extra(fnode) in mir_collect_binary_layouts.

Parser change: ps_parse_binary_block_field now accepts optional = <expr> after the type. The expression AST handle is stashed in the field’s extras slot (free on NODE_BINARY_FIELD today).


Track A restrictions (filed as v2 follow-ups if a consumer asks)

  1. self.name is mandatory for prior-field references. Bare counter won’t resolve — no implicit-receiver scope yet. File as TA-F1 if a user complains.
  2. No decode validation. If a peer sends a packet with the wrong computed value, we pass it through. Validation at UNPACK time is in the Phase 2 design but out of Track A’s scope — file as TA-F2 when a consumer needs it (typical ask: TCP checksum rejection).
  3. Computed fields are still user-constructible. Pkt { doubled: 0, ... } accepts the user’s value even though .encode() overrides it. Making the constructor elide computed fields would need struct-literal typecheck cooperation — file as TA-F3.
  4. One clone per encode. No liveness analysis to elide the copy. File as TA-F4 (perf, not correctness).

Copy-paste handoff prompt (paste into a fresh session)

Read docs/handoff/overnight-binary-dsl-phase-2-track-a-done.md FIRST.
The earlier phase-2-kickoff has the full Tracks B / C descriptions —
Track A is done (computed fields); pick B or C next.

Starting state (verified at handoff 801ed0c5):
- Trunk clean. Guard stamp valid at 2088 functions. Smoke green.
- 13 binary-DSL specs, 86 tests, all green.
- Session backup from Track A is quartz-pre-binary-phase2-golden; create
  a new fix-specific backup before any compiler work:
    cp self-hosted/bin/quartz self-hosted/bin/backups/quartz-pre-binary-phase2-trackX-golden
  (Substitute trackb / trackc.)

NEVER overwrite the quartz-pre-binary-phase2-trackX-golden snapshot until
the last STEP of the new session is committed AND smoke + targeted specs
pass. The rolling quartz-golden managed by `quake guard` gets overwritten
on every successful build — the fix-specific copy is your escape hatch.

Pick ONE of the two remaining tracks and ship it clean. Don't start
the other until the first is committed end-to-end with tests.

TRACK B — Discriminated unions inside binary {} (higher impact).
  Surface (proposed, see BINARY_DSL.md):
    type Tcp = binary {
      data_offset: u4
      flags:       u8
      ...
      options: [TcpOption]           # uses Track C [T] form
    }
    type TcpOption = binary {
      kind: u8
      match kind
        0 => { }                     # END_OF_LIST, no body
        1 => { }                     # NOP
        2 => { mss: u16be }          # MSS
        8 => { tsval: u32be; tsecr: u32be }  # Timestamps
      end
    }
  Semantics:
    - Discriminator is always the FIRST field, is a primitive integer.
    - Each variant adds additional field(s) after the discriminator.
    - Decode reads discriminator, dispatches to variant layout.
    - Encode: the Quartz value is an enum with the discriminator baked
      in; pack writes discriminator + variant body.
  Scope: parser (match inside binary block), typecheck (variant type
  registration), MIR (new opcode OR extend PACK/UNPACK with a variant-
  dispatch indirection), codegen. Commit the new enum + variant-aware
  prefix emitter as a coherent chunk.
  Size estimate: 800-1200 lines.
  Spec: spec/qspec/binary_union_spec.qz — TCP options, PE section
  kinds (.text / .data), ELF section header types.

TRACK C — Array forms [T; n] / [T; field] / [T] (primitive elements).
  Surface (parser already accepts):
    type DnsQuery = binary {
      id:         u16be
      flags:      u16be
      qdcount:    u16be
      questions:  [u8; qdcount]        # count-prefixed
      rest:       [u8]                 # rest-of-stream
    }
    type UuidSlot = binary {
      uuid: [u8; 16]                   # fixed-length
    }
  Semantics (primitives only in first pass — nested binary blocks in
  element positions are a Phase 2d follow-up):
    - Struct field presents as Vec<Int> to the user (all numeric
      primitives map to Int).
    - PACK: loop N times, encode each element as its primitive type
      at current cursor.
    - UNPACK: allocate Vec, loop N times, decode primitive, push.
    - [T; n]     — N known at codegen time.
    - [T; field] — N loaded from prior struct field slot at runtime.
    - [T]        — N computed from (remaining_bytes / element_size).
  Scope: typecheck (_tc_bin_field_annotation should return "Vec<Int>"
  for array specs — currently returns "Int" placeholder), codegen
  (add classes -10..-12 to _cg_bin_var_spec_class, extend both
  pack_variable and unpack_variable tail loops).
  Size estimate: 200-400 lines.
  Spec: spec/qspec/binary_arrays_spec.qz.

Recommendation: Track B is higher impact per the kickoff doc. Track C
is the smaller scope and fills a Phase 1 gap (the only Phase 1
deliverable left out). Pick based on session energy / appetite.

Workflow per STEP (identical to prior phases):
1. Write QSpec tests FIRST (red phase) — failures must be specific.
2. Implement the minimum to green.
3. Run `./self-hosted/bin/quake guard` before EVERY commit.
4. Smoke after every guard — brainfuck, expr_eval (both in ~10s).
5. Commit each STEP as a single coherent commit.

Prime Directives v2 compact:
1. Pick highest-impact, not easiest.
2. Design is locked (BINARY_DSL.md) — implement, don't redesign.
3. Pragmatism = sequencing correctly; shortcut = wrong thing.
4. Work spans sessions; don't compromise because context is ending.
5. Report reality. Partial = say partial.
6. Holes get filled or filed.
7. Delete freely. Pre-launch.
8. Binary discipline: guard mandatory, smokes + backups not optional.
9. Quartz-time = traditional ÷ 4.
10. Corrections = calibration, not conflict.

Stop conditions:
- Track complete with fixpoint stable → write next handoff.
- Blocked on compiler bug → file in Discoveries, commit what works.
- Context limit → stop at next clean commit boundary, write handoff.

Pointers (verified 2026-04-17 post-Track-A):
- binary field AST: `NODE_BINARY_FIELD.extras` holds computed expr
  AST handle (Track A uses this slot). Leave untouched for B/C if
  possible — or carve out a second slot explicitly.
- MIR layout registry: add parallel Vec<Int> slots alongside
  binary_field_{names,specs,widths,compute_asts}. `mir_register_binary_layout`
  takes parallel vecs; pass yours too.
- `_cg_bin_var_spec_class` returns -99 for arrays today. That's the
  hook-in point for Track C. Classes are negative ints; next free is
  -10 and below.
- Fixed-prefix emit helpers (`_cg_bin_emit_pack_prefix_stores` /
  `_cg_bin_emit_unpack_prefix_reads`) handle straddle + sub-byte +
  byte-aligned. Extend them, don't inline new codepaths.
- Variable-tail pack emitter around line 761 in cg_intrinsic_binary.qz;
  unpack around line 989. Both loop over [split..field_count) and
  dispatch on spec class.

Test status after Track A

FileTestsStatus
binary_parse_spec.qz14🟢 green
binary_typecheck_spec.qz19🟢 green
binary_mir_spec.qz10🟢 green
binary_types_spec.qz5🟢 green
binary_methods_spec.qz3🟢 green
binary_bitcast_spec.qz3🟢 green
binary_with_spec.qz3🟢 green
binary_roundtrip_spec.qz5🟢 green
binary_varwidth_spec.qz5🟢 green
binary_straddle_spec.qz3🟢 green
binary_eof_spec.qz4🟢 green
binary_strict_spec.qz6🟢 green
binary_computed_spec.qz (new)6🟢 green
Total86🟢 green

Smokes (run after quake guard at end of session): examples/brainfuck.qz + examples/expr_eval.qz both pass.

Full QSpec suite NOT run from Claude Code (CLAUDE.md protocol). Run ./self-hosted/bin/quake qspec in a terminal to catch any cross-spec regression before declaring Track A “fully done.”


Safety rails (verify before starting Track B or C)

  1. Quake guard before every commit. Pre-commit hook enforces it.
  2. Smoke after every guard. brainfuck + expr_eval are enough.
  3. Fix-specific backup at self-hosted/bin/backups/quartz-pre-binary-phase2-trackX-golden (create at top of next session).
  4. Full QSpec NOT in Claude Code. The harness PTY can hang on large runs. Use targeted FILE=... invocations for spec files.
  5. Crash reports first (CLAUDE.md): on silent SIGSEGV check ~/Library/Logs/DiagnosticReports/quartz-*.ips before ASAN/lldb.