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
211 changes: 211 additions & 0 deletions docs/prd/CLIENTSIDE_AI_SNAKE_MOUNTAINCAR_PRD.md

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions docs/prd/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,29 @@ in parallel). **Supersedes PG4.** Also advances MintPlayer.Polyglot (FruitCake i
> architecture):** the shipped browser net is the **G3** net; if its net+search play quality (G4 A/B vs the
> ~50%-watermelon / ~2505 bar) is unsatisfying, retrain (M30/G4) and replace `ClientApp/public/fruitcake-net.ckpt`.
> Still recommended: **split into separate PRs** (NetTransfer is independently mergeable; M30/M31/M32 are entangled).
> **UPDATE: merged to master via PR #23 (single PR) 2026-07-05.** A/B ship-as-is (2493 / 49% watermelon, on par).

## M33 — Client-side AI for Snake & MountainCar *(planned — see `CLIENTSIDE_AI_SNAKE_MOUNTAINCAR_PRD.md`)* 🔜

Extend M32 to the **only two remaining WebSocket-AI games** (2048/RushHour/Cube are request/response REST → out of
scope). A 3-agent investigation + Polyglot **0.2.0** probes (2026-07-05) confirmed: 0.2.0 **fixed all three M32
codegen bugs** (#9 closed — casts / null-typed-locals / nested-generics now work); std.math still has **no
transcendentals** (only `abs`/`floor`/`sqrt`/`min`/`max`); **user `.pg`→`.pg` imports work but *inline* the imported
symbols** (a standalone library `.pg` would double-define under the glob-all build) → **duplicate the net-forward per
game** (simpler than the glob-exclusion a shared `nn.pg` needs); the `.ckpt` parser generalizes to one shared `ckpt.ts`
(`dueling-q` + a new `mlp` branch);
`EpisodeStreamer` is used only by these two → deletable when both migrate. **Two independent PRs, Snake first.**
- **Snake — ✅ DONE (client-side, verified in-browser).** obs **177** (9×9×2 patch + 15 scalars; the "8-ray" memory is stale); net =
plain `DuelingQNet 177→[256,256]→4` → **reuses `PgDuelingNet` + the parser verbatim**; **no search** (one greedy
masked Q-step). Real work: port the **action mask** (reversal + anti-self-trap **flood-fill shield**, load-bearing —
net trained with it) and re-express `LinkedList`/`HashSet`/`Queue` as flat `List`s. Pure integer math → byte-identity
free. Plan SN0–SN6.
- **MountainCar — ✅ DONE (client-side, verified; uniform Polyglot — 0.3.0 added cos/tanh).** Two transcendentals on the client path (`cos(3·pos)` in the env, **`tanh`**
in the PPO `Mlp` net) — **neither writable in a `.pg`**. **Recommended: Option B** — pragmatic client-side hand-port
(reuse `mountaincar-logic.ts` dynamics, ~30-line TS `Mlp`+`tanh` forward, new `parseMlp`, ship ckpt, delete socket);
no net perturbation, keeps a trivial ~15-line env twin. **Option A** (uniform Polyglot with byte-identical `cos`/`tanh`
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.

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
build-only transpiler OUT of this published package's dependencies. The 0.1.3+ CLI is bundled for
win-x64/linux-x64/linux-arm64 (covers Windows dev + Linux CI/deploy); macOS dev must set $(PolyglotTool). -->
<ItemGroup>
<PackageReference Include="MintPlayer.Polyglot.MSBuild" Version="0.1.4" PrivateAssets="all" />
<PackageReference Include="MintPlayer.Polyglot.MSBuild" Version="0.3.1" PrivateAssets="all" />
</ItemGroup>

<!-- The generated solver types (PgFruitCakeWorld etc.) are internal; the public FruitCakeWorld facade wraps
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// mountaincar_solver.pg — single-source MountainCar env + inference (M33), transpiled to C# and TS.
//
// ONE source for the classic-control dynamics + observation + the PPO policy forward pass, so the C# training/eval
// env AND the browser "watch AI" run the same logic. Unlike FruitCake/Snake this uses transcendentals — `cos` in
// the dynamics and `tanh` in the net (Polyglot 0.3.0+ std.math). Those are NOT byte-identical across .NET and JS
// (~1 ULP), but there's no server twin after the cutover and the decision is argmax over 3 logits (robust to sub-ULP
// drift), so it's harmless. The C# side is exact (same Math.Cos/Math.Tanh as before).
//
// Generated C# is transpiled at build; the TS is committed (mountaincar_solver.ts), regenerated on Windows:
// polyglot build mountaincar_solver.pg --target typescript --out <clientapp>

import { List } from "std.collections"
import { Math } from "std.math"

fn clampF(v: f64, lo: f64, hi: f64): f64 => Math.min(hi, Math.max(lo, v))

// ── PPO policy net (Mlp): Linear + Tanh per hidden layer, linear output; serving = argmax over the logits ──
// Flat weights (all layers concatenated) with per-layer offsets derived from `sizes` = [in, h.., out].
class PgMlpNet {
var sizes: List<i32> // [inputSize, hidden…, outputSize]
var wFlat: List<f64> // per layer, row-major [in, out], concatenated
var bFlat: List<f64> // per layer [out], concatenated

init(sizes: List<i32>, wFlat: List<f64>, bFlat: List<f64>) {
this.sizes = sizes
this.wFlat = wFlat
this.bFlat = bFlat
}

// Single-observation forward → output logits (length sizes[last]).
fn forward(input: List<f64>): List<f64> {
var x = input
var wOff = 0
var bOff = 0
let layers = this.sizes.count - 1
for l in 0..layers {
let inDim = this.sizes[l]
let outDim = this.sizes[l + 1]
x = linear(x, this.wFlat, wOff, this.bFlat, bOff, inDim, outDim)
wOff += inDim * outDim
bOff += outDim
if l < layers - 1 { x = tanhv(x) } // hidden activation; output layer is linear
}
return x
}

static fn linear(x: List<f64>, w: List<f64>, wOff: i32, b: List<f64>, bOff: i32, inDim: i32, outDim: i32): List<f64> {
var out: List<f64> = List<f64>()
for o in 0..outDim {
var s = b[bOff + o]
for i in 0..inDim { s += x[i] * w[wOff + i * outDim + o] }
out.add(s)
}
return out
}

static fn tanhv(x: List<f64>): List<f64> {
var out: List<f64> = List<f64>()
for v in x { out.add(Math.tanh(v)) }
return out
}
}

// ── MountainCar-v0 (faithful port of MountainCarEnv.cs) ──────────────────────────────────────────────────────
class PgMountainCarEnv {
const MinPosition: f64 = -1.2
const MaxPosition: f64 = 0.6
const MaxSpeed: f64 = 0.07
const GoalPosition: f64 = 0.5
const Force: f64 = 0.001
const Gravity: f64 = 0.0025
const ShapeScale: f64 = 13.0
const ActionCount: i32 = 3

var maxEpisodeSteps: i32
var shapeReward: bool
var position: f64
var velocity: f64
var elapsedSteps: i32
var done: bool
// Step outputs (read by the C# training facade).
var lastReward: f64
var lastTerminated: bool
var lastTruncated: bool

init(maxEpisodeSteps: i32, shapeReward: bool) {
this.maxEpisodeSteps = maxEpisodeSteps
this.shapeReward = shapeReward
this.position = 0.0
this.velocity = 0.0
this.elapsedSteps = 0
this.done = true
this.lastReward = 0.0
this.lastTerminated = false
this.lastTruncated = false
}

// Start a fresh episode at the given position (caller draws it ~U[-0.6,-0.4]; RNG stays host-side), velocity 0.
fn reset(startPos: f64) {
this.position = startPos
this.velocity = 0.0
this.elapsedSteps = 0
this.done = false
}

// Pin the physics state directly (golden-trajectory tests, demos).
fn setState(pos: f64, vel: f64) {
this.position = pos
this.velocity = vel
this.elapsedSteps = 0
this.done = false
}

fn step(action: i32) {
var velocity = this.velocity + f64(action - 1) * Force + Math.cos(3.0 * this.position) * (0.0 - Gravity)
velocity = clampF(velocity, 0.0 - MaxSpeed, MaxSpeed)
var position = clampF(this.position + velocity, MinPosition, MaxPosition)
if position <= MinPosition && velocity < 0.0 { velocity = 0.0 } // inelastic left wall

this.position = position
this.velocity = velocity
this.elapsedSteps = this.elapsedSteps + 1

let terminated = position >= GoalPosition
let truncated = !terminated && this.elapsedSteps >= this.maxEpisodeSteps
this.done = terminated || truncated

var reward = 0.0 - 1.0
if this.shapeReward { reward += ShapeScale * Math.abs(velocity) }
this.lastReward = reward
this.lastTerminated = terminated
this.lastTruncated = truncated
}

// Observation normalised to ~[-1,1]: position centred on −0.3 (half-range 0.9), velocity ÷ MaxSpeed.
fn buildObservation(): List<f64> {
var obs: List<f64> = List<f64>()
obs.add((this.position + 0.3) / 0.9)
obs.add(this.velocity / MaxSpeed)
return obs
}

// The full single-source decision: argmax over the policy logits (mirrors PolicyAgent.Act greedy → Categorical.Mode).
fn chooseAction(net: PgMlpNet): i32 {
let logits = net.forward(this.buildObservation())
var best = 0
for a in 1..logits.count {
if logits[a] > logits[best] { best = a }
}
return best
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,14 @@
namespace MintPlayer.AI.ReinforcementLearning.Environments;

/// <summary>
/// Faithful port of Gymnasium MountainCar-v0 (classic_control/mountain_car.py): an underpowered car must
/// build momentum by swinging back and forth to escape a valley. State [position, velocity], 3 actions
/// (push left / none / right), reward −1 every step, terminated at the goal (position ≥ 0.5), truncated at
/// the step cap. Solved: mean return ≥ −110 over 100 episodes.
/// <para>
/// Two training aids (off by default to keep the env v0-faithful for eval): <c>maxEpisodeSteps</c>
/// can be raised above 200 during training so a fresh high-entropy policy ever reaches the goal (the −1 reward
/// is otherwise flat and PPO never bootstraps), and <c>shapeReward</c> adds a small speed-gain bonus as a
/// fallback if plain PPO stalls. Gate/eval always use the standard 200-step, unshaped env.
/// </para>
/// Gymnasium MountainCar-v0 as an RL environment — a <b>public facade</b> over the single-source transpiled core
/// (<c>MountainCar/polyglot/mountaincar_solver.pg</c> → <c>PgMountainCarEnv</c>): the dynamics + normalised
/// observation live <b>once</b> in the <c>.pg</c>, shared with the browser's <c>mountaincar_solver.ts</c> (M33).
/// The facade re-adds host concerns: the <see cref="IEnvironment{T,U}"/>/<see cref="IStatefulEnvironment"/> API, the
/// start-state RNG (a seeded <see cref="Xoshiro256StarStar"/>, kept out of the single source), state (de)serialization,
/// and the throw-on-illegal-action contract. The C# dynamics stay byte-identical to the original (same
/// <c>Math.Cos</c>); the browser's <c>cos</c>/<c>tanh</c> differ by ≤1 ULP but that's harmless (argmax decision, no
/// server twin). State [position, velocity], 3 actions (push left/none/right), reward −1/step, terminated at the goal.
/// </summary>
public sealed class MountainCarEnv : IEnvironment<float[], int>, IStatefulEnvironment
{
Expand All @@ -25,92 +23,61 @@ public sealed class MountainCarEnv : IEnvironment<float[], int>, IStatefulEnviro
public const double Force = 0.001;
public const double Gravity = 0.0025;
public const int DefaultMaxEpisodeSteps = 200;
// Speed bonus (training only): reward += ShapeScale·|velocity|, comparable in magnitude to the −1 step
// cost (|v| ≤ 0.07 → bonus ≤ ~0.9). Drives the back-and-forth that builds momentum to escape the valley.
private const double ShapeScale = 13.0;

private readonly int _maxEpisodeSteps;
private readonly bool _shapeReward;

private Xoshiro256StarStar _rng = new(0);
private double _position, _velocity;
private int _elapsedSteps;
private bool _done = true;
private readonly PgMountainCarEnv _core;

public MountainCarEnv(int maxEpisodeSteps = DefaultMaxEpisodeSteps, bool shapeReward = false)
{
_maxEpisodeSteps = maxEpisodeSteps;
_shapeReward = shapeReward;
// Observation is NORMALISED to ~[-1,1] (see Observation): raw velocity (~0.07) is ~14× smaller than
// raw position, so an un-normalised dense net effectively can't see velocity — the signal it must
// condition on to pump correctly. Normalising both is what makes the swing-up learnable.
_core = new PgMountainCarEnv(maxEpisodeSteps, shapeReward);
ObservationSpace = new BoxSpace([-1f, -1f], [1f, 1f]);
ActionSpace = new DiscreteSpace(3);
}

public Space<float[]> ObservationSpace { get; }
public Space<int> ActionSpace { get; }

public double Position => _position;
public double Velocity => _velocity;
public double Position => _core.position;
public double Velocity => _core.velocity;

public (float[] Observation, EnvInfo Info) Reset(ulong? seed = null)
{
if (seed.HasValue)
_rng = new Xoshiro256StarStar(seed.Value);
_position = _rng.NextDouble(-0.6, -0.4);
_velocity = 0.0;
_elapsedSteps = 0;
_done = false;
_core.reset(_rng.NextDouble(-0.6, -0.4));
return (Observation(), EnvInfo.Empty);
}

public StepResult<float[]> Step(int action)
{
if (_done)
if (_core.done)
throw new InvalidOperationException("Episode is done; call Reset() before stepping.");
if (!ActionSpace.Contains(action))
throw new ArgumentOutOfRangeException(nameof(action));

double velocity = _velocity + (action - 1) * Force + Math.Cos(3 * _position) * -Gravity;
velocity = Math.Clamp(velocity, -MaxSpeed, MaxSpeed);
double position = Math.Clamp(_position + velocity, MinPosition, MaxPosition);
if (position <= MinPosition && velocity < 0) velocity = 0.0; // inelastic left wall

_position = position;
_velocity = velocity;
_elapsedSteps++;

bool terminated = position >= GoalPosition;
bool truncated = !terminated && _elapsedSteps >= _maxEpisodeSteps;
_done = terminated || truncated;

double reward = -1.0;
if (_shapeReward) reward += ShapeScale * Math.Abs(velocity); // speed bonus (training aid)
return new StepResult<float[]>(Observation(), reward, terminated, truncated, EnvInfo.Empty);
_core.step(action);
return new StepResult<float[]>(Observation(), _core.lastReward, _core.lastTerminated, _core.lastTruncated, EnvInfo.Empty);
}

/// <summary>Pins the physics state directly (golden-trajectory tests, demos).</summary>
public void SetState(double position, double velocity)
public void SetState(double position, double velocity) => _core.setState(position, velocity);

private float[] Observation()
{
(_position, _velocity) = (position, velocity);
_elapsedSteps = 0;
_done = false;
var core = _core.buildObservation();
return [(float)core[0], (float)core[1]];
}

// Normalised to ~[-1,1]: position centred on −0.3 (half-range 0.9), velocity scaled by MaxSpeed.
private float[] Observation() => [(float)((_position + 0.3) / 0.9), (float)(_velocity / MaxSpeed)];

public byte[] SaveState()
{
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
var (s0, s1, s2, s3) = _rng.GetState();
writer.Write(s0); writer.Write(s1); writer.Write(s2); writer.Write(s3);
writer.Write(_position);
writer.Write(_velocity);
writer.Write(_elapsedSteps);
writer.Write(_done);
writer.Write(_core.position);
writer.Write(_core.velocity);
writer.Write(_core.elapsedSteps);
writer.Write(_core.done);
writer.Flush();
return stream.ToArray();
}
Expand All @@ -119,16 +86,16 @@ public void RestoreState(byte[] state)
{
using var reader = new BinaryReader(new MemoryStream(state));
_rng.SetState(reader.ReadUInt64(), reader.ReadUInt64(), reader.ReadUInt64(), reader.ReadUInt64());
_position = reader.ReadDouble();
_velocity = reader.ReadDouble();
_elapsedSteps = reader.ReadInt32();
_done = reader.ReadBoolean();
_core.position = reader.ReadDouble();
_core.velocity = reader.ReadDouble();
_core.elapsedSteps = reader.ReadInt32();
_core.done = reader.ReadBoolean();
}

public string RenderString()
{
const int width = 41;
int col = (int)Math.Round((_position - MinPosition) / (MaxPosition - MinPosition) * (width - 1));
int col = (int)Math.Round((_core.position - MinPosition) / (MaxPosition - MinPosition) * (width - 1));
var sb = new StringBuilder();
for (int i = 0; i < width; i++) sb.Append(i == col ? 'O' : i == width - 1 ? '|' : '_');
return sb.ToString();
Expand Down
Loading
Loading