Skip to content

2.2 swift: type-mapping spec (step 1) - #55

Draft
cunninghamcard-bit wants to merge 8 commits into
exp/2.2-codegen-gofrom
exp/2.2-codegen-swift
Draft

cunninghamcard-bit wants to merge 8 commits into
exp/2.2-codegen-gofrom
exp/2.2-codegen-swift

Conversation

@cunninghamcard-bit

@cunninghamcard-bit cunninghamcard-bit commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

2.2 (Swift): step 1, the type-mapping spec

Stacked on #54 (exp/2.2-codegen-go, df2a90d). This PR adds only docs/plan/2.2-swift-mapping.md. There is no emitter code yet, and bindings/, aimux-core/ and ts-rs are untouched.

Mapping decisions

  • Same IR as Go: the spec covers every construct in wire.rs:22-129 and in emit/go.rs. Output goes to target/codegen-out/swift/ only through the unchanged emit::write guard.
  • Records: explicit CodingKeys and hand-emitted init(from:)/encode(to:) for every record. The presence matrix:
    • Option without skip uses encodeNil (serde writes null).
    • Option with skip uses encodeIfPresent.
    • Non-Option #[serde(default)] and skip_if_empty fields decode with contains(k) ? decode : default/empty, so a present null is rejected as in serde.
    • tri-state uses a runtime TriState<T> (absent/null/value).
  • Bytes: Vec<u8> maps to [UInt8] behind typealias WireBytes, which encodes as a number array. Data is never emitted, because JSONEncoder writes it as base64 (the same trap as Go []byte). D33 is pending.
  • Untagged unions (12 enums): strict decode tried in serde declaration order. null and [null] are rejected by JSONDecoder itself; the Go null-parity fixtures will verify this, with a mutation.
  • Internally tagged unions (30 in the IR, including the AiMuxError proxy): tag key plus payload keys in one container. All 19 external enums are unit-only and become string enums (19 string enums counted from the IR rule wire.rs:244-248 with the command in the doc; Go's 21 type X string lines include the Size/AspectRatio aliases); the {"name": payload} path gets a synthetic unit test. Adjacent tagging is supported but unused.
  • Open string enums (#[serde(other)]): a RawRepresentable struct that keeps the received string, as Go does. Variant aliases are accepted on decode.
  • Proxies and generics: proxies get their wire shape, e.g. Size/AspectRatio become String. StreamPart is instantiated per its ts(concrete) (stream_part.rs:28). Flatten is already expanded in the IR.
  • Integers and JSON: exact integer widths (UInt32/UInt64/...). The emitter errors on u128/i128, of which there are none. JSON values use a JSONValue enum like the hand binding (Types.swift:29-58).

Plan

  • tests/wire_diff_swift.rs mirrors wire_diff_go.rs: the same 223 serde fixtures (14 + 124 + 80 + 5) and the same tests (coverage, empty-value, both directions, null, tri-state, proxy/generic, null parity, binary AudioData/VideoData equal as serde_json::Value, mutations).
  • docs/plan/2.2-diff-swift.md is generated and stale-checked. docs/plan/2.2-diff-swift-verdicts.json uses the same {type, field, verdict ∈ {手写错, 生成器错, 已知差异}, reason} schema.

Rulings (aimux RFC, folded into the spec)

  • D33 stays pending. typealias WireBytes = [UInt8] is the one place to switch later.
  • JSONValue: the generated JSONValue has Int64/UInt64 cases and decodes Bool → Int64 → UInt64 → Double inside JSONDecoder, so big integers keep exact precision. It is not claimed to match serde_json::Value everywhere: 1.0/1e3/-0.0 may come back as integers (a possible known difference, measured after install). The hand-written Double-only storage loses precision above 2^53 and gets the verdict 手写错. A big-integer fixture (2^53+1, u64::MAX) is planned to prove it.
  • Open enums keep the received string, the same as Go.
  • Where the diff runs: local Linux, and it is mandatory. A missing swift makes the test fail, not skip.
  • Toolchain: the official swift.org Ubuntu 24.04 tarball (not installed yet). The macOS CI Swift tests job is supplementary; Linux/macOS Foundation differences are recorded and non-blocking.
  • No swift-format. The gate is a compile check plus --check.
  • Hand-written type names come from swiftc -dump-parse. Tests cover nested types, extensions and multiple declarations on one line, and a line-by-line reader is shown by mutation to miss them.

Spec corrections (6c3dbbc)

  • Non-Option #[serde(default)] fields decode only when the key is present (contains ? decode : default), so a null is rejected, as in serde and Go's rejectNull. A null-alignment fixture is planned.
  • JSONValue decodes Bool → Int64 → UInt64 → Double inside JSONDecoder, with no custom parser, and big-integer precision is a hard requirement. 1.0, 1e3 and -0.0 may come back as integers, a possible known difference caused by a Foundation limitation. It is measured first after the install. If confirmed, the diff stays strict and those fixtures get the verdict 已知差异. The fixtures are -1, i64::MIN, below i64::MIN, 2^53+1, u64::MAX, 0.5, 1.0, 1e3 and -0.0, with a mutation that drops the int/uint cases.
  • Swift ≥ 6.0. The first measurement is the exact decode of u64::MAX and 2^53+1 on Linux.
  • Equality is checked as serde_json::Value (preserve_order, Cargo.toml:53), not byte identity. Map equality ignores key order, so the internal tag's key position is not verified, and no assertion is added for it.
  • Planned tests: an IR scan asserting no untagged variant is Unit or Json (the premise for having no rejectNull), and emitter mutations for encodeNil, the open-enum unknown value, integer overflow/fraction and u128. The missing-toolchain failure will be shown once here from a run with PATH stripped.
  • TextStreamPart is defined at stream_part.rs:176; aimux-ffi/src/lib.rs:2304 is a call site. The hand-written Swift has it (Types.swift:1163), so it is compared in the Swift diff.

Third verdict 已知差异 (bcd3e63, aimux-plan PHASES.md:307)

  • Scope: only for generated code that cannot match serde because of a platform limitation. The sole candidate is Foundation JSONDecoder number parsing.
  • Reason: must name the limitation source and cite PHASES.md:307. It is registered per language.
  • No stale exemptions: wire_diff_swift.rs fails if a 已知差异 row actually matches serde. Planned mutation: mark the matching 0.5 fixture as 已知差异 and the test goes red.
  • Generator errors are still caught before the table, as in Go, and never enter it. A real generator difference marked 已知差异 must still fail as a generator error.

Final mapping revision (102abf8)

  • Known differences registry: docs/plan/2.2-known-differences.json, machine-readable, so tests never parse Markdown.
    • Each entry has id, language, limitationSource, a verbatim phasesExcerpt of aimux-plan PHASES.md:307 (the in-repo copy is authoritative) and a scope of {fixture, leaf path}.
    • An entry applies only when the label and leaf path match and both values are numbers.
    • Three-way checks, each with a mutation, red test and expected error: the ID is registered with a matching language; the row is in scope (Usage inputTokens.total gives "out of scope"); every ID is referenced. The stale-exemption check is kept.
    • KD-SWIFT-NUM-1 is planned and unmeasured.
  • Provenance: docs/plan/2.2-provenance.md holds in-repo excerpts of PHASES.md:307 and DECISIONS D33. Swift byte reasons will cite it. 2.2: wire IR + Go emitter; generated Go vs hand-written diff (on #53) #54's Go verdicts still cite out-of-repo DECISIONS D33; switching them is a 2.2 follow-up.
  • Default fields: skip_if_empty non-nullable fields decode with contains ? decode : empty, and null is rejected (wire.rs:348,351).
  • IR premise scan: expands aliases first (DecisionState, TextContentBinding and ToolChoice are Alias(Json)) and treats Value(Nullable) as null-accepting.
  • First measurements add 0, 1 and true (Bool/Int exclusivity). These are non-blocking until the toolchain is installed.
  • Mutations: every planned mutation now names its red test and expected error (doc table).

Toolchain status

swift is not installed on the dev box (Debian 13, x86_64). The install size is an estimate (about 1 GB download, 3 GB on disk) and has not been measured.

Open questions

None. All six earlier questions are ruled above.

Step 2: the Swift emitter (head a3fee77)

Commits:

  • 115cf75c emitter emit/swift.rs, Emitter::check (runs in emit::write before any file), IR StringEnum now carries variant aliases, tests/emit_swift_test.rs (15), fixtures moved unchanged to tests/common/fixtures.rs, plus the doc fixes (alias sentence cites decision_model.rs:15 / tool.rs:214 / wire.rs:290-292; the :71 key-order clause is deleted; the KD reason cites 2.2-provenance.md; ir_has_19_string_enums replaces the counting one-liner; final KD conditions with f64 equality).
  • 262e8f61 tests/wire_diff_swift.rs harness (21 tests), known-difference checks, 2.2-diff-swift-verdicts.json ([]), 2.2-diff-swift.md placeholder, 2.2-swift-mutations.md.
  • a3fee776 regenerate closure.md (the new file is now tracked).

No Swift toolchain on the box (install pending Trey's approval), so nothing Swift has been compiled or run. The generated Swift has never gone through swiftc. Tests that need swift/swiftc fail; they are not skipped.

Tests (aimux-codegen): lib 11, codegen 8, emit_go 15, emit_relative_root 1, emit_swift 15/15, extract_prereq 17, manifest 16, wire_diff_go 12, wire_diff_swift 4 pass / 17 fail. Of the 17 failures, 16 are "swift missing" and 1 is known_differences_are_registered_scoped_and_used (KD-SWIFT-NUM-1 is not referenced by any verdict). That one is intentionally red until measurement, with no exemption. clippy -D warnings clean, fmt clean, aimux-codegen --check and gen_closure_inventory.py --check up to date.

Output of wire_diff_swift with swift absent from PATH (one run):

     Running tests/wire_diff_swift.rs (target/debug/deps/wire_diff_swift-62cc5351e2387aa5)

running 1 test
test decode_and_encode_directions_match_serde ... FAILED

failures:

---- decode_and_encode_directions_match_serde stdout ----

thread 'decode_and_encode_directions_match_serde' (1900994) panicked at tools/aimux-codegen/tests/wire_diff_swift.rs:272:10:
`swiftc` is required for the Swift type-name reader (it is not skipped when missing): Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    decode_and_encode_directions_match_serde

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 20 filtered out; finished in 0.45s

error: test failed, to rerun pass `-p aimux-codegen --test wire_diff_swift`

Mutations: every mutation is listed in docs/plan/2.2-swift-mutations.md, run on the Rust side (22 rows, all red as expected) or marked not run (9 rows, need swift).

Spec deviations, all recorded in the doc:

  1. Payload structs are top-level X<Variant> (Go naming), not nested X.<Variant>. A nested struct would shadow a same-named top-level type inside the enum.
  2. A #[serde(default)] field of a Named record or union type has no default in the IR, so it is kept as T? (16 fields). Decoding uses contains ? decode : nil (a present null is still rejected), and encoding uses encodeIfPresent.
  3. Variant aliases: the IR dropped them for string enums (wire.rs:244-250), so the IR now carries them. Go still ignores them (follow-up).
  4. Hand-written copies have import CAimuxFFI removed. Whether they compile without FFI is unverified.
  5. The swiftc -dump-parse output format was assumed (S-expression (struct_decl … "Name") and has not been checked against a real dump.

docs/plan/2.2-swift-mapping.md maps every wire IR construct (wire.rs:22-129) and everything the Go emitter handles (emit/go.rs) to a Swift shape: primitives and widths, JSONValue, arrays/maps/Nullable, Vec<u8> as [UInt8] (no Data, which JSONEncoder writes as base64; D33 pending), records and the presence matrix (encodeNil vs encodeIfPresent, TriState), aliases/proxies, concrete generics, flatten, open/closed string enums, and internal/external/adjacent/untagged unions (declaration-order strict decode, null rejected like serde). It also covers reuse of the emit::write guard, the test/doc/verdict mirror of Go (wire_diff_swift.rs, 2.2-diff-swift.md, 2.2-diff-swift-verdicts.json), toolchain status (swift not installed on the box) and open questions.
- D33 pending; typealias WireBytes = [UInt8], switched in one place later.
- The generated JSONValue gets Int64/UInt64 cases (as serde_json::Value keeps integers). The hand-written Double-only storage loses precision above 2^53, which is 手写错. A big-integer fixture (2^53+1, u64::MAX) is planned.
- Open enums keep the received string, the same as Go.
- The diff runs on local Linux and is mandatory: a missing toolchain fails the test, never skips it. Toolchain: official Ubuntu 24.04 tarball (not installed here). The macOS CI Swift job is supplementary; Foundation differences are recorded and non-blocking.
- No swift-format: compile check plus --check only.
- Hand-written type names come from swiftc -dump-parse. Tests cover nested types, extensions and multiple declarations on one line; a line-by-line reader is shown to miss them.
The open-questions section is now empty.
…, counts)

- A non-Option #[serde(default)] field (omittable only, wire.rs:348) decodes only when the key is present (contains ? decode : default), so a null is rejected as in serde and Go rejectNull (go.rs:158). Planned null-alignment fixture: DecisionCapabilities supports_images: null (decision_model.rs:362-363).
- JSONValue decodes Bool -> Int64 -> UInt64 -> Double inside JSONDecoder (no custom parser). Big-integer precision is a hard requirement. 1.0, 1e3 and -0.0 may come back as integers, a possible known difference from a Foundation limitation, to be measured first after the install. Fixture list and the drop-int/uint mutation are in the doc.
- Fixture count is 223 (14 + 124 + 80 + 5), not 224.
- Swift >= 6.0. First measurements: u64::MAX and 2^53+1 on Linux.
- 'Byte-identical' is replaced by 'equal as serde_json::Value' (preserve_order, Cargo.toml:53).
- IR union counts: internal 30 including the AiMuxError proxy, untagged 12, adjacent 0; all 19 external enums are unit-only (source: Go output from the IR at df2a90d).
- Planned: an IR premise test (no untagged Unit/Json variant), a synthetic external-payload unit test, and emitter mutations (encodeNil, open enum, int overflow/fraction, u128). The missing-toolchain failure will be shown once in the PR body.
- Minor: ImageResult example marked 节选 (image_model.rs:183); TriState only with a codec (wire.rs:358-360); TextStreamPart is defined at stream_part.rs:176 (aimux-ffi/src/lib.rs:2304 is a call site) and is in the Swift comparison set (hand Types.swift:1163).
The verdict schema becomes {手写错, 生成器错, 已知差异}. 已知差异 is only for generated code that cannot match serde because of a platform limitation. The sole candidate is Foundation JSONDecoder number parsing (1.0/1e3/-0.0), and only if measured. Its reason must name the limitation source and cite PHASES.md:307, and it is registered per language.

The test fails when a 已知差异 row actually matches serde (no stale exemptions). Planned mutations: a matching 0.5 fixture marked 已知差异 goes red; a real generator difference marked 已知差异 still fails as a generator error. Generator errors are still caught before the table, as in Go, and never appear in it. Open-enum string keeping is a design choice and is excluded. The 'schema gains that value later' note is removed.
…ance, A/B points)

- skip_if_empty non-nullable decodes with contains ? decode : empty (wire.rs:348,351), so null is rejected; the null-alignment fixture and its mutation cover it.
- JSONValue first measurements add 0, 1 and true (Bool/Int exclusivity); non-blocking until the toolchain is installed.
- New docs/plan/2.2-known-differences.json, machine-readable: id, language, limitationSource, a phasesExcerpt quoting aimux-plan PHASES.md:307 verbatim (the in-repo excerpt is authoritative), and a scope of (fixture, leaf path). An entry applies only on label + path match with both values numbers. Three-way checks (ID registered with matching language, row in scope, every ID referenced) plus the stale check each have a planned mutation, red test and expected error; the second mutation uses Usage inputTokens.total ('out of scope'). KD-SWIFT-NUM-1 is planned and unmeasured.
- New docs/plan/2.2-provenance.md: in-repo excerpts of PHASES.md:307 and DECISIONS D33. Swift byte reasons will cite it. The Go verdicts (#54) still cite out-of-repo DECISIONS D33; switching them is noted as a 2.2 follow-up.
- IR premise scan expands aliases first (DecisionState, TextContentBinding, ToolChoice are Alias(Json)) and treats Value(Nullable) as null-accepting.
- Stated as not verified: internal tag position, since the comparison ignores key order; no assertion added.
- String enums counted from the IR rule (wire.rs:244-248) = 19, with the exact command. Go's 21 includes the Size and AspectRatio aliases.
- Every planned mutation names its red test and expected error.
…ests, doc fixes

- emit/swift.rs: records with explicit CodingKeys/init(from:)/encode(to:)
  per the presence matrix, open/closed string enums, internal/external/
  adjacent/untagged unions, JSONValue (Bool->Int64->UInt64->Double),
  WireBytes, TriState only with a codec; Swift::check refuses u128/i128,
  variant-level untagged in tagged unions, by-value record cycles.
- Emitter::check (default Ok) runs in emit::write before any file.
- wire.rs StringEnum carries variant aliases (ModelType); Go ignores them.
- tests/emit_swift_test.rs: ir_has_19_string_enums, untagged premise
  (aliases expanded, Nullable counted), u128 error, synthetic external
  payload and adjacent unions, presence rows.
- fixtures moved unchanged to tests/common/fixtures.rs (shared).
- doc: alias sentence factual (decision_model.rs:15, tool.rs:214,
  wire.rs:290-292), :71 key-order clause removed, KD reason cites
  2.2-provenance.md, IR test replaces the counting one-liner, final KD
  conditions incl. f64 equality and their mutations.
… record

- tests/wire_diff_swift.rs: SwiftPM scratch harness (generated vs copies
  of the hand Swift), shared fixtures + 12 JSONValue number fixtures,
  reject-parity cases (default-field null, skip_if_empty null, u32
  overflow/negative/fraction), open-enum unknown + alias, null parity,
  binary, wire-name mutation, swiftc -dump-parse type-name reader.
  Fails (not skips) when swift/swiftc are missing.
- Known differences: label AND path in scope AND both numbers AND equal
  as f64; three-way checks, stale check; each mutation asserted.
- null_parity_cases moved unchanged into tests/common/fixtures.rs.
- docs: 2.2-diff-swift-verdicts.json ([]), 2.2-diff-swift.md placeholder
  (not generated: no toolchain), 2.2-swift-mutations.md.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant