Skip to content

Latest commit

 

History

History
382 lines (277 loc) · 19.9 KB

File metadata and controls

382 lines (277 loc) · 19.9 KB

AffineScript Migration Playbook: Re-decomposition Patterns

Note

This guide is the systematic companion to frontier-guide.adoc.

  • The Frontier Guide unveils what AffineScript is — read it if you are starting a new program.

  • This playbook describes how to carry an existing codebase across the boundary — read it if you are translating ReScript, TypeScript, or another -script family language into AffineScript.

The machine-readable companion for AI agents is AI.a2ml.

1. The Cardinal Rule

Re-decompose. Do not transliterate.

A 1:1 syntactic port — let mutable becomes mut, option<T> becomes Option[T], try/catch becomes try/catch, classes become records-with-methods — produces code that type-checks in AffineScript while still carrying the design problems AffineScript was built to solve.

The Frontier Guide’s thesis: every X-Script’s six pathologies share one root — the runtime does not know the program’s intent. Translation must therefore re-express intent, not just syntax. If a faithful port leaves the original design untouched, the destination language is being used as a syntax veneer and none of its guarantees do any work.

The rest of this document is a catalogue of common source-language patterns and the re-decompositions they invite.

2. How to Use This Playbook

  1. Start from the source file. Identify the patterns it contains using Source-Pattern Index.

  2. For each pattern, read the Decomposition column — that is the AffineScript shape, not just the AffineScript syntax.

  3. If the source mixes several patterns into one structure (a class with mutable fields, an exception path, and a callback), expect the AffineScript output to have more, smaller declarations — not the same number of larger ones.

  4. Where two decompositions are valid, the Decision Criteria table tells you which one fits.

  5. The Anti-patterns: Faithful-but-monolithic Translation section shows what a faithful-but-monolithic translation looks like, paired with its re-decomposed form.

3. Source-Pattern Index

3.1. ReScript → AffineScript

Source Decomposition Why

let mutable x = …​

mut field on an owned record, or lift to a State effect

Local mutation in a single owner stays local; mutation visible across calls becomes an effect.

ref<'a> (mutable cell)

Same choice as let mutable; default to lifting to an effect when the cell crosses a function boundary

A bare ref is mutation that has already escaped its scope — typically the wrong default.

option<'a> (built-in)

Option[T]

Direct mapping; no decomposition needed.

result<'a, 'e>

Result[T, E]

Direct mapping; tighten 'e from string or exn to a named error type if you can.

exception Foo + try/catch

Result[T, E] where E is a named variant covering each failure

Replaces an untyped throw path with an exhaustive return type. The compiler forces every caller to handle the Err shape.

Js.Promise.t<'a> / async ReScript

Async effect on the function row

Promises silently allow racing, ignoring errors, and detached lifetimes. The Async effect makes lifetime and error explicit.

ReScript object types Js.t<{..}>

Records with row variables { field: T, ..r }

Row polymorphism replaces structural object types principle-for-principle.

Polymorphic variants [#A | #B]

Open variant rows / sum types

Same idea, integrated with the rest of the type system rather than parallel to it.

Module functors

Row-polymorphic functions parameterised over their effects

Most functor uses are an indirect way to parameterise over a small set of operations — effects do this directly.

Class via PPX (%@react.component, etc.)

Owned record + standalone functions; lift effectful methods into effect rows

A class is a tangle of state, methods, and implicit lifetimes. Each strand becomes its own declaration.

unit returns from impure code

() / IO (or whichever effects apply)

In ReScript, unit says nothing about whether the function did IO. In AffineScript, the effect row makes it visible.

Module-level getter for a singleton runtime — Module.get(): Option<Thing>

Lift to an effect (effect Thing { fn current() → Option[ref Thing]; }); make the "is the runtime available?" branch a typed Result, not a silent no-op

A service-locator is mutable global state with a thin Option wrapper. Every consumer gains a hidden dependency on whether the global has been initialised, and the "not initialised" branch tends to become a silent no-op that masks real bugs. Lifting it to an effect makes the dependency visible at the call site and lets tests install a mock without monkey-patching a global.

3.2. TypeScript → AffineScript

Source Decomposition Why

T | null | undefined

Option[T]

Frontier Guide chapter 1: one nothing, not three states.

throw new Error() + try/catch

Result[T, E] for recoverable errors; Exn[E] effect for cross-cutting failure

A bare throw in TypeScript is invisible at the call site. Both replacements make it visible.

Promise<T> / async function

Async effect

Same reasoning as the ReScript case.

class Foo { private x; method() {} }

Owned record + standalone functions; lift mutation to mut or State

Encapsulation comes from ownership and module scope, not from class privacy.

any / unknown

Forbidden in AffineScript output. Pin the type, or describe the unknown shape with a row variable.

any is the absence of a thesis. Every translation must replace it with one.

Discriminated unions ({ kind: "a" } | { kind: "b" })

Sum types with named constructors; match on the constructor

Pattern matching is exhaustive; the compiler enforces what TypeScript can only suggest.

2.0 + 3.0 (any Float arithmetic in JS/TS source)

Direct mapping. Both operands must be Float; the lhs drives the op’s type.

Until 2026-05-02 the AffineScript typechecker hard-coded arithmetic as Int×Int→Int — Float math didn’t typecheck at all. That gap is now closed: OpAdd/OpSub/OpMul/OpDiv/OpMod and the comparisons synthesise the lhs first, and if it’s Float, pin the rhs to Float. There is no implicit coercion: Float + Int is still a type error. Translators previously forced to scale Floats into Ints (e.g. idaptik-hitbox.adoc) no longer need that workaround.

arr[i] = expr where arr is a function parameter

AffineScript: mut arr: Array[T] parameter syntax; the borrow checker now seeds parameters into mutable bindings.

Until 2026-05-02 the borrow checker rejected indexed writes through mut parameters because (a) param ownership wasn’t seeded into mutable bindings and (b) places_overlap had no PlaceIndex case so arr[i] against arr returned "no overlap." Both fixed. Note the keyword position: AffineScript writes mut arr: Array[T] (binder-modifier), not arr: mut Array[T] (type-modifier — rejected by the grammar).

3.3. Calling Target Intrinsics (CUDA __shfl_xor, ONNX Relu, Faust tanh, Verilog $display, …)

Backends often need to call runtime ops that AffineScript’s resolver doesn’t know about — GPU shuffle intrinsics, ONNX operators, DSP primitives, hardware-simulation tasks. The canonical pattern is:

  1. Declare the op as a stub function in user code with the right signature.

  2. The resolver and typechecker accept the stub.

  3. The backend pattern-matches the stub’s name (case-insensitive where appropriate) and emits the target’s intrinsic.

  4. The stub’s body is irrelevant — it’s a placeholder so the AS frontend has something to bind. Use any value-shape that returns a sensible default.

// Stubs for ONNX operators — the body is placeholder; the ONNX
// codegen pattern-matches the name `relu` → ONNX op `Relu`, etc.
fn relu(x: Array[Float]) -> Array[Float] { x }
fn add(a: Array[Float], b: Array[Float]) -> Array[Float] { a }

// Use them as if they were real:
fn graph(x: Array[Float], y: Array[Float]) -> Array[Float] {
  let s: Array[Float] = add(x, y);
  let r: Array[Float] = relu(s);
  r
}

The affinescript compile graph.affine -o graph.onnx invocation produces a real ONNX ModelProto with an Add node and a Relu node — the codegen ignored the stub bodies and used the names. The same pattern applies to every kernel-sublanguage backend (Tier C in the backend taxonomy).

Why this matters for migration: when porting code that calls platform-specific functions, you do not need to extend AffineScript itself. Declare the platform’s API as stubs in your translation unit, and let the backend recognise them. This keeps the frontend small and the backend self-contained.

3.4. Portable HTTP (ReScript fetch / Js.PromiseHttp stdlib)

ReScript networking is fetch(…​) returning a Js.Promise.t, usually wrapped in a .then/.catch umbrella. Do not port the promise. Port to the Http stdlib module (use Http::{fetch, get, post, request};), whose surface is a typed request/response with an honest effect row:

pub fn fetch(req: Request) -> Response / { Net, Async }
// Request  #{ url, method, headers: [(String, String)], body: Option<String> }
// Response #{ status: Int, headers: [(String, String)], body: String }

Re-decomposition rules:

  • The .catch umbrella becomes a match on is_ok(resp) / resp.status — failure is data, not a detached rejected promise.

  • Headers are an association list [(String, String)], not an opaque object. Build/read them explicitly. (This will become Dict once #162 lands; the change is source-compatible for callers using the helpers.)

  • The response body is raw text. JSON decoding is a separate step (the #161 Json primitive) — do not conflate transport with parsing.

  • Every function that transitively calls Http carries / { Net, Async } in its signature. That is the point: the network and its asynchrony are visible in the type, where ReScript hid them in a Promise.

Backend status: the Deno-ESM target lowers this to a globalThis.fetch round-trip (Deno / Node 18+ / browser ESM) and is fully exercised end-to-end. The typed-wasm target is a tracked next slice — the convergence ABI shared with Ephapax (see Settled Decisions, typed-wasm ADR-004); AffineScript is not coupled to it.

4. Decision Criteria

When a source pattern admits more than one AffineScript shape, choose with these tests.

4.1. Option[T] vs Result[T, E]

  • Option[T] when absence is normal — a lookup that may not find anything, a config field that may be unset.

  • Result[T, E] when absence has a reason you can describe — parse failure, validation error, IO error.

If you find yourself reaching for Result[T, ()] or Result[T, String], you probably wanted Option[T] (or a richer E).

4.2. mut vs effect

  • mut for local mutation contained in a single owner — buffer construction, an accumulator inside a function.

  • State[S] effect for mutation that must be observable across calls — game state, session state, a setting visible to many subsystems.

If two callers need to see the same mutation, it is no longer local — lift it.

4.3. ref vs mut vs own

  • ref Buffer — the caller keeps the value and the function only reads it.

  • mut Buffer — the function modifies in place; the caller keeps using the value afterwards.

  • own Buffer — the function consumes the value; the caller cannot use it after the call.

The default for resources (files, sockets, tokens, allocations) is own — anything else hides the lifetime.

4.4. Linear (@linear) vs affine (own)

  • own (affine) — may be used at most once. Dropping is allowed.

  • @linearmust be used exactly once. Dropping is a compile error.

Prefer own. Reach for @linear only when forgetting to consume the value is a real bug — typically protocol handles, transactions, and capability tokens.

4.5. Coupled writes — multiple effects in one action

When a single user action must update both an in-memory subsystem AND persist to storage (volume that touches Audio and Storage, theme that touches UI and Storage, etc.), there are two ways to express it:

Shape When to choose

One function calling two (or more) effects. Default.

Each effect is independently meaningful and independently testable. The wrapper function is the coupling; it is visible at every call site through both effects appearing in the row.

A wrapper effect that internally sequences the two. E.g. effect Settings { fn set_master_volume(v: Float) → Result[(), NoEngine]; }.

The runtime invariant is "Audio and Storage must always agree" and partial success is a bug. The wrapper’s handler enforces atomicity (both succeed or neither does), which the two-effect call site cannot.

The decision tracks the same logic as mut vs State: keep coupling local unless the invariant must be observable across callers. If two callers can validly compose Audio.set_master_volume and Storage.set_number differently — for example, "set the audio without persisting" for a transient ducking effect — the wrapper effect is wrong, because it forecloses that composition.

5. Anti-patterns: Faithful-but-monolithic Translation

The following ReScript is a small file buffer that conflates state, lifetime, and IO.

// ReScript — original
type fileBuffer = {
  mutable data: array<string>,
  mutable open_: bool,
}

let make = (): fileBuffer => { data: [], open_: true }

let read = (b: fileBuffer): string =>
  if b.open_ { Array.joinWith(b.data, "") } else { raise(Failure("closed")) }

let write = (b: fileBuffer, s: string): unit => {
  if !b.open_ { raise(Failure("closed")) }
  b.data = Array.concat(b.data, [s])
  Console.log("wrote " ++ s)
}

let close = (b: fileBuffer): unit => { b.open_ = false }

5.1. Faithful-but-monolithic translation — don’t do this

// AffineScript — same shape, none of the guarantees doing any work
type FileBuffer = own {
  data: mut Array[String],
  open_: mut Bool,
}

fn make() -> own FileBuffer { FileBuffer #{ data: [], open_: true } }

fn read(b: ref FileBuffer) -> String {
  if b.open_ { String.join(b.data, "") } else { panic("closed") }
}

fn write(b: mut FileBuffer, s: String) -> () {
  if !b.open_ { panic("closed") }
  b.data = b.data ++ [s];
  // IO is silently invisible — same problem as the original
}

fn close(b: mut FileBuffer) -> () { b.open_ = false }

This compiles. It is also no better than the ReScript original: the closed invariant is still a runtime check, the IO is still invisible, and close does not actually consume the buffer.

5.2. Re-decomposed translation

// Two types — a closed buffer is statically distinguishable from an open one
type OpenBuffer = own { data: Array[String] }
type ClosedBuffer = own { data: Array[String] }

effect IO { fn log(s: String); }

fn make() -> own OpenBuffer { OpenBuffer #{ data: [] } }

// read takes a borrow — caller keeps the buffer:
fn read(b: ref OpenBuffer) -> String { String.join(b.data, "") }

// write is in-place; IO is in the row:
fn write(b: mut OpenBuffer, s: String) -> () / IO {
  b.data = b.data ++ [s];
  IO.log("wrote " ++ s)
}

// close consumes Open and returns Closed — "use after close" becomes a type error:
fn close(b: own OpenBuffer) -> own ClosedBuffer { ClosedBuffer #{ data: b.data } }

What changed:

  1. closed moved from a runtime field to a type (OpenBuffer vs ClosedBuffer) — "use after close" is now a compile error.

  2. close consumes its argument (own) — there is no b left to misuse afterwards.

  3. IO is in the effect row of write — every caller of write declares it transitively.

  4. The mutable boolean is gone; data is reached only through mut OpenBuffer, which makes ownership explicit.

The two versions are roughly the same number of lines. They are different programs.

6. Lessons from Real Migrations

Case studies from in-flight migrations land in docs/guides/lessons/migrations/. (The sibling lessons/ folder is the 10-lesson tutorial track for new AffineScript users — different audience.)

Existing entries:

  • idaptik Hitbox.resHitbox.affine — the first compilable translation; pure-leaf collision primitives. Surfaces the v0.1.0 Float-arithmetic typechecker gap.

  • idaptik UserSettings.res (design-only) — multi-pattern, dependency-heavy persistence layer. Stress-tests the playbook against five external module dependencies; surfaces three small playbook gaps (addressed in v1.1).

  • idaptik PlayerHP.resPlayerHP.affine — six-field mutable record with three orthogonal aspects (HP, i-frames, knockback). Compiled to a 952-byte WASM module via the v0.1.0 toolchain. Stress-tests the mut vs State decision criterion; surfaces the aspect-decomposition pattern as a v1.2 candidate addition, and the v0.1.0 mut-with-struct-field-assignment gap as a second expedient (alongside Float→Int).

If you complete a non-trivial .res → .affine translation and the re-decomposition reveals something future translators would benefit from, write it up there. The format is loose: original snippet, faithful port, re-decomposed port, and what the second one buys you. Mark the lesson "design-only" if the destination toolchain cannot yet compile it — the design walk-through still pressures the playbook.

7. See Also

Appendix A: Revision History

Revision Date Notes

1.0

2026-05-02

Initial draft. ReScript and TypeScript pattern indices, decision criteria, file-buffer anti-pattern. Companion to frontier-guide.adoc v1.0.

1.1

2026-05-02

Two additions surfaced by the idaptik UserSettings design walk-through: (1) service-locator-globals row added to the ReScript pattern index; (2) coupled-writes subsection added to Decision Criteria, with the rule "keep coupling local unless the invariant must be observable across callers." No removals; existing v1.0 guidance unchanged.

1.2

2026-05-02

Backend-buildout findings folded into TS→AS index and a new recipe: (1) Float-arithmetic typechecker gap is now closed (no more Int-scaling workaround); (2) mut-parameter indexed-write borrow gap is now closed (note the binder-modifier keyword position); (3) new Calling Target Intrinsics (CUDA __shfl_xor, ONNX Relu, Faust tanh, Verilog $display, …) recipe documents the stub-as-intrinsic pattern that lets user code call CUDA / ONNX / Faust / Verilog runtime ops without extending the language.

1.3

2026-05-18

New Portable HTTP (ReScript fetch / Js.PromiseHttp stdlib) recipe for the C-spine Http stdlib primitive (issue #160): ReScript fetch/Js.Promise → typed Request/Response with a / { Net, Async } effect row. Deno-ESM target landed and exercised end-to-end; typed-wasm target tracked as the next slice (convergence ABI shared with Ephapax). No existing guidance changed.