Skip to content

Latest commit

 

History

History
733 lines (582 loc) · 28.5 KB

File metadata and controls

733 lines (582 loc) · 28.5 KB

AffineScript Frontier Guide: The Unveiling

Note

Status: canonical, living document. This guide is the design reference for AffineScript itself and for any migration into AffineScript. Edits are welcome; record them in the Revision History appendix so the timeline is visible to future readers and AI agents.

For systematic translation patterns from ReScript, TypeScript, or another -script family language, read this guide first, then the Migration Playbook.

The Frontier Guide takes you from "I write JavaScript / Python" to "I understand why AffineScript’s design choices produce better programs." It is not a reference manual — it is an unveiling.

Each section begins by naming a problem you already know, then shows exactly how AffineScript solves it, then gives you something to try.

The Six Problems of X-Scripts

Before AffineScript, there were many "improved" JS variants. Most of them fixed one or two problems and ignored the rest. AffineScript’s thesis is that all six of these problems have the same root cause: the runtime does not know the program’s intent — so neither does the programmer.

Problem Symptom AffineScript answer

Null is a lie

Two nothings: null and undefined

Option[T] — exactly one nothing

Mutation is invisible

Every variable is secretly mutable

own/ref/mut — mutation is in the type

Errors throw sideways

Untyped throw / catch anywhere

Result[T, E] — errors in the return type

Async is bolted on

Callbacks → Promises → async/await → signals

Algebraic effects — one composable model

Types become unreadable

TypeScript infer, conditional types, mapped types

Row polymorphism — structural, principled, readable

Runtime is still JS

Type erasure — guarantees vanish at run time

Typed WASM (WasmGC) — types survive into execution

Let us unveil each one.

Chapter 1: No Null

JavaScript gives you two ways to say "nothing": null and undefined. They behave differently. TypeScript adds T | null | undefined which proliferates at every call site.

AffineScript has one nothing.

// Option[T] — present or absent, not three states:
let name: Option[String] = Some("Alice")
let missing: Option[String] = None

// Pattern matching forces you to handle both cases:
fn greet(name: Option[String]) -> String {
  match name {
    Some(n) => "Hello, " ++ n ++ "!"
    None    => "Hello, stranger!"
  }
}

The compiler rejects any code that uses a Some without matching on None. There is no way to accidentally treat None as a value.

Try it

// Type-check this file and read the error:
fn greet_broken(name: Option[String]) -> String {
  // Deliberate mistake: treating None as Some
  name ++ "!"   // ERROR: Option[String] is not String
}

Run: affinescript check — the error tells you exactly what went wrong and on which line.

Chapter 2: Ownership (Mutation You Can See)

In JavaScript, every object is a shared mutable reference by default. You pass a value to a function; the function might mutate it; you have no idea. You copy defensively. You introduce bugs through aliasing.

AffineScript’s type system makes mutation visible in the type:

type Buffer = own { bytes: Array[Int], length: Int }

// ref: read-only borrow — the caller keeps the buffer
fn size(buf: ref Buffer) -> Int {
  buf.length
}

// mut: mutable borrow — modifies in place, caller sees the change
fn append(buf: mut Buffer, byte: Int) -> () {
  buf.bytes = buf.bytes ++ [byte];
  buf.length = buf.length + 1;
}

// own: transfers ownership — caller cannot use buf after this call
fn into_bytes(buf: own Buffer) -> Array[Int] {
  buf.bytes
}

The compiler tracks usage automatically. If you pass buf to into_bytes, any later use of buf is a compile-time error. No runtime check needed.

Note

Where the keyword goes. Notice that mut / ref / own annotates the binder, not the type. AffineScript writes mut buf: Buffer, never buf: mut Buffer. The grammar rejects the latter. This is a real fork in the road for translators coming from Rust (&mut T), Swift (var/let), or TypeScript (readonly T[]):

  • mut buf: Bufferthe binding is mutable; the buffer it points at may also be mutable, but ownership of "who is allowed to mutate" lives with the binder.

  • buf: mut Buffer — would say the type is mutable, separable from the binding. AffineScript made the first choice; the compiler tracks mutability through the binder, so the mut lives there.

When porting Rust-shaped code, the mut moves left.

The quantity annotations

For fine-grained control, the underlying quantity annotations are available:

// @linear: must be used exactly once
fn consume_once(@linear handle: Int) -> () { () }

// @erased: compile-time only (proof terms, type-class witnesses)
fn trust_me(@erased _proof: Bool) -> String { "I proved it" }

// @unrestricted: unlimited use (the default for all values)
fn read_many(@unrestricted n: Int) -> Int { n + n }

Try it

type File = own { fd: Int }

fn open() -> own File { File { fd: 1 } }
fn read(f: ref File) -> String { "contents" }
fn close(f: own File) -> () { () }

fn broken() -> String {
  let file = open();
  close(file);          // file is consumed here
  read(file)            // ERROR: file used after move
}

Chapter 3: Errors in the Return Type

JavaScript throw can happen from anywhere. TypeScript does not check it. You write try/catch and hope.

AffineScript errors live in the return type:

// The function signature tells you everything:
fn parse_int(s: ref String) -> Result[Int, String] {
  match s {
    "0" => Ok(0)
    "1" => Ok(1)
    _   => Err("not an integer: " ++ s)
  }
}

// You must handle both cases — the compiler enforces it:
fn double_parsed(s: ref String) -> Result[Int, String] {
  match parse_int(s) {
    Ok(n)  => Ok(n * 2)
    Err(e) => Err(e)
  }
}

// The `?` operator propagates errors up automatically:
fn double_parsed_short(s: ref String) -> Result[Int, String] {
  let n = parse_int(s)?;
  Ok(n * 2)
}

There is no hidden throw path. Reading the signature tells you what can go wrong.

Try it

fn safe_head[T](xs: ref Array[T]) -> Result[ref T, String] {
  // Complete this function:
  // Return Ok(first element) if the array is non-empty,
  // or Err("empty array") if it is empty.
  Err("TODO")
}

Chapter 4: Effects (One Model for Everything Impure)

JavaScript’s async history: callbacks (2009), Promises (2012), async/await (2017), React Hooks (2019), signals (2023-). Each layer partially fixes the previous one. They do not compose.

AffineScript uses algebraic effects — one model for all of this:

// Declare what the effect offers:
effect IO {
  fn println(s: String);
  fn read_line() -> String;
}

effect Async {
  fn await[T](promise: Promise[T]) -> T;
  fn spawn[T](f: () -> T) -> Promise[T];
}

effect State[S] {
  fn get() -> S;
  fn put(s: S) -> ();
}

// Declare which effects a function uses — no surprise impurity:
fn echo() -> () / IO {
  let line = IO.read_line();
  IO.println("You said: " ++ line)
}

// Compose effects naturally — they are rows:
fn fetch_and_log(url: ref String) -> Result[String, String] / IO + Async {
  let response = Async.await(http_get(url));
  IO.println("fetched " ++ url);
  Ok(response)
}

Because effects are types, the compiler rejects code that uses IO in a context where IO was not declared. No accidental side effects.

Chapter 5: Row Polymorphism (Duck Typing with Proofs)

TypeScript’s structural typing is powerful but the inference gets complex. AffineScript’s row polymorphism is principled and readable:

// This function works on any record that has at least `name: String`.
// Whatever other fields it has (captured by `..r`) are passed through.
fn greet[..r](entity: {name: String, ..r}) -> String {
  "Hello, " ++ entity.name ++ "!"
}

type Person = {name: String, age: Int}
type Company = {name: String, employees: Int, revenue: Float}

// Both work — the row variable absorbs the extra fields:
greet(Person #{ name: "Alice", age: 30 })
greet(Company #{ name: "Acme", employees: 100, revenue: 9999.0 })

Row polymorphism is also how effects compose:

// This function accepts any function that uses at least IO,
// plus any additional effects it may use (`..e`):
fn run_logged[..e](f: () -> () / IO + ..e) -> () / IO + ..e {
  IO.println("start");
  f();
  IO.println("end");
}

Arithmetic Without Typeclasses

Other languages let you forget which numeric type you’re in. JavaScript’s ` is overloaded across numbers and strings; Python's ` polymorphises across int, float, Decimal. AffineScript makes you commit — but only on the lhs — and the rhs and result follow.

// lhs is Float → rhs is checked as Float, result is Float:
fn scale(x: Float, k: Float) -> Float { x * k + 1.5 }

// lhs is Int → rhs is checked as Int, result is Int:
fn step(n: Int) -> Int { n * 2 + 1 }

// Mixed Int/Float — type error, no coercion:
fn bad(x: Float, n: Int) -> Float { x + n }     // ERROR: Float vs Int

The rule: arithmetic and comparison operators (+, -, *, /, %, <, , >, >=) synthesise the lhs type first. If it’s Float, the rhs is checked against Float. Otherwise the operator is monomorphic at Int×Int → Int. There is no implicit promotion and no typeclass dispatch. If you want to mix, convert explicitly.

This earns its keep in two ways. First, error messages name a single concrete type instead of a tyvar; you read the failure on the offending line. Second, the lowering to every backend (WASM, JS, C, Faust, WGSL, …) emits the right per-type instruction without needing dictionary passing: add becomes i64.add or fadd, never both.

Chapter 6: Typed WASM (Guarantees at Runtime)

TypeScript erases all its types when it compiles to JavaScript. The runtime has no idea what you proved. You rely on defensive checks, branded types, and faith.

AffineScript targets Typed WebAssembly (WasmGC). The type guarantees survive into the binary. The WASM runtime enforces them.

This means: - Your struct layout is what you declared — no fields added at runtime. - Your variant tags are what the compiler assigned — no stringly-typed dispatch. - Linear memory is managed by the AffineScript runtime, not the JS GC. - Your ownership invariants hold at the machine level, not just in the source.

# Compile to standard WASM:
affinescript compile hello.affine --output hello.wasm

# Compile to WasmGC (struct.new, array.new_fixed, typed references):
affinescript compile-gc hello.affine --output hello.wasm

# Run in Deno (which supports WasmGC natively):
deno run --allow-read runner.js

Chapter 7: Faces In, Backends Out

AffineScript reaches users through two orthogonal axes. Reading the documentation of either alone leads to the wrong assumption that they compose. They don’t. The diagram is:

[ Python source ]   [ JS source ]   [ Lucid source ]   [ canonical .affine ]
        \                |                |                /
         ─────────► AffineScript AST ◄──────────────
                          |
       ┌──────┬──────┬────┴────┬──────┬──────┬─ ─ ─
     WASM    JS     C       WGSL   Faust   ONNX  ...

Faces are input skins. A face is a parser front-end: Python-shaped syntax, JavaScript-shaped syntax, pseudocode, PureScript-shaped Lucid, CoffeeScript-shaped Cafe. The --face flag picks the parser; the AST that comes out the other side is canonical AffineScript. Error messages are translated back into the face’s vocabulary, so a Python-face user sees def, True, indentation, not fn, true, {}.

Backends are output skins. A backend is a code generator: WASM, WASM-GC, Julia, JavaScript, C, Rust, OCaml, Lua, Bash, Gleam, ReScript, Nickel, WGSL (compute shaders), Faust (DSP), ONNX (tensor graphs), LLVM IR, Verilog, … The output extension picks the backend (-o foo.js, -o foo.wgsl, -o foo.onnx, …).

The two axes don’t compose: there is no "JS face implies JS output" or "Python face implies Python output." You can write Python-faced source and compile it to WGSL; you can write canonical AffineScript and compile it to ReScript. The face you read in is independent from the target you write out.

Note

Why this matters for design. Once you internalise the orthogonality, the whole affinescript compile command becomes legible: it’s N faces × M backends compositions, not a fixed pipeline. New surface syntaxes don’t require new backends; new backends don’t require new surfaces. This is why the language can keep adding both without combinatorial cost.

Chapter 8: What Every Backend Gets vs. What Some Get

The backend list is long, but not every backend accepts every program. Backends are tiered by how much of the source language they take. The tier is part of the user contract, not a limitation to be apologised for — kernel sublanguages are restricted on purpose, because their target runtimes are restricted.

Tier Members Acceptance

A — host-language emitters

WASM 1.0, WASM-GC, Julia, JS, C, Rust, OCaml, ReScript, Lua, Bash, Gleam, Nickel

Full AffineScript surface. Anything you can write in canonical AS, these accept. Missing features in the codegen produce a loud error at compile time, never silent miscompilation.

B — IR / native

LLVM IR (Cranelift planned)

Full surface, lower level of abstraction. Some passes (closures, traits) may need lowering that doesn’t exist yet. From LLVM IR, llc retargets to x86-64, ARM64, RISC-V, AVR, PTX, AMDGPU.

C — kernel sublanguages

WGSL (compute shaders), SPIR-V (planned), CUDA / Metal / OpenCL (planned), Faust (DSP), ONNX (tensor graphs), MLIR/StableHLO (planned)

Deliberately restricted subset. No match, no algebraic effects, no dynamic allocation, often no String. Anything outside the subset rejects explicitly. The acceptance criteria are documented per backend.

A program that compiles to Tier A may not compile to Tier C. That is by design: you wouldn’t expect match over enum variants to work on a GPU shader, and the WGSL backend’s job is to tell you that explicitly rather than emit shader code that misbehaves at runtime.

A practical implication for translators: identify your target’s tier before you start re-decomposing. If you’re aiming at WGSL, you’re writing kernel-shaped code from the first line; sum types and effects won’t be available, and that should drive the design.

Chapter 9: The Co-processor and the Integer

You already know the problem, even if you have not named it. You have a working ReScript or JavaScript program — a game, a simulator, a tool — and you want the logic to run with AffineScript’s guarantees, compiled to wasm, without rewriting the rendering, the input handling, or the network. The instinct is to port the whole thing. The better move is to split it.

AffineScript is the brain; JS/Pixi are the senses. The wasm core does logic, physics, decisions, state machines; the host does rendering, input, strings, and dispatch. The two halves communicate by passing raw primitives — Ints, Floats, Arrays — across the wasm boundary. Nothing stringly-typed crosses; nothing stateful crosses; only numbers.

This chapter unveils the mental model. The systematic, verified re-decomposition patterns live in the Migration Playbook’s co-processor section — read that when you are actually carrying a codebase across.

The integer IS the X

The keystone idea: a value drawn from a small, named domain — a security rank Open/Weak/Medium/Strong, a device-port name, a tile kind — is encoded to a canonical integer on the host side, and the wasm core computes on that integer. The host keeps the names; the core gets the code.

// The encoding IS the rank.  0 = Open, 1 = Weak, 2 = Medium, 3 = Strong.
// The host owns "Open" <-> 0; this core owns the arithmetic.
pub fn rank_security_level(v: Int) -> Int {
  if v < 0 { 0 } else { if v > 3 { 3 } else { v } }
}

pub fn passes_min_security(actual: Int, required: Int) -> Bool {
  rank_security_level(actual) >= rank_security_level(required)
}

There is no String here, and no sum type crossing the boundary. The four rank names never enter wasm; only their codes do. Compiled host-native, this is a tiny module — in idaptik’s case 454 bytes, whose only WASI import is fd_write. Every decision the host used to phrase in terms of rank names is re-phrased as integer arithmetic on rank codes. That is the whole pattern, and its slogan is "the integer IS the X."

Why a co-processor, and not a rewrite

Three things fall out of the split that a full rewrite would not give you:

  1. The boundary is narrow and auditable. Only primitives cross. You can enumerate exactly what passes between brain and senses, because it is a short list of Ints and Floats — not an open-ended graph of objects.

  2. The core is small and verifiable. A pure-integer kernel has no strings to allocate, no state to corrupt, no effects to handle. It compiles to a handful of kilobytes and its behaviour is a function of its inputs — which is exactly what makes differential parity (below) a usable acceptance test.

  3. The senses stay where they are good. Rendering, input, and the DOM remain in the host, which already does them well. You are not reimplementing Pixi in AffineScript; you are giving Pixi a verified brain to call.

Note

This is the operational form of the WASM Co-processor Model described in the Frontier Programming Practices guide. That guide states the posture; this chapter and the Playbook show you how to port into it.

How you know it worked: differential parity

A co-processor port has a crisp acceptance bar, and it is not "the compiler accepted it." It is two conditions, both required:

  • Build-green — the .affine compiles to wasm, host-native (affinescript compile FILE -o OUT.wasm).

  • Parity-green — the wasm output matches an independent oracle — a separate implementation written from the contract, not from the .affine — across a sweep of inputs, including out-of-band ones.

idaptik’s SecurityRank core cleared this bar at 78/78 against a JS oracle of the documented 0..3 contract: every input, in-band and out-of-band, agreed. Build-green alone would only have proved the compiler ran; parity-green proves the kernel is right.

The honest edges

The co-processor model is sharp because its limits are sharp, and the limits are teaching points, not apologies:

  • Strings have an ABI, so avoid crossing them. When a string genuinely must cross, there is a real wire format ([len: i32 little-endian][utf8]) and an exported allocator to honour — see the Playbook. The cheapest way to handle a string at the boundary is to keep it host-side and send a code instead (the integerization above). Sometimes the data was never a string — idaptik’s Kernel_IO does a sandbox prefix check on bytes that are already an Int array, so it compares integer arrays directly and never builds a String at all.

  • No code runs at module load. AffineScript modules have no load-time side effect to hook. ReScript that mutates a module-level cell, reads Date.now(), or calls Console.log ambiently has nowhere to put that after the port — those capabilities must be threaded in as parameters (or supplied by extern host functions) until effect-handling codegen lands. A kernel that relies on module-load effects will not compile to wasm today; that is the compiler telling you to make the dependency explicit.

  • The faithfulness is provable. "The integer IS the X" is not folklore — it is an encoding-faithfulness theorem, machine-checked in EchoEncodingFaithfulness.agda. An injective encoder is lossless; a clamp is provable, localised loss with no decoder. SecurityRank’s clamp is exactly that certified hazard — and which direction it clamps (garbage up to Strong is fail-open; garbage down to Open is fail-closed) is a security decision the proof surfaces but does not make for you.

Try it

Sketch the boundary for a different domain before reading the Playbook. Suppose the host has tile kinds Empty | Wall | Door | Water:

  1. Choose the integer encoding (Empty = 0, Wall = 1, …). Write it down — that table is the contract.

  2. Write a pure-integer AffineScript function is_passable(tile: Int) → Bool that the host can call per tile. No String, no sum type crosses.

  3. Name one out-of-band input (a negative code, or 99) and decide — as a game-design question — what is_passable should return for it, and whether that default is fail-open or fail-closed.

You have just done the three hardest parts of a co-processor port: the encoding, the pure kernel, and the out-of-band policy. The Playbook turns the sketch into a compiled, parity-checked module.

Putting It Together: A Complete Program

// complete_example.affine — a small resource manager

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

effect Exn[E] {
  fn throw(err: E) -> Never;
}

type Config = { max_retries: Int, timeout: Int }
type Request = own { url: String, config: ref Config }
type Response = { body: String, status: Int }
type FetchError = { message: String }

// Open a request (produces a linear resource):
fn make_request(url: String, config: ref Config) -> own Request {
  Request #{ url: url, config: config }
}

// Send the request (consumes it — can't send twice):
fn send(req: own Request) -> Result[Response, FetchError] / IO {
  IO.println("Sending request to " ++ req.url);
  // Real implementation would use the Async effect
  Ok(Response #{ body: "OK", status: 200 })
}

// The return type tells you: this can fail, and it does IO.
fn fetch_with_retry(
  url: String,
  config: ref Config
) -> Result[String, FetchError] / IO {
  let req = make_request(url, config);
  match send(req) {
    Ok(resp) =>
      if resp.status == 200 {
        Ok(resp.body)
      } else {
        Err({ message: "HTTP " ++ Int.to_string(resp.status) })
      }
    Err(e) => Err(e)
  }
}

Faces: Meeting Learners Where They Are

If canonical AffineScript syntax feels unfamiliar, the --face flag lets you write in a surface syntax closer to what you already know:

# Python-style syntax — def, True/False/None, indentation:
affinescript --face python check my_program.py

# Preview what the preprocessor produces from a Python-face file:
affinescript preview-python-transform my_program.py

Regardless of which face you use, the type checker sees canonical AffineScript. Error messages are translated back to the face you chose, so "Ownership error: single-use variable" appears instead of internal jargon.

Additional faces (JS-face, Pseudocode-face, and others) are on the roadmap. See faces.adoc for the architecture.

Appendix A: Appendix: Bringing in Target Intrinsics

Backends often need to call runtime ops the AffineScript resolver doesn’t know about — GPU shuffle intrinsics, ONNX operators, DSP primitives, hardware-simulation tasks. You don’t need to extend the language for these. The pattern is declare-then-recognise:

  1. Declare the op as a stub function in your source file with the right signature.

  2. The resolver and typechecker accept the stub as a normal function.

  3. The backend codegen 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. Bodies are placeholders; the ONNX codegen
// recognises `relu` → ONNX op `Relu` and ignores what follows the `=`.
fn relu(x: Array[Float]) -> Array[Float] { x }
fn add(a: Array[Float], b: Array[Float]) -> Array[Float] { a }

fn graph(x: Array[Float], y: Array[Float]) -> Array[Float] {
  let s: Array[Float] = add(x, y);
  let r: Array[Float] = relu(s);
  r
}

affinescript compile graph.affine -o graph.onnx produces a real ONNX ModelProto containing Add and Relu nodes. The codegen ignored the stub bodies and used the names. The same pattern applies to every Tier-C kernel-sublanguage backend: CUDA’s __shfl_xor, Metal’s metal::float4, Faust’s tanh, Verilog’s $display, and so on.

Why this is preferable to extending the language: each backend stays self-contained. The frontend remains target-agnostic. Stubs live in the user’s source where they’re visible at the call site, rather than in a hidden compiler-internal table that drifts as targets evolve. And when a new target ships, its intrinsic vocabulary is just whatever names its codegen recognises — a property the user can read off from the backend’s documentation and reproduce locally.

Appendix B: Revision History

This document is intended to evolve. When you change it — adding a chapter, sharpening an example, retiring an obsolete claim — record the change here so that downstream readers (especially AI agents loading the guide at session start) can see what has moved.

Revision Date Notes

1.0

2026-04-11

Initial unveiling. The six X-Script problems and AffineScript’s answers; chapters 1–6; complete-program example; faces.

1.1

2026-05-02

Findings from the May-2026 backend buildout (16 new backends shipped: JS, C, WGSL, Faust, ONNX, OCaml, Lua, Bash, Nickel, ReScript, Rust, LLVM, Verilog, Gleam, plus protobuf and onnx_proto support modules) folded back into the guide: (1) Chapter 2 gains a "where the keyword goes" sidebar — mut annotates the binder, not the type. (2) Chapter 5 gains the Arithmetic Without Typeclasses sub-section — the typechecker now dispatches arithmetic on the lhs type (Float-aware as of 2026-05-02), with no implicit promotion. (3) New Chapter 7 — Faces In, Backends Out — makes the orthogonality of input skins and output backends explicit. (4) New Chapter 8 — What Every Backend Gets vs. What Some Get — introduces the A/B/C tier taxonomy so users know which backends accept full AS and which are kernel sublanguages by design. (5) New Appendix — Bringing in Target Intrinsics — documents the stub-as-intrinsic pattern that lets user code call CUDA / ONNX / Faust / Verilog runtime ops without extending the language. No removals; existing v1.0 content unchanged.

1.2

2026-06-04

New Chapter 9 — The Co-processor and the Integer — unveils the ReScript → AffineScript → wasm co-processor mental model ("AffineScript is the brain; JS/Pixi the senses; only primitives cross the boundary") and the "the integer IS the X" encoding idea, grounded in idaptik’s verified 454-byte SecurityRank core (78/78 differential-parity). The chapter introduces the model and the honest edges (String ABI, no module-load effects, machine-checked faithfulness via EchoEncodingFaithfulness.agda); the systematic re-decomposition patterns live in the Migration Playbook §Co-processor Integerization (playbook v1.4). No removals; existing v1.1 content unchanged.