Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/prd/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,49 @@ game** (simpler than the glob-exclusion a shared `nn.pg` needs); the `.ckpt` par
polynomial approximations) only if uniform single-source is a hard requirement, gated on an argmax-parity spike (MC0).
Plan MC0–MC5. **Payoff:** the last two per-viewer AI sockets → zero server inference.

## M34 — Snake: look-ahead search (client-side, single-source) *(branch `m34-snake-search`, off `master`; see `SNAKE_SEARCH_PRD.md`)* ⏳

**Problem.** Shipped client-side Snake (M33) plays masked-greedy **one-step** over the 177-dim net and is stuck at the
**~50 food@12** reactive plateau M27 already charted (capacity/features/reward/horizon all swept → ~50; a reactive net
can't avoid a trap that forms several moves ahead). The reachable-free-space input + the 1-ply flood-fill shield M27
added are *already shipped* — the missing lever is **planning more than one ply**, not more training.

**Fix.** Port PR #11's proven idea — net-guided multi-ply look-ahead (food@12 ~50 → ~78.6) — into the **single-source
`snake_solver.pg`**. (PR #11 itself is unmergeable: it predates M32/M33's Polyglot + client-side rewrite — server-side C#
`SnakeSearchAgent` + 39-dim ray obs; `CONFLICTING`.) `chooseActionSearch` runs a receding-horizon **beam search** over
cloned envs with **pure-survival leaf scoring** (board-full win ≫ everything; a death *delayed* beats a death now; else
`food·w − trapPenalty·[reachable<len] + freeSpaceAhead·w − headFoodDist·w`), reusing the shipped `reachableFreeSpace`
flood-fill. The trained net breaks ties between **equally-safe root moves** (one forward per move, not per node). No
retrain, no obs change; runs in C# eval AND the browser director byte-identically.

**Experiment ledger (food@12, 12×12, shipped 177-dim net, no retrain; greedy ≈ 50; d12/b16, net-tiebreak 50):**
| config | food | latency | verdict |
|---|---|---|---|
| survival only (`SpaceRatioWeight` 0) | 70.3 / 72.6 (seed1/100) | 10.7 ms/move | prior baseline |
| **+ anti-fragmentation ratio 100k (SHIPPED)** | **81.3 / 80.6** (max 106–108) | ~11 ms/move | **shipped** — +14%, robust across seeds |
| ratio 200k / 400k | 82.2 / 76.0 | ~10 ms/move | 200k ~ties 100k; 400k over-weights → under-eats |
| net-tiebreak net 500 (ratio 0) | 67.8 | 10.6 ms/move | heavy net weight overrides survival → worse |
| net-guided *per node* d10/b16 | 74.0 | **89 ms/move** | rejected: ~9× cost, no strength gain |
| d20/b32 (ratio 0) | 66.2 | 38 ms/move | rejected: deeper/wider misranks under beam pruning |

**Key findings.** (1) **The anti-fragmentation term is the biggest single lever** — scoring the *fraction of free cells
still reachable* (the user's original reachability-ratio idea, applied in the **search leaf score**, not as a net input)
lifts food@12 ~71 → ~81 (+14%, robust on a second seed base; single games now reach a near-full board, 106–108). It
catches fragmentation the absolute `reachable < length` trap test misses. (2) Depth has a **sweet spot ~12** — deeper+wider
*misranks* under beam pruning. (3) Evaluating the net at every node buys **no** strength for ~9× the latency — the search
carries it — so the net is a cheap **root-move tiebreak**. "Make the Snake net strong" resolves as: the net's reactive
ceiling is real (~50) and can't be trained away; the **agent** is made strong by search, the net a leaf/tiebreak evaluator.

**Client.** `snake-director.ts` swaps greedy `chooseAction` → `chooseActionSearch` (incl. `SpaceRatioWeight`), drives the
live env with `safeMask: false` (planner supersedes the 1-ply shield); `snake_solver.ts` regenerated from the `.pg`; the
shipped `snake-net.ckpt` reused verbatim.

**Gate.** food@12 ≈ **81** (markedly past ~50; high per-episode variance ~55–108), fully client-side, byte-identical
C#/TS, watchable in-browser cadence (~11 ms/move C#). **Honest ceiling.** ~81 mean (single games hit ~106); a reliable
clean 100+ needs a **tail-reachability invariant / Hamiltonian endgame** (PR #11's stretch) — next milestone.
**Transpiler note.** A multi-`.pg` **incremental**-rebuild codegen bug surfaced (stale out-dir → duplicate prelude /
non-`partial` PolyglotProgram); clean/CI builds unaffected. Handoff: `polyglot-pilot/POLYGLOT_TOPLEVEL_RECORD_BUG.md`.

## Testing strategy (cross-cutting, from research)

1. **Known-solved thresholds** as integration tests (median over ≥3 seeds) — slow bucket.
Expand Down
17 changes: 17 additions & 0 deletions docs/prd/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,3 +557,20 @@ via `CampaignRunner`. Investigation showed the web's `EnsureModel` training was
committed to `models/` (Git LFS) and seeded at startup, so it never ran in production. So M26 instead made the web
**load-only**: training lives solely on the dev side (Lab campaigns / Console) and is committed; the web loads and
serves. The campaigns stay `internal` to the Lab (no shared library needed — the web doesn't train). See PLAN M26.

## 15. Snake — strength via search, not more training *(PRD §15; inserted 2026-07-10; see [SNAKE_SEARCH_PRD.md](SNAKE_SEARCH_PRD.md) + [PLAN.md](PLAN.md) M34)*

A learned Snake policy is **structurally capped at ~50 food@12** (M27's sweep: capacity, features, reward and horizon
all plateau there — a reactive net can't avoid a trap that forms several moves ahead). The lever is **planning, not
training**: a multi-ply look-ahead, single-sourced in `snake_solver.pg` so C# eval and the browser director share it
byte-for-byte, lifts play to **~81 food@12 (+60% over the plateau)** with **no retraining and no observation change**.

> **Design decision (2026-07-10).** The strength comes from an exact **flood-fill survivability search** (reuses the
> `reachableFreeSpace` the net already carries as an input). The **biggest single lever** turned out to be an
> **anti-fragmentation term** — scoring the *fraction of currently-free cells still reachable* (the reachability-ratio
> idea originally floated as a net input, but far more effective in the **search leaf score**): it lifts food@12 ~71 → ~81
> (+14%, robust across seed bases). A per-node net evaluation, by contrast, added **no** strength for ~9× the latency, so
> the trained net is kept in the loop only as a cheap **tie-breaker between equally-safe moves**. This reframes "make the
> Snake net strong": the net's reactive ceiling is real and low, so the *agent* is made strong by search while the *net*
> stays a leaf/tiebreak evaluator. The residual cap (~81 mean; single games hit ~106) is self-traps beyond the horizon —
> left to a future tail-reachability / Hamiltonian endgame.
138 changes: 138 additions & 0 deletions docs/prd/SNAKE_SEARCH_PRD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Snake — net-guided look-ahead search (client-side) — PRD

**Status:** in progress · 2026-07-10 · branch `m34-snake-search` (off `master`)
**Owner:** Pieterjan
**Milestone:** [PLAN.md](PLAN.md) M34 · **Depends on:** M33 (Snake single-source `.pg` + fully client-side AI)

## 1. Problem

The shipped Snake demo (M33) plays with a **masked-greedy one-step** policy over the trained 177-dim dueling-Q net.
That policy is stuck at **~50 food on 12×12** — and PLAN M27 already established this is a **structural ceiling of a
reactive learned policy, not a training shortfall**: capacity (128→256), features, reward shaping and horizon were all
swept and all plateaued at ~50. A reactive net cannot avoid walking into a region it can no longer fit its body into,
because that trap forms several moves ahead of the one-step decision.

The observation *already* carries the anti-trap signal M27 added — per-move `reachableFreeSpace / cells` (4 inputs) and
a 1-ply flood-fill **shield** in the action mask — so "add a reachability input" is largely already done. The missing
piece is **planning more than one ply ahead** with that signal.

## 2. Goal & success criteria

Make the demo Snake **strong** while keeping it the trained model playing (the net stays in the decision) and fully
client-side (zero server inference, single-source `.pg`).

- **Gate:** food@12 (12×12, ≥ 20-ep mean) **markedly past the ~50 reactive plateau** — measured **≈ 81** (per-episode
variance is high, ~55–108, since one bad trap ends a game; single games now reach a near-full board). Stretch: a clean 100.
- **No retraining, no observation change** — the existing shipped `snake-net.ckpt` is reused verbatim.
- **Single-source:** the search lives once in `snake_solver.pg` and transpiles to C# (training/eval) **and** the browser
TS director, byte-identically.
- **Browser cadence stays watchable** — one AI move per visible tick; the planner must fit that budget.

## 3. Key decision — port the idea, don't merge the branch

PR **#11** (`snake-ray-obs`) already proved the lever: **net-guided multi-ply look-ahead → food@12 ~50 → ~78.6** (3.7×
the original 21). But PR #11 **predates the M32/M33 Polyglot + client-side rewrite** — it is a hand-written C#
`SnakeSearchAgent` driving a **server-side** `SnakeController.Live`, plus a 39-dim ray-cast observation. It shows
`CONFLICTING` against `master` and cannot be merged or cleanly cherry-picked (it resurrects deleted server files and a
different observation).

**Decision:** take PR #11 as *inspiration* and **re-implement the search inside the single-source `snake_solver.pg`**,
on top of master's current 177-dim net and the `reachableFreeSpace` flood-fill it already ships. The net is kept as the
**leaf evaluator** (PR #11's finding: the survival term carries the search, the net is a marginal tiebreak — but keeping
it in the loop keeps the demo a genuine "watch the trained model" showcase).

## 4. Design (`snake_solver.pg` → C# + TS)

`chooseActionSearch(net, maxDepth, beamWidth, foodWeight, trapPenalty, netWeight, spaceWeight, foodDistWeight)`:

- **Receding-horizon beam search.** From the live state, simulate every legal (non-reversal) line to `maxDepth` plies
on **cloned** envs, keeping the best `beamWidth` survivors per ply; play the first move of the best-scoring line and
re-plan next tick. Snake is deterministic between food, so the look-ahead is exact.
- **Leaf score:** a board-full win dominates; a death is dominated but ranked to prefer a *later* death; a survived leaf =
`foodGained·FoodWeight − TrapPenalty·[reachable < length] + freeSpaceAhead·SpaceWeight
+ SpaceRatioWeight·(freeSpaceAhead / freeCells) − headFoodDist·FoodDistWeight`. `freeSpaceAhead` is the max
`reachableFreeSpace` over the 4 next-head cells. The **`SpaceRatioWeight` term is the key addition** — the *fraction* of
currently-free cells still reachable, which penalizes fragmentation the absolute `reachable < length` test misses (the
snake cutting itself off from most of the board while its body still fits the pocket). The trained net enters only as a
**root-move tiebreak** (`maxQ·NetWeight`, one forward per move — see below), not per leaf.
- **Reuses what M33 already shipped:** `reachableFreeSpace` (= PR #11's `FreeSpaceAhead`), deterministic dynamics, the
177-dim observation, `PgSnakeNet`.
- **RNG-free simulation:** when a simulated line eats, food is respawned at the **first free cell** (`simSpawnFood`), not
a random one — the single source keeps RNG with the caller, so the search must be deterministic to stay byte-identical
across C#/TS. The fictional food cell barely matters: survival scoring drives the search and every tick re-plans.
- **Live env runs with `safeMask: false`** — the planner's multi-ply survival scoring supersedes the reactive 1-ply
shield, and the returned move is always a non-reversal (hence always legal).

**Public surface.** The internal transpiled `PgSnakeNet` is exposed only through the `SnakeEnv` facade:
`LoadSearchNet(Stream)` (a C# twin of the browser's `snake-net.ts` parser — same RLNC/`dueling-q` bytes) +
`ChooseActionSearch(SnakeSearchConfig)`.

## 5. Results (measured — C#, shipped 177-dim net, **no retraining**)

food@12 on 12×12 (mean, high variance — min/max in parens); greedy baseline ≈ 50 (M27). d12/b16, net-tiebreak 50.

**The anti-fragmentation term (`SpaceRatioWeight`) is the biggest lever** — a paired sweep (same seeds), confirmed on a
second seed base:

| `SpaceRatioWeight` | food@12 (seed 1, 20 eps) | food@12 (seed 100, 30 eps) | note |
|---|---|---|---|
| 0 (survival only) | 70.3 | 72.6 | prior shipped baseline |
| 50k | 75.8 | — | |
| **100k (SHIPPED)** | **81.3** (max 108) | **80.6** (max 106) | robust peak; ~+10 food (+14%) |
| 200k | 82.2 | 79.2 | ~tied with 100k but nearer the cliff |
| 400k | 76.0 | — | over-weights connectivity → under-eats |

Other levers (all d12/b16, at `SpaceRatioWeight` 0 unless noted):

| config | food@12 | latency | verdict |
|---|---|---|---|
| **SHIPPED — d12/b16, net 50, ratio 100k** | **≈ 81** | ~11 ms/move | anti-fragmentation term + survival search + net near-tie breaker |
| net-tiebreak, net 500 (ratio 0) | 67.8 | 10.6 ms/move | a heavy net weight overrides survival moves → slightly hurts |
| net-guided *per node*, net 500 | 74.0 | **89 ms/move** | rejected: ~9× cost, no strength gain (survival carries it) |
| d20/b32 (ratio 0) | 66.2 | 38 ms/move | rejected: deeper/wider MISRANKS under beam pruning |
| _(reference)_ PR #11 net-guided d20/b32 | ~78.6 | server-side | the original pre-Polyglot result (39-dim ray obs) |

All configs run the **identical** `.pg` search, so the browser reproduces them byte-for-byte. **Findings:** (1) the
fraction-of-free-cells-reachable term (the user's original reachability-ratio idea, used in the *search score* — not as
a net input) is the strongest single lever, +14%; (2) depth has a sweet spot (~12) — deeper misranks; (3) evaluating the
net at every node buys no strength for ~9× the latency, so the net is a cheap root-move tiebreak. The search — not the
net — is the strength lever.

## 6. Client integration

`snake-director.ts`: swap the greedy `chooseAction(net)` for `chooseActionSearch(net, …)`, drive the live core with
`safeMask: false`, and tune `SEARCH_DEPTH`/`SEARCH_BEAM` for a watchable move cadence. The shipped `snake-net.ckpt` and
`snake-net.ts` parser are unchanged; `snake_solver.ts` is regenerated from the `.pg` (never hand-edited).

## 7. Non-goals

- **Retraining / a new net / a new observation** — out of scope; the reactive net cannot beat ~50 regardless (M27), and
the search delivers the strength on top of the existing net.
- **A guaranteed clean 100** — see §8.

## 8. Honest ceiling & follow-up

The residual cap (~81 mean, though single games now hit 106–108 — a near-full board) is self-traps that form *beyond*
the search horizon. A reliable clean **100+** needs a **tail-reachability survival invariant** (guarantee the head can
always reach its own tail → follow it indefinitely) and/or explicit **Hamiltonian-style endgame** play. Proposed as the
next milestone, not built here.

## 9. Transpiler findings (MintPlayer.Polyglot 0.3.1)

Two issues surfaced; both were worked around from the `.pg` side.

- **TS `any`-inference (shaped the search).** The transpiler drops type annotations on local `List` variables (emits
`let x = []`). A first attempt used **parallel `List` frontiers** (`List<PgSnakeEnv>` + `List<i32>` + `List<f64>`);
because `childFirst` derived from `beamFirst[n]` and `beamFirst` was reassigned from a list holding `childFirst`,
TS strict-mode saw a **circular `any`** and failed to compile (`TS7022`/`TS7034`). Fix: use a **`record
PgSnakeBeamNode`** as the frontier element (like FruitCake's `PgPlyResult`) — the typed list element breaks the
cycle. Also compute the net's `rootQ` unconditionally (typed by `net.forward`) rather than in a branch.
- **Incremental-rebuild prelude collision (build flake, not shipping).** In a multi-`.pg` project, an *incremental*
rebuild after editing a `.pg` intermittently emits one unit in "standalone" mode (duplicate global
`Option/Some/None` prelude + a **non-`partial`** `PolyglotProgram`) → `CS0260`/`CS0101`/`CS8863`. Root cause: the CLI
doesn't clean its `--out` dir, so a stale `__polyglot_prelude.cs` collides on re-transpile; the target's incremental
gate then caches the bad `.cs`. **Clean/CI builds are unaffected** (verified). Dev workaround:
`rm obj/*/net10.0/polyglot/*.cs` then rebuild. (This is independent of the record above — records build fine cleanly,
as FruitCake proves.)

Full repro + fix ideas: `docs/prd/polyglot-pilot/POLYGLOT_TOPLEVEL_RECORD_BUG.md`.
Loading
Loading