|
| 1 | +<!-- SPDX-License-Identifier: MPL-2.0 --> |
| 2 | +# ReScript → AffineScript conversion notes |
| 3 | + |
| 4 | +This documents the port of the Seven Tentacles agent system from ReScript |
| 5 | +(`.res`) to AffineScript (`.affine`). Source of truth for syntax was the |
| 6 | +reference repo cloned at `/tmp/affinescript-ref` (the OCaml compiler's |
| 7 | +`lib/parser.mly`, the stdlib `.affine` modules, and the example/test |
| 8 | +`.affine` files), **not** the tree-sitter grammar (which is a simplified |
| 9 | +editor grammar and lags the real parser — e.g. its `use_decl` does not show |
| 10 | +the `::{...}` / `as` forms the real parser accepts). |
| 11 | + |
| 12 | +## Files |
| 13 | + |
| 14 | +Converted (then the `.res` originals were deleted): |
| 15 | + |
| 16 | +| ReScript | AffineScript | |
| 17 | +|---|---| |
| 18 | +| `agents/Types.res` | `agents/Types.affine` | |
| 19 | +| `agents/RedAgent.res` | `agents/RedAgent.affine` | |
| 20 | +| `agents/OrangeAgent.res` | `agents/OrangeAgent.affine` | |
| 21 | +| `agents/YellowAgent.res` | `agents/YellowAgent.affine` | |
| 22 | +| `agents/GreenAgent.res` | `agents/GreenAgent.affine` | |
| 23 | +| `agents/BlueAgent.res` | `agents/BlueAgent.affine` | |
| 24 | +| `agents/IndigoAgent.res` | `agents/IndigoAgent.affine` | |
| 25 | +| `agents/VioletAgent.res` | `agents/VioletAgent.affine` | |
| 26 | +| `tools/RevealSystem.res` | `tools/RevealSystem.affine` | |
| 27 | + |
| 28 | +Build config: `rescript.json` removed; `Justfile` (`build-agents`, `watch`, |
| 29 | +`status`), `deno.json` (imports + `build` task), and `docs/TENTACLES_MAP.adoc` |
| 30 | +updated. Per-file compile commands documented in `BUILD.adoc`. |
| 31 | + |
| 32 | +## ⚠️ Compile verification was NOT possible |
| 33 | + |
| 34 | +No runnable AffineScript compiler was available in this environment: |
| 35 | + |
| 36 | +* The compiler is a native OCaml binary built with `dune` — but `dune`, |
| 37 | + `ocaml`, and `opam` are **not installed** here (`bin/main.ml` + `dune` |
| 38 | + exist as source only; there is no `_build/`). |
| 39 | +* `packages/affinescript-cli/mod.js` is only a *download shim*: it fetches a |
| 40 | + per-platform native binary from a GitHub Release and execs it. That needs |
| 41 | + network + a matching release artifact; neither is available offline. |
| 42 | +* `deno` is not installed either (only `node` is present), so even the shim |
| 43 | + could not run. |
| 44 | + |
| 45 | +**Therefore the `.affine` files have NOT been machine-checked.** They were |
| 46 | +pattern-matched against the reference as carefully as possible. The |
| 47 | +"verified" vs "inferred/guessed" breakdown is below. Run |
| 48 | +`just build-agents` (or the commands in `BUILD.adoc`) once a compiler is |
| 49 | +available and fix any residual errors. |
| 50 | + |
| 51 | +## Mapping applied |
| 52 | + |
| 53 | +| ReScript | AffineScript | Confidence | |
| 54 | +|---|---|---| |
| 55 | +| (file = module) | `module PascalName;` header | verified (`parser.mly` `module_decl`; stdlib uses it) | |
| 56 | +| `open Types` | `use Types::{ A, B, ... };` (explicit names) | verified (`import_decl` ImportList; stdlib `use prelude::{...}`) | |
| 57 | +| exported `let`/`type` | `pub` prefix | verified (`visibility: PUB`) | |
| 58 | +| `type t = \| A \| B(p)` | `pub enum T { A, B(P) }` | verified (`enum_decl`; `comprehensive_test.affine`) | |
| 59 | +| `type r = { f: T }` (+ mutually-rec `and`) | separate `pub struct R { f: T }` decls (no `and`) | verified (`struct_decl`); recursion handled by declaring structs before/independently of users | |
| 60 | +| record literal `{ f: v }` | `#{ f: v }` | verified (`HASH_LBRACE`; `comprehensive_test.affine` `#{ x: 3, y: 4 }`) | |
| 61 | +| `option<T>` / `Some` / `None` | `Option<T>` / `Some` / `None` (from `prelude`) | verified (`prelude.affine`) | |
| 62 | +| `array<T>` / `[a,b]` / `arr[i]` | `[T]` / `[a, b]` / `arr[i]` | verified (`collections.affine`, `option.affine`) | |
| 63 | +| `Array.length(a)` | `len(a)` builtin | verified (used throughout stdlib) | |
| 64 | +| string/array concat `++` | `++` | verified (`prelude.affine`, `string.affine`) | |
| 65 | +| `switch x { \| A => e }` | `match x { A => e, }` (comma arms, `_` wildcard) | verified (`option.affine`, `math.affine`) | |
| 66 | +| tuple `(int,int)` / tuple patterns | `(Int, Int)` / `match (a,b) { (x,y) => ... }` | verified (`option.affine` `match (a, b) { (Some(x), ...) => ... }`) | |
| 67 | +| `float` / Int→Float | `Float` / `float(n)` builtin | verified (`testing.affine` uses `float(iterations)`) | |
| 68 | +| `bool` / `true` / `false` | `Bool` / `true` / `false` | verified | |
| 69 | +| `Js.Math.random_int(0, n)` | `Deno::random_in_range(0, n)` | **inferred** — see RNG below | |
| 70 | +| `arr[i]->Option.getOr(d)` | `random_element` helper (guard `len`, else fallback) | inferred — see array-index below | |
| 71 | +| `module Red = RedAgent` | `use RedAgent as Red;` + qualified calls | verified the *syntax*; **the semantics forced a rename** — see collisions below | |
| 72 | + |
| 73 | +## Non-trivial decisions |
| 74 | + |
| 75 | +### 1. Top-level `let` → zero-arg `fn` |
| 76 | + |
| 77 | +AffineScript's grammar (`top_level` in `parser.mly`) allows only `fn`, |
| 78 | +`type`, `effect`, `trait`, `impl`, `const`, and `extern` at module top level — |
| 79 | +**there is no top-level `let`**. `const` exists but every example in the |
| 80 | +reference uses it for *scalars* only (`const PI: Float = ...`); no `.affine` |
| 81 | +file uses a `const` initialised to a record/array/enum value. |
| 82 | + |
| 83 | +So each ReScript top-level binding of a record/array value |
| 84 | +(`let names`, `let personality`, `let teaches`, `let lessons`, `let agent`) |
| 85 | +became a **zero-argument public function** returning that value: |
| 86 | + |
| 87 | +``` |
| 88 | +pub fn names() -> AgentNames { #{ ... } } |
| 89 | +``` |
| 90 | + |
| 91 | +and every reference was updated to a call: `names.cuttle` → `names().cuttle`, |
| 92 | +`personality.encouragement` → `personality().encouragement`. This is the |
| 93 | +idiom the whole stdlib uses (functions returning records/arrays) and is |
| 94 | +definitely supported. Trade-off: the value is rebuilt on each call rather |
| 95 | +than shared — semantically identical for this pure, immutable data. |
| 96 | + |
| 97 | +### 2. RNG (`Js.Math.random_int`) — INFERRED |
| 98 | + |
| 99 | +`stdlib/Deno.affine` declares `pub extern fn random_in_range(lo, hi) -> Int` |
| 100 | +("uniform integer draw in `[lo, hi)`", lowered to |
| 101 | +`Math.floor(Math.random()*(hi-lo))+lo` on the `--deno-esm` backend). That is |
| 102 | +an exact match for ReScript `Js.Math.random_int(0, len)`, so `random_element` |
| 103 | +in `Types.affine` uses it via `use Deno;` + `Deno::random_in_range(0, n)`. |
| 104 | + |
| 105 | +I deliberately did **not** hand-roll `floor(math_random() * to_float(n))` |
| 106 | +(the suggestion in the task) because: |
| 107 | +* `floor`'s signature is `Float -> Int` (a builtin), and there is **no `*.` |
| 108 | + float-multiply operator** — `math.affine` multiplies floats with plain `*` |
| 109 | + (`degrees * PI / 180.0`). So the hand-rolled form would be |
| 110 | + `floor(math_random() * float(n))`, which works, but |
| 111 | +* `random_in_range` is the purpose-built primitive and is clearer/safer. |
| 112 | + |
| 113 | +**Caveat / coupling:** importing `Deno` ties `Types.affine` (hence every |
| 114 | +agent) to a host providing `Deno::*` (the `--deno-esm` backend or the |
| 115 | +`affine-deno` runtime shim). This is acceptable because the original targeted |
| 116 | +JS/Deno (`Js.Math`), but a non-Deno backend would need the RNG call swapped. |
| 117 | +**Unverified:** that `Deno::random_in_range` resolves cleanly for a |
| 118 | +non-`main` library module under `check`. |
| 119 | + |
| 120 | +### 3. Array-index "random element or fallback" |
| 121 | + |
| 122 | +ReScript `arr[idx]->Option.getOr(fallback)` relies on indexing returning |
| 123 | +`option`. In AffineScript `arr[i]` returns the **element directly** (verified: |
| 124 | +`collections.affine` `list[0]`, `option.affine` `list[index]`), and `len` |
| 125 | +gives the length. So the idiom became the `random_element(arr, fallback)` |
| 126 | +helper in `Types.affine`: |
| 127 | + |
| 128 | +``` |
| 129 | +pub fn random_element(arr: [String], fallback: String) -> String { |
| 130 | + let n = len(arr); |
| 131 | + if n > 0 { arr[Deno::random_in_range(0, n)] } else { fallback } |
| 132 | +} |
| 133 | +``` |
| 134 | + |
| 135 | +Behaviour preserved: a uniformly-random element, or the fallback when empty. |
| 136 | +(The original could never actually hit the fallback for the non-empty |
| 137 | +personality arrays; the helper keeps the same safety net.) |
| 138 | + |
| 139 | +### 4. Module aliasing AND a forced rename — the important one |
| 140 | + |
| 141 | +ReScript `module Red = RedAgent` + `Red.getName(stage)` namespaces each |
| 142 | +agent. AffineScript supports `use RedAgent as Red;` and qualified value paths |
| 143 | +`Red::red_get_name(x)` (verified in `parser.mly`: `upper_ident COLONCOLON |
| 144 | +lower_ident` → `ExprField`, lowered by `Resolve.lower_qualified_value_paths`). |
| 145 | + |
| 146 | +**However** — and this is the subtle part — `lower_qualified_value_paths` |
| 147 | +**erases the qualifier**: `Red::red_get_name` is rewritten to the bare |
| 148 | +`red_get_name`, and the module loader *flat-imports* every imported module's |
| 149 | +public functions **by name into one namespace**, deduplicating on collision |
| 150 | +(`module_loader.ml` `already_in`). If all seven agents each exported a |
| 151 | +function literally named `get_name`, the seven `get_name`s would collide and |
| 152 | +only the first would survive — the per-module qualifier cannot disambiguate |
| 153 | +them because it is gone before resolution. |
| 154 | + |
| 155 | +**Decision:** each agent's *stage-dispatched* public functions are uniquely |
| 156 | +prefixed with the colour: |
| 157 | +`red_get_name`, `orange_get_name`, …, `red_get_hidden_concept`, …, |
| 158 | +`red_reveal_text`, …, `red_encourage`/`red_correct`/`red_celebrate`. The |
| 159 | +data accessors (`names`, `personality`, `teaches`, `lessons`, `agent`) keep |
| 160 | +their plain names because they are **only called within their own module**, |
| 161 | +never cross-module, so they never collide in `RevealSystem`'s import set. |
| 162 | + |
| 163 | +This changes the public API names of the per-agent functions but preserves |
| 164 | +all behaviour and is the faithful way to compile. `RevealSystem` still reads |
| 165 | +naturally: `Red::red_get_name(stage)`. |
| 166 | + |
| 167 | +**Unverified:** whether the resolver is happy with `use X as Y;` where the |
| 168 | +flattened public set is large; and whether `agent()`/`names()` etc. (same |
| 169 | +name across modules) cause a problem *in RevealSystem* — they should not, |
| 170 | +because `RevealSystem` never imports those names (it only imports |
| 171 | +`Types::{...}`, `prelude::{...}`, and the seven agents *as aliases*; the |
| 172 | +aliased ImportSimple still flat-imports the modules' public fns by name, so |
| 173 | +the seven `names`/`personality`/`agent`/etc. **do** collide in |
| 174 | +RevealSystem's flattened set). RevealSystem never *calls* those collided |
| 175 | +names, so it is harmless for type-checking RevealSystem's own bodies — but if |
| 176 | +the loader rejects duplicate flat imports outright (rather than deduping), |
| 177 | +RevealSystem would fail to load. The observed loader behaviour |
| 178 | +(`already_in` dedup, no error) suggests dedup, not rejection. **This is the |
| 179 | +single most likely place to need a fix once a compiler is available** — the |
| 180 | +robust fallback is to also prefix `names`/`personality`/`teaches`/`lessons`/ |
| 181 | +`agent` per colour (e.g. `red_agent()`), at the cost of more churn. |
| 182 | + |
| 183 | +### 5. `filterMap` + `Option.map` |
| 184 | + |
| 185 | +`RevealSystem.generateStageReveal` used |
| 186 | +`colors->Array.filterMap(c => getRevealText(...)->Option.map(t => (c, t)))`. |
| 187 | +The stdlib *does* provide `option::map_filter` and `option::map`, but they |
| 188 | +are **not `pub`** (only `option::unwrap_or` is `pub`), so they cannot be |
| 189 | +cross-module imported. Rather than depend on that, I inlined the logic with a |
| 190 | +`for` loop + `match` accumulating into a `mut` array — the exact idiom |
| 191 | +`option.affine`'s own `cat_options`/`map_filter` use internally: |
| 192 | + |
| 193 | +``` |
| 194 | +let mut agentReveals = []; |
| 195 | +for color in colors { |
| 196 | + match get_reveal_text(color, fromStage, toStage) { |
| 197 | + Some(text) => { agentReveals = agentReveals ++ [(color, text)]; }, |
| 198 | + None => {} |
| 199 | + } |
| 200 | +} |
| 201 | +``` |
| 202 | + |
| 203 | +### 6. Float arithmetic & no string interpolation |
| 204 | + |
| 205 | +* `Int.toFloat(a) /. Int.toFloat(b) *. 100.0` → `float(a) / float(b) * 100.0`. |
| 206 | + There is **no `*.`/`/.`** in AffineScript; `math.affine` uses plain `*`/`/` |
| 207 | + on `Float`. `float(n)` is the Int→Float builtin (used by `to_float`, which |
| 208 | + is itself not `pub`, so `float` is called directly). |
| 209 | +* ReScript template literals `` `${x} ...` `` → `++` concatenation. No |
| 210 | + string-interpolation syntax exists in the reference (searched lexer/parser/ |
| 211 | + spec). The multi-line `theBigReveal` string is an ordinary double-quoted |
| 212 | + literal containing newlines (the reference's string literals are not |
| 213 | + newline-restricted in any way I could find — **mildly unverified**; if the |
| 214 | + lexer rejects raw newlines in `"..."`, this needs `\n` escapes or |
| 215 | + line-wise `++`). |
| 216 | + |
| 217 | +### 7. Naming conventions |
| 218 | + |
| 219 | +* Types/enums/structs/variants → PascalCase (`stage`→`Stage`, `agentColor`→ |
| 220 | + `AgentColor`, variants already PascalCase). |
| 221 | +* Functions/fields → kept the original field names (records are structs with |
| 222 | + the same field identifiers) but multi-word top-level fns were snake_cased |
| 223 | + to match stdlib style (`stageToAge`→`stage_to_age`, `getRevealText`→ |
| 224 | + `get_reveal_text`). Struct *field* names kept their original camelCase |
| 225 | + (`hiddenConcept`, `winCondition`, …) to preserve the data shape exactly. |
| 226 | + |
| 227 | +## Things specifically left UNVERIFIED (no compiler to confirm) |
| 228 | + |
| 229 | +1. Whether a library module with no `fn main()` type-checks under |
| 230 | + `affinescript check` (all stdlib modules are libraries, so this should be |
| 231 | + fine, but I could not run it). |
| 232 | +2. The cross-module flat-import collision behaviour in `RevealSystem` |
| 233 | + (decision #4) — most likely fix-point if anything fails. |
| 234 | +3. Raw newlines inside the `theBigReveal` string literal (#6). |
| 235 | +4. That `use Deno;` resolves for a JS/Wasm target invoked via `check` without |
| 236 | + a backend flag (#2 RNG). |
| 237 | +5. Trailing commas in `match` arms and multi-line `#{ }` literals — used |
| 238 | + pervasively in the reference, so high-confidence, but not run here. |
| 239 | +6. Struct field *punning* was avoided entirely (always `field: value`), so no |
| 240 | + risk there even though ReScript used `{ names, teaches, ... }` shorthand. |
0 commit comments