Skip to content

Commit c402ff5

Browse files
committed
refactor(frontier-practices): port ReScript agents to AffineScript
Convert the Seven-Tentacles agent system from ReScript (.res) to AffineScript (.affine), resolving the `Language / package anti-pattern policy` finding at source (the language the policy actually wants) rather than via the .hypatia-ignore exemption. - 9 files ported: agents/{Types,Red,Orange,Yellow,Green,Blue,Indigo,Violet}Agent and tools/RevealSystem. ADTs -> enum/struct, records -> #{..} literals, switch -> match, array<>/option<> -> [T]/Option<T>, ReScript stdlib -> AffineScript prelude/option/Deno equivalents. - Top-level `let` data -> zero-arg `pub fn` (AffineScript has no top-level let). - Cross-module fns colour-prefixed (red_*, blue_*, ...) so RevealSystem's `use XAgent as X` qualified calls resolve without collision. - RNG: Js.Math.random_int -> Deno::random_in_range; build config moved off rescript.json (BUILD.adoc documents `affinescript compile`). - Mapping + every nontrivial decision + unverified points in CONVERSION-NOTES.md. NOT compiler-verified: no AffineScript toolchain (ocaml/dune/deno) is available in this environment, so the files are pattern-matched against the affinescript reference (lib/parser.mly + stdlib) but not machine-checked. See CONVERSION-NOTES.md. https://claude.ai/code/session_014uKLLhZiAGNayhLjdxGXUx
1 parent a0a2397 commit c402ff5

24 files changed

Lines changed: 2290 additions & 1773 deletions

frontier-practices/BUILD.adoc

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
= Building the Seven Tentacles AffineScript sources
3+
:toc: macro
4+
5+
The agent definitions and reveal system are written in *AffineScript*
6+
(`.affine`), ported from the original ReScript (`.res`) sources. AffineScript
7+
has *no project-manifest build* (there is no `affinescript.json` listing
8+
sources, the way `rescript.json` did). The compiler is invoked *per file* via
9+
its subcommands, so this document is the canonical list of entry points and
10+
commands. (`rescript.json` was removed in the port.)
11+
12+
toc::[]
13+
14+
== Compiler front door
15+
16+
The AffineScript compiler is a native OCaml binary. Two ways to invoke it:
17+
18+
* Installed binary on `PATH`:
19+
+
20+
[source,sh]
21+
----
22+
affinescript <subcommand> <file>
23+
----
24+
25+
* Deno shim (downloads + execs the pinned native binary; canonical per ADR-019):
26+
+
27+
[source,sh]
28+
----
29+
deno run -A npm:@hyperpolymath/affinescript <subcommand> <file>
30+
----
31+
32+
Useful subcommands (from the reference repo's `justfile`):
33+
34+
[cols="1,3"]
35+
|===
36+
|Subcommand |Purpose
37+
38+
|`lex <file>` |Tokenise only
39+
|`parse <file>` |Parse to AST
40+
|`check <file>` |Type-check (use this for verification)
41+
|`eval <file>` |Interpret/run
42+
|`compile <file> -o <out>` |Compile to Wasm
43+
|===
44+
45+
== Entry points (compile/check order)
46+
47+
`Types.affine` is the foundation and must be resolvable first; every agent
48+
imports it, and `RevealSystem.affine` imports every agent. Type-check (or
49+
compile) in this dependency order:
50+
51+
[source,sh]
52+
----
53+
# 1. Foundation
54+
affinescript check agents/Types.affine
55+
56+
# 2. Agents (each `use Types::{...}`)
57+
affinescript check agents/RedAgent.affine
58+
affinescript check agents/OrangeAgent.affine
59+
affinescript check agents/YellowAgent.affine
60+
affinescript check agents/GreenAgent.affine
61+
affinescript check agents/BlueAgent.affine
62+
affinescript check agents/IndigoAgent.affine
63+
affinescript check agents/VioletAgent.affine
64+
65+
# 3. Reveal system (imports Types + all seven agents)
66+
affinescript check tools/RevealSystem.affine
67+
----
68+
69+
The `just build-agents` recipe runs exactly this list (and degrades to a
70+
hint when no compiler is on `PATH`).
71+
72+
== Module resolution notes
73+
74+
* Each file declares a `module <Name>;` header matching its file name
75+
(PascalCase), e.g. `module RedAgent;` in `RedAgent.affine`.
76+
* Cross-file references use `use <Module>::{ ... };` (explicit name lists) or
77+
`use <Module> as <Alias>;` (module alias, used in `RevealSystem.affine`).
78+
* `Types.affine` additionally does `use Deno;` for the `random_in_range`
79+
randomness primitive (see CONVERSION-NOTES.adoc / `.md`). This couples the
80+
agent layer to a host that provides `Deno::*` (the `--deno-esm` backend or
81+
the `affine-deno` runtime shim). If you target a non-Deno backend, swap the
82+
`random_element` helper's RNG call for that backend's equivalent.
83+
84+
See `CONVERSION-NOTES.md` for the full ReScript -> AffineScript mapping and the
85+
decisions made during the port.
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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.

frontier-practices/Justfile

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,18 @@ build-docs:
1616
@asciidoctor -D dist/docs README.adoc VISION.adoc 2>/dev/null || echo "Note: Install asciidoctor for full docs build"
1717
@echo "✅ Documentation built in dist/docs/"
1818

19-
# Build ReScript agents (when rescript is installed)
19+
# Type-check the AffineScript agents (requires the `affinescript` compiler).
20+
# AffineScript has no project-manifest build; the compiler is invoked per
21+
# file. See BUILD.adoc for the full per-entry-point command list.
22+
# Front door: `deno run -A npm:@hyperpolymath/affinescript check <file>`.
2023
build-agents:
21-
@echo "🐙 Building agents..."
22-
@if [ -f "node_modules/.bin/rescript" ]; then \
23-
npx rescript build; \
24+
@echo "🐙 Type-checking AffineScript agents..."
25+
@if command -v affinescript >/dev/null 2>&1; then \
26+
for f in agents/Types.affine agents/RedAgent.affine agents/OrangeAgent.affine agents/YellowAgent.affine agents/GreenAgent.affine agents/BlueAgent.affine agents/IndigoAgent.affine agents/VioletAgent.affine tools/RevealSystem.affine; do \
27+
echo " check $$f"; affinescript check "$$f" || exit 1; \
28+
done; \
2429
else \
25-
echo "Note: Run 'npm install' to enable ReScript builds"; \
30+
echo "Note: install the 'affinescript' compiler (or use 'deno run -A npm:@hyperpolymath/affinescript') to type-check. See BUILD.adoc"; \
2631
fi
2732

2833
# Start development server
@@ -71,7 +76,7 @@ test:
7176
# Watch for changes and rebuild
7277
watch:
7378
@echo "👀 Watching for changes..."
74-
@watchexec -e res,adoc,html,css,js "just build"
79+
@watchexec -e affine,adoc,html,css,js "just build"
7580

7681
# Clean build artifacts
7782
clean:
@@ -127,9 +132,9 @@ status:
127132
@echo "=================================="
128133
@echo ""
129134
@echo "📁 Structure:"
130-
@echo " Agents: $(find agents -name '*.res' 2>/dev/null | wc -l) files"
131-
@echo " Tools: $(find tools -name '*.res' -o -name '*.html' 2>/dev/null | wc -l) files"
132-
@echo " Languages: $(find languages -name '*.res' -o -name '*.js' 2>/dev/null | wc -l) files"
135+
@echo " Agents: $(find agents -name '*.affine' 2>/dev/null | wc -l) files"
136+
@echo " Tools: $(find tools -name '*.affine' -o -name '*.html' 2>/dev/null | wc -l) files"
137+
@echo " Languages: $(find languages -name '*.affine' -o -name '*.js' 2>/dev/null | wc -l) files"
133138
@echo ""
134139
@echo "📚 Curriculum:"
135140
@just count-lessons

0 commit comments

Comments
 (0)