|
1 | 1 | // SPDX-License-Identifier: MPL-2.0 |
2 | 2 | = AffineScript Frontier Guide: The Unveiling |
3 | 3 | Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
4 | | -v1.0, 2026-04-11 |
| 4 | +v1.2, 2026-06-04 |
5 | 5 | :toc: |
6 | 6 | :icons: font |
7 | 7 | :source-highlighter: rouge |
8 | | -:revnumber: 1.0 |
9 | | -:revdate: 2026-04-11 |
| 8 | +:revnumber: 1.2 |
| 9 | +:revdate: 2026-06-04 |
10 | 10 |
|
11 | 11 | [NOTE] |
12 | 12 | ==== |
@@ -444,6 +444,141 @@ A practical implication for translators: identify your target's tier |
444 | 444 | writing kernel-shaped code from the first line; sum types and effects |
445 | 445 | won't be available, and that should drive the design. |
446 | 446 |
|
| 447 | +== Chapter 9: The Co-processor and the Integer |
| 448 | + |
| 449 | +You already know the problem, even if you have not named it. You have a |
| 450 | +working ReScript or JavaScript program — a game, a simulator, a tool — and |
| 451 | +you want the *logic* to run with AffineScript's guarantees, compiled to |
| 452 | +wasm, without rewriting the rendering, the input handling, or the network. |
| 453 | +The instinct is to port the whole thing. The better move is to split it. |
| 454 | + |
| 455 | +**AffineScript is the brain; JS/Pixi are the senses.** The wasm core does |
| 456 | +logic, physics, decisions, state machines; the host does rendering, input, |
| 457 | +strings, and dispatch. The two halves communicate by passing **raw |
| 458 | +primitives** — Ints, Floats, Arrays — across the wasm boundary. Nothing |
| 459 | +stringly-typed crosses; nothing stateful crosses; only numbers. |
| 460 | + |
| 461 | +This chapter unveils the mental model. The systematic, verified |
| 462 | +re-decomposition patterns live in the |
| 463 | +link:migration-playbook.adoc#coprocessor-integerization[Migration Playbook's |
| 464 | +co-processor section] — read that when you are actually carrying a codebase |
| 465 | +across. |
| 466 | + |
| 467 | +=== The integer IS the X |
| 468 | + |
| 469 | +The keystone idea: a value drawn from a small, named domain — a security |
| 470 | +rank `Open/Weak/Medium/Strong`, a device-port name, a tile kind — is |
| 471 | +**encoded to a canonical integer on the host side**, and the wasm core |
| 472 | +computes on that integer. The host keeps the names; the core gets the code. |
| 473 | + |
| 474 | +[source,affine] |
| 475 | +---- |
| 476 | +// The encoding IS the rank. 0 = Open, 1 = Weak, 2 = Medium, 3 = Strong. |
| 477 | +// The host owns "Open" <-> 0; this core owns the arithmetic. |
| 478 | +pub fn rank_security_level(v: Int) -> Int { |
| 479 | + if v < 0 { 0 } else { if v > 3 { 3 } else { v } } |
| 480 | +} |
| 481 | +
|
| 482 | +pub fn passes_min_security(actual: Int, required: Int) -> Bool { |
| 483 | + rank_security_level(actual) >= rank_security_level(required) |
| 484 | +} |
| 485 | +---- |
| 486 | + |
| 487 | +There is no `String` here, and no sum type crossing the boundary. The four |
| 488 | +rank *names* never enter wasm; only their codes do. Compiled host-native, |
| 489 | +this is a tiny module — in idaptik's case **454 bytes**, whose only WASI |
| 490 | +import is `fd_write`. Every decision the host used to phrase in terms of |
| 491 | +rank *names* is re-phrased as integer arithmetic on rank *codes*. That is |
| 492 | +the whole pattern, and its slogan is _"the integer IS the X."_ |
| 493 | + |
| 494 | +=== Why a co-processor, and not a rewrite |
| 495 | + |
| 496 | +Three things fall out of the split that a full rewrite would not give you: |
| 497 | + |
| 498 | +. **The boundary is narrow and auditable.** Only primitives cross. You can |
| 499 | + enumerate exactly what passes between brain and senses, because it is a |
| 500 | + short list of Ints and Floats — not an open-ended graph of objects. |
| 501 | +. **The core is small and verifiable.** A pure-integer kernel has no |
| 502 | + strings to allocate, no state to corrupt, no effects to handle. It |
| 503 | + compiles to a handful of kilobytes and its behaviour is a function of its |
| 504 | + inputs — which is exactly what makes <<chapter-9-parity,differential |
| 505 | + parity>> (below) a usable acceptance test. |
| 506 | +. **The senses stay where they are good.** Rendering, input, and the DOM |
| 507 | + remain in the host, which already does them well. You are not reimplementing |
| 508 | + Pixi in AffineScript; you are giving Pixi a verified brain to call. |
| 509 | + |
| 510 | +[NOTE] |
| 511 | +==== |
| 512 | +This is the operational form of the WASM Co-processor Model described in the |
| 513 | +link:frontier-programming-practices/Human_Programming_Guide.adoc[Frontier |
| 514 | +Programming Practices] guide. That guide states the posture; this chapter and |
| 515 | +the Playbook show you how to *port into it*. |
| 516 | +==== |
| 517 | + |
| 518 | +[#chapter-9-parity] |
| 519 | +=== How you know it worked: differential parity |
| 520 | + |
| 521 | +A co-processor port has a crisp acceptance bar, and it is *not* "the compiler |
| 522 | +accepted it." It is two conditions, both required: |
| 523 | + |
| 524 | +* **Build-green** — the `.affine` compiles to wasm, host-native |
| 525 | + (`affinescript compile FILE -o OUT.wasm`). |
| 526 | +* **Parity-green** — the wasm output matches an **independent oracle** — a |
| 527 | + separate implementation written from the *contract*, not from the |
| 528 | + `.affine` — across a sweep of inputs, including out-of-band ones. |
| 529 | + |
| 530 | +idaptik's `SecurityRank` core cleared this bar at **78/78** against a JS |
| 531 | +oracle of the documented `0..3` contract: every input, in-band and |
| 532 | +out-of-band, agreed. Build-green alone would only have proved the compiler |
| 533 | +ran; parity-green proves the kernel is *right*. |
| 534 | + |
| 535 | +=== The honest edges |
| 536 | + |
| 537 | +The co-processor model is sharp because its limits are sharp, and the limits |
| 538 | +are teaching points, not apologies: |
| 539 | + |
| 540 | +* **Strings have an ABI, so avoid crossing them.** When a string genuinely |
| 541 | + must cross, there is a real wire format (`[len: i32 little-endian][utf8]`) |
| 542 | + and an exported allocator to honour — see the Playbook. The cheapest way |
| 543 | + to handle a string at the boundary is to keep it host-side and send a code |
| 544 | + instead (the integerization above). Sometimes the data was *never* a |
| 545 | + string — idaptik's `Kernel_IO` does a sandbox prefix check on bytes that |
| 546 | + are already an `Int` array, so it compares integer arrays directly and |
| 547 | + never builds a `String` at all. |
| 548 | +* **No code runs at module load.** AffineScript modules have no load-time |
| 549 | + side effect to hook. ReScript that mutates a module-level cell, reads |
| 550 | + `Date.now()`, or calls `Console.log` ambiently has nowhere to put that |
| 551 | + after the port — those capabilities must be *threaded in as parameters* |
| 552 | + (or supplied by `extern` host functions) until effect-handling codegen |
| 553 | + lands. A kernel that relies on module-load effects will not compile to |
| 554 | + wasm today; that is the compiler telling you to make the dependency |
| 555 | + explicit. |
| 556 | +* **The faithfulness is provable.** "The integer IS the X" is not folklore — |
| 557 | + it is an encoding-faithfulness theorem, machine-checked in |
| 558 | + link:../../proposals/echo-types/README.adoc[`EchoEncodingFaithfulness.agda`]. |
| 559 | + An *injective* encoder is lossless; a *clamp* is provable, localised loss |
| 560 | + with no decoder. SecurityRank's clamp is exactly that certified hazard — |
| 561 | + and which *direction* it clamps (garbage up to `Strong` is fail-open; |
| 562 | + garbage down to `Open` is fail-closed) is a security decision the proof |
| 563 | + surfaces but does not make for you. |
| 564 | + |
| 565 | +=== Try it |
| 566 | + |
| 567 | +Sketch the boundary for a different domain before reading the Playbook. |
| 568 | +Suppose the host has tile kinds `Empty | Wall | Door | Water`: |
| 569 | + |
| 570 | +. Choose the integer encoding (`Empty = 0`, `Wall = 1`, …). Write it down — |
| 571 | + that table is the contract. |
| 572 | +. Write a pure-integer AffineScript function `is_passable(tile: Int) -> Bool` |
| 573 | + that the host can call per tile. No `String`, no sum type crosses. |
| 574 | +. Name one out-of-band input (a negative code, or `99`) and decide — *as a |
| 575 | + game-design question* — what `is_passable` should return for it, and |
| 576 | + whether that default is fail-open or fail-closed. |
| 577 | + |
| 578 | +You have just done the three hardest parts of a co-processor port: the |
| 579 | +encoding, the pure kernel, and the out-of-band policy. The Playbook turns |
| 580 | +the sketch into a compiled, parity-checked module. |
| 581 | + |
447 | 582 | == Putting It Together: A Complete Program |
448 | 583 |
|
449 | 584 | [source,affinescript] |
@@ -591,4 +726,8 @@ This document is intended to evolve. When you change it — adding a chapter, sh |
591 | 726 | (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. |
592 | 727 | No removals; existing v1.0 content unchanged. |
593 | 728 |
|
| 729 | +| 1.2 |
| 730 | +| 2026-06-04 |
| 731 | +| 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 link:migration-playbook.adoc#coprocessor-integerization[Migration Playbook §Co-processor Integerization] (playbook v1.4). No removals; existing v1.1 content unchanged. |
| 732 | + |
594 | 733 | |=== |
0 commit comments