commit a3fc1b5fcb0bd481dcc8579e5a2e4182ac68e75e
Author: James Eversole <james@eversole.co>
Date: Thu, 24 Sep 2026 09:49:03 -0500
semif-api: HTTP API around SemIf + llama backend
FastAPI wrapper (SemIf consumed as a library, no fork) exposing its
semantic decision readout over HTTP: /decide and /decide-batch read the
softmax over declared options from logits (torch ROCm backend) or from
an OpenAI-compatible llama-server hosting a GGUF. The llama backend adds
/plan, a reasoning chat completion that turns an action transcript into
rules, plus runtime model selection.
The browser UI (served same-origin) includes four demos: platformer and
puzzle room, each in ruled and self-learning variants. The self-learning
loops are the point of the repo: the model plays from neutral
observations alone, and on failure a universal planner prompt rewrites
its rules from the transcript of what happened.
Packaging: nix flake (upstream SemIf as an input, ROCm dev shell for
gfx1151, NixOS module with systemd service), offline test suites for the
game prompts and the backend contract, validation script, docs.
Diffstat:
38 files changed, 6868 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,24 @@
+.venv/
+hf-cache/
+__pycache__/
+*.egg-info/
+results*.jsonl
+uvicorn.log
+*.swp
+.*.swp
+WD
+
+# Editor / environment dotfiles
+.env
+.bashrc
+.bash_profile
+.gitconfig
+.profile
+.ripgreprc
+.zprofile
+.zshrc
+.idea
+.vscode
+.mcp.json
+.claude/
+.DS_Store
diff --git a/AGENTS.md b/AGENTS.md
@@ -0,0 +1,79 @@
+# AGENTS.md — where to change things
+
+Guide for agent sessions working on the SemIf self-learning demos (platformer + puzzle room).
+Repo root: everything below is relative to here. JS tests: `node --test "tests/**/*.cjs"`.
+Python tests need the venv (see "Running" at bottom). **Run the JS tests after any change to `semif-api/src/semif_api/web/` — they pin prompt and state-text strings.**
+
+---
+
+## 1. Planner prompts (what /plan tells the rule-writer)
+
+| What | Where | Notes |
+|---|---|---|
+| **Universal planner system prompt** (`PLAN_SYSTEM`) | `semif-api/src/semif_api/llama.py` (~line 32) | One prompt for every game — must stay demo-term-free ("key", "door", "exit", "flag", "jump"… are all banned; `test_llama.py::test_plan_system_prompt_is_universal_and_committal` pins this). Blame-the-previous-rules and reset paragraphs live here too. |
+| Planner sampling params (`PLAN_SAMPLING`) | same file, right below `PLAN_SYSTEM` | Pinned to the model card's thinking-mode recommendation. |
+| **Per-game goal statement** (`PLAN_GOAL`) | `semif-api/src/semif_api/web/self-rules.js:106` (platformer), `semif-api/src/semif_api/web/room-self-rules.js:92` (room) | The ONLY deliberate goal channel. Never name the objective anywhere else — decision questions, transcript outcomes, and plan triggers must stay goal-free (goal leaks are a bug class here). |
+| Planner user-prompt assembly | `semif-api/src/semif_api/web/planner.js` → `Planner.context(goal, trigger, rules)` | Wraps `PLAN_GOAL` + trigger note + previous rules (pre-indicted on repeat failures). |
+| Trigger phrasing (what failed) | `semif-api/src/semif_api/web/self-game.js` and `semif-api/src/semif_api/web/room-self-game.js`, in the `replan()` functions | Mechanical, goal-free strings, e.g. `"the actor used up the step budget and the attempt ended"`. |
+| Trigger **policy** (when to plan) | `semif-api/src/semif_api/web/planner.js` (`triggered`, `INSUFFICIENT_THRESHOLD`); per-game thresholds passed at the call sites | Platformer: p ≥ 0.99 on `insufficient`. Room: 0 — any plurality plans, since a wandering model must be able to ask early. |
+| Transcript shape the planner reads | `semif-api/src/semif_api/web/planner.js` (`record`, `TRANSCRIPT_KEEP = 24`); written by the `*-game.js` loops | Transcript records the **bare** state + chosen action + mechanical outcome. Rules reach the planner once, via `context` — never re-embedded per turn. |
+| Decision-model system prompt (`DIRECT_SYSTEM`) | **NOT in this repo** — `direct_messages()` comes from the `semif_phase1` package (nix store) | Meta-only ("you are a decision maker…"). Change it upstream, not here. |
+
+## 2. Game rules
+
+**Puzzle room** (start here — most iteration happens in this demo):
+
+| What | Where |
+|---|---|
+| Shared core: grid gen, physics, sight, state text | `semif-api/src/semif_api/web/room-rules.js` |
+| — Layout generation (`makeLayout`, `FALLBACK`, seeds) | `semif-api/src/semif_api/web/room-rules.js:79` |
+| — Step budget | `semif-api/src/semif_api/web/room-rules.js:12` (`MAX_STEPS = 80`) |
+| — Physics (`forward`, `turn`, pick-up/unlock/win outcomes) | `semif-api/src/semif_api/web/room-rules.js` (`forward` ~line 140) |
+| — Sight: `cast` (DDA), `occluded`/`lineClear` (3×3 multi-ray), `visibleObjects`, bearings, `Known:` line | `semif-api/src/semif_api/web/room-rules.js` (~lines 175–345) |
+| — FPP state text (`fppText`, `adjacentLines`), map text (`mapText`) | `semif-api/src/semif_api/web/room-rules.js` (~lines 355–410) |
+| — Action options (`OPTIONS`, `optionsFor` — forward withheld when faced cell is a no-op) | `semif-api/src/semif_api/web/room-rules.js:409` |
+| SL variant: assembly, `insufficient` option, `PLAN_GOAL` | `semif-api/src/semif_api/web/room-self-rules.js` |
+| Ruled variant adds: instructions, guided hint | `semif-api/src/semif_api/web/room.js` + `fppText(guided)` in room-rules.js |
+
+**Platformer:**
+
+| What | Where |
+|---|---|
+| Level gen, physics (`advance`), prose state text, `OPTIONS`, `MAX_JUMPS` | `semif-api/src/semif_api/web/game-rules.js` |
+| SL variant: `stateText` (prose/runlength/ascii modes), `insufficient`, `PLAN_GOAL` | `semif-api/src/semif_api/web/self-rules.js` |
+
+**Tests** (pin nearly every string above — update them in the same commit):
+`tests/test_roomself.cjs` (room), `tests/test_self.cjs` (platformer SL), `tests/test_game.cjs` (platformer + planner.js). `tests/test_llama.py` / `tests/test_llama_api.py` pin the backend prompts (`PLAN_SYSTEM` etc.).
+
+## 3. Web UI wiring
+
+`semif-api/src/semif_api/web/index.html` loads scripts **in dependency order** (a test pins the order — keep it when adding files):
+`planner.js` → `*-rules.js` → `*-game.js`.
+
+| Piece | File | Key hooks |
+|---|---|---|
+| Backend endpoints `/decide`, `/plan` (llama-only; torch → 422) | `semif-api/src/semif_api/app.py:203–236` | Thin; all prompt logic is in `llama.py`. |
+| Platformer ruled loop | `semif-api/src/semif_api/web/game.js` | `requestDecision` → `/decide`; `tick` physics |
+| Platformer SL loop | `semif-api/src/semif_api/web/self-game.js` | + `replan`/`replanAndRetry` (trigger strings, transcript writes) |
+| Room ruled loop | `semif-api/src/semif_api/web/room.js` | same shape; manual keydown drive |
+| Room SL loop | `semif-api/src/semif_api/web/room-self-game.js` | same; state pane, plan triggers, editable rules pane |
+| Shared SL helpers (`Planner.context`, thresholds, transcript) | `semif-api/src/semif_api/web/planner.js` | |
+| Page: tabs, state/rules panes, script order | `semif-api/src/semif_api/web/index.html` | rules panes are `<textarea class="rules-edit">` — editable, bound live to `learnedRules` |
+| Styling | `semif-api/src/semif_api/web/style.css` | `.demo-panel` scoped; `.rules-edit`, `.probs.expandable` |
+
+**Data flow to remember:** game loop composes state text (`stateText()`) → POSTs `{state, question, options}` to `/decide` → applies argmax → on failure calls `/plan` with `{prompt: Planner.context(PLAN_GOAL, trigger, learnedRules), transcript}` → stores `payload.rules` into `learnedRules` (shown in the editable pane) → **restarts the level** with rules leading every observation. Manual play mirrors the same state text into the side pane for debugging.
+
+## Conventions that bite
+
+- **Egocentric vocabulary only** — no compass/cardinal directions in observations; `Ahead:` self-anchors. A constant facing line contradicts relative bearings (tried, failed, reverted).
+- **No event/action history in observations** — it's few-shot imitation bait for a one-pass decider. History belongs to the planner's transcript.
+- **No goal leaks** — objective appears exactly once, in `PLAN_GOAL`.
+- **"Less is more" for prompts** — no numeric caps, placeholder-only style examples; uncertainties get litigated in the thinking trace.
+- Every state change restarts the sim after planning — `PLAN_SYSTEM`'s reset promise must stay literally true.
+- The dotfiles (`.env`, `.bashrc`, `.idea`, …) are gitignored; keep them out of commits.
+
+## Running
+
+- JS tests: `node --test "tests/**/*.cjs"`
+- Python tests: `.venv/bin/python -m unittest discover -s tests -p 'test_llama*.py'` (venv sometimes needs a rebuild: `uv venv --clear .venv && uv pip install -e semif-api`, then restart `semif-api.service`)
+- Services: `llama-cpp.service` (port 8080), `semif-api.service` (port 8321). No live testing against llama-server — the user runs live.
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 James Eversole
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/README.md b/README.md
@@ -0,0 +1,71 @@
+# semif-api
+
+An HTTP API around [SemIf](https://github.com/TheoLeeCJ/SemIf) (consumed as a
+library — no fork), with two serving backends, plus a browser UI with
+self-learning game demos: an agent plays a platformer and a puzzle room by
+asking the model to decide, and rewrites its own rules from a transcript of
+failures.
+
+Two ways to score a decision:
+
+- **torch** — loads an HF causal LM (ROCm build); the softmax over your
+ declared options is read from logits. No decoding.
+- **llama** — talks to `llama-server` hosting a GGUF; same readout over the
+ server's candidate probabilities. This backend also serves `/plan`.
+
+## Quickstart
+
+```bash
+nix develop # ROCm torch (gfx1151), transformers, fastapi, uvicorn
+
+# torch (default model Qwen/Qwen3.5-4B):
+.venv/bin/python -m uvicorn semif_api.app:app --host 127.0.0.1 --port 8321
+
+# or llama (default server http://127.0.0.1:8080, alias Qwen3.5-4B):
+SEMIF_BACKEND=llama .venv/bin/python -m uvicorn semif_api.app:app --host 127.0.0.1 --port 8321
+```
+
+Open <http://127.0.0.1:8321/ui/> — a request builder for `/decide` and
+`/decide-batch`, plus four demo tabs (platformer and puzzle room, each in
+ruled and self-learning variants).
+
+## Layout
+
+- `semif-api/` — the FastAPI package (endpoints, web UI as package data)
+- `docs/usage.md` — API walkthrough · `docs/games.md` — demo design ·
+ `docs/llama-backend.md` — GGUF backend configuration
+- `examples/` — decision fixtures and reference outputs
+- `tests/` — offline suites (JS prompt/game tests, Python backend tests)
+- `scripts/validate.sh` — full GPU-host validation (upstream tests, CLI
+ scoring, drift compare, HTTP parity)
+- `flake.nix` — builds upstream `semif`, `semif-api`, a dev shell, and a
+ `nixosModule`
+
+## Tests
+
+```bash
+node --test "tests/**/*.cjs"
+.venv/bin/python -m unittest discover -s tests -p 'test_llama*.py'
+```
+
+## NixOS module
+
+```nix
+services.semif-api = {
+ enable = true;
+ backend = "llama"; # or "torch"
+ llamaUrl = "http://127.0.0.1:8080";
+ llamaModel = "Qwen3.5-4B"; # any alias served by llama-server
+};
+```
+
+Options: `backend`, `host` (127.0.0.1), `port` (8321), `model`, `revision`,
+`user`, `openFirewall`, `maxTokens`, and the `llama*` settings in
+[docs/llama-backend.md](docs/llama-backend.md).
+
+## Notes
+
+- Developed and measured on an AMD Strix Halo APU (gfx1151); the nixpkgs pin
+ reflects where prebuilt gfx1151 torch is available.
+- Batch shape changes bf16 results (upstream-documented); GPU work serializes
+ per request and only batches within `/decide-batch`.
diff --git a/docs/games.md b/docs/games.md
@@ -0,0 +1,87 @@
+# Game demos
+
+Four tabs in `/ui/`: platformer and puzzle room, each ruled (the model plays
+from a briefing) and self-learning (SL — the model plays from neutral
+observations and learns rules via `/plan` after failures). The SL loop is
+game-agnostic: `planner.js` holds the trigger policy and wire format, and a
+per-game `PLAN_GOAL` one-liner is the entire game-specific surface.
+
+## Platformer
+
+Integer horizontal positions: run moves 1 unit, jump moves 5 units before
+landing, each pit is 3 units wide; pits at [14,17), [31,34), [48,51), flag at
+64, start at 3, four jumps for three pits. Vertical animation uses fractional
+gravity internally; the model never sees it (`web/game-rules.js` owns
+physics and prompt text). The prompt teaches "jump at the edge" without
+exposing jump distance. Each pit has two valid takeoff positions:
+
+| Position near first pit | Jump landing | Safe action |
+|---|---|---|
+| 10 | 15, inside pit | Run |
+| 11 | 16, inside pit | Run |
+| 12 | 17, right edge | Run or jump |
+| 13 | 18, ground | Jump |
+
+State text, most to least legible: **Guided** (adds a per-turn hint),
+**Unguided** (prose counts: "4 ground spaces, 3 hole spaces, …"), **Run-length**
+(`-7 | #3 | -14 | … | [!]`), **ASCII** (one symbolic row: `- # * !`). Terrain
+behind the player and any action outcomes are omitted; counts start at the
+next space.
+
+## Platformer SL (`/ui/#self`)
+
+Same physics, inverted information policy: the observation carries only
+neutral facts (player state, jumps remaining, terrain ahead — the payload
+lines are byte-identical to the ruled modes) and the options are `run`,
+`jump`, plus `insufficient`: "Insufficient evidence to decide". No goal line,
+no rules, no hints.
+
+Three events trigger `/plan`, a reasoning chat completion over the transcript
+of completed actions (bare state + chosen action + outcome, capped at 24
+turns):
+
+1. `insufficient` argmax with p ≥ 0.99 (`Planner.INSUFFICIENT_THRESHOLD`).
+ A weaker `insufficient` argmax does not plan — the game takes the best
+ real action and says so.
+2. The player falls into a pit.
+3. Three jump requests in a row with no jumps remaining.
+
+After planning, the level restarts with the learned rules injected at the top
+of every observation; rules, transcript, and stats carry across restarts of
+the same run (Reset clears everything). The planner writes the decision
+model's entire prompt — facts and imperatives in the model's own voice, no
+if-then branches — under one universal system prompt (`PLAN_SYSTEM` in
+`semif_api/llama.py`); the goal reaches it only as the composed `PLAN_GOAL`,
+and repeat failures attach the previous rules pre-indicted.
+
+## Puzzle room and room SL (`/ui/#room`, `/ui/#roomself`)
+
+First-person grid room: the human gets a raycast view; the model sees text
+only — facing, all four adjacent cells ("Ahead: open floor"; egocentric
+directions only, no compass frame), an "In view:" line for objects in a 120°
+cone, a "Known:" line of bearings to discovered landmarks out of sight (exact
+alignments read "directly ahead/right/behind/left"; a walled straight line
+reads "(blocked)"), carry status, and a step count — or a top-down map with a
+neutral legend. It must find the key, unlock the dividing door, and reach the
+exit within the 80-step budget (`RoomRules.MAX_STEPS`). Layouts are seeded and
+always solvable (verified by BFS over the decision state space). Options are
+`forward` / `left` / `right` (+ `insufficient` in SL), with `forward`
+withheld when the faced cell is a wall or a locked door without the key — a
+silent no-op, not a decision.
+
+The SL loop mirrors the platformer with one policy difference: any
+`insufficient` plurality plans (threshold 0), because the room has no failure
+signal until the step budget runs out — a wandering model must be able to ask
+early. Running out of steps also plans; either way the room then resets to
+the same layout with the learned rules in place.
+
+## Try it / test
+
+Spot-check decisions offline:
+
+```bash
+node examples/game-fixtures.cjs > /tmp/game-decisions.jsonl
+.venv/bin/python -m semif_api.llama --input /tmp/game-decisions.jsonl --timeout 600
+```
+
+Prompt/physics coverage: `node --test "tests/**/*.cjs"`.
diff --git a/docs/llama-backend.md b/docs/llama-backend.md
@@ -0,0 +1,82 @@
+# GGUF backend (llama)
+
+`SEMIF_BACKEND=llama` talks to any stock `llama-server` hosting a GGUF
+instead of loading torch: the same logit readout over the server's candidate
+probabilities, plus `/plan` and runtime model selection. No fork, no local
+tokenizer or weights; torch stays selectable and remains in the Nix closure.
+
+## Run
+
+```bash
+nix develop
+SEMIF_BACKEND=llama .venv/bin/python -m uvicorn semif_api.app:app --host 127.0.0.1 --port 8322
+```
+
+`/decide` and `/decide-batch` keep their request shapes; batches run
+sequentially with server prefix caching and do not claim torch's
+shared-prefill semantics. `/healthz` is liveness only — it never loads or
+warms the remote model (`backend_status: not_checked`).
+
+## Configuration
+
+| Environment | NixOS option (`services.semif-api`) | Default |
+|---|---|---|
+| `SEMIF_BACKEND` | `backend` | `torch` |
+| `SEMIF_LLAMA_URL` | `llamaUrl` | `http://127.0.0.1:8080` |
+| `SEMIF_LLAMA_MODEL` | `llamaModel` | `Qwen3.5-4B` |
+| `SEMIF_LLAMA_TIMEOUT` | `llamaTimeout` | `600` s per HTTP request |
+| `SEMIF_LLAMA_N_PROBS` | `llamaNProbs` | `1024` |
+| `SEMIF_LLAMA_MAX_N_PROBS` | `llamaMaxNProbs` | `16384` |
+| `SEMIF_LLAMA_CACHE_PROMPT` | `llamaCachePrompt` | `true` |
+| `SEMIF_MAX_TOKENS` | `maxTokens` | `4096` |
+
+`SEMIF_LLAMA_MODEL` is an alias from the server's `/v1/models` list; the
+server needs enough context for the prompt plus one token.
+`SEMIF_MODEL`/`SEMIF_REVISION` apply to torch only.
+
+Behavior enforced in code and covered by `tests/test_llama*.py`: option-letter
+token checks are cached per model, prompt boundary checks run per decision,
+missing option scores retry up a `n_probs` ladder (1024 → 4096 → 16384, then
+502 — never invented scores), invalid input is 422.
+
+## Response fields
+
+Beyond the torch shape, rows carry `choice`, `model.backend`, and a `llama`
+metadata block: `timings`, `attempts`, `n_probs`, `tokens_cached`, `cache_n`,
+`slot_id`. `cache_n` is the reused prompt count from server timings — note
+some server builds also report newly cached tokens in `tokens_cached`.
+`option_logits` are full-vocabulary log probabilities (logits up to an
+additive constant); `probabilities` remain uncalibrated conditional option
+scores. `forward_seconds` covers the completion HTTP call and retries;
+`total_seconds` adds prompt preparation.
+
+## Standalone probe
+
+```bash
+.venv/bin/python -m semif_api.llama --timeout 600
+.venv/bin/python -m semif_api.llama --input examples/examples-shared.jsonl > results-llama.jsonl
+```
+
+Input is JSONL with `id`, `state`, `question`, and 2–16 `options` (`id` +
+`description`); omit `--input` for a built-in example, `--input -` reads
+stdin. `--url`, `--model`, `--n-probs`, `--max-n-probs`, `--max-tokens`, and
+`--no-cache` override. A failed row exits nonzero, leaving partial output.
+
+## Planning
+
+`/plan` runs one `/v1/chat/completions` with thinking enabled under the
+universal `PLAN_SYSTEM` prompt (in `semif_api/llama.py`); no `max_tokens` is
+sent, since it would cap the reasoning trace. See [games.md](games.md) for
+the learning loop and
+[../semif-api/README.md](../semif-api/README.md) for the request/response
+shape.
+
+## Validation
+
+```bash
+.venv/bin/python -m unittest discover -s tests -p 'test_llama*.py' -v
+```
+
+`tests/test_api.py` and `scripts/validate.sh` are torch parity tools, not
+llama tests. Server API reference:
+[llama.cpp server](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md).
diff --git a/docs/usage.md b/docs/usage.md
@@ -0,0 +1,75 @@
+# semif-api usage
+
+`semif-api` serves SemIf's semantic decision readout over HTTP: send evidence
++ a natural-language question + typed options, get back the model's softmax
+over **exactly those options** — read from logits, not generated text.
+
+Base URL: `http://127.0.0.1:8321`. Request/response field details live in
+[../semif-api/README.md](../semif-api/README.md).
+
+- `probabilities` are conditional option scores, not calibrated confidence —
+ use them for ranking and coarse thresholds, not as "88% sure."
+- Always include an `insufficient`-style option when the evidence might not
+ decide the question; without one, the model is forced to pick.
+- `state` may be a string, JSON object, or array.
+
+## Endpoints
+
+- `GET /healthz` — liveness + model metadata
+- `POST /decide` — one decision
+- `POST /decide-batch` — many decisions against **one identical state**
+ (single KV-cache prefill on torch; sequential with prefix caching on llama)
+- `POST /plan` — rule generation from an action transcript (llama backend
+ only; see [games.md](games.md) for the learning loop it powers)
+
+## Example: one decision
+
+```bash
+curl -s -X POST localhost:8321/decide \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "id": "ticket-1042",
+ "state": "Customer asks to reset a forgotten password and says the reset email never arrived.",
+ "question": "Which queue should handle this request?",
+ "options": [
+ {"id": "account_access", "description": "Account access and authentication support."},
+ {"id": "billing", "description": "Billing and payment support."},
+ {"id": "insufficient", "description": "The evidence does not clearly fit one queue."}
+ ]
+ }' | jq -r '[.option_ids, .probabilities] | transpose | max_by(.[1]) | .[0]'
+# -> account_access
+```
+
+## Example: batch against one state
+
+Every decision in a batch shares the exact same `state`:
+
+```bash
+curl -s -X POST localhost:8321/decide-batch \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "state": "Postmortem: API latency spiked from 14:02 to 14:40 UTC after a config push removed the rate-limit cache key. Error rate stayed below 0.1%. No customer data was affected.",
+ "decisions": [
+ {"id": "customer-impact", "question": "Was there customer-visible impact?",
+ "options": [{"id": "yes", "description": "Customers were affected."},
+ {"id": "no", "description": "No customer-visible impact."}]},
+ {"id": "action-required", "question": "Does the postmortem identify a concrete follow-up action?",
+ "options": [{"id": "yes", "description": "A follow-up action is identified."},
+ {"id": "no", "description": "No follow-up action is identified."}]}
+ ]
+ }' | jq -r '.results[] | ([.option_ids, .probabilities] | transpose | max_by(.[1])) as $w | "\(.id)\t\($w[0])\tp=\($w[1])"'
+```
+
+## Error contract
+
+Bad input fails fast with 422 and the upstream validation message:
+
+```bash
+curl -s -X POST localhost:8321/decide -H 'Content-Type: application/json' \
+ -d '{"id": "x", "state": "s", "question": "q?", "options": [{"id": "only", "description": "one"}]}'
+# {"detail":"options must contain 2-16 entries"}
+```
+
+Other hard rules: duplicate option ids, empty state, missing fields, prompts
+over `max_tokens` — all 422. Remote llama-server failures are 502; scores are
+never invented.
diff --git a/examples/examples-shared.jsonl b/examples/examples-shared.jsonl
@@ -0,0 +1,4 @@
+{"id":"check-1","state":"The deployment completed at 14:02 UTC. Health checks passed in all three zones. No rollback was initiated.","question":"Is there evidence that the deployment succeeded?","options":[{"id":"yes","description":"The deployment succeeded."},{"id":"no","description":"The deployment did not succeed."},{"id":"insufficient","description":"The evidence is insufficient to decide."}]}
+{"id":"check-2","state":"The deployment completed at 14:02 UTC. Health checks passed in all three zones. No rollback was initiated.","question":"Did any rollback occur during the deployment?","options":[{"id":"rollback","description":"A rollback was initiated."},{"id":"no_rollback","description":"No rollback was initiated."}]}
+{"id":"check-3","state":"The deployment completed at 14:02 UTC. Health checks passed in all three zones. No rollback was initiated.","question":"Which zones passed health checks?","options":[{"id":"all_three","description":"All three zones passed."},{"id":"some","description":"Only some zones passed."},{"id":"none","description":"No zones passed."}]}
+{"id":"check-4","state":"The deployment completed at 14:02 UTC. Health checks passed in all three zones. No rollback was initiated.","question":"Is the evidence sufficient to determine the deployment time?","options":[{"id":"yes","description":"The deployment time is stated."},{"id":"no","description":"The deployment time is not stated."}]}
diff --git a/examples/game-fixtures.cjs b/examples/game-fixtures.cjs
@@ -0,0 +1,23 @@
+// Generate unguided prompt spot checks without playing the full level:
+// node examples/game-fixtures.cjs > /tmp/game-decisions.jsonl
+// .venv/bin/python -m semif_api.llama --input /tmp/game-decisions.jsonl
+const G = require('./semif-api/src/semif_api/web/game-rules.js');
+const cases = [
+ ['start-run', G.START_X, 4],
+ ['early-run', 10, 4],
+ ['one-tick-too-early-run', 11, 4],
+ ['valid-takeoff-either', 12, 4],
+ ['last-chance-jump', 13, 4],
+ ['second-pit-jump', 30, 3],
+ ['third-pit-jump', 47, 2],
+ ['past-pits-run', 52, 1],
+ ['near-flag-run', G.GOAL - 1, 1],
+];
+// IDs describe expected actions for the human reader; SemIf's direct_messages
+// does not include row IDs in model evidence. x=12 allows either action safely.
+for (const [id, x, jumpsLeft] of cases) {
+ const player = { x, y: G.GROUND, vy: 0, onGround: true };
+ console.log(JSON.stringify({
+ id, state: G.stateText(player, jumpsLeft, false), question: G.QUESTION, options: G.OPTIONS,
+ }));
+}
diff --git a/examples/reference-direct.jsonl b/examples/reference-direct.jsonl
@@ -0,0 +1,4 @@
+{"id": "check-1", "option_ids": ["yes", "no", "insufficient"], "probabilities": [0.9994852422786518, 0.00017946777542364315, 0.0003352899459245753], "option_logits": [27.75, 19.125, 19.75], "input_tokens": 142, "prompt_sha256": "7ac35785358f0c656eeb741ca0a51f03e0133753a71e2879a18ad0ab56d0c024", "prompt_version": "direct-options-v1", "model": {"source": "Qwen/Qwen3.5-4B", "revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a", "dtype": "bfloat16", "torch_version": "2.12.0", "transformers_version": "5.5.4"}, "readout": "native full-vocabulary last-position logits restricted to declared answer slots", "probability_status": "conditional option score; uncalibrated as decision confidence"}
+{"id": "check-2", "option_ids": ["rollback", "no_rollback"], "probabilities": [0.0007096703991005882, 0.9992903296008995], "option_logits": [21.0, 28.25], "input_tokens": 124, "prompt_sha256": "86a43592753e7bced3d136c1f0482475d9192130daf09046152705df8857b9bd", "prompt_version": "direct-options-v1", "model": {"source": "Qwen/Qwen3.5-4B", "revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a", "dtype": "bfloat16", "torch_version": "2.12.0", "transformers_version": "5.5.4"}, "readout": "native full-vocabulary last-position logits restricted to declared answer slots", "probability_status": "conditional option score; uncalibrated as decision confidence"}
+{"id": "check-3", "option_ids": ["all_three", "some", "none"], "probabilities": [0.9999141762532671, 5.828966066441289e-05, 2.753408606849224e-05], "option_logits": [27.75, 18.0, 17.25], "input_tokens": 137, "prompt_sha256": "d03fec9a6f1a890f85daaa250f959a701007ff7047de4ddcc0f39e77e78ee32d", "prompt_version": "direct-options-v1", "model": {"source": "Qwen/Qwen3.5-4B", "revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a", "dtype": "bfloat16", "torch_version": "2.12.0", "transformers_version": "5.5.4"}, "readout": "native full-vocabulary last-position logits restricted to declared answer slots", "probability_status": "conditional option score; uncalibrated as decision confidence"}
+{"id": "check-4", "option_ids": ["yes", "no"], "probabilities": [0.9998911030899734, 0.00010889691002655443], "option_logits": [28.25, 19.125], "input_tokens": 129, "prompt_sha256": "e8e78b793a77a04186fa7012e227d5064cc1d085d830a67d440d10f2f6bce1ca", "prompt_version": "direct-options-v1", "model": {"source": "Qwen/Qwen3.5-4B", "revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a", "dtype": "bfloat16", "torch_version": "2.12.0", "transformers_version": "5.5.4"}, "readout": "native full-vocabulary last-position logits restricted to declared answer slots", "probability_status": "conditional option score; uncalibrated as decision confidence"}
diff --git a/examples/reference-shared.jsonl b/examples/reference-shared.jsonl
@@ -0,0 +1,4 @@
+{"id": "check-1", "option_ids": ["yes", "no", "insufficient"], "probabilities": [0.3891374337863064, 0.2674499862175602, 0.34341257999613345], "option_logits": [22.25, 21.875, 22.125], "input_tokens": 142, "prompt_sha256": "7ac35785358f0c656eeb741ca0a51f03e0133753a71e2879a18ad0ab56d0c024", "prompt_version": "direct-options-v1", "model": {"source": "Qwen/Qwen3.5-4B", "revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a", "dtype": "bfloat16", "torch_version": "2.12.0", "transformers_version": "5.5.4", "serving_config": "native-state-prefix-parallel-v1"}, "readout": "native selected suffix-position logits", "probability_status": "conditional option score; uncalibrated as decision confidence"}
+{"id": "check-2", "option_ids": ["rollback", "no_rollback"], "probabilities": [0.017986209962091555, 0.9820137900379085], "option_logits": [20.375, 24.375], "input_tokens": 124, "prompt_sha256": "86a43592753e7bced3d136c1f0482475d9192130daf09046152705df8857b9bd", "prompt_version": "direct-options-v1", "model": {"source": "Qwen/Qwen3.5-4B", "revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a", "dtype": "bfloat16", "torch_version": "2.12.0", "transformers_version": "5.5.4", "serving_config": "native-state-prefix-parallel-v1"}, "readout": "native selected suffix-position logits", "probability_status": "conditional option score; uncalibrated as decision confidence"}
+{"id": "check-3", "option_ids": ["all_three", "some", "none"], "probabilities": [0.9769792834764103, 0.017893999757928193, 0.005126716765661509], "option_logits": [24.375, 20.375, 19.125], "input_tokens": 137, "prompt_sha256": "d03fec9a6f1a890f85daaa250f959a701007ff7047de4ddcc0f39e77e78ee32d", "prompt_version": "direct-options-v1", "model": {"source": "Qwen/Qwen3.5-4B", "revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a", "dtype": "bfloat16", "torch_version": "2.12.0", "transformers_version": "5.5.4", "serving_config": "native-state-prefix-parallel-v1"}, "readout": "native selected suffix-position logits", "probability_status": "conditional option score; uncalibrated as decision confidence"}
+{"id": "check-4", "option_ids": ["yes", "no"], "probabilities": [0.5621765008857981, 0.4378234991142019], "option_logits": [23.375, 23.125], "input_tokens": 129, "prompt_sha256": "e8e78b793a77a04186fa7012e227d5064cc1d085d830a67d440d10f2f6bce1ca", "prompt_version": "direct-options-v1", "model": {"source": "Qwen/Qwen3.5-4B", "revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a", "dtype": "bfloat16", "torch_version": "2.12.0", "transformers_version": "5.5.4", "serving_config": "native-state-prefix-parallel-v1"}, "readout": "native selected suffix-position logits", "probability_status": "conditional option score; uncalibrated as decision confidence"}
diff --git a/flake.lock b/flake.lock
@@ -0,0 +1,45 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1785828668,
+ "narHash": "sha256-8fsyqeO+mJqvIzeO4xIpgJe/f7MTbbVTEC6RT6WSXNs=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "e72e4f299401a3689d4b3d5fc6496b11db7064eb",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "e72e4f299401a3689d4b3d5fc6496b11db7064eb",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs",
+ "semif-src": "semif-src"
+ }
+ },
+ "semif-src": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1789793193,
+ "narHash": "sha256-xMNwYZPwioyrfLYt1+sd/v0ziD+2stbJ6y0006K6b9Y=",
+ "owner": "TheoLeeCJ",
+ "repo": "SemIf",
+ "rev": "ca3ba65f142967030ecb453346e94d6f476a69df",
+ "type": "github"
+ },
+ "original": {
+ "owner": "TheoLeeCJ",
+ "repo": "SemIf",
+ "rev": "ca3ba65f142967030ecb453346e94d6f476a69df",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
@@ -0,0 +1,202 @@
+{
+ description = "SemIf HTTP API (semif-api) + ROCm dev shell (gfx1151)";
+
+ inputs = {
+ # Pin chosen for ROCm torch (gfx1151) prebuilt availability on cache.nixos.org.
+ nixpkgs.url = "github:NixOS/nixpkgs/e72e4f299401a3689d4b3d5fc6496b11db7064eb";
+ # Upstream SemIf, consumed as a library dependency (not a fork).
+ semif-src = {
+ url = "github:TheoLeeCJ/SemIf/ca3ba65f142967030ecb453346e94d6f476a69df";
+ flake = false;
+ };
+ };
+
+ outputs = { self, nixpkgs, semif-src }:
+ let
+ system = "x86_64-linux";
+ pkgs = import nixpkgs { inherit system; config.allowUnfree = true; };
+ py = pkgs.python3Packages;
+
+ # ROCm torch, prebuilt for gfx1151 on cache.nixos.org.
+ rocmTorch = py.torchWithRocm;
+
+ # torch is provided by rocmTorch; drop it from accelerate so the env
+ # never contains a second (plain CUDA) torch.
+ accelerate' = py.accelerate.overridePythonAttrs (old: {
+ propagatedBuildInputs =
+ builtins.filter (p: (p.pname or "") != "torch")
+ (old.propagatedBuildInputs or [ ]);
+ dependencies =
+ builtins.filter (p: (p.pname or "") != "torch")
+ (old.dependencies or [ ]);
+ });
+
+ # Upstream SemIf built as a library. nixpkgs supplies the deps
+ # (nixpkgs does not resolve the pinned versions in its pyproject).
+ semif = py.buildPythonPackage {
+ pname = "semif-phase1";
+ version = "0.1.0";
+ src = semif-src;
+ pyproject = true;
+ # Upstream pins exact versions (torch==2.10.0 is CUDA-only); the nix
+ # env supplies newer compatible ones.
+ pythonRelaxDeps = true;
+ propagatedBuildInputs = with py; [
+ rocmTorch
+ accelerate'
+ transformers
+ safetensors
+ huggingface-hub
+ tokenizers
+ numpy
+ sentencepiece
+ protobuf
+ ];
+ pythonImportsCheck = [ "semif_phase1" ];
+ };
+
+ semif-api = py.buildPythonPackage {
+ pname = "semif-api";
+ version = "0.1.0";
+ src = ./semif-api;
+ pyproject = true;
+ propagatedBuildInputs = [ semif py.fastapi py.uvicorn ];
+ pythonImportsCheck = [ "semif_api" ];
+ };
+
+ devPython = pkgs.python3.withPackages (ps: with ps; [
+ semif
+ fastapi
+ uvicorn
+ pytest
+ httpx
+ pip
+ ]);
+
+ servePython = pkgs.python3.withPackages (ps: [ semif-api ps.uvicorn ]);
+ in
+ {
+ packages.${system} = {
+ inherit semif semif-api;
+ default = semif-api;
+ };
+
+ devShells.${system}.default = pkgs.mkShell {
+ packages = [ devPython pkgs.git ];
+
+ shellHook = ''
+ export IN_SEMIF_ROCM=1
+ export SEMIF_SRC=${semif-src}
+ export HF_HOME="$PWD/hf-cache"
+ if [ -d .venv ]; then
+ VENV_BASE=$(.venv/bin/python -c 'import sys; print(sys.base_prefix)' 2>/dev/null || echo none)
+ CUR_BASE=$(python -c 'import sys; print(sys.prefix)')
+ if [ "$VENV_BASE" != "$CUR_BASE" ]; then
+ echo "[semif-rocm] python env changed (stale venv), recreating..."
+ rm -rf .venv
+ fi
+ fi
+ if [ ! -d .venv ]; then
+ echo "[semif-rocm] creating venv (system site packages)..."
+ python -m venv --system-site-packages .venv
+ fi
+ if ! .venv/bin/python -c "import semif_api" 2>/dev/null; then
+ echo "[semif-rocm] installing semif-api (editable, no deps)..."
+ .venv/bin/python -m pip install -e ./semif-api --no-deps
+ fi
+ echo "[semif-rocm] torch: $(.venv/bin/python -c 'import torch; print(torch.__version__)') (ROCm/HIP: $(.venv/bin/python -c 'import torch; print(torch.version.hip is not None)'))"
+ echo "[semif-rocm] GPU visible to torch: $(.venv/bin/python -c 'import torch; print(torch.cuda.is_available())') (must be run on the host)"
+ '';
+ };
+
+ nixosModules.default = { config, lib, ... }:
+ let
+ cfg = config.services.semif-api;
+ serveScript = pkgs.writeShellScript "semif-api-serve" ''
+ export HF_HOME="''${STATE_DIRECTORY:-/var/lib/semif-api}/huggingface"
+ export CUDA_VISIBLE_DEVICES=''${CUDA_VISIBLE_DEVICES:-0}
+ exec ${semif-api}/bin/semif-serve
+ '';
+ in
+ {
+ options.services.semif-api = {
+ enable = lib.mkEnableOption "semif-api semantic decision HTTP server";
+ host = lib.mkOption { type = lib.types.str; default = "127.0.0.1"; };
+ port = lib.mkOption { type = lib.types.port; default = 8321; };
+ backend = lib.mkOption { type = lib.types.enum [ "torch" "llama" ]; default = "torch"; };
+ maxTokens = lib.mkOption { type = lib.types.ints.positive; default = 4096; };
+ llamaUrl = lib.mkOption { type = lib.types.str; default = "http://127.0.0.1:8080"; };
+ llamaModel = lib.mkOption {
+ type = lib.types.str;
+ default = "Qwen3.5-4B";
+ };
+ llamaTimeout = lib.mkOption { type = lib.types.ints.positive; default = 600; };
+ llamaNProbs = lib.mkOption { type = lib.types.ints.positive; default = 1024; };
+ llamaMaxNProbs = lib.mkOption { type = lib.types.ints.positive; default = 16384; };
+ llamaCachePrompt = lib.mkOption { type = lib.types.bool; default = true; };
+ model = lib.mkOption { type = lib.types.str; default = "Qwen/Qwen3.5-4B"; };
+ revision = lib.mkOption {
+ type = lib.types.str;
+ default = "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a";
+ };
+ user = lib.mkOption { type = lib.types.str; default = "semif"; };
+ openFirewall = lib.mkOption {
+ type = lib.types.bool;
+ default = false;
+ description = "expose the API on the LAN (binds cfg.host; set host to 0.0.0.0 to listen on all interfaces)";
+ };
+ };
+
+ config = lib.mkIf cfg.enable {
+ assertions = [{
+ assertion = cfg.backend != "llama" || (cfg.llamaNProbs >= 16 && cfg.llamaMaxNProbs >= cfg.llamaNProbs);
+ message = "semif-api requires llamaMaxNProbs >= llamaNProbs >= 16";
+ }];
+ users.users.${cfg.user} = {
+ isSystemUser = true;
+ group = cfg.user;
+ extraGroups = [ "render" "video" ];
+ };
+ users.groups.${cfg.user} = { };
+
+ systemd.services.semif-api = {
+ description = "SemIf semantic decision API (semif-api)";
+ wantedBy = [ "multi-user.target" ];
+ after = [ "network.target" ];
+
+ environment = {
+ SEMIF_BACKEND = cfg.backend;
+ SEMIF_MAX_TOKENS = toString cfg.maxTokens;
+ SEMIF_LLAMA_URL = cfg.llamaUrl;
+ SEMIF_LLAMA_MODEL = cfg.llamaModel;
+ SEMIF_LLAMA_TIMEOUT = toString cfg.llamaTimeout;
+ SEMIF_LLAMA_N_PROBS = toString cfg.llamaNProbs;
+ SEMIF_LLAMA_MAX_N_PROBS = toString cfg.llamaMaxNProbs;
+ SEMIF_LLAMA_CACHE_PROMPT = lib.boolToString cfg.llamaCachePrompt;
+ SEMIF_MODEL = cfg.model;
+ SEMIF_REVISION = cfg.revision;
+ SEMIF_HOST = cfg.host;
+ SEMIF_PORT = toString cfg.port;
+ # System user has no home; MIOpen (ROCm) needs a writable
+ # kernel cache or every conv fails with miopenStatusUnknownError.
+ HOME = "%S/semif-api";
+ XDG_CACHE_HOME = "%S/semif-api/.cache";
+ };
+
+ serviceConfig = {
+ User = cfg.user;
+ Group = cfg.user;
+ StateDirectory = "semif-api";
+ StateDirectoryMode = "0750";
+ ExecStart = serveScript;
+ Restart = "on-failure";
+ RestartSec = 5;
+ };
+ };
+
+ networking.firewall.allowedTCPPorts =
+ lib.mkIf cfg.openFirewall [ cfg.port ];
+ };
+ };
+ };
+}
diff --git a/scripts/compare.py b/scripts/compare.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+"""Compare direct vs shared scoring outputs for argmax agreement and drift."""
+import json
+import sys
+from pathlib import Path
+
+BASE = Path.cwd() # validate.sh runs from the repo root
+
+def load(path):
+ with open(BASE / path) as handle:
+ return {row["id"]: row for row in map(json.loads, handle)}
+
+def argmax(row):
+ best = max(range(len(row["probabilities"])), key=lambda i: row["probabilities"][i])
+ return row["option_ids"][best]
+
+def main():
+ direct_path = sys.argv[1] if len(sys.argv) > 1 else "results-direct-on-shared.jsonl"
+ shared_path = sys.argv[2] if len(sys.argv) > 2 else "results-shared.jsonl"
+ direct, shared = load(direct_path), load(shared_path)
+ if set(direct) != set(shared):
+ print(f"ID mismatch: only-direct={sorted(set(direct) - set(shared))} "
+ f"only-shared={sorted(set(shared) - set(direct))}")
+ flips = 0
+ for rid in sorted(set(direct) & set(shared)):
+ d, s = direct[rid], shared[rid]
+ match = argmax(d) == argmax(s)
+ flips += not match
+ print(f"{rid}: argmax {'MATCH' if match else 'FLIP '} "
+ f"({argmax(d)} vs {argmax(s)})")
+ print(f" direct: {[round(p, 4) for p in d['probabilities']]}")
+ print(f" shared: {[round(p, 4) for p in s['probabilities']]}")
+ print(f"\n{flips} argmax flip(s) across {len(set(direct) & set(shared))} rows")
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/validate.sh b/scripts/validate.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+# Full validation: upstream unit tests, CLI direct + shared scoring,
+# drift compare, HTTP API parity.
+# Safe to run from any directory; re-execs into the nix dev shell if needed.
+set -euo pipefail
+cd "$(dirname "$(readlink -f "$0")")/.."
+ROOT=$PWD
+
+if [ "${IN_SEMIF_ROCM:-0}" != "1" ]; then
+ echo "[validate] entering nix dev shell..."
+ exec nix develop "$ROOT" --command "$0" "$@"
+fi
+
+PY=.venv/bin/python
+SCORER=semif-score # from the nix env (semif package), not the venv
+MODEL="Qwen/Qwen3.5-4B"
+REV="851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a"
+export CUDA_VISIBLE_DEVICES=0
+
+echo "=== [1/4] upstream unit tests (mocked, mlx tests skipped) ==="
+$PY -m pytest "$SEMIF_SRC/tests" -q -p no:cacheprovider \
+ --ignore="$SEMIF_SRC/tests/test_mlx.py" \
+ --ignore="$SEMIF_SRC/tests/test_mlx_evidence.py"
+
+echo "=== [2/4] direct mode on shared-state input ==="
+rm -f results-direct-on-shared.jsonl
+$SCORER --mode direct --model "$MODEL" --revision "$REV" \
+ --input examples/examples-shared.jsonl --output results-direct-on-shared.jsonl
+
+echo "=== [3/4] shared mode + drift compare ==="
+rm -f results-shared.jsonl
+$SCORER --mode shared --model "$MODEL" --revision "$REV" \
+ --input examples/examples-shared.jsonl --output results-shared.jsonl
+$PY scripts/compare.py
+
+echo "=== [4/4] HTTP API parity ==="
+$PY -m uvicorn semif_api.app:app --host 127.0.0.1 --port 8321 > uvicorn.log 2>&1 &
+SERVER_PID=$!
+trap 'kill $SERVER_PID 2>/dev/null || true' EXIT
+for attempt in $(seq 1 90); do
+ if $PY -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8321/healthz', timeout=2)" 2>/dev/null; then
+ break
+ fi
+ if ! kill -0 $SERVER_PID 2>/dev/null; then
+ echo "uvicorn died during startup; last log lines:"
+ tail -20 uvicorn.log
+ exit 1
+ fi
+ sleep 2
+done
+$PY tests/test_api.py
+kill $SERVER_PID 2>/dev/null || true
+trap - EXIT
+
+echo "=== all done ==="
diff --git a/semif-api/README.md b/semif-api/README.md
@@ -0,0 +1,98 @@
+# semif-api
+
+HTTP API wrapper around [TheoLeeCJ/SemIf](https://github.com/TheoLeeCJ/SemIf),
+consumed as a library (`semif_phase1`) — no scorer code is copied. See
+[../README.md](../README.md) for the quickstart and
+[../docs/llama-backend.md](../docs/llama-backend.md) for the GGUF backend
+(`SEMIF_BACKEND=llama`) and its NixOS options; the notes below cover the
+torch path.
+
+## Run
+
+```bash
+nix develop <repo root>
+semif-serve # 127.0.0.1:8321, or SEMIF_HOST/SEMIF_PORT
+```
+
+The torch model is loaded once at startup (env overrides: `SEMIF_MODEL`,
+`SEMIF_REVISION`, `SEMIF_MAX_TOKENS`).
+
+## API
+
+The web UI rides in the wheel as package data (`src/semif_api/web/`) and is
+served same-origin at `/ui/` — no CORS.
+
+### `GET /healthz`
+
+Model metadata, `backend`, `backend_status`, and `model_state` (`pending` =
+selected GGUF not yet confirmed by a score, `loaded`).
+
+### `GET /models` / `POST /models` — llama backend only
+
+List the llama-server's model aliases, or switch with `{"model": "<alias>"}`.
+The alias is validated before the current model is unloaded; unloading is
+best-effort (failure returns a `warning`, and the old model may stay resident
+until evicted). The new GGUF loads lazily on the first scored request; a
+restart restores the configured default. Unknown alias → 422, server failure
+→ 502. Torch returns `switching: false`.
+
+### `POST /decide` — one decision
+
+```json
+{
+ "id": "support-1",
+ "state": "The deployment completed at 14:02 UTC...",
+ "question": "Is there evidence that the deployment succeeded?",
+ "options": [
+ {"id": "yes", "description": "The deployment succeeded."},
+ {"id": "no", "description": "The deployment did not succeed."},
+ {"id": "insufficient", "description": "The evidence is insufficient to decide."}
+ ]
+}
+```
+
+→ scorer result: `option_ids`, `probabilities` (conditional option scores —
+uncalibrated as decision confidence), `option_logits`, `prompt_sha256`,
+model/revision metadata.
+
+### `POST /decide-batch` — one shared state, many decisions
+
+```json
+{
+ "state": "...shared evidence...",
+ "decisions": [{"id": "...", "question": "...", "options": [...]}, ...]
+}
+```
+
+→ `{"results": [...], "timing": {...}}`, each result tagged `shared_timing`.
+Every decision in a batch shares the exact same `state`. On llama, rows run
+sequentially (`timing.mode: llama-sequential`) with server prefix caching.
+
+### `POST /plan` — llama backend only
+
+Rule generation from an action transcript; torch refuses with 422 (it only
+reads logits). Game-agnostic: the caller composes `prompt` from facts it owns
+(goal, what triggered planning, rules currently in effect) plus `transcript`
+turns (`system|user|assistant`). One chat completion under a universal system
+prompt with thinking enabled; sampling is pinned to the model card's
+thinking-mode settings.
+
+```json
+{
+ "id": "self-plan-1",
+ "prompt": "Objective: ...\nTrigger: ...\nPrevious rules (the actor failed despite them — amend or replace):\n...",
+ "transcript": [{"role": "user", "content": "Observation: …\nChosen action: run\nOutcome: ..."}]
+}
+```
+
+→ `{"id", "rules", "reasoning", "truncated", "usage", "model",
+"total_seconds"}`. `rules` is injected into later decision states; `reasoning`
+is the thinking trace when the server surfaces one. See
+[../docs/games.md](../docs/games.md) for the demos built on this.
+
+## Notes
+
+- Input validation reuses upstream `validate_row`; violations → 422. Remote
+ backend failures → 502.
+- All GPU work serializes on a lock: batch shape changes bf16 results
+ (documented upstream), so requests are never implicitly batched together.
diff --git a/semif-api/pyproject.toml b/semif-api/pyproject.toml
@@ -0,0 +1,36 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "semif-api"
+version = "0.1.0"
+description = "HTTP API wrapper around TheoLeeCJ/SemIf (library dependency, not a fork)"
+readme = "README.md"
+requires-python = ">=3.10"
+dependencies = [
+ # Provided by the nix dev shell / venv, NOT resolved from PyPI:
+ # - semif-phase1: vendored clone of github.com/TheoLeeCJ/SemIf (install with --no-deps)
+ # - torch: ROCm build from nixpkgs (SemIf's pinned torch==2.10.0 is CUDA-only)
+ # - fastapi, uvicorn: from the nix env
+ "semif-phase1",
+ "torch",
+ "fastapi",
+ "uvicorn",
+]
+
+[project.scripts]
+semif-serve = "semif_api.app:run"
+semif-llama-probe = "semif_api.llama:main"
+
+[tool.setuptools]
+include-package-data = true
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+# The web UI must ride in the wheel: the nix build installs non-editable, so a
+# missing glob ships a package whose /ui route 500s on a missing file while the
+# editable dev shell works fine.
+[tool.setuptools.package-data]
+semif_api = ["web/*.html", "web/*.css", "web/*.js"]
diff --git a/semif-api/src/semif_api/__init__.py b/semif-api/src/semif_api/__init__.py
@@ -0,0 +1 @@
+"""semif-api: HTTP API around TheoLeeCJ/SemIf."""
diff --git a/semif-api/src/semif_api/app.py b/semif-api/src/semif_api/app.py
@@ -0,0 +1,281 @@
+"""HTTP API around TheoLeeCJ/SemIf.
+
+SemIf is used purely as a library (semif_phase1); this package contains no
+copied scorer logic, so upstream updates apply without a rebase.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import time
+from contextlib import asynccontextmanager
+from pathlib import Path
+
+import uvicorn
+from fastapi import FastAPI, HTTPException
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel
+from starlette.concurrency import run_in_threadpool
+
+from semif_phase1.core import load_causal_model, validate_row
+from .llama import BackendError, DEFAULT_MODEL, LlamaBackend
+
+MODEL = os.environ.get("SEMIF_MODEL", "Qwen/Qwen3.5-4B")
+REVISION = os.environ.get("SEMIF_REVISION", "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a")
+MAX_TOKENS = int(os.environ.get("SEMIF_MAX_TOKENS", "4096"))
+# Note: no planning sampling knobs — plan() pins the model card's
+# thinking-mode settings (PLAN_SAMPLING), and no max_tokens is sent since it
+# would cap the reasoning trace too.
+HOST = os.environ.get("SEMIF_HOST", "127.0.0.1")
+PORT = int(os.environ.get("SEMIF_PORT", "8321"))
+
+# The browser UI ships as package data (see pyproject package-data) and is served
+# by the API itself, so the page is same-origin: no CORS, and it works on a GPU
+# host with no network access.
+WEB_DIR = Path(__file__).parent / "web"
+
+
+class OptionIn(BaseModel):
+ id: str
+ description: str
+
+
+class DecisionIn(BaseModel):
+ """One decision: evidence state + criterion + declared options."""
+
+ id: str
+ state: str | dict | list
+ question: str
+ options: list[OptionIn]
+
+
+class BatchDecisionIn(BaseModel):
+ id: str
+ question: str
+ options: list[OptionIn]
+
+
+class BatchIn(BaseModel):
+ """Many decisions against one state; execution depends on the backend."""
+
+ state: str | dict | list
+ decisions: list[BatchDecisionIn]
+
+
+class ModelIn(BaseModel):
+ model: str
+
+
+class PlanTurn(BaseModel):
+ """One transcript entry: a completed action and its observed outcome."""
+
+ role: str
+ content: str
+
+
+class PlanIn(BaseModel):
+ """A planning request: context note + action history for a reasoning chat.
+
+ Game-agnostic by construction: the caller composes the prompt from facts it
+ owns — the simulation's one-line goal, what triggered planning, and the
+ rules currently in effect — and the transcript of completed actions; the
+ backend runs one chat completion under a universal system prompt and
+ returns the generated rules plus the thinking trace.
+ """
+
+ id: str
+ prompt: str
+ transcript: list[PlanTurn] = []
+
+
+async def _score(function, *args, **kwargs):
+ """Run one scorer call, mapping upstream input failures to 422.
+
+ Upstream raises ValueError for every input problem it detects -- token
+ budget in encode_prompt, answer-slot tokenisation, the shared-state and
+ batch-id rules in score_shared -- and those are contract violations, not
+ server faults. Without this they escape as a bare 500 whose real reason is
+ only in the log, contradicting docs/usage.md. Remote backend failures
+ become 502; unexpected local faults still propagate as 500.
+ """
+ try:
+ return await run_in_threadpool(function, *args, **kwargs)
+ except ValueError as error:
+ raise HTTPException(status_code=422, detail=str(error)) from error
+ except BackendError as error:
+ raise HTTPException(status_code=502, detail=str(error)) from error
+
+
+def _row(decision: DecisionIn) -> dict:
+ row = decision.model_dump()
+ try:
+ validate_row(row)
+ except ValueError as error:
+ raise HTTPException(status_code=422, detail=str(error)) from error
+ return row
+
+
+def create_app() -> FastAPI:
+ backend_name = os.environ.get("SEMIF_BACKEND", "torch")
+ if backend_name not in {"torch", "llama"}:
+ raise ValueError("SEMIF_BACKEND must be torch or llama")
+
+ @asynccontextmanager
+ async def lifespan(app):
+ app.state.lock = asyncio.Lock()
+ app.state.llama = None
+ if backend_name == "llama":
+ app.state.llama = LlamaBackend(
+ os.environ.get("SEMIF_LLAMA_URL", "http://127.0.0.1:8080"),
+ os.environ.get("SEMIF_LLAMA_MODEL", DEFAULT_MODEL),
+ timeout=float(os.environ.get("SEMIF_LLAMA_TIMEOUT", "600")),
+ n_probs=int(os.environ.get("SEMIF_LLAMA_N_PROBS", "1024")),
+ max_n_probs=int(os.environ.get("SEMIF_LLAMA_MAX_N_PROBS", "16384")),
+ max_tokens=MAX_TOKENS,
+ cache_prompt=os.environ.get("SEMIF_LLAMA_CACHE_PROMPT", "true").lower() == "true",
+ )
+ app.state.metadata = app.state.llama.metadata
+ # The server loads the selected GGUF lazily; the first score confirms it.
+ app.state.model_state = "pending"
+ else:
+ # No torch model or scorer initialization on the llama path.
+ from semif_phase1.direct import score as direct_score
+ from semif_phase1.shared import score_shared
+ app.state.direct_score = direct_score
+ app.state.shared_score = score_shared
+ model, tokenizer, metadata = await run_in_threadpool(load_causal_model, MODEL, REVISION)
+ app.state.model, app.state.tokenizer, app.state.metadata = model, tokenizer, metadata
+ app.state.model_state = "loaded"
+ yield
+
+ app = FastAPI(title="semif-api", version="0.1.0", lifespan=lifespan)
+ app.mount("/ui", StaticFiles(directory=WEB_DIR, html=True), name="ui")
+
+ @app.get("/healthz")
+ def healthz() -> dict:
+ # Liveness, not a remote readiness check: never load a GGUF on polling.
+ return {"status": "ok", "backend": backend_name, "model": app.state.metadata,
+ "max_tokens": MAX_TOKENS,
+ "backend_status": "not_checked" if backend_name == "llama" else "loaded",
+ "model_state": app.state.model_state}
+
+ @app.get("/models")
+ async def models() -> dict:
+ """Served models + the runtime selection. `switching` is false on torch."""
+ if app.state.llama is None:
+ return {"switching": False, "current": app.state.metadata["source"],
+ "model_state": app.state.model_state, "models": []}
+ try:
+ available = await run_in_threadpool(app.state.llama.list_models)
+ except BackendError as error:
+ raise HTTPException(status_code=502, detail=str(error)) from error
+ return {"switching": True, "current": app.state.llama.model,
+ "model_state": app.state.model_state, "models": available}
+
+ @app.post("/models")
+ async def select_model(selection: ModelIn) -> dict:
+ """Switch the llama backend's model: unload current, lazy-load on first request.
+
+ Serialized on the same lock as scoring, so a switch never interleaves
+ with a score that still needs the old weights. Runtime state only:
+ nothing here changes the process environment or the llama-server config.
+ """
+ if app.state.llama is None:
+ raise HTTPException(status_code=422,
+ detail="model selection is only available on the llama backend")
+ async with app.state.lock:
+ try:
+ result = await run_in_threadpool(app.state.llama.switch, selection.model)
+ except ValueError as error:
+ raise HTTPException(status_code=422, detail=str(error)) from error
+ except BackendError as error:
+ raise HTTPException(status_code=502, detail=str(error)) from error
+ if not result["unchanged"]:
+ app.state.model_state = "pending"
+ app.state.metadata = app.state.llama.metadata
+ return {"current": app.state.llama.model, "model_state": app.state.model_state,
+ "warning": result["warning"],
+ "status": "selected " + result["selected"] + (
+ " (no change)" if result["unchanged"]
+ else "; loads on first request")}
+
+ @app.post("/decide")
+ async def decide(decision: DecisionIn) -> dict:
+ row = _row(decision)
+ async with app.state.lock:
+ if app.state.llama is not None:
+ result = await _score(app.state.llama.score, row)
+ app.state.model_state = "loaded"
+ return result
+ return await _score(
+ app.state.direct_score, app.state.model, app.state.tokenizer, row, app.state.metadata, MAX_TOKENS
+ )
+
+ @app.post("/plan")
+ async def plan(request: PlanIn) -> dict:
+ """Generate environment rules from an action transcript; llama backend only.
+
+ Torch has no text-generation path (it exists purely for logit readout),
+ so planning is refused there rather than silently degraded.
+ """
+ if app.state.llama is None:
+ raise HTTPException(
+ status_code=422,
+ detail="/plan requires the llama backend (SEMIF_BACKEND=llama); "
+ "the torch backend only scores declared options")
+ async with app.state.lock:
+ result = await _score(
+ app.state.llama.plan, request.id, request.prompt,
+ [turn.model_dump() for turn in request.transcript],
+ )
+ app.state.model_state = "loaded"
+ return result
+
+ @app.post("/decide-batch")
+ async def decide_batch(batch: BatchIn) -> dict:
+ rows = [
+ {
+ "id": decision.id,
+ "state": batch.state,
+ "question": decision.question,
+ "options": [option.model_dump() for option in decision.options],
+ }
+ for decision in batch.decisions
+ ]
+ if not rows:
+ raise HTTPException(status_code=422, detail="Shared scoring requires one nonempty exact state")
+ if len({row["id"] for row in rows}) != len(rows):
+ raise HTTPException(status_code=422, detail="Decision IDs must be unique")
+ for row in rows:
+ try:
+ validate_row(row)
+ except ValueError as error:
+ raise HTTPException(status_code=422, detail=str(error)) from error
+ async with app.state.lock:
+ if app.state.llama is not None:
+ started = time.perf_counter()
+ results = []
+ for row in rows:
+ results.append(await _score(app.state.llama.score, row))
+ app.state.model_state = "loaded"
+ timing = {"total_seconds": time.perf_counter() - started,
+ "batch_size": len(rows), "mode": "llama-sequential"}
+ else:
+ results, timing = await _score(
+ app.state.shared_score, app.state.model, app.state.tokenizer,
+ rows, app.state.metadata, MAX_TOKENS
+ )
+ return {
+ "results": [{**result, "shared_timing": timing} for result in results],
+ "timing": timing,
+ }
+
+ return app
+
+
+app = create_app()
+
+
+def run() -> None:
+ uvicorn.run(app, host=HOST, port=PORT)
diff --git a/semif-api/src/semif_api/llama.py b/semif-api/src/semif_api/llama.py
@@ -0,0 +1,403 @@
+"""Semantic decision probe using an existing llama-server; no local weights needed.
+
+Uses SemIf's messages, but the GGUF's own template/tokenizer. This is an
+independent serving backend, not a numerically equivalent torch replacement.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import os
+import sys
+import time
+from urllib.error import HTTPError, URLError
+from urllib.request import Request, urlopen
+
+from semif_phase1.core import LETTERS, digest, direct_messages, softmax
+
+# Planner framing for /plan: ONE universal system prompt for every simulation.
+# The caller composes the user prompt from facts it owns — the simulation's
+# one-line goal statement, a note on what triggered planning, and the rules
+# previously in effect — so no game needs its own prompt. The output
+# discipline matters because the consumer is a logit-probed decision pass:
+# one forward pass, no deliberation, so the rule set must be facts and
+# imperatives the model can weigh in a single read — never if-state-then-
+# action branches it would have to execute. Length is bounded by spirit, not
+# numbers: a handful of brief lines, no counting. Everything uncertain gets
+# litigated inside the thinking; the visible reply is committal rules only.
+# The style example is placeholder-only on purpose: weaker models copy
+# concrete example vocabulary into their plans, so the example carries no
+# content to copy — only shape.
+PLAN_SYSTEM = (
+ "You are writing the prompt for a decision-making model that runs a "
+ "single forward pass — it cannot deliberate, only decide. Your "
+ "entire response is that prompt: it is prepended to every game state "
+ "the model reads, and it is the only guidance the model ever gets. "
+ "Write it addressed to the model in its own voice: you are a "
+ "decision maker, this is what your input looks like, this is how "
+ "to respond.\n\n"
+ "Structure the prompt as:\n"
+ "1. What the input is — a plain-text state (what the actor currently "
+ "sees and knows) followed by a question and a fixed list of possible "
+ "actions.\n"
+ "2. How to read the state — what each standing part of it means for "
+ "the decision at hand.\n"
+ "3. How to decide — the objective, the mechanics that bear on it, "
+ "stated as facts, and the priorities for choosing among the "
+ "actions.\n"
+ "4. The limits that end an attempt, stated as plain facts.\n\n"
+ "Never write if-state-then-action branches; the model picks actions, "
+ "you decide what it should know. It is a capable decision maker "
+ "whose only limitation is that it cannot deliberate.\n\n"
+ "Keep the prompt short: a handful of brief lines, each a single "
+ "imperative or fact. Nothing else — no headings, no preamble, no "
+ "explanation.\n\n"
+ "If previous rules are attached, the actor failed while following "
+ "them: that failure indicts the rules — their facts, priorities, or "
+ "framing — not the actor's comprehension of them. Never resubmit a "
+ "reworded or lightly edited version; change the substance, or "
+ "discard the set and write a fresh one.\n\n"
+ "The environment resets to its initial state after planning: the "
+ "rules must hold from the very first state, not from the situation "
+ "that triggered the plan. Resolve uncertainty inside your thinking; "
+ "the rules themselves must be plain and committal.\n\n"
+ "Style example — the shape, not the words:\n"
+ "GOOD:\n"
+ "<you are a decision maker, and this is the input you receive>\n"
+ "<one part of the state and what it means for the decision>\n"
+ "<the objective and one priority, stated as plain facts>\n"
+ "<one limit that ends an attempt, stated as a plain fact>\n\n"
+ "BAD:\n"
+ "<if this state, then that action; if that state, then this action; "
+ "and so on — a branching chain of situations and prescriptions>"
+)
+PLAN_ROLES = {"system", "user", "assistant"}
+# Sampling pinned to the model card's thinking-mode recommendation: the
+# planner must explore while it reasons, so no per-request overrides. All six
+# are accepted by llama-server's OpenAI-compatible endpoint (top_k, min_p,
+# repetition_penalty are llama.cpp extensions); min_p/presence_penalty/
+# repetition_penalty are sent explicitly to pin them against server defaults.
+PLAN_SAMPLING = {"temperature": 1.0, "top_p": 0.95, "top_k": 20,
+ "min_p": 0.0, "presence_penalty": 0.0, "repetition_penalty": 1.0}
+
+# Default llama-server model alias. Any served GGUF works; the torch backend
+# defaults to the HF repo form (Qwen/Qwen3.5-4B) instead.
+DEFAULT_MODEL = "Qwen3.5-4B"
+
+
+class BackendError(RuntimeError):
+ """Backend transport or readout failure (never an invented score)."""
+
+
+class MissingOptions(BackendError):
+ """Valid readout, but the candidate list omitted declared options."""
+
+
+def option_logprobs(response: dict, slots: list[int]) -> list[float]:
+ """Read pre-sampling logprobs from modern or legacy native responses."""
+ entries = response.get("completion_probabilities", response.get("probs"))
+ if not isinstance(entries, list) or len(entries) != 1:
+ raise BackendError("Expected exactly one token's probability readout")
+ entry = entries[0]
+ if not isinstance(entry, dict):
+ raise BackendError("Malformed probability readout")
+ candidates = entry.get("top_logprobs", entry.get("probs", []))
+ if not isinstance(candidates, list) or not all(isinstance(c, dict) for c in candidates):
+ raise BackendError("Malformed candidate list")
+ scores = {}
+ for candidate in candidates:
+ token = candidate.get("id")
+ if token not in slots:
+ continue
+ if token in scores:
+ raise BackendError(f"Duplicate candidate token ID: {token}")
+ if "logprob" in candidate:
+ value = candidate["logprob"]
+ else:
+ probability = candidate.get("prob")
+ if not isinstance(probability, (int, float)) or not 0 < probability <= 1:
+ raise BackendError(f"Invalid probability for token {token}")
+ value = math.log(probability)
+ if not isinstance(value, (int, float)) or not math.isfinite(value):
+ raise BackendError(f"Non-finite logprob for token {token}")
+ scores[token] = value
+ missing = [token for token in slots if token not in scores]
+ if missing:
+ raise MissingOptions(
+ f"Missing option token IDs {missing}; increase the candidate limit "
+ "(--max-n-probs / SEMIF_LLAMA_MAX_N_PROBS). No scores were fabricated."
+ )
+ return [scores[token] for token in slots]
+
+
+class LlamaBackend:
+ def __init__(self, url: str, model: str = DEFAULT_MODEL, *, timeout: float = 180,
+ n_probs: int = 1024, max_n_probs: int = 16384, max_tokens: int = 4096, cache_prompt: bool = True):
+ if max_n_probs < n_probs or n_probs < 16 or max_tokens < 1 or timeout <= 0:
+ raise ValueError("Require max_n_probs >= n_probs >= 16, max_tokens >= 1, timeout > 0")
+ self.url = url.rstrip("/")
+ self.model = model
+ self.timeout = timeout
+ self.n_probs = n_probs
+ self.max_n_probs = max_n_probs
+ self.max_tokens = max_tokens
+ self.cache_prompt = cache_prompt
+ self._slots: dict[str, int] = {}
+ self.metadata = {"source": self.model, "backend": "llama", "url": self.url}
+
+ def request(self, method: str, path: str, payload: dict | None = None) -> dict:
+ body = None
+ if payload is not None:
+ body = json.dumps({**payload, "model": self.model}, allow_nan=False).encode()
+ req = Request(self.url + path, data=body, headers={"Content-Type": "application/json"},
+ method=method)
+ try:
+ with urlopen(req, timeout=self.timeout) as response:
+ raw = response.read()
+ except HTTPError as error:
+ detail = error.read(2048).decode(errors="replace")
+ raise BackendError(f"{method} {path}: HTTP {error.code}: {detail}") from error
+ except (URLError, TimeoutError, OSError, ValueError) as error:
+ raise BackendError(f"{method} {path}: {error}") from error
+ try:
+ result = json.loads(raw) if raw.strip() else {}
+ except ValueError as error:
+ raise BackendError(f"{method} {path}: response was not JSON: {str(error)}") from error
+ if not isinstance(result, dict) or "error" in result:
+ raise BackendError(f"{method} {path}: unexpected response: {str(result)[:500]}")
+ return result
+
+ def post(self, path: str, payload: dict) -> dict:
+ return self.request("POST", path, payload)
+
+ def get(self, path: str) -> dict:
+ return self.request("GET", path)
+
+ def list_models(self) -> list[str]:
+ """Model aliases known to the server (models dir + currently loaded)."""
+ data = self.get("/v1/models").get("data")
+ if not isinstance(data, list):
+ raise BackendError("/v1/models did not return a data list")
+ return sorted({entry["id"] for entry in data
+ if isinstance(entry, dict) and isinstance(entry.get("id"), str)})
+
+ def unload(self) -> None:
+ """Unload the selected model via the server's model-management API.
+
+ POST /models/unload with the model name in the body (llama-server's
+ router-mode model-management API). Raises on any non-success response; whether
+ that blocks a switch is the caller's decision (see switch).
+ """
+ result = self.post("/models/unload", {})
+ if result.get("success") is not True:
+ raise BackendError(f"/models/unload: unexpected response: {str(result)[:500]}")
+
+ def switch(self, model: str) -> dict:
+ """Select a different served model: unload the current one, lazy-load on first request.
+
+ The new model is validated against the server's own list BEFORE the current
+ model is unloaded, so a typo cannot leave the server with nothing loaded.
+ The server loads the new weights lazily, on the next request that names it.
+
+ Unloading the previous model is best-effort: a failed unload must not
+ block the selection, or the API would be stuck expecting a model the
+ caller no longer wants (recoverable only by scoring it). The old model
+ may stay resident until the server evicts it; that is reported as a
+ warning, not a refusal.
+ """
+ model = model.strip()
+ if not model:
+ raise ValueError("model must be a nonempty string")
+ if model == self.model:
+ return {"selected": model, "unchanged": True, "warning": None}
+ known = self.list_models()
+ if model not in known:
+ raise ValueError(f"model {model!r} is not served (known: {', '.join(known) or 'none'})")
+ warning = None
+ try:
+ self.unload()
+ except BackendError as error:
+ warning = f"previous model may still be loaded: {error}"
+ self.model = model
+ self.metadata = {"source": self.model, "backend": "llama", "url": self.url}
+ # Option-letter token IDs are model-specific; force re-probing on next score.
+ self._slots.clear()
+ return {"selected": model, "unchanged": False, "warning": warning}
+
+ def tokenize(self, text: str) -> list[int]:
+ ids = self.post("/tokenize", {
+ "content": text, "add_special": False, "parse_special": True,
+ }).get("tokens")
+ if not isinstance(ids, list) or not all(type(token) is int for token in ids):
+ raise BackendError("/tokenize did not return integer token IDs")
+ return ids
+
+ def score(self, row: dict) -> dict:
+ started = time.perf_counter()
+ messages = direct_messages(row) # upstream validation and decision format
+ prompt = self.post("/apply-template", {
+ "messages": messages, "add_generation_prompt": True,
+ "chat_template_kwargs": {"enable_thinking": False},
+ }).get("prompt")
+ if not isinstance(prompt, str) or not prompt:
+ raise BackendError("/apply-template did not return a nonempty prompt")
+ ids = self.tokenize(prompt)
+ if not ids or len(ids) > self.max_tokens:
+ raise ValueError(f"Prompt has {len(ids)} tokens; limit is {self.max_tokens}")
+ slots = []
+ for letter in LETTERS[:len(row["options"])]:
+ if letter not in self._slots:
+ encoded = self.tokenize(letter)
+ if len(encoded) != 1 or self.post("/detokenize", {"tokens": encoded}).get("content") != letter:
+ raise BackendError(f"Option letter {letter} is not one round-trip token")
+ self._slots[letter] = encoded[0]
+ token = self._slots[letter]
+ if self.tokenize(prompt + letter) != ids + [token]:
+ raise BackendError(f"Answer boundary changes tokenization for {letter}")
+ slots.append(token)
+ if len(set(slots)) != len(slots):
+ raise BackendError("Option token IDs collide")
+ forward_start = time.perf_counter()
+ n_probs = self.n_probs
+ attempts = 0
+ while True:
+ attempts += 1
+ response = self.post("/completion", {
+ "prompt": ids, "n_predict": 1, "n_probs": n_probs,
+ "post_sampling_probs": False, "temperature": 1.0,
+ "samplers": [], "seed": 0, "stream": False,
+ "cache_prompt": self.cache_prompt, "return_tokens": True,
+ })
+ if response.get("truncated"):
+ raise BackendError("llama-server truncated the prompt")
+ try:
+ selected = option_logprobs(response, slots)
+ break
+ except MissingOptions:
+ if n_probs >= self.max_n_probs:
+ raise
+ n_probs = min(n_probs * 4, self.max_n_probs)
+ forward_seconds = time.perf_counter() - forward_start
+ probabilities = softmax(selected)
+ option_ids = [option["id"] for option in row["options"]]
+ return {
+ "id": row["id"], "choice": option_ids[max(range(len(slots)), key=probabilities.__getitem__)],
+ "option_ids": option_ids, "probabilities": probabilities,
+ "option_logits": selected,
+ "option_logits_kind": "full-vocabulary log probabilities; logits up to an additive constant",
+ "input_tokens": len(ids), "forward_seconds": forward_seconds,
+ "total_seconds": time.perf_counter() - started,
+ "prompt_sha256": digest(prompt), "prompt_version": "direct-options-v1-gguf-template",
+ "model": self.metadata,
+ "readout": "pre-sampling next-token scores restricted to declared answer slots",
+ "probability_status": "conditional option score; uncalibrated as decision confidence",
+ "llama": {"timings": response.get("timings"), "tokens_cached": response.get("tokens_cached"),
+ "slot_id": response.get("id_slot"), "n_probs": n_probs, "attempts": attempts,
+ "cache_n": (response.get("timings") or {}).get("cache_n"),
+ "cache_prompt": self.cache_prompt},
+ }
+
+ def plan(self, plan_id: str, prompt: str, transcript: list[dict]) -> dict:
+ """Reasoning chat completion that derives environment rules from a transcript.
+
+ Unlike score(), this generates text: a regular /v1/chat/completions with
+ thinking enabled (enable_thinking template kwarg), so the model can reason
+ about how previous actions went wrong before committing to rules. The
+ caller (the /plan endpoint) supplies the game-specific instruction as the
+ user prompt; completed actions arrive as extra transcript turns. No
+ max_tokens is sent — it would cap the reasoning trace, not just the
+ reply, and the server applies its own generation limit. Sampling is
+ pinned to PLAN_SAMPLING (the model card's thinking-mode settings).
+ Returns the generated rules, the thinking trace when the server
+ surfaces one, and usage/timing. Raises ValueError on contract
+ violations before any HTTP.
+ """
+ started = time.perf_counter()
+ if not isinstance(plan_id, str) or not plan_id:
+ raise ValueError("plan id must be a nonempty string")
+ if not isinstance(prompt, str) or not prompt.strip():
+ raise ValueError("prompt must be a nonempty string")
+ messages = [{"role": "system", "content": PLAN_SYSTEM},
+ {"role": "user", "content": prompt}]
+ for index, turn in enumerate(transcript):
+ if not isinstance(turn, dict) or turn.get("role") not in PLAN_ROLES:
+ raise ValueError(f"transcript[{index}] needs role system|user|assistant")
+ if not isinstance(turn.get("content"), str) or not turn["content"].strip():
+ raise ValueError(f"transcript[{index}] needs nonempty content")
+ messages.append({"role": turn["role"], "content": turn["content"]})
+ response = self.post("/v1/chat/completions", {
+ "messages": messages,
+ "stream": False,
+ "chat_template_kwargs": {"enable_thinking": True},
+ **PLAN_SAMPLING,
+ })
+ choices = response.get("choices")
+ if not isinstance(choices, list) or len(choices) != 1:
+ raise BackendError("/v1/chat/completions: expected exactly one choice")
+ message = choices[0].get("message") if isinstance(choices[0], dict) else None
+ if not isinstance(message, dict):
+ raise BackendError("/v1/chat/completions: malformed message")
+ content = message.get("content")
+ if isinstance(content, list): # content-parts form: keep the text pieces
+ content = "".join(part.get("text", "") for part in content if isinstance(part, dict))
+ if not isinstance(content, str) or not content.strip():
+ raise BackendError("Planner produced no rules content")
+ reasoning = message.get("reasoning_content")
+ if not isinstance(reasoning, str) or not reasoning.strip():
+ reasoning = None # template/server may not split thinking out
+ usage = response.get("usage")
+ return {
+ "id": plan_id,
+ "rules": content,
+ "reasoning": reasoning,
+ "truncated": choices[0].get("finish_reason") == "length",
+ "model": self.metadata,
+ "usage": usage if isinstance(usage, dict) else {},
+ "total_seconds": time.perf_counter() - started,
+ "prompt_version": "plan-transcript-v1-chat-thinking",
+ }
+
+
+EXAMPLE = {
+ "id": "interrupt-1",
+ "state": "Alex is in a meeting. A production service is down and customers cannot sign in.",
+ "question": "Should this notification interrupt Alex now?",
+ "options": [
+ {"id": "interrupt", "description": "Interrupt now: urgent action is needed."},
+ {"id": "later", "description": "Queue for after the meeting."},
+ {"id": "ignore", "description": "No notification is necessary."},
+ ],
+}
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--url", default=os.environ.get("SEMIF_LLAMA_URL", "http://127.0.0.1:8080"))
+ parser.add_argument("--model", default=os.environ.get("SEMIF_LLAMA_MODEL", DEFAULT_MODEL))
+ parser.add_argument("--input", help="JSONL decisions, or - for stdin; omitted: built-in notification example")
+ parser.add_argument("--n-probs", type=int, default=1024)
+ parser.add_argument("--max-n-probs", type=int, default=16384)
+ parser.add_argument("--max-tokens", type=int, default=4096)
+ parser.add_argument("--timeout", type=float, default=180)
+ parser.add_argument("--no-cache", action="store_true")
+ args = parser.parse_args()
+ try:
+ backend = LlamaBackend(args.url, args.model, timeout=args.timeout, n_probs=args.n_probs,
+ max_n_probs=args.max_n_probs, max_tokens=args.max_tokens, cache_prompt=not args.no_cache)
+ if args.input:
+ if args.input == "-":
+ rows = [json.loads(line) for line in sys.stdin if line.strip()]
+ else:
+ with open(args.input) as source:
+ rows = [json.loads(line) for line in source if line.strip()]
+ else:
+ rows = [EXAMPLE]
+ for row in rows:
+ print(json.dumps(backend.score(row), allow_nan=False), flush=True)
+ except (BackendError, ValueError, OSError) as error:
+ parser.exit(1, f"semif llama probe: {error}\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/semif-api/src/semif_api/web/app.js b/semif-api/src/semif_api/web/app.js
@@ -0,0 +1,786 @@
+/* semif-api web UI — plain DOM, no dependencies.
+ *
+ * Conventions that matter:
+ * - Option rows and decision cards are UNCONTROLLED: the DOM is the source of
+ * truth and is read on submit. Nothing re-renders a list from state, so
+ * focus and caret survive typing.
+ * - Every string that comes from the API or from user input is written with
+ * textContent. innerHTML is never used with interpolated data.
+ */
+"use strict";
+
+const $ = (sel, root) => (root || document).querySelector(sel);
+const el = (tag, cls, text) => {
+ const node = document.createElement(tag);
+ if (cls) node.className = cls;
+ if (text !== undefined && text !== null) node.textContent = text;
+ return node;
+};
+const clear = (node) => { while (node.firstChild) node.removeChild(node.firstChild); };
+
+const MAX_OPTIONS = 16;
+const DRAFT_KEY = "semif-ui-draft-v1";
+
+/* ── numeric helpers ───────────────────────────────────────────────── */
+
+const argmax = (values) =>
+ values.reduce((best, value, i) => (value > values[best] ? i : best), 0);
+const p4 = (value) => (typeof value === "number" ? value.toFixed(4) : String(value));
+const p3 = (value) => (typeof value === "number" ? value.toFixed(3) : String(value));
+const ms = (seconds) =>
+ typeof seconds === "number" ? (seconds * 1000).toFixed(0) + " ms" : "—";
+
+/* ── HTTP ──────────────────────────────────────────────────────────── */
+
+async function api(path, body) {
+ const started = performance.now();
+ let response;
+ try {
+ response = await fetch(path, {
+ method: body ? "POST" : "GET",
+ headers: body ? { "Content-Type": "application/json" } : undefined,
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ } catch (networkError) {
+ return { ok: false, kind: "network", text:
+ "Could not reach " + location.origin + path + " — the server is down, or the request went to the wrong origin." };
+ }
+ const elapsedMs = performance.now() - started;
+ let payload = null;
+ const raw = await response.text();
+ try { payload = raw ? JSON.parse(raw) : null; } catch (_) { payload = null; }
+ if (!response.ok) return { ok: false, kind: response.status >= 500 ? "server" : "client",
+ status: response.status, payload: payload, raw: raw, elapsedMs: elapsedMs };
+ if (payload === null) return { ok: false, kind: "client", status: response.status,
+ text: "Response was not JSON (" + raw.slice(0, 200) + ")" };
+ return { ok: true, payload: payload, elapsedMs: elapsedMs };
+}
+
+/* FastAPI returns two different 422 shapes and both are part of the contract:
+ * a plain string from the upstream validate_row messages, and pydantic's list
+ * of {loc, msg} objects for malformed bodies. Handle both. */
+function errorText(outcome) {
+ if (outcome.text) return outcome.text;
+ const detail = outcome.payload && outcome.payload.detail;
+ if (typeof detail === "string") return detail;
+ if (Array.isArray(detail)) {
+ const lines = detail.map((item) => {
+ const loc = Array.isArray(item.loc) ? item.loc.filter((p) => p !== "body").join(".") : "";
+ return (loc ? loc + ": " : "") + (item.msg || JSON.stringify(item));
+ });
+ return "Request body rejected:\n" + lines.join("\n");
+ }
+ if (outcome.kind === "server") {
+ return "HTTP " + outcome.status + " from the server -- an unexpected fault, not a rejected " +
+ "input. Input problems come back as 422 with the upstream message; if a long state or " +
+ "duplicate batch ids landed here instead, the server predates that mapping. The reason is " +
+ "in the server log:\n" +
+ " journalctl -u semif-api -n 40 # systemd\n" +
+ " tail -n 40 uvicorn.log # dev shell";
+ }
+ return "HTTP " + outcome.status + (outcome.raw ? "\n" + outcome.raw.slice(0, 400) : "");
+}
+
+/* ── client-side mirror of upstream validate_row ───────────────────────
+ * Mirrors semif_phase1.core.validate_row so an obvious mistake is instant
+ * instead of a round trip. The server stays the authority: whatever passes
+ * here is still sent and its 422 is still displayed. */
+function validateRow(row, label) {
+ const problems = [];
+ if (!row.id) problems.push(label + ": id must be a nonempty string");
+ if (!row.question) problems.push(label + ": question must be a nonempty string");
+ const state = row.state;
+ const stateEmpty = typeof state === "string" ? !state : !(state && Object.keys(state).length);
+ if (stateEmpty) problems.push(label + ": state must be a nonempty string, object, or array");
+ const n = row.options.length;
+ if (n < 2 || n > MAX_OPTIONS) problems.push(label + ": options must contain 2-16 entries (have " + n + ")");
+ if (!row.options.every((o) => o.id && o.description)) problems.push(label + ": each option needs id and description");
+ const ids = row.options.map((o) => o.id);
+ if (new Set(ids).size !== ids.length) problems.push(label + ": option ids must be unique");
+ return problems;
+}
+
+/* Rough token guard: the browser cannot tokenize, so this is deliberately
+ * worded as an estimate. It exists because encode_prompt rejects an over-budget
+ * prompt before any scoring happens -- warning first saves a round trip and
+ * explains a rejection you would otherwise have to go find. */
+function stateWarning(chars, maxTokens) {
+ if (!maxTokens || !chars) return "";
+ const estimate = Math.round(chars / 3);
+ if (estimate > maxTokens * 0.8) {
+ return "~" + estimate + " estimated input tokens against a " + maxTokens +
+ " limit. Long states are rejected without truncation — trim the evidence or raise SEMIF_MAX_TOKENS.";
+ }
+ return "";
+}
+
+/* ── option rows (uncontrolled) ───────────────────────────────────── */
+
+function makeOptionRow(idValue, descValue) {
+ const row = el("div", "option-row");
+ const id = el("input", "mono opt-id");
+ id.value = idValue || "";
+ id.placeholder = "id";
+ id.setAttribute("aria-label", "option id");
+ const desc = el("input", "opt-desc");
+ desc.value = descValue || "";
+ desc.placeholder = "description — a complete, independent restatement of the option";
+ desc.setAttribute("aria-label", "option description");
+ const remove = el("button", "ghost remove-option", "×");
+ remove.type = "button";
+ remove.title = "Remove option";
+ remove.addEventListener("click", () => {
+ row.remove();
+ scheduleDraftSave();
+ });
+ row.append(id, desc, remove);
+ return row;
+}
+
+const optionHost = (form) => $(".options", form);
+const readOptions = (form) =>
+ Array.from(optionHost(form).children).map((row) => ({
+ id: $(".opt-id", row).value.trim(),
+ description: $(".opt-desc", row).value.trim(),
+ }));
+const fillOptions = (form, options) => {
+ const host = optionHost(form);
+ clear(host);
+ options.forEach((option) => host.appendChild(makeOptionRow(option.id, option.description)));
+};
+
+/* ── batch decision cards (also uncontrolled) ─────────────────────── */
+
+function makeDecisionCard(decision) {
+ const card = el("div", "decision");
+ const head = el("div", "decision-head");
+ const id = el("input", "mono d-id");
+ id.value = (decision && decision.id) || "";
+ id.placeholder = "decision id";
+ id.setAttribute("aria-label", "decision id");
+ const remove = el("button", "ghost remove-decision", "×");
+ remove.type = "button";
+ remove.title = "Remove decision";
+ remove.addEventListener("click", () => { card.remove(); scheduleDraftSave(); });
+ head.append(el("span", "decision-n", "id"), id, remove);
+
+ const question = el("input", "d-question");
+ question.value = (decision && decision.question) || "";
+ question.placeholder = "One criterion for this state";
+ question.setAttribute("aria-label", "question");
+
+ const options = el("div", "options");
+ const add = el("button", "ghost add-decision-option", "+ option");
+ add.type = "button";
+ add.addEventListener("click", () => options.appendChild(makeOptionRow("", "")));
+ const optHead = el("div", "field-head", "");
+ optHead.append(el("label", null, "options"), add);
+ (decision ? decision.options : [{ id: "", description: "" }]).forEach((option) =>
+ options.appendChild(makeOptionRow(option.id, option.description)));
+
+ card.append(head, question, optHead, options);
+ return card;
+}
+
+const readDecisions = () =>
+ Array.from($("#b-decisions").children).map((card) => ({
+ id: $(".d-id", card).value.trim(),
+ question: $(".d-question", card).value.trim(),
+ options: Array.from($(".options", card).children).map((row) => ({
+ id: $(".opt-id", row).value.trim(),
+ description: $(".opt-desc", row).value.trim(),
+ })),
+ }));
+
+/* ── state field: text or JSON ─────────────────────────────────────── */
+
+const stateMode = (name) => $('input[name="' + name + '"]:checked').value;
+
+/* Radio restore tolerates an unknown stored value by leaving the default. */
+function checkStateMode(name, value) {
+ const radio = $('input[name="' + name + '"][value="' + value + '"]');
+ if (radio) radio.checked = true;
+}
+
+function readState(textarea, modeName, noteNode) {
+ const raw = textarea.value;
+ noteNode.textContent = stateWarning(raw.length, healthInfo.max_tokens);
+ noteNode.classList.toggle("warn", Boolean(noteNode.textContent));
+ if (stateMode(modeName) === "text") return { value: raw };
+ if (!raw.trim()) return { value: raw };
+ try {
+ const parsed = JSON.parse(raw);
+ if (typeof parsed === "string" || parsed === null || typeof parsed !== "object") {
+ return { error: "JSON mode expects an object or array (or switch back to text mode)." };
+ }
+ return { value: parsed };
+ } catch (error) {
+ return { error: "state is not valid JSON: " + error.message };
+ }
+}
+
+/* ── request bodies from the DOM ───────────────────────────────────── */
+
+function singleBody() {
+ const form = $("#panel-single");
+ const state = readState($("#s-state"), "s-state-mode", $("#s-state-note"));
+ if (state.error) return { error: state.error };
+ const row = {
+ id: $("#s-id").value.trim(),
+ state: state.value,
+ question: $("#s-question").value.trim(),
+ options: readOptions(form),
+ };
+ const problems = validateRow(row, "decision");
+ return problems.length ? { error: problems.join("\n") } : { body: row };
+}
+
+function batchBody() {
+ const state = readState($("#b-state"), "b-state-mode", $("#b-state-note"));
+ if (state.error) return { error: state.error };
+ const decisions = readDecisions();
+ const problems = [];
+ if (!decisions.length) problems.push("batch needs at least one decision");
+ const batchIds = decisions.map((d) => d.id);
+ if (new Set(batchIds).size !== batchIds.length) problems.push("Decision IDs must be unique (batch-level)");
+ decisions.forEach((decision, index) =>
+ problems.push(...validateRow({ ...decision, state: state.value }, "decision " + (index + 1))));
+ if (problems.length) return { error: [...new Set(problems)].join("\n") };
+ return { body: { state: state.value, decisions: decisions } };
+}
+
+/* ── result rendering ─────────────────────────────────────────────── */
+
+function optionTable(optionIds, probabilities, logits) {
+ const order = optionIds.map((_, i) => i).sort((a, b) => probabilities[b] - probabilities[a]);
+ const winner = order[0];
+ const table = el("table", "probs");
+ const head = el("tr");
+ ["option", "score", "logit"].forEach((label) => head.appendChild(el("th", null, label)));
+ table.appendChild(head);
+ order.forEach((i) => {
+ const tr = el("tr", i === winner ? "is-winner" : null);
+ const nameCell = el("td");
+ const bar = el("span", "bar");
+ bar.style.setProperty("--w", (probabilities[i] * 100).toFixed(2) + "%");
+ nameCell.append(bar, el("code", null, optionIds[i]));
+ tr.append(nameCell, el("td", "num", p4(probabilities[i])), el("td", "num dim", p3(logits[i])));
+ table.appendChild(tr);
+ });
+ return table;
+}
+
+function metaGrid(entries) {
+ const grid = el("dl", "meta");
+ entries.forEach(([key, value, title]) => {
+ grid.appendChild(el("dt", null, key));
+ const dd = el("dd", "mono", value);
+ if (title) dd.title = title;
+ grid.appendChild(dd);
+ });
+ return grid;
+}
+
+function renderResult(result, host, extra) {
+ const winner = argmax(result.probabilities);
+ const card = el("div", "result-card");
+
+ const headline = el("div", "headline");
+ headline.append(
+ el("span", "chosen mono", result.option_ids[winner]),
+ el("span", "score mono", "p = " + p4(result.probabilities[winner])));
+ if (result.id !== undefined) headline.appendChild(el("span", "rid mono", result.id));
+ card.appendChild(headline);
+ card.appendChild(optionTable(result.option_ids, result.probabilities, result.option_logits));
+
+ const model = result.model || {};
+ const entries = [
+ ["input_tokens", String(result.input_tokens)],
+ ["prompt_sha256", String(result.prompt_sha256).slice(0, 16) + "…", String(result.prompt_sha256)],
+ ["prompt_version", String(result.prompt_version)],
+ ["readout", String(result.readout)],
+ ["model", model.source + (model.revision ? " @" + String(model.revision).slice(0, 12) : "")],
+ ["backend", model.backend || "torch"],
+ ["dtype / torch", (model.dtype || "—") + " / " + (model.torch_version || "—")],
+ ];
+ if (result.llama) {
+ entries.push(["cached prompt tokens reused", String(result.llama.cache_n ?? "—")]);
+ entries.push(["score retrieval attempts", String(result.llama.attempts ?? 1)]);
+ }
+ if (model.serving_config) entries.push(["serving_config", model.serving_config]);
+ if (extra) entries.push(...extra);
+ card.appendChild(metaGrid(entries));
+
+ card.appendChild(el("p", "disclaimer", String(result.probability_status) +
+ " — ranking and coarse thresholds only."));
+ host.appendChild(card);
+}
+
+function renderTiming(timing, host) {
+ const table = el("table", "probs timing");
+ Object.keys(timing).forEach((key) => {
+ const tr = el("tr");
+ const value = timing[key];
+ tr.append(el("td", "dim", key),
+ el("td", "num", typeof value === "number"
+ ? (key.endsWith("seconds") ? value.toFixed(4) : String(value))
+ : String(value)));
+ table.appendChild(tr);
+ });
+ host.appendChild(el("h3", "section-title", "batch timing"));
+ host.appendChild(table);
+}
+
+/* ── submit flow ──────────────────────────────────────────────────── */
+
+let inFlight = false;
+
+function setPending(active, text) {
+ $("#idle").hidden = true;
+ $("#error").hidden = true;
+ $("#result").hidden = true;
+ $("#pending").hidden = !active;
+ if (text) $("#pending-text").textContent = text;
+}
+
+function showError(text) {
+ $("#idle").hidden = true;
+ $("#pending").hidden = true;
+ $("#result").hidden = true;
+ const box = $("#error");
+ clear(box);
+ box.hidden = false;
+ box.appendChild(el("h3", null, "Rejected"));
+ const pre = el("pre", null, text);
+ box.appendChild(pre);
+}
+
+function showResult(hostBuilder) {
+ $("#idle").hidden = true;
+ $("#pending").hidden = true;
+ $("#error").hidden = true;
+ const host = $("#result");
+ clear(host);
+ host.hidden = false;
+ hostBuilder(host);
+}
+
+async function submitSingle(event) {
+ event.preventDefault();
+ if (inFlight) return;
+ const built = singleBody();
+ if (built.error) return showError(built.error);
+ inFlight = true;
+ $("#s-submit").disabled = true;
+ setPending(true, "scoring one decision…");
+ const outcome = await api("/decide", built.body);
+ $("#s-submit").disabled = false;
+ inFlight = false;
+ if (!outcome.ok) return showError(errorText(outcome));
+ showResult((host) => {
+ host.appendChild(el("h2", "section-title",
+ "browser " + outcome.elapsedMs.toFixed(0) + " ms · server total " + ms(outcome.payload.total_seconds) +
+ " · forward " + ms(outcome.payload.forward_seconds)));
+ renderResult(outcome.payload, host, [["browser wall clock", outcome.elapsedMs.toFixed(0) + " ms"]]);
+ host.appendChild(rawJSON(built.body, outcome.payload));
+ });
+}
+
+async function submitBatch(event) {
+ event.preventDefault();
+ if (inFlight) return;
+ const built = batchBody();
+ if (built.error) return showError(built.error);
+ inFlight = true;
+ $("#b-submit").disabled = true;
+ setPending(true, built.body.decisions.length + " decisions against one state…");
+ const outcome = await api("/decide-batch", built.body);
+ $("#b-submit").disabled = false;
+ inFlight = false;
+ if (!outcome.ok) return showError(errorText(outcome));
+ const timing = outcome.payload.timing || {};
+ showResult((host) => {
+ host.appendChild(el("h2", "section-title",
+ "browser " + outcome.elapsedMs.toFixed(0) + " ms · server total " + ms(timing.total_seconds) +
+ " · " + (timing.batch_size || "?") + " decisions · " + (timing.mode || "torch shared")));
+ renderTiming(timing, host);
+ (outcome.payload.results || []).forEach((result) => renderResult(result, host));
+ host.appendChild(rawJSON(built.body, outcome.payload));
+ });
+}
+
+function rawJSON(request, response) {
+ const details = el("details", "raw");
+ details.appendChild(el("summary", null, "raw request / response"));
+ details.appendChild(el("pre", null,
+ "→ " + JSON.stringify(request, null, 2) + "\n\n← " + JSON.stringify(response, null, 2)));
+ return details;
+}
+
+/* ── curl export (matches docs/usage.md so a UI finding becomes a bug report) */
+
+function copyCurl(path, built) {
+ if (built.error) return showError(built.error);
+ const body = JSON.stringify(built.body, null, 2).replace(/'/g, "'\\''");
+ const command = "curl -s -X POST " + location.origin + path +
+ " \\\n -H 'Content-Type: application/json' \\\n -d '" + body + "'";
+ copyText(command, "curl command copied");
+}
+
+function copyText(text, confirmation) {
+ const done = () => flash(confirmation);
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, done));
+ } else {
+ fallbackCopy(text, done);
+ }
+}
+
+function fallbackCopy(text, done) {
+ const area = el("textarea", "sr-only");
+ area.value = text;
+ document.body.appendChild(area);
+ area.select();
+ try { document.execCommand("copy"); done(); } catch (_) { showError("Copy failed; select the text manually."); }
+ area.remove();
+}
+
+function flash(message) {
+ const box = $("#result");
+ $("#idle").hidden = true;
+ $("#pending").hidden = true;
+ $("#error").hidden = true;
+ box.hidden = false;
+ clear(box);
+ box.appendChild(el("p", "flash", message));
+}
+
+/* ── health strip ─────────────────────────────────────────────────── */
+
+const healthInfo = { max_tokens: 0 };
+
+async function checkHealth() {
+ const dot = $("#health-dot");
+ const text = $("#health-text");
+ dot.className = "dot pending";
+ text.textContent = "checking…";
+ const outcome = await api("/healthz");
+ if (!outcome.ok) {
+ dot.className = "dot down";
+ text.textContent = "unreachable — " + errorText(outcome).split("\n")[0];
+ $("#model-picker").hidden = true;
+ setModelNote("");
+ return;
+ }
+ const model = outcome.payload.model || {};
+ healthInfo.max_tokens = outcome.payload.max_tokens || 0;
+ dot.className = "dot up";
+ clear(text);
+ const isLlama = outcome.payload.backend === "llama";
+ text.append(
+ el("span", null, isLlama ? "API ready · llama · " : "warm · torch · "),
+ el("code", null, String(model.source)),
+ el("span", null, " @ "),
+ el("code", null, String(model.revision || "").slice(0, 12)),
+ el("span", null, " · max_tokens "),
+ el("code", null, String(outcome.payload.max_tokens)),
+ el("span", null, " · "),
+ el("code", null, String(model.dtype || "—") + " / " + String(model.torch_version || "—")));
+ if (isLlama && outcome.payload.model_state === "pending") {
+ text.appendChild(el("span", "warn-text", " · selected model loads on first request"));
+ }
+ $("#foot-max-tokens").textContent = "max_tokens " + outcome.payload.max_tokens;
+ if (isLlama) loadModelPicker();
+ else { $("#model-picker").hidden = true; setModelNote(""); }
+}
+
+/* ── model picker (llama backend only; torch has no runtime switching) ──
+ * The dropdown lists the model aliases the llama-server knows about (its
+ * models dir plus whatever is loaded). Selecting one POSTs /models: the
+ * server-side switch unloads the current weights and the new model loads
+ * lazily, on the next scored request. Runtime state only — nothing here
+ * survives a restart of either process. */
+
+let modelSwitching = false;
+
+function setModelNote(message, kind) {
+ const note = $("#model-note");
+ note.textContent = message;
+ note.classList.toggle("is-error", kind === "error");
+ note.classList.toggle("is-warn", kind === "warn");
+}
+
+async function loadModelPicker() {
+ const picker = $("#model-picker");
+ if (modelSwitching) return; // don't clobber a switch that is in flight
+ const outcome = await api("/models");
+ if (!outcome.ok) { picker.hidden = true; return; }
+ const payload = outcome.payload;
+ if (!payload.switching) { picker.hidden = true; return; }
+ const previous = picker.dataset.current || payload.current;
+ clear(picker);
+ (payload.models || []).forEach((id) => {
+ const option = el("option", null, id);
+ option.value = id;
+ picker.appendChild(option);
+ });
+ picker.dataset.current = payload.current;
+ picker.value = (payload.models || []).includes(previous) ? previous : payload.current;
+ picker.hidden = false;
+ if (payload.model_state === "pending") {
+ setModelNote(payload.current + " selected — loads on first request");
+ } else if (picker.value === payload.current) {
+ setModelNote("");
+ }
+}
+
+async function selectModel(model) {
+ const picker = $("#model-picker");
+ if (!model || model === picker.dataset.current) return;
+ const previous = picker.dataset.current;
+ modelSwitching = true;
+ picker.disabled = true;
+ setModelNote("switching to " + model + " — unloading the current model…");
+ const outcome = await api("/models", { model: model });
+ picker.disabled = false;
+ modelSwitching = false;
+ if (!outcome.ok) {
+ picker.value = previous;
+ setModelNote("switch failed — " + errorText(outcome).split("\n")[0], "error");
+ return;
+ }
+ picker.dataset.current = model;
+ if (outcome.payload.warning) {
+ // Selection succeeded; only freeing the old weights failed. Not an error:
+ // the new model still loads on first request, the old one stays resident.
+ setModelNote(model + " selected — loads on first request · ⚠ " + outcome.payload.warning, "warn");
+ } else {
+ setModelNote(outcome.payload.model_state === "pending"
+ ? model + " selected — loads on first request"
+ : model + " selected");
+ }
+ checkHealth();
+}
+
+/* ── examples (from docs/usage.md) ─────────────────────────────────── */
+
+const PRESETS = [
+ {
+ name: "support triage",
+ mode: "single",
+ row: {
+ id: "ticket-1042",
+ state: "Customer asks to reset a forgotten password and says the reset email never arrived.",
+ question: "Which queue should handle this request?",
+ options: [
+ { id: "account_access", description: "Account access and authentication support." },
+ { id: "billing", description: "Billing and payment support." },
+ { id: "sales", description: "Sales and product evaluation." },
+ ],
+ },
+ },
+ {
+ name: "deploy gate",
+ mode: "single",
+ row: {
+ id: "deploy-gate",
+ state: "pytest 214 passed, 0 failed, 3 skipped in 41.2s\ncoverage: 88%\nlint: no issues found",
+ question: "Does this test output provide evidence that the suite passed with no failures?",
+ options: [
+ { id: "pass", description: "All tests passed." },
+ { id: "fail", description: "One or more tests failed." },
+ { id: "insufficient", description: "The output does not clearly show pass or fail." },
+ ],
+ },
+ },
+ {
+ name: "incident review (batch)",
+ mode: "batch",
+ state: "Postmortem: API latency spiked from 14:02 to 14:40 UTC after a config push removed the " +
+ "rate-limit cache key. Error rate stayed below 0.1%. No customer data was affected. " +
+ "Rollback completed at 14:40 UTC.",
+ decisions: [
+ { id: "customer-impact", question: "Was there customer-visible impact?", options: [
+ { id: "yes", description: "Customers were affected." },
+ { id: "no", description: "No customer-visible impact." },
+ { id: "insufficient", description: "Cannot be determined from the evidence." }] },
+ { id: "action-required", question: "Does the postmortem identify a concrete follow-up action?", options: [
+ { id: "yes", description: "A follow-up action is identified." },
+ { id: "no", description: "No follow-up action is identified." }] },
+ { id: "sev", question: "What severity best fits this incident per the described scope?", options: [
+ { id: "sev1", description: "Critical: data loss or outage." },
+ { id: "sev2", description: "Major: degraded functionality with partial customer impact." },
+ { id: "sev3", description: "Minor: brief internal degradation, no customer impact." }] },
+ ],
+ },
+];
+
+function loadPreset(preset) {
+ switchMode(preset.mode);
+ if (preset.mode === "single") {
+ $("#s-id").value = preset.row.id;
+ $("#s-state").value = preset.row.state;
+ $("#s-question").value = preset.row.question;
+ fillOptions($("#panel-single"), preset.row.options);
+ } else {
+ $("#b-state").value = preset.state;
+ clear($("#b-decisions"));
+ preset.decisions.forEach((decision) => $("#b-decisions").appendChild(makeDecisionCard(decision)));
+ }
+ $("#s-state-note").textContent = "";
+ $("#b-state-note").textContent = "";
+ saveDraft();
+}
+
+/* ── draft persistence ────────────────────────────────────────────── */
+
+let draftTimer = null;
+function scheduleDraftSave() {
+ clearTimeout(draftTimer);
+ draftTimer = setTimeout(saveDraft, 400);
+}
+
+function saveDraft() {
+ const draft = {
+ mode: currentMode,
+ single: {
+ id: $("#s-id").value,
+ state: $("#s-state").value,
+ stateMode: stateMode("s-state-mode"),
+ question: $("#s-question").value,
+ options: readOptions($("#panel-single")),
+ },
+ batch: {
+ state: $("#b-state").value,
+ stateMode: stateMode("b-state-mode"),
+ decisions: readDecisions(),
+ },
+ };
+ try { localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); } catch (_) { /* quota / private mode */ }
+}
+
+function restoreDraft() {
+ let draft = null;
+ try {
+ draft = JSON.parse(localStorage.getItem(DRAFT_KEY) || "null");
+ // A draft from an incompatible shape must not blank the page.
+ if (draft && (!draft.single || !draft.batch || !Array.isArray(draft.single.options))) draft = null;
+ } catch (_) { draft = null; }
+ if (!draft) {
+ fillOptions($("#panel-single"), [
+ { id: "yes", description: "" },
+ { id: "no", description: "" },
+ { id: "insufficient", description: "The evidence is insufficient to decide." },
+ ]);
+ $("#b-decisions").appendChild(makeDecisionCard({ id: "check-1", options: [
+ { id: "yes", description: "" }, { id: "no", description: "" }] }));
+ return;
+ }
+ $("#s-id").value = draft.single.id || "";
+ $("#s-state").value = draft.single.state || "";
+ $("#s-question").value = draft.single.question || "";
+ checkStateMode("s-state-mode", draft.single.stateMode);
+ fillOptions($("#panel-single"), draft.single.options.length ? draft.single.options
+ : [{ id: "", description: "" }, { id: "", description: "" }]);
+ $("#b-state").value = draft.batch.state || "";
+ checkStateMode("b-state-mode", draft.batch.stateMode);
+ clear($("#b-decisions"));
+ (draft.batch.decisions.length ? draft.batch.decisions : [{ id: "", question: "", options: [] }])
+ .forEach((decision) => $("#b-decisions").appendChild(makeDecisionCard(decision)));
+ switchMode(draft.mode === "batch" ? "batch" : "single");
+}
+
+/* ── mode switching ───────────────────────────────────────────────── */
+
+let currentMode = "single";
+
+function switchMode(mode) {
+ currentMode = mode;
+ const batch = mode === "batch";
+ const game = mode === "game";
+ const self = mode === "self";
+ const room = mode === "room";
+ const roomself = mode === "roomself";
+ const demo = game || self || room || roomself;
+ // Single & batch share the request/response columns; the demos replace them.
+ $("#request").hidden = demo;
+ $("#response").hidden = demo;
+ $("#panel-game").hidden = !game;
+ $("#panel-self").hidden = !self;
+ $("#panel-room").hidden = !room;
+ $("#panel-roomself").hidden = !roomself;
+ $("#panel-single").hidden = batch || demo;
+ $("#panel-batch").hidden = !batch;
+ $("#tab-single").classList.toggle("is-active", mode === "single");
+ $("#tab-batch").classList.toggle("is-active", batch);
+ $("#tab-game").classList.toggle("is-active", game);
+ $("#tab-self").classList.toggle("is-active", self);
+ $("#tab-room").classList.toggle("is-active", room);
+ $("#tab-roomself").classList.toggle("is-active", roomself);
+ $("#tab-single").setAttribute("aria-selected", String(mode === "single"));
+ $("#tab-batch").setAttribute("aria-selected", String(batch));
+ $("#tab-game").setAttribute("aria-selected", String(game));
+ $("#tab-self").setAttribute("aria-selected", String(self));
+ $("#tab-room").setAttribute("aria-selected", String(room));
+ $("#tab-roomself").setAttribute("aria-selected", String(roomself));
+}
+
+/* ── wiring ───────────────────────────────────────────────────────── */
+
+function init() {
+ $("#panel-single").addEventListener("submit", submitSingle);
+ $("#panel-batch").addEventListener("submit", submitBatch);
+ $("#tab-single").addEventListener("click", () => { switchMode("single"); saveDraft(); });
+ $("#tab-batch").addEventListener("click", () => { switchMode("batch"); saveDraft(); });
+ $("#tab-game").addEventListener("click", () => switchMode("game"));
+ $("#tab-self").addEventListener("click", () => switchMode("self"));
+ $("#tab-room").addEventListener("click", () => switchMode("room"));
+ $("#tab-roomself").addEventListener("click", () => switchMode("roomself"));
+ $("#health-refresh").addEventListener("click", checkHealth);
+ $("#model-picker").addEventListener("change", (event) => selectModel(event.target.value));
+
+ $(".add-option", $("#panel-single")).addEventListener("click", (event) => {
+ const host = optionHost($("#panel-single"));
+ if (host.children.length >= MAX_OPTIONS) return showError("At most " + MAX_OPTIONS + " options.");
+ host.appendChild(makeOptionRow("", ""));
+ scheduleDraftSave();
+ });
+ $("#b-add-decision").addEventListener("click", () => {
+ $("#b-decisions").appendChild(makeDecisionCard({ options: [
+ { id: "", description: "" }, { id: "", description: "" }] }));
+ scheduleDraftSave();
+ });
+
+ $("#s-curl").addEventListener("click", () => copyCurl("/decide", singleBody()));
+ $("#b-curl").addEventListener("click", () => copyCurl("/decide-batch", batchBody()));
+ $("#s-clear").addEventListener("click", () => {
+ $("#s-state").value = ""; $("#s-question").value = ""; $("#s-id").value = "adhoc";
+ fillOptions($("#panel-single"), [{ id: "", description: "" }, { id: "", description: "" }]);
+ saveDraft();
+ });
+ $("#b-clear").addEventListener("click", () => {
+ $("#b-state").value = "";
+ clear($("#b-decisions"));
+ $("#b-decisions").appendChild(makeDecisionCard({ options: [
+ { id: "", description: "" }, { id: "", description: "" }] }));
+ saveDraft();
+ });
+
+ const presetHost = $("#preset-buttons");
+ PRESETS.forEach((preset) => {
+ const button = el("button", "preset", preset.name);
+ button.type = "button";
+ button.addEventListener("click", () => loadPreset(preset));
+ presetHost.appendChild(button);
+ });
+
+ document.addEventListener("input", scheduleDraftSave);
+ restoreDraft();
+ if (location.hash === "#game") switchMode("game"); // deep-link the demo tabs
+ else if (location.hash === "#self") switchMode("self");
+ else if (location.hash === "#room") switchMode("room");
+ else if (location.hash === "#roomself") switchMode("roomself");
+ checkHealth();
+}
+
+document.addEventListener("DOMContentLoaded", init);
diff --git a/semif-api/src/semif_api/web/game-rules.js b/semif-api/src/semif_api/web/game-rules.js
@@ -0,0 +1,221 @@
+"use strict";
+
+// Shared by the browser and offline tests: prompt facts come from the physics.
+const GameRules = (() => {
+ const W = 68, H = 8, GROUND = 7;
+ const GOAL = 64, START_X = 3;
+ const RUN_VX = 1, JUMP_VY = -2.3, GRAV = 0.9;
+
+ // Level shape. Width and count are deliberately fixed; only pit *positions*
+ // are randomisable. A 3-wide pit is what the jump arc clears with two safe
+ // takeoff tiles (the edge, and one back), and 3 pits => MAX_JUMPS 4. Changing
+ // width or count would change the difficulty class and the solvability proof,
+ // so the generator never does it. Positions vary within boundaries that keep
+ // the standard "jump at the edge" strategy a guaranteed solve.
+ const PIT_W = 3; // every pit is exactly PIT_W tiles wide
+ const MIN_FIRST = START_X + 3; // earliest left edge (runway after the start)
+ const STEP = PIT_W + 3; // min left-edge gap between pits (>=3 solid tiles)
+ const MAX_LAST = GOAL - 6; // latest left edge (landing + runway before the flag)
+
+ const DEFAULT_PITS = [[14, 16], [31, 33], [48, 50]]; // seed-free baseline the UI boots on
+ let pits = DEFAULT_PITS.map(([a, b]) => [a, b]); // current mutable layout
+
+ const floorAt = (col) => !pits.some(([a, b]) => col >= a && col <= b);
+
+ // mulberry32: small, deterministic, good enough for shuffling 3 positions.
+ function rng(seed) {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6D2B79F5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+ }
+ const randInt = (r, lo, hi) => lo + Math.floor(r() * (hi - lo + 1)); // inclusive
+
+ // Three width-PIT_W pits at seeded positions, always valid by construction:
+ // draw the first left edge, then each next edge at least STEP ahead, clamped
+ // so the last edge can never exceed MAX_LAST.
+ function makePits(seed) {
+ const r = rng(seed);
+ const a1 = randInt(r, MIN_FIRST, MAX_LAST - 2 * STEP);
+ const a2 = randInt(r, a1 + STEP, MAX_LAST - STEP);
+ const a3 = randInt(r, a2 + STEP, MAX_LAST);
+ return [a1, a2, a3].map((a) => [a, a + PIT_W - 1]);
+ }
+
+ // Returns human-readable problems; an empty array means the layout is valid.
+ function validatePits(p) {
+ const errs = [];
+ if (p.length !== 3) errs.push("exactly 3 pits required");
+ let prev = null;
+ for (const [a, b] of p) {
+ if (b - a + 1 !== PIT_W) errs.push(`pit ${a}-${b} must be ${PIT_W} tiles wide`);
+ if (a <= START_X) errs.push(`pit at ${a} is on or behind the start`);
+ if (b >= GOAL) errs.push(`pit at ${b} covers the goal`);
+ if (prev !== null && a - prev < STEP) errs.push(`pits at ${prev} and ${a} are too close`);
+ prev = a;
+ }
+ if (p.length && p[0][0] < MIN_FIRST) errs.push(`first pit left of ${MIN_FIRST}`);
+ if (p.length && p[p.length - 1][0] > MAX_LAST) errs.push(`last pit right of ${MAX_LAST}`);
+ return errs;
+ }
+
+ // Swaps the live layout (floorAt, the prompts and the renderer all read it).
+ function setPits(next) {
+ const errs = validatePits(next);
+ if (errs.length) throw new Error("Invalid pit layout: " + errs.join("; "));
+ pits = next.map(([a, b]) => [a, b]);
+ return pits;
+ }
+
+ function airborneStep(y, vy) {
+ vy += GRAV;
+ return { y: y + vy, vy };
+ }
+
+ function jumpTicks() {
+ let y = GROUND, vy = JUMP_VY, ticks = 0;
+ do {
+ ({ y, vy } = airborneStep(y, vy));
+ ticks++;
+ } while (y < GROUND);
+ return ticks;
+ }
+ const JUMP_TICKS = jumpTicks();
+ const JUMP_DISTANCE = JUMP_TICKS * RUN_VX;
+
+ // Returns a new state. Once below the surface, moving under a solid tile
+ // cannot teleport the player back onto it.
+ function advance(player, moveRight) {
+ const next = { ...player };
+ if (!next.onGround || moveRight) next.x += RUN_VX;
+ if (next.onGround && !floorAt(Math.floor(next.x))) {
+ next.onGround = false;
+ next.vy = 0;
+ }
+ if (!next.onGround) {
+ const previousY = next.y;
+ Object.assign(next, airborneStep(next.y, next.vy));
+ if (next.vy > 0 && previousY <= GROUND && next.y >= GROUND && floorAt(Math.floor(next.x))) {
+ next.y = GROUND;
+ next.vy = 0;
+ next.onGround = true;
+ }
+ }
+ return { player: next, fell: next.y > H + 2 };
+ }
+
+ function guidedJump(player, jumpsLeft) {
+ return player.onGround && jumpsLeft > 0 && !floorAt(Math.floor(player.x + RUN_VX));
+ }
+
+ // Whole level as one row of symbols: - floor, # pit, * you, ! flag.
+ // Cells are joined with spaces so every glyph is its own token — without
+ // them BPE merges runs like "------" into few meaningless tokens and the
+ // model's failures are perception (tokenization) errors, not reasoning ones.
+ // Still harder than prose: the model must decode the symbols and locate
+ // itself, the pits and the flag on the row before deciding.
+ function asciiText(player, jumpsLeft) {
+ const here = Math.floor(player.x);
+ const row = [];
+ for (let x = 0; x <= GOAL; x++) {
+ if (x === here) row.push("*");
+ else if (x === GOAL) row.push("!");
+ else row.push(floorAt(x) ? "-" : "#");
+ }
+ return [
+ "Reach the flag without falling into a pit.",
+ "Legend: [`*`: player, `-`: safe floor, `#`: fail pit, `!`: goal flag]",
+ "Run moves the `*` one space forward",
+ "If the next space is a pit, run will make you fail.",
+ "Jump at the edge of a pit to cross it.",
+ `Jumps remaining: ${jumpsLeft}`,
+ "",
+ row.join(" "),
+ ].join("\n");
+ }
+
+ // Run-length symbolic: terrain ahead of the player as run-length segments.
+ // Easier to parse than the raw ASCII row (no counting columns) but still
+ // symbolic, so the model must read the legend and act on the next segment.
+ function runLengthText(player, jumpsLeft) {
+ const terrain = [];
+ for (let x = player.x + 1; x < GOAL; x++) {
+ const kind = floorAt(x) ? "ground" : "hole";
+ const last = terrain[terrain.length - 1];
+ if (last && last.kind === kind) last.count++;
+ else terrain.push({ kind, count: 1 });
+ }
+ const segs = terrain.map(({ kind, count }) => `${kind === "ground" ? "-" : "#"}${count}`);
+ segs.push("[!]");
+ return [
+ "Reach the flag without falling into a pit.",
+ "Legend: [`*`: player, `>`: facing right, `-N`: run of N safe floor, `#N`: run of N fail pit, `!`: goal flag]",
+ "Run moves the `[*]` one space forward.",
+ "If the next segment is a pit, run will make you fail.",
+ "Jump at the edge of a pit to cross it.",
+ `Jumps remaining: ${jumpsLeft}`,
+ "",
+ `[*] > ${segs.join(" | ")}`,
+ ].join("\n");
+ }
+
+ // mode: "guided" | "unguided" | "runlength" | "ascii". Booleans are accepted
+ // for callers that predate the newer modes: true => guided, false => unguided.
+ function stateText(player, jumpsLeft, mode = "unguided") {
+ const m = mode === true ? "guided" : mode === false ? "unguided" : mode;
+ if (m === "ascii") return asciiText(player, jumpsLeft);
+ if (m === "runlength") return runLengthText(player, jumpsLeft);
+ const guided = m === "guided";
+ // Describe destination spaces, starting one move ahead. The flag occupies
+ // its own ground space; don't count it twice in the preceding ground run.
+ const terrain = [];
+ for (let x = player.x + 1; x < GOAL; x++) {
+ const kind = floorAt(x) ? "ground" : "hole";
+ const last = terrain[terrain.length - 1];
+ if (last && last.kind === kind) last.count++;
+ else terrain.push({ kind, count: 1 });
+ }
+ const lines = [
+ "Reach the flag without falling into a pit.",
+ "Run moves you one space forward.",
+ "If the next space is a hole, running makes you fall.",
+ "Jump at the edge of a pit to cross it.",
+ "You can act again after running or landing.",
+ "",
+ `Player: ${player.onGround ? "standing on ground" : "airborne"}, facing right`,
+ `Jumps remaining: ${jumpsLeft}`,
+ "",
+ // When the flag is the very next space, the enumeration would be an
+ // empty list under a header — say the useful thing instead.
+ player.x + 1 === GOAL ? "The flag is right in front of you!"
+ : "Ahead, from nearest to farthest (starting with the next space):",
+ ...terrain.map(({ kind, count }) => `${count} ${kind} space${count === 1 ? "" : "s"}`),
+ player.x < GOAL ? "" : "Flag reached",
+
+ ];
+ if (guided) {
+ lines.push(!player.onGround ? "Hint: wait for the current jump to finish."
+ : guidedJump(player, jumpsLeft) ? "Hint: jump now; the next running tick would enter a pit."
+ : "Hint: run for one tick, then reassess.");
+ }
+ return lines.join("\n");
+ }
+
+ const QUESTION = "What should the player do now?";
+ const OPTIONS = [
+ { id: "run", description: "Run" },
+ { id: "jump", description: "Jump" },
+ ];
+ return { W, H, GROUND,
+ get PITS() { return pits; },
+ get MAX_JUMPS() { return pits.length + 1; },
+ GOAL, START_X, RUN_VX, JUMP_VY, GRAV,
+ JUMP_TICKS, JUMP_DISTANCE, floorAt, advance, guidedJump, stateText, asciiText, runLengthText,
+ DEFAULT_PITS, PIT_W, MIN_FIRST, STEP, MAX_LAST, makePits, validatePits, setPits,
+ QUESTION, OPTIONS };
+})();
+
+if (typeof module !== "undefined") module.exports = GameRules;
diff --git a/semif-api/src/semif_api/web/game.js b/semif-api/src/semif_api/web/game.js
@@ -0,0 +1,259 @@
+/* Platformer demo — the game loop lives inside a host page (index.html) as the
+ * "Platformer demo" tab. Wrapped in an IIFE so its local `const $`
+ * (getElementById) never collides with app.js's top-level `$` (querySelector).
+ * Requires game-rules.js to have defined the global `GameRules` first. */
+(() => {
+"use strict";
+
+/* ── level & physics (cells: column 0..67, row 0 top .. 7 ground line) ─ */
+// PITS and MAX_JUMPS are read via GameRules.* getters at use time (the pit
+// layout can be re-randomised at runtime), so they are NOT destructured here.
+const { W, H, GROUND, GOAL, START_X, JUMP_VY, floorAt, QUESTION, OPTIONS } = GameRules;
+const TICK_MS = 90;
+
+/* ── mutable game state ─────────────────────────────────────────────── */
+const player = { x: START_X, y: GROUND, vy: 0, onGround: true };
+let running = false; // auto mode: player currently holds "run right"
+let mode = "idle"; // idle | auto | manual | won | lost
+let deciding = false;
+let stateMode = "guided"; // "guided" = prose + hint; "unguided" = prose facts; "runlength" = run-length encoding; "ascii" = symbolic row (none but guided include a hint)
+let jumpsLeft = GameRules.MAX_JUMPS;
+let deniedStreak = 0; // consecutive jump-attempts with an empty budget
+let tickTimer = null;
+let decisionN = 0;
+let runToken = 0; // invalidates in-flight decisions on reset
+
+const $ = (id) => document.getElementById(id);
+const cv = $("cv"), ctx = cv.getContext("2d");
+const CELL = cv.width / W, ROWH = cv.height / H;
+function refreshLevelLabel() {
+ $("level-label").textContent =
+ `Level — pits at ${GameRules.PITS.map(([a, b]) => `${a}–${b + 1}`).join(", ")}; flag at ${GOAL}`;
+}
+refreshLevelLabel();
+
+// The game shares a page with text fields, so its keyboard shortcuts must not
+// swallow typing or drive the game from another tab. Only act while the game
+// panel is visible and focus is not in an editable element.
+const gameActive = () => {
+ const t = document.activeElement;
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return false;
+ return !$("panel-game").hidden;
+};
+
+/* ── the ONE textual state: source for rendering prompts AND the API ── */
+function stateText() {
+ return GameRules.stateText(player, jumpsLeft, stateMode);
+}
+
+/* ── rendering (uses the same coordinates as the state text) ────────── */
+function render() {
+ ctx.clearRect(0, 0, cv.width, cv.height);
+ for (let c = 0; c < W; c++) {
+ if (!floorAt(c)) { // pit: dark shaft
+ ctx.fillStyle = "#0a0c11";
+ ctx.fillRect(c * CELL, GROUND * ROWH, CELL, cv.height - GROUND * ROWH);
+ continue;
+ }
+ ctx.fillStyle = "#2a3140";
+ ctx.fillRect(c * CELL, GROUND * ROWH, CELL, cv.height - GROUND * ROWH);
+ ctx.fillStyle = "#3a4356";
+ ctx.fillRect(c * CELL, GROUND * ROWH, CELL, 3);
+ }
+ // goal flag
+ const gx = GOAL * CELL;
+ ctx.strokeStyle = "#56d364"; ctx.lineWidth = 2;
+ ctx.beginPath(); ctx.moveTo(gx, GROUND * ROWH); ctx.lineTo(gx, GROUND * ROWH - 34); ctx.stroke();
+ ctx.fillStyle = "#56d364";
+ ctx.beginPath(); ctx.moveTo(gx, GROUND * ROWH - 34);
+ ctx.lineTo(gx + 18, GROUND * ROWH - 27); ctx.lineTo(gx, GROUND * ROWH - 20); ctx.fill();
+ // player: blue when grounded, amber mid-jump
+ ctx.fillStyle = player.onGround ? "#69c0ff" : "#e3a008";
+ ctx.beginPath(); ctx.arc(player.x * CELL, player.y * ROWH - 8, 8, 0, Math.PI * 2); ctx.fill();
+}
+
+/* ── physics tick ───────────────────────────────────────────────────── */
+function tick() {
+ if (mode !== "auto" && mode !== "manual") return;
+ const next = GameRules.advance(player, mode === "auto" ? running : keyRun);
+ Object.assign(player, next.player);
+ if (next.fell) { lose("The player fell into a pit."); return; }
+ if (player.x >= GOAL) { win(); return; }
+ render();
+ // Mirror the exact state text into the pane on every tick — in manual mode
+ // this is the debugging view of what the decider would receive right now.
+ $("state-view").textContent = stateText();
+ // Running commits to one tick. A jump commits until landing.
+ if (mode === "auto" && player.onGround) { stopTicks(); void requestDecision(); }
+}
+
+function jump() {
+ if (!player.onGround || jumpsLeft <= 0 || mode === "won" || mode === "lost") return;
+ jumpsLeft--; deniedStreak = 0;
+ player.onGround = false; player.vy = JUMP_VY;
+}
+
+/* ── SemIf decision loop ────────────────────────────────────────────── */
+async function requestDecision() {
+ if (deciding || mode !== "auto") return;
+ deciding = true;
+ const token = runToken;
+ stopTicks(); // physics freezes while we think
+ setStatus("thinking…", "");
+ const text = stateText();
+ $("state-view").textContent = text;
+ const n = ++decisionN;
+ let result, elapsedMs, err = null;
+ try {
+ const t0 = performance.now();
+ const resp = await fetch("/decide", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ id: `platformer-${n}`, state: text, question: QUESTION, options: OPTIONS }),
+ });
+ elapsedMs = performance.now() - t0;
+ const payload = JSON.parse(await resp.text());
+ if (!resp.ok) throw new Error(typeof payload.detail === "string" ? payload.detail : resp.statusText);
+ result = payload;
+ } catch (e) { err = e; }
+ if (token !== runToken || mode !== "auto") return; // reset (or re-run) while we waited
+ deciding = false;
+ if (err) {
+ logError(n, err.message);
+ setStatus(`decision failed: ${err.message} — retrying in 2 s`, "err");
+ setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, 2000);
+ return;
+ }
+ const pairs = result.option_ids.map((id, i) => [id, result.probabilities[i]]);
+ const best = pairs.reduce((a, b) => (b[1] > a[1] ? b : a));
+ logDecision(n, best, pairs, elapsedMs, text);
+ setStatus(`playing… · ${jumpsLeft} jump${jumpsLeft === 1 ? "" : "s"} left`, "");
+ running = best[0] === "run";
+ if (running) deniedStreak = 0;
+ if (best[0] === "jump") {
+ if (jumpsLeft > 0) {
+ jump();
+ } else {
+ deniedStreak++; running = false;
+ if (deniedStreak >= 3) { lose("The model is out of jumps and keeps trying to jump."); return; }
+ setStatus(`jump denied — 0 jumps left (asked ${deniedStreak}× in a row)`, "err");
+ }
+ }
+ if (mode !== "auto") return;
+ startTicks();
+ render();
+}
+
+/* ── decision log (DOM built with textContent, like the main UI) ────── */
+function clearLog() { const l = $("log"); while (l.firstChild) l.removeChild(l.firstChild); }
+function addEntry(n, cls) {
+ const e = document.createElement("div"); e.className = `entry${cls ? " " + cls : ""}`;
+ const head = document.createElement("div"); head.className = "head";
+ const num = document.createElement("span"); num.className = "n";
+ num.textContent = `#${n}${stateMode === "guided" ? "" : ` · ${stateMode}`}`;
+ const choice = document.createElement("span"); choice.className = "choice";
+ const ms = document.createElement("span"); ms.className = "ms";
+ head.append(num, choice, ms); e.appendChild(head);
+ $("log").prepend(e);
+ return { e, choice, ms };
+}
+function logDecision(n, best, pairs, elapsedMs, sentState) {
+ const { e, choice, ms } = addEntry(n);
+ choice.textContent = `→ ${best[0]} (p=${best[1].toFixed(3)})`;
+ ms.textContent = `${elapsedMs.toFixed(0)} ms`;
+ const probs = document.createElement("div"); probs.className = "probs";
+ probs.textContent = pairs.map(([id, p]) => `${id} ${p.toFixed(3)}`).join(" ");
+ e.appendChild(probs);
+ const bar = document.createElement("div"); bar.className = "bar";
+ const fill = document.createElement("span"); fill.style.width = `${(best[1] * 100).toFixed(1)}%`;
+ bar.appendChild(fill); e.appendChild(bar);
+ e.title = sentState; // hover to see the exact state that produced this call
+}
+function logError(n, msg) {
+ const { e, choice } = addEntry(n, "error");
+ choice.textContent = "request failed";
+ const d = document.createElement("div"); d.className = "probs"; d.textContent = msg;
+ e.appendChild(d);
+}
+
+/* ── status / win / lose / reset ────────────────────────────────────── */
+function setStatus(t, cls) { const s = $("status"); s.textContent = t; s.className = `status${cls ? " " + cls : ""}`; }
+function startTicks() { if (!tickTimer) tickTimer = setInterval(tick, TICK_MS); }
+function stopTicks() { if (tickTimer) { clearInterval(tickTimer); tickTimer = null; } }
+function win() {
+ mode = "won"; stopTicks(); running = false;
+ setStatus(`🏁 level complete in ${decisionN} SemIf decision(s), ${jumpsLeft} jump${jumpsLeft === 1 ? "" : "s"} to spare — press Reset to run it again`, "win");
+ render();
+}
+function lose(msg) {
+ mode = "lost"; stopTicks(); running = false;
+ setStatus(`✗ ${msg} The model chose badly — press Reset to retry.`, "err");
+ render();
+}
+function reset() {
+ runToken++;
+ stopTicks(); deciding = false; running = false; keyRun = false;
+ jumpsLeft = GameRules.MAX_JUMPS; deniedStreak = 0;
+ player.x = START_X; player.y = GROUND; player.vy = 0; player.onGround = true;
+ mode = "idle"; decisionN = 0;
+ $("btn-start").disabled = false;
+ setStatus("idle — press Start", "");
+ $("state-view").textContent = stateText(); // idle preview: same text /decide would receive
+ clearLog();
+ const empty = document.createElement("div"); empty.className = "empty";
+ empty.textContent = "No decisions yet."; $("log").appendChild(empty);
+ render();
+}
+
+/* ── controls ───────────────────────────────────────────────────────── */
+const stateModes = ["guided", "unguided", "runlength", "ascii"];
+const setStateMode = (m) => {
+ stateMode = m;
+ for (const name of stateModes) $("mode-" + name).className = name === m ? "on" : "";
+};
+for (const name of stateModes) $("mode-" + name).addEventListener("click", () => setStateMode(name));
+
+$("btn-start").addEventListener("click", () => {
+ if (mode === "auto") return;
+ if (mode === "won" || mode === "lost") reset();
+ mode = "auto";
+ $("btn-start").disabled = true;
+ void requestDecision();
+});
+$("btn-reset").addEventListener("click", reset);
+
+/* ── random pit layout (seeded, reproducible) ──────────────────── */
+const parseSeed = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n >>> 0 : null; };
+function applySeed(seedNum) {
+ GameRules.setPits(GameRules.makePits(seedNum));
+ refreshLevelLabel();
+ reset(); // floorAt/MAX_JUMPS already reflect the new layout
+}
+$("btn-randomize").addEventListener("click", () => {
+ const s = (Math.random() * 0x100000000) >>> 0;
+ $("seed").value = String(s);
+ applySeed(s);
+});
+// Type a seed and press Enter (or blur) to reproduce that exact layout.
+$("seed").addEventListener("change", () => {
+ const s = parseSeed($("seed").value);
+ if (s !== null) applySeed(s);
+});
+
+let keyRun = false;
+addEventListener("keydown", (e) => {
+ if (!gameActive()) return;
+ if (mode === "auto" || mode === "won" || mode === "lost") return;
+ if (e.key === "ArrowRight" || e.key === "d") {
+ keyRun = true;
+ if (mode === "idle") { mode = "manual"; setStatus("manual mode", ""); startTicks(); }
+ }
+ if (e.key === " " || e.key === "ArrowUp" || e.key === "w") {
+ e.preventDefault();
+ if (mode === "idle") { mode = "manual"; setStatus("manual mode", ""); startTicks(); }
+ jump();
+ }
+});
+addEventListener("keyup", (e) => { if (e.key === "ArrowRight" || e.key === "d") keyRun = false; });
+
+reset();
+})();
diff --git a/semif-api/src/semif_api/web/index.html b/semif-api/src/semif_api/web/index.html
@@ -0,0 +1,300 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>semif-api — decision readout</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<header>
+ <div class="brand">
+ <h1>semif-api</h1>
+ <p class="tagline">semantic decision readout — native option-slot logits, not generated text</p>
+ </div>
+ <div id="health" class="health" aria-live="polite">
+ <span class="dot" id="health-dot"></span>
+ <span id="health-text">checking…</span>
+ <button type="button" id="health-refresh" class="ghost" title="Re-check /healthz">↻</button>
+ <select id="model-picker" class="model-picker" hidden aria-label="model selector"
+ title="Served models — switching unloads the current model and loads the new one on the next request"></select>
+ <span id="model-note" class="model-note" role="status"></span>
+ </div>
+</header>
+
+<nav class="tabs" role="tablist">
+ <button type="button" class="tab is-active" id="tab-single" role="tab" aria-selected="true"
+ aria-controls="panel-single">Single decision <code>/decide</code></button>
+ <button type="button" class="tab" id="tab-batch" role="tab" aria-selected="false"
+ aria-controls="panel-batch">Shared state <code>/decide-batch</code></button>
+ <button type="button" class="tab" id="tab-game" role="tab" aria-selected="false"
+ aria-controls="panel-game">Platformer demo</button>
+ <button type="button" class="tab" id="tab-self" role="tab" aria-selected="false"
+ aria-controls="panel-self">Platformer SL</button>
+ <button type="button" class="tab" id="tab-room" role="tab" aria-selected="false"
+ aria-controls="panel-room">Puzzle room</button>
+ <button type="button" class="tab" id="tab-roomself" role="tab" aria-selected="false"
+ aria-controls="panel-roomself">Puzzle room SL</button>
+</nav>
+
+<main>
+ <!-- ── request ────────────────────────────────────────────────── -->
+ <section class="col" id="request">
+
+ <form id="panel-single" class="panel" novalidate>
+ <div class="field">
+ <label for="s-id">id</label>
+ <input id="s-id" class="mono" value="adhoc" autocomplete="off" spellcheck="false">
+ </div>
+
+ <div class="field">
+ <div class="field-head">
+ <label for="s-state">state <span class="hint">the evidence</span></label>
+ <span class="state-mode">
+ <input type="radio" name="s-state-mode" id="s-state-text" value="text" checked>
+ <label for="s-state-text">text</label>
+ <input type="radio" name="s-state-mode" id="s-state-json" value="json">
+ <label for="s-state-json">JSON</label>
+ </span>
+ </div>
+ <textarea id="s-state" rows="7" spellcheck="false"
+ placeholder="Evidence the model must decide from — prose, or a JSON object/array in JSON mode."></textarea>
+ <p class="note" id="s-state-note"></p>
+ </div>
+
+ <div class="field">
+ <label for="s-question">question <span class="hint">the criterion applied to the evidence</span></label>
+ <input id="s-question" autocomplete="off" placeholder="Does this evidence support…?">
+ </div>
+
+ <div class="field">
+ <div class="field-head">
+ <label>options <span class="hint">2–16, ids must be unique</span></label>
+ <button type="button" class="ghost add-option">+ option</button>
+ </div>
+ <div class="options" id="s-options"></div>
+ </div>
+
+ <div class="actions">
+ <button type="submit" class="primary" id="s-submit">Decide</button>
+ <button type="button" id="s-curl">Copy as curl</button>
+ <button type="button" id="s-clear">Clear</button>
+ </div>
+ </form>
+
+ <form id="panel-batch" class="panel" novalidate hidden>
+ <p class="constraint">
+ Every decision below is scored against the same evidence. Torch shares a prefill;
+ the llama backend scores sequentially and lets the server reuse cached prefixes.
+ </p>
+
+ <div class="field">
+ <div class="field-head">
+ <label for="b-state">shared state</label>
+ <span class="state-mode">
+ <input type="radio" name="b-state-mode" id="b-state-text" value="text" checked>
+ <label for="b-state-text">text</label>
+ <input type="radio" name="b-state-mode" id="b-state-json" value="json">
+ <label for="b-state-json">JSON</label>
+ </span>
+ </div>
+ <textarea id="b-state" rows="7" spellcheck="false"></textarea>
+ <p class="note" id="b-state-note"></p>
+ </div>
+
+ <div class="field">
+ <div class="field-head">
+ <label>decisions <span class="hint">ids must be unique across the batch</span></label>
+ <button type="button" class="ghost" id="b-add-decision">+ decision</button>
+ </div>
+ <div id="b-decisions"></div>
+ </div>
+
+ <div class="actions">
+ <button type="submit" class="primary" id="b-submit">Run batch</button>
+ <button type="button" id="b-curl">Copy as curl</button>
+ <button type="button" id="b-clear">Clear</button>
+ </div>
+ </form>
+
+ <div class="presets">
+ <span class="presets-label">Load example</span>
+ <div id="preset-buttons"></div>
+ </div>
+ </section>
+
+ <!-- ── response ───────────────────────────────────────────────── -->
+ <section class="col" id="response">
+ <div id="idle" class="placeholder">
+ <p>No request sent yet.</p>
+ <p class="dim">Probabilities are <em>conditional option scores</em>: use them to rank options and
+ set coarse thresholds, never as calibrated confidence.</p>
+ </div>
+ <div id="pending" class="placeholder" hidden>
+ <p class="spinner" aria-hidden="true">◌</p>
+ <p id="pending-text">scoring…</p>
+ <p class="dim">GPU work serializes on a per-request lock — concurrent requests queue, they do not
+ speed each other up.</p>
+ </div>
+ <div id="error" class="error" hidden role="alert"></div>
+ <div id="result" hidden></div>
+ </section>
+
+ <!-- ── platformer demo (third tab; see game.js + game-rules.js) ────── -->
+ <section id="panel-game" class="demo-panel" role="tabpanel" aria-labelledby="tab-game" hidden>
+ <div class="game-stage">
+ <h2 id="level-label">Level</h2>
+ <canvas id="cv" width="960" height="200"></canvas>
+ <div class="controls">
+ <button type="button" id="btn-start" class="primary">▶ Let SemIf play</button>
+ <button type="button" id="btn-reset">↺ Reset</button>
+ <button type="button" id="btn-randomize" title="Shuffle pit positions to a new random seed">⚄ Randomize</button>
+ <label class="seed" title="Level seed — type one and press Enter to reproduce that exact layout">seed<input type="text" id="seed" class="seed-input" inputmode="numeric" autocomplete="off" spellcheck="false" placeholder="default"></label>
+ <div class="seg" role="group" aria-label="state text mode" title="Guided injects a hint about the right move; Unguided gives only the prose facts; Run-length encodes the terrain as run-length segments (-N floor, #N pit) the model must parse; ASCII replaces the readout with a single symbolic row (- floor, # pit, * you, ! flag). Hardest last. Applies from the next decision.">
+ <button type="button" id="mode-guided" class="on">Guided</button><button type="button" id="mode-unguided">Unguided</button><button type="button" id="mode-runlength">Run-length</button><button type="button" id="mode-ascii">ASCII</button>
+ </div>
+ <span class="status" id="status">idle — press Start</span>
+ </div>
+ <p class="game-hint">Manual drive (when idle): <code>→</code>/<code>D</code> run right, <code>space</code>/<code>W</code> jump.
+ Physics pauses while a decision is pending. In <b>Guided</b> mode the state text includes a hint about the right move;
+ in <b>Unguided</b> mode only the facts are given and the model has to decide on its own;
+ in <b>Run-length</b> mode the terrain is a compact run-length encoding
+ (<code>-N</code> floor, <code>#N</code> pit) the model must parse;
+ in <b>ASCII</b> mode the facts are a single symbolic row (<code>-</code> floor, <code>#</code> pit, <code>*</code> you, <code>!</code> flag)
+ the model must decode — the hardest of the four.<br>
+ <b>⚄ Randomize</b> moves the three pits to new (always-solvable) positions; the seed box shows the seed so a
+ layout can be reproduced — type a seed and press Enter.</p>
+ </div>
+ <aside class="game-side">
+ <h2>State text — rendered from, and sent verbatim to, /decide</h2>
+ <pre id="state-view"></pre>
+ <h2>Decision log</h2>
+ <div id="log" class="log"></div>
+ </aside>
+ </section>
+ <!-- ── no-rules platformer (fourth tab; see self-game.js + self-rules.js) ── -->
+ <section id="panel-self" class="demo-panel" role="tabpanel" aria-labelledby="tab-self" hidden>
+ <div class="game-stage">
+ <h2 id="self-level-label">Level</h2>
+ <canvas id="self-cv" width="960" height="200"></canvas>
+ <div class="controls">
+ <button type="button" id="self-start" class="primary">▶ Let SemIf play</button>
+ <button type="button" id="self-reset">↺ Reset</button>
+ <button type="button" id="self-forget" title="Discard the learned rules and the action transcript">✕ Forget rules</button>
+ <button type="button" id="self-randomize" title="Shuffle pit positions to a new random seed">⚄ Randomize</button>
+ <label class="seed" title="Level seed — type one and press Enter to reproduce that exact layout">seed<input type="text" id="self-seed" class="seed-input" inputmode="numeric" autocomplete="off" spellcheck="false" placeholder="default"></label>
+ <div class="seg" role="group" aria-label="bare state text mode" title="All three modes send the same neutral observation with no rules: Prose lists the terrain ahead as counts, Run-length encodes it as segments (-N ground, #N hole), ASCII as one symbolic row (- ground, # hole, * you, ! flag). The model must learn how to play from /plan. Applies from the next decision.">
+ <button type="button" id="self-mode-prose" class="on">Prose</button><button type="button" id="self-mode-runlength">Run-length</button><button type="button" id="self-mode-ascii">ASCII</button>
+ </div>
+ <span class="status" id="self-status">idle — press Start</span>
+ </div>
+ <p class="game-hint">Same physics as the Platformer demo, but the decision model is given <b>no rules</b> — only a
+ neutral observation of the level and the actions <code>run</code> / <code>jump</code>, plus an
+ <code>insufficient</code> escape hatch. When it picks <code>insufficient</code> with p ≥ 0.99, or the player
+ falls into a pit, or it keeps requesting jumps with none left, the client calls <code>/plan</code>: a reasoning
+ chat completion that reviews the transcript of completed actions and writes a set of rules. The rules then lead
+ every later observation — that is the only way the model learns how to play. Every trigger restarts the level
+ with the learned rules: the planner writes full game rules, not a fix for one stuck spot, so the model gets a
+ fresh attempt to apply them from the start. Reset restarts the level and keeps the learned rules;
+ ✕ Forget rules clears them. Manual drive (when idle): <code>→</code>/<code>D</code> run right,
+ <code>space</code>/<code>W</code> jump.</p>
+ </div>
+ <aside class="game-side">
+ <h2>State text — rendered from, and sent verbatim to, /decide</h2>
+ <pre id="self-state-view"></pre>
+ <h2>Rules learned from /plan (editable)</h2>
+ <textarea id="self-rules-view" class="rules-edit" rows="7" spellcheck="false"
+ placeholder="(none yet — rules learned from /plan appear here; edit or paste to set them by hand)"></textarea>
+ <h2>Run stats</h2>
+ <pre id="self-stats">(run not started)</pre>
+ <h2>Decision log</h2>
+ <div id="self-log" class="log"></div>
+ </aside>
+ </section>
+ <!-- ── puzzle room demo (fifth tab; see room.js + room-rules.js) ──── --> <section id="panel-room" class="demo-panel" role="tabpanel" aria-labelledby="tab-room" hidden>
+ <div class="game-stage">
+ <h2 id="room-label">Room</h2>
+ <canvas id="room-cv" width="960" height="420"></canvas>
+ <div class="controls">
+ <button type="button" id="room-start" class="primary">▶ Let SemIf solve</button>
+ <button type="button" id="room-reset">↺ Reset</button>
+ <button type="button" id="room-randomize" title="New random room; the seed box shows it so the layout can be reproduced">⚄ Randomize</button>
+ <label class="seed" title="Room seed — type one and press Enter to reproduce that exact layout">seed<input type="text" id="room-seed" class="seed-input" inputmode="numeric" autocomplete="off" spellcheck="false" placeholder="default"></label>
+ <div class="seg" role="group" aria-label="room state text mode" title="Guided FPP describes your view plus a compass hint toward the current objective; FPP gives only what you see and remember; Map hands the model the whole top-down layout. Applies from the next decision.">
+ <button type="button" id="room-mode-guided" class="on">Guided FPP</button><button type="button" id="room-mode-fpp">FPP</button><button type="button" id="room-mode-map">Map</button>
+ </div>
+ <span class="status" id="room-status">idle — press Start</span>
+ </div>
+ <p class="game-hint">Manual drive (when idle): <code>←</code>/<code>A</code> turn left, <code>→</code>/<code>D</code> turn right, <code>↑</code>/<code>W</code> step forward.
+ The first-person view is yours — the model only ever sees the text in the side pane: the four adjacent cells and what is in view,
+ so it must build its own map by dead-reckoning. It must find the key, unlock the door in the dividing wall, and reach the exit,
+ one action per decision, within the step budget. In <b>Guided FPP</b> the state includes a compass hint toward the key/door/exit;
+ in <b>FPP</b> only what you see and what happened; in <b>Map</b> it gets the whole layout.
+ <b>⚄ Randomize</b> builds a new (always-solvable) room; the seed box reproduces it — type a seed and press Enter.</p>
+ </div>
+ <aside class="game-side">
+ <h2>State text — rendered from, and sent verbatim to, /decide</h2>
+ <pre id="room-state-view"></pre>
+ <h2>Decision log</h2>
+ <div id="room-log" class="log"></div>
+ </aside>
+ </section>
+ <!-- ── self-learning puzzle room (sixth tab; see room-self-game.js + room-self-rules.js) ── -->
+ <section id="panel-roomself" class="demo-panel" role="tabpanel" aria-labelledby="tab-roomself" hidden>
+ <div class="game-stage">
+ <h2 id="rs-label">Room</h2>
+ <canvas id="rs-cv" width="960" height="420"></canvas>
+ <div class="controls">
+ <button type="button" id="rs-start" class="primary">▶ Let SemIf play</button>
+ <button type="button" id="rs-reset">↺ Reset</button>
+ <button type="button" id="rs-forget" title="Discard the learned rules and the action transcript">✕ Forget rules</button>
+ <button type="button" id="rs-randomize" title="New random room; the seed box shows it so the layout can be reproduced">⚄ Randomize</button>
+ <label class="seed" title="Room seed — type one and press Enter to reproduce that exact layout">seed<input type="text" id="rs-seed" class="seed-input" inputmode="numeric" autocomplete="off" spellcheck="false" placeholder="default"></label>
+ <div class="seg" role="group" aria-label="bare room state text mode" title="Both modes send the same neutral observation with no rules: FPP describes all four adjacent cells (open floor included), whatever your 120° view cone currently sees, and bearings back to any landmark it has discovered (key, locked door, exit) that is out of sight — bearings only, no guidance, and a bearing whose straight line is walled reads "(blocked)" — plus your carry status and step count; Map hands over the whole top-down layout with a neutral symbol legend. There is deliberately no event log: a list of the actor's own recent actions is imitation bait for a single-pass reader, not a memory. The model must learn what to do from /plan. Applies from the next decision.">
+ <button type="button" id="rs-mode-fpp" class="on">FPP</button><button type="button" id="rs-mode-map">Map</button>
+ </div>
+ <span class="status" id="rs-status">idle — press Start</span>
+ </div>
+ <p class="game-hint">Same room as the Puzzle room demo, but the decision model is given <b>no rules</b> — only a neutral
+ observation (all four adjacent cells, in-view bearings, carry status and a step count — or the whole top-down map)
+ and the actions <code>forward</code> / <code>left</code> / <code>right</code>, plus an <code>insufficient</code>
+ escape hatch. When <code>insufficient</code> is its leading option at all, or the step budget runs out, the client
+ calls <code>/plan</code>: a reasoning chat completion that reviews the transcript of completed actions and writes a
+ set of rules. The rules then lead every later observation — that is the only way the model learns how to play.
+ Either trigger restarts the room with the learned rules: the planner writes full game rules, not a fix for one
+ stuck spot, so the model gets a fresh attempt to apply them from the start.
+ Reset restarts the room and keeps the learned rules; ✕ Forget rules clears them. Manual drive (when idle):
+ <code>←</code>/<code>A</code> turn left, <code>→</code>/<code>D</code> turn right, <code>↑</code>/<code>W</code> step forward.</p>
+ </div>
+ <aside class="game-side">
+ <h2>State text — rendered from, and sent verbatim to, /decide</h2>
+ <pre id="rs-state-view"></pre>
+ <h2>Rules learned from /plan (editable)</h2>
+ <textarea id="rs-rules-view" class="rules-edit" rows="7" spellcheck="false"
+ placeholder="(none yet — rules learned from /plan appear here; edit or paste to set them by hand)"></textarea>
+ <h2>Run stats</h2>
+ <pre id="rs-stats">(run not started)</pre>
+ <h2>Decision log</h2>
+ <div id="rs-log" class="log"></div>
+ </aside>
+ </section>
+</main>
+
+<footer>
+ <p>Readout reads logits at the declared answer slots; there is no decoding loop and no output tokens.
+ Prompts over <code id="foot-max-tokens">max_tokens</code> are rejected, never silently truncated.</p>
+</footer>
+
+<script src="game-rules.js"></script>
+<script src="game.js"></script>
+<script src="planner.js"></script>
+<script src="self-rules.js"></script>
+<script src="self-game.js"></script>
+<script src="room-rules.js"></script>
+<script src="room.js"></script>
+<script src="room-self-rules.js"></script>
+<script src="room-self-game.js"></script>
+<script src="app.js"></script>
+</body>
+</html>
diff --git a/semif-api/src/semif_api/web/planner.js b/semif-api/src/semif_api/web/planner.js
@@ -0,0 +1,73 @@
+"use strict";
+
+/* Shared client-side helpers for the /plan learning loop.
+ *
+ * Game-agnostic by design: a demo records each completed action as a transcript
+ * turn, and Planner.triggered() decides when a decision result demands new
+ * rules (a confident "insufficient" choice). The no-rules platformer uses this
+ * today; the puzzle room can adopt the same trigger policy and wire format.
+ */
+const Planner = (() => {
+ const INSUFFICIENT_ID = "insufficient";
+ // Plan only on a confident "I cannot decide"; a weak insufficient just loses
+ // the argmax to the best real action (self-game.js handles that fallback).
+ const INSUFFICIENT_THRESHOLD = 0.99;
+ const TRANSCRIPT_KEEP = 24; // completed actions retained for planning
+
+ const fresh = () => [];
+
+ // One completed action + observed outcome, as the user turn the planner reads.
+ const record = (transcript, content) => {
+ transcript.push({ role: "user", content });
+ while (transcript.length > TRANSCRIPT_KEEP) transcript.shift();
+ };
+
+ // [optionId, probability] of the server's choice, mirroring its argmax.
+ const best = (result) => {
+ let top = 0;
+ result.probabilities.forEach((p, i) => { if (p > result.probabilities[top]) top = i; });
+ return [result.option_ids[top], result.probabilities[top]];
+ };
+
+ // A confident "I cannot decide" demands new rules. The threshold is the
+ // caller's policy: the platformer wants real confidence (0.99) before it
+ // spends a planner run, while the puzzle room passes 0 — any plurality win
+ // for "insufficient" plans, since it has no failure signal until the step
+ // budget runs out and a wandering model must be able to ask for rules early.
+ const triggered = (result, threshold = INSUFFICIENT_THRESHOLD) => {
+ const [id, p] = best(result);
+ return id === INSUFFICIENT_ID && p >= threshold;
+ };
+
+ // Composes the /plan user prompt from facts the orchestrator owns: the
+ // simulation's one-line goal, a note on what triggered planning, and the
+ // rules currently in effect ("" on the first plan). Game-agnostic — every
+ // simulation supplies the same three facts, so no game needs its own prompt.
+ const context = (goal, trigger, rules) => {
+ const parts = [`Objective: ${goal}`, `Trigger: ${trigger}`];
+ if (rules) {
+ parts.push(
+ "Previous rules — the actor failed while these were in force. " +
+ "That failure indicts the rules (their facts, priorities, or " +
+ "framing), not the actor's comprehension of them. Never resubmit " +
+ "a reworded or lightly edited version: change the substance, or " +
+ "discard the set and write a fresh one.\n" + rules);
+ }
+ return parts.join("\n");
+ };
+
+ async function request(body) {
+ const response = await fetch("/plan", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const payload = JSON.parse(await response.text());
+ if (!response.ok) {
+ throw new Error(typeof payload.detail === "string" ? payload.detail : response.statusText);
+ }
+ return payload;
+ }
+
+ return { INSUFFICIENT_ID, INSUFFICIENT_THRESHOLD, TRANSCRIPT_KEEP,
+ fresh, record, best, triggered, context, request };
+})();
diff --git a/semif-api/src/semif_api/web/room-rules.js b/semif-api/src/semif_api/web/room-rules.js
@@ -0,0 +1,450 @@
+"use strict";
+
+// Shared by the browser and offline tests: room facts come from the rules.
+// Turn-based puzzle room on a grid. The player has a position and a facing;
+// each decision is ONE action — step forward, turn left, turn right. The model
+// never sees the map: the FPP state text reports what is adjacent (touch
+// range), what the FOV cone sees (bearings) and carry status, so navigation
+// is dead-reckoning from prose.
+const RoomRules = (() => {
+ const W = 11, H = 9;
+ const DIV = 5; // dividing wall column; the locked door is its only gap
+ const MAX_STEPS = 80; // step budget per attempt (turns count)
+
+ const DIRS = [
+ { dx: 0, dy: -1, name: "NORTH", arrow: "^" },
+ { dx: 1, dy: 0, name: "EAST", arrow: ">" },
+ { dx: 0, dy: 1, name: "SOUTH", arrow: "v" },
+ { dx: -1, dy: 0, name: "WEST", arrow: "<" },
+ ];
+
+
+ // mulberry32: small, deterministic, good enough for shuffling wall bits.
+ function rng(seed) {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6D2B79F5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+ }
+ const randInt = (r, lo, hi) => lo + Math.floor(r() * (hi - lo + 1)); // inclusive
+
+ const blocked = (cell, doorPassable) =>
+ cell === "#" || (cell === "D" && !doorPassable);
+
+ // BFS over floor cells; K/E passable, D passable only when doorPassable.
+ function reachable(grid, from, to, doorPassable) {
+ if (from.x === to.x && from.y === to.y) return true;
+ const seen = new Set([from.x + "," + from.y]);
+ const queue = [from];
+ while (queue.length) {
+ const cur = queue.shift();
+ for (const d of DIRS) {
+ const nx = cur.x + d.dx, ny = cur.y + d.dy;
+ const id = nx + "," + ny;
+ if (seen.has(id) || ny < 0 || ny >= H || nx < 0 || nx >= W) continue;
+ if (blocked(grid[ny][nx], doorPassable)) continue;
+ if (nx === to.x && ny === to.y) return true;
+ seen.add(id);
+ queue.push({ x: nx, y: ny });
+ }
+ }
+ return false;
+ }
+
+ // Hand-authored layout used if seeded generation ever fails all attempts.
+ // start (1,1) · key (2,7) · door (5,5) · exit (9,7); always solvable.
+ const FALLBACK = {
+ grid: [
+ "###########",
+ "#.....#...#",
+ "#.....#...#",
+ "#..#..#...#",
+ "#..#..#...#",
+ "#..#..D...#",
+ "#..#..#...#",
+ "#.K#..#..E#",
+ "###########",
+ ],
+ start: { x: 1, y: 1 }, key: { x: 2, y: 7 },
+ exit: { x: 9, y: 7 }, doorRow: 5,
+ };
+
+ // Seeded layout: border + a dividing wall with one locked door, a few random
+ // wall segments inside each zone, then entities. Accepted only when the key
+ // is reachable without the door AND the exit is reachable with it — so the
+ // intended solve (key → door → exit) always exists.
+ function makeLayout(seed) {
+ for (let attempt = 0; attempt < 200; attempt++) {
+ const r = rng((seed + attempt * 0x9E3779B9) >>> 0);
+ const grid = Array.from({ length: H }, (_, y) =>
+ Array.from({ length: W }, (_, x) =>
+ (x === 0 || y === 0 || x === W - 1 || y === H - 1 || x === DIV) ? "#" : "."));
+ const doorRow = randInt(r, 1, H - 2);
+ grid[doorRow][DIV] = "D";
+ const segs = randInt(r, 3, 5);
+ for (let s = 0; s < segs; s++) {
+ const horiz = r() < 0.5;
+ const len = randInt(r, 1, 3);
+ const [zx0, zx1] = r() < 0.5 ? [1, DIV - 1] : [DIV + 1, W - 2];
+ const x0 = randInt(r, zx0, zx1 - (horiz ? len - 1 : 0));
+ const y0 = randInt(r, 1, H - 2 - (horiz ? 0 : len - 1));
+ for (let i = 0; i < len; i++) {
+ const x = x0 + (horiz ? i : 0), y = y0 + (horiz ? 0 : i);
+ if (grid[y][x] === ".") grid[y][x] = "#";
+ }
+ }
+ const cellsIn = (x0, x1) => {
+ const out = [];
+ for (let y = 1; y < H - 1; y++)
+ for (let x = x0; x <= x1; x++)
+ if (grid[y][x] === ".") out.push({ x, y });
+ return out;
+ };
+ const left = cellsIn(1, DIV - 1), right = cellsIn(DIV + 1, W - 2);
+ if (left.length < 2 || right.length < 1) continue;
+ const start = left[randInt(r, 0, left.length - 1)];
+ const keyPool = left.filter((c) => c.x !== start.x || c.y !== start.y);
+ const key = keyPool[randInt(r, 0, keyPool.length - 1)];
+ const exit = right[randInt(r, 0, right.length - 1)];
+ grid[key.y][key.x] = "K";
+ grid[exit.y][exit.x] = "E";
+ if (!reachable(grid, start, key, false)) continue; // key on the near side
+ if (!reachable(grid, start, exit, true)) continue; // exit via the door
+ return { grid: grid.map((row) => row.join("")), start, key, exit, doorRow, seed };
+ }
+ return { ...FALLBACK, seed };
+ }
+
+ function newState(layout) {
+ return {
+ x: layout.start.x, y: layout.start.y, dir: 0, // 0 = NORTH
+ hasKey: false, doorOpen: false, steps: 0,
+ };
+ }
+
+ // side: "left" | "right". Turning costs a step. There is deliberately no
+ // event log in the observation: for a single-pass reader a list of recent
+ // actions is few-shot imitation bait — the list ends with what was just
+ // done, which is the pattern to continue. Outcomes reach the PLANNER via
+ // the transcript, which is a different reader with different machinery.
+ function turn(state, side) {
+ const next = { ...state };
+ next.dir = (state.dir + (side === "left" ? 3 : 1)) % 4;
+ next.steps += 1;
+ return next;
+ }
+
+ // Step into the faced cell. Outcomes: moved | bump | locked | opened | key | win
+ function forward(state, layout) {
+ const next = { ...state };
+ const d = DIRS[state.dir];
+ const nx = state.x + d.dx, ny = state.y + d.dy;
+ const cell = layout.grid[ny][nx];
+ next.steps += 1;
+ if (cell === "#") return { state: next, outcome: "bump" };
+ if (cell === "D" && !next.hasKey) return { state: next, outcome: "locked" };
+ next.x = nx; next.y = ny;
+ if (cell === "D" && !next.doorOpen) next.doorOpen = true;
+ if (cell === "K" && !next.hasKey) {
+ next.hasKey = true;
+ return { state: next, outcome: "key" };
+ }
+ if (cell === "E") return { state: next, outcome: "win" };
+ return { state: next, outcome: cell === "D" ? "opened" : "moved" };
+ }
+
+ // Camera geometry shared by the first-person renderer and the FPP state
+ // text: PLANE = tan(60°) gives a 120° FOV (2*atan(PLANE)), so an object
+ // projects into view within ±60° of facing — the same cone the canvas
+ // draws. Wide on purpose: the decision model should almost never be
+ // looking at "nothing".
+ const PLANE = Math.tan(Math.PI / 3);
+ // 8-way egocentric bearing of a cell: index 0 is always dead ahead, 2 is
+ // always to the right, whatever the actor's orientation.
+ const SECTORS = ["ahead", "ahead-right", "right", "behind-right",
+ "behind", "behind-left", "left", "ahead-left"];
+
+ // DDA cast from the player's cell center along (rdx, rdy). Returns the
+ // distance to, side of, and content of the first sight-blocking cell — a
+ // wall, or the door while locked (an open door is transparent). This is
+ // the single definition of "what blocks sight": the renderer's wall
+ // columns, sprite clipping, and the text state's visibility all use it.
+ // Classic tie-break (y-step at exact corner ties): corner handling lives
+ // in occluded(), which samples several rays — one per point of the
+ // target cell — the way the renderer samples one per screen column.
+ function cast(state, layout, rdx, rdy) {
+ const px = state.x + 0.5, py = state.y + 0.5;
+ let mx = Math.floor(px), my = Math.floor(py);
+ const ddx = Math.abs(rdx) < 1e-9 ? 1e30 : Math.abs(1 / rdx);
+ const ddy = Math.abs(rdy) < 1e-9 ? 1e30 : Math.abs(1 / rdy);
+ let stepX, stepY, sdx, sdy;
+ if (rdx < 0) { stepX = -1; sdx = (px - mx) * ddx; } else { stepX = 1; sdx = (mx + 1.0 - px) * ddx; }
+ if (rdy < 0) { stepY = -1; sdy = (py - my) * ddy; } else { stepY = 1; sdy = (my + 1.0 - py) * ddy; }
+ let side = 0, cell = ".", x = mx, y = my;
+ for (let n = 0; n < 64; n++) {
+ if (sdx < sdy) { sdx += ddx; mx += stepX; side = 0; }
+ else { sdy += ddy; my += stepY; side = 1; }
+ cell = (my >= 0 && my < H && mx >= 0 && mx < W) ? layout.grid[my][mx] : "#";
+ x = mx; y = my;
+ if (cell === "#" || (cell === "D" && !state.doorOpen)) break;
+ }
+ return { dist: Math.max(0.05, side === 0 ? sdx - ddx : sdy - ddy), side, cell, x, y };
+ }
+
+ // Camera-plane projection of a cell center: tx = lateral offset, ty =
+ // depth along facing (ty <= 0.15 means behind the view plane).
+ function project(state, cell) {
+ const dir = DIRS[state.dir];
+ const planeX = -dir.dy * PLANE, planeY = dir.dx * PLANE;
+ const relX = cell.x + 0.5 - (state.x + 0.5), relY = cell.y + 0.5 - (state.y + 0.5);
+ const invDet = 1 / (planeX * dir.dy - dir.dx * planeY || 1e-9);
+ return { tx: invDet * (dir.dy * relX - dir.dx * relY),
+ ty: invDet * (-planeY * relX + planeX * relY) };
+ }
+
+ // 8-way egocentric bearing of a cell: "ahead" is always dead ahead,
+ // "right" is always to the right, whatever the actor's orientation.
+ function sector(state, cell) {
+ const eighth = Math.round(Math.atan2(cell.x - state.x, -(cell.y - state.y)) / (Math.PI / 4));
+ return SECTORS[(((eighth - state.dir * 2) % 8) + 8) % 8];
+ }
+
+ // Bearing for the In-view/Known lines, with exact-cardinal precision: when
+ // the cell lies exactly on a cardinal line in the facing frame ("directly
+ // ahead/behind/left/right"), say so — under tank controls that means one
+ // turn and then forward closes distance. Otherwise the plain eighth.
+ function bearing(state, cell) {
+ const dx = cell.x - state.x, dy = cell.y - state.y;
+ const dir = DIRS[state.dir], right = DIRS[(state.dir + 1) % 4];
+ const fwdC = dx * dir.dx + dy * dir.dy;
+ const rightC = dx * right.dx + dy * right.dy;
+ if (rightC === 0) return "directly " + (fwdC > 0 ? "ahead" : "behind");
+ if (fwdC === 0) return "directly " + (rightC > 0 ? "right" : "left");
+ return sector(state, cell);
+ }
+
+ // One sightline to a point inside the target cell: clear if it enters the
+ // target strictly before the first blocker, or if the target itself is
+ // that blocker (the locked door is visible — it must not occlude itself).
+ // Entry is the box crossing — the MAX of the two slab crossings — because
+ // min() credits entry at a corner the ray merely touches from the side.
+ function lineClear(state, layout, tx, ty, cell) {
+ const px = state.x + 0.5, py = state.y + 0.5;
+ const rx = tx - px, ry = ty - py;
+ const norm = Math.hypot(rx, ry);
+ const rdx = rx / norm, rdy = ry / norm;
+ const hit = cast(state, layout, rdx, rdy);
+ const blocking = hit.cell === "#" || (hit.cell === "D" && !state.doorOpen);
+ if (!blocking) return true; // no blocker: ran past the target
+ if (hit.x === cell.x && hit.y === cell.y) return true; // target itself blocks = seen
+ const ex = rdx > 0 ? (cell.x - px) / rdx : rdx < 0 ? (cell.x + 1 - px) / rdx : -Infinity;
+ const ey = rdy > 0 ? (cell.y - py) / rdy : rdy < 0 ? (cell.y + 1 - py) / rdy : -Infinity;
+ return Math.max(ex, ey) + 1e-9 < hit.dist; // entered the cell before the blocker
+ }
+
+ // True when every sightline from the actor to the cell is crossed. The
+ // renderer draws a sprite when ANY of its per-column rays reaches it, so
+ // the text samples a 3x3 grid of points across the cell the same way:
+ // one clean ray means visible; hidden only when all nine are crossed.
+ // A single center ray is stricter than the render at corner grazes (it
+ // can be blocked by a wall the sprite visibly peeks past) — that mismatch
+ // both hid the door standing on the key and leaked the exit in earlier
+ // single-ray fixes.
+ function occluded(state, layout, cell) {
+ for (const fx of [0.3, 0.5, 0.7])
+ for (const fy of [0.3, 0.5, 0.7])
+ if (lineClear(state, layout, cell.x + fx, cell.y + fy, cell)) return false;
+ return true;
+ }
+
+ // Objects a human would currently see: within the FOV cone and not
+ // wall-occluded, nearest first. Bearing only — distances live in the
+ // rays, where they serve collision. Text and canvas share cast/project,
+ // and occluded() samples the cell the way the renderer samples its
+ // columns, so "In view" agrees with what the renderer draws.
+ function visibleObjects(state, layout) {
+ const door = findDoor(layout);
+ const items = [];
+ if (!state.hasKey) items.push({ cell: layout.key, name: "the key" });
+ if (door && !state.doorOpen) items.push({ cell: door, name: "a locked door" });
+ items.push({ cell: layout.exit, name: "the exit" });
+ const seen = [];
+ for (const it of items) {
+ const { tx, ty } = project(state, it.cell);
+ if (ty <= 0.15) continue; // behind the view plane
+ // cam = tx/ty is the renderer's column coordinate: rays span cam ∈
+ // [-1, 1], so |cam| <= 1 is exactly "the sprite falls on screen".
+ if (Math.abs(tx / ty) > 1) continue; // outside the cone
+ if (occluded(state, layout, it.cell)) continue;
+ const norm = Math.hypot(it.cell.x - state.x, it.cell.y - state.y);
+ seen.push({ name: it.name, bearing: bearing(state, it.cell), dist: norm });
+ }
+ seen.sort((a, b) => a.dist - b.dist);
+ return seen.map(({ name, bearing: b }) => ({ name, bearing: b }));
+ }
+
+ function findDoor(layout) {
+ for (let y = 0; y < H; y++) for (let x = 0; x < W; x++)
+ if (layout.grid[y][x] === "D") return { x, y };
+ return null;
+ }
+
+ // Adjacent-cell descriptions: one line per direction, ALWAYS all four —
+ // "open floor" included, so no direction ever goes unreported. Egocentric
+ // labels only — ahead/right/behind/left — matching the bearing vocabulary.
+ // No compass line exists to anchor, and none is needed: "Ahead:" defines
+ // the frame word itself.
+ function adjacentLines(state, layout) {
+ const order = [["Ahead", state.dir], ["Right", (state.dir + 1) % 4],
+ ["Behind", (state.dir + 2) % 4], ["Left", (state.dir + 3) % 4]];
+ return order.map(([word, di]) => {
+ const d = DIRS[di];
+ const c = layout.grid[state.y + d.dy][state.x + d.dx];
+ const name =
+ c === "#" ? "a wall"
+ : c === "K" && !state.hasKey ? "the key"
+ : c === "D" && !state.doorOpen ? "a locked door"
+ : c === "E" ? "the exit"
+ : "open floor";
+ return `${word}: ${name}`;
+ });
+ }
+
+ // "In view" line shared by the ruled and neutral FPP texts — always
+ // present (a stable schema beats a variable one for a single-pass
+ // reader), bearing only, nearest first.
+ function inViewLine(state, layout) {
+ const seen = visibleObjects(state, layout);
+ return "In view: " + (seen.length
+ ? seen.map((o) => `${o.name} ${o.bearing}`).join("; ") + "."
+ : "nothing.");
+ }
+
+ // Discovered-landmark bearings. The caller threads a `seen` set through
+ // and adds whatever the cone reveals (the game loop folds each
+ // observation in, so the state that first reveals a POI is also the last
+ // one without its bearing). A POI appears here only while it is present
+ // (key until carried, the locked door until opened, the exit always)
+ // AND out of view — In view owns the visible ones, so each object is
+ // described exactly once. Bearing only; no claim about why it matters.
+ function knownLine(state, layout, seen = new Set()) {
+ const door = findDoor(layout);
+ const inView = new Set(visibleObjects(state, layout).map((o) => o.name));
+ const items = [];
+ if (seen.has("key") && !state.hasKey && !inView.has("the key"))
+ items.push({ cell: layout.key, name: "the key" });
+ if (seen.has("door") && !state.doorOpen && door && !inView.has("a locked door"))
+ items.push({ cell: door, name: "a locked door" });
+ if (seen.has("exit") && !inView.has("the exit"))
+ items.push({ cell: layout.exit, name: "the exit" });
+ if (!items.length) return "Known: nothing.";
+ items.sort((a, b) =>
+ Math.hypot(a.cell.x - state.x, a.cell.y - state.y) - Math.hypot(b.cell.x - state.x, b.cell.y - state.y));
+ // A bearing the actor cannot actually walk reads as an instruction to a
+ // single-pass reader — mark it, so a blocked compass never masquerades
+ // as a walkable direction. Unblocked bearings are guaranteed clear
+ // straight lines; blocked ones say "not this way, go around".
+ return "Known: " + items.map((o) =>
+ `${o.name} ${bearing(state, o.cell)}${occluded(state, layout, o.cell) ? " (blocked)" : ""}`).join("; ") + ".";
+ }
+
+ // First-person state: all four adjacent cells, the FOV cone, known-landmark
+ // bearings, carry status and a step count. The model must build its own
+ // map from this prose.
+ // Every direction word is relative to facing — there is no compass line.
+ function fppText(state, layout, guided, seen) {
+ const lines = [
+ "You are in a walled room. Reach the exit.",
+ "You can step forward into the space you face, or turn left or right.",
+ "A locked door blocks the room: find the key, then pass the door to reach the exit.",
+ "",
+ // Ordered for a single-pass reader: standing context first, immediate
+ // sensory evidence last (the freshest tokens weigh most).
+ "Current State:",
+ `Steps taken: ${state.steps}/${MAX_STEPS}`,
+ "",
+ knownLine(state, layout, seen),
+ `You carry: ${state.hasKey ? "the key" : "no key"}`,
+ "",
+ inViewLine(state, layout),
+ "",
+ ...adjacentLines(state, layout),
+ ];
+ if (guided) {
+ const [target, label] = !state.hasKey ? [layout.key, "the key"]
+ : state.doorOpen ? [layout.exit, "the exit"]
+ : [{ x: DIV, y: layout.doorRow }, "the door"];
+ // Relative like everything else in this state: no compass line exists.
+ lines.push("", `Hint: ${label} is roughly ${sector(state, target)} of you.`);
+ }
+ return lines.join("\n");
+ }
+
+ // The easy mode: hand the model the whole layout. Player cell is the facing
+ // arrow (^ > v <) so the grid stays one glyph per cell.
+ function mapText(state, layout) {
+ const rows = [];
+ for (let y = 0; y < H; y++) {
+ const row = [];
+ for (let x = 0; x < W; x++) {
+ if (x === state.x && y === state.y) { row.push(DIRS[state.dir].arrow); continue; }
+ let c = layout.grid[y][x];
+ if (c === "K" && state.hasKey) c = ".";
+ if (c === "D" && state.doorOpen) c = "."; // an opened door is just floor
+ row.push(c);
+ }
+ rows.push(row.join(" "));
+ }
+ return [
+ "Top-down map of the room. Reach the exit.",
+ "Legend: [`#`: wall, `.`: floor, `D`: locked door, `K`: key, `E`: exit, `^ > v <`: you, facing that way]",
+ "Stepping onto the key picks it up; the locked door opens once you carry the key.",
+ "",
+ ...rows,
+ "",
+ `Steps taken: ${state.steps}/${MAX_STEPS}`,
+ ].join("\n");
+ }
+
+ // mode: "guided" | "fpp" | "map" — guided = FPP prose + compass hint.
+ function stateText(state, layout, mode = "fpp", seen) {
+ if (mode === "map") return mapText(state, layout);
+ return fppText(state, layout, mode === "guided", seen);
+ }
+
+ // Neutral by design: the question must not leak the goal. It rides along
+ // with the state into every decision prompt AND every transcript turn the
+ // planner reads — "progress toward the exit" would tell the actor what the
+ // rules are supposed to make it discover.
+ const QUESTION = "Which action should the actor take?";
+ const OPTIONS = [
+ // Names only, deliberately: the mechanics (key + locked door, tank
+ // controls) are the planner's job to discover and state as rules, not
+ // ours to pre-chew in every decision prompt.
+ { id: "forward", description: "Move one step ahead." },
+ { id: "left", description: "Turn in place 90 degrees to the left." },
+ { id: "right", description: "Turn in place 90 degrees to the right." },
+ ];
+
+ // Action options for the CURRENT state: forward is offered only when the
+ // faced cell is enterable. A no-op input (a wall, or a locked door without
+ // the key) is not a decision the actor can meaningfully make — and with no
+ // event log it would produce no feedback either, just a silently burned
+ // step. Turns and the insufficient escape hatch are always available.
+ function optionsFor(state, layout) {
+ const d = DIRS[state.dir];
+ const cell = layout.grid[state.y + d.dy][state.x + d.dx];
+ const enterable = cell !== "#" && !(cell === "D" && !state.hasKey);
+ return enterable ? OPTIONS : OPTIONS.filter((o) => o.id !== "forward");
+ }
+
+ return { W, H, DIV, MAX_STEPS, DIRS, PLANE,
+ FALLBACK, rng, randInt, reachable, makeLayout, newState, turn, forward,
+ stateText, fppText, mapText, adjacentLines, cast, project, sector,
+ visibleObjects, inViewLine, knownLine, QUESTION, OPTIONS, optionsFor };
+})();
+
+if (typeof module !== "undefined") module.exports = RoomRules;
diff --git a/semif-api/src/semif_api/web/room-self-game.js b/semif-api/src/semif_api/web/room-self-game.js
@@ -0,0 +1,562 @@
+/* Puzzle room, self-learning variant — the game loop for the "Puzzle room SL"
+ * tab. Same room and first-person renderer as room.js, but the decision model
+ * is given no rules: only a neutral observation (FPP prose or the top-down
+ * map) and the bare actions forward/left/right, plus an "insufficient"
+ * escape hatch. Two events trigger a /plan call whose output is injected into
+ * every later observation as "Rules":
+ *
+ * 1. The model picks "insufficient" as its leading option at all
+ * (INSUFFICIENT_P = 0). The room has no failure signal until the step
+ * budget runs out, so a wandering model must be able to ask for rules
+ * early — unlike the platformer, which demands p ≥ 0.99. The planner
+ * writes full game rules, not a fix for one stuck situation, so the
+ * room restarts with the learned rules rather than re-deciding the
+ * same dead end (the platformer, with its dense per-step feedback,
+ * re-decides the same state instead).
+ * 2. The step budget (RoomRules.MAX_STEPS) runs out. The planner runs,
+ * then the room resets with the learned rules in place.
+ *
+ * Wrapped in an IIFE so its local `$` never collides with app.js. Requires
+ * room-rules.js, room-self-rules.js, and planner.js to have defined their
+ * globals first. */
+(() => {
+"use strict";
+
+const { W, H, MAX_STEPS, DIRS, makeLayout, newState, turn, forward } = RoomRules;
+const { QUESTION, optionsFor } = SelfRoomRules;
+const STEP_PAUSE_MS = 400; // pacing between decisions so a human can watch
+// Any plurality win for "insufficient" plans — see Planner.triggered's
+// threshold argument. The platformer's 0.99 would let a lost model wander
+// for most of the 80-step budget before anything intervened.
+const INSUFFICIENT_P = 0;
+
+/* ── mutable game state ─────────────────────────────────────────────── */
+let layout = makeLayout(1); // deterministic default; Randomize reseeds
+let state = newState(layout);
+let mode = "idle"; // idle | auto | manual | won | lost
+let deciding = false;
+let planning = false;
+let stateMode = "fpp"; // "fpp" = neutral prose | "map" = top-down
+let decisionN = 0;
+let planN = 0;
+let learnedRules = ""; // last /plan output; leads every observation
+// POIs the cone has ever revealed this layout: Known-line bearings persist
+// for the layout's life — auto-restarts keep them (same landmarks), full
+// Reset clears them (new layout).
+const discovered = new Set();
+let transcript = Planner.fresh(); // completed actions + outcomes for /plan
+let pending = null; // decision being executed, recorded once it resolves
+let runToken = 0; // invalidates in-flight work on reset
+// Run stats for the completion summary: wall time from the Start click
+// (spanning auto-restarts), failures, decisions, and the total token spend
+// of every planner call.
+const freshStats = () => ({ startedAt: null, failures: 0, decisions: 0, plans: 0,
+ planCompletionTokens: 0, planReasoningTokens: 0, planPromptTokens: 0, planMs: 0 });
+let stats = freshStats();
+const count = (n) => n.toLocaleString("en-US");
+
+const $ = (id) => document.getElementById(id);
+const cv = $("rs-cv"), ctx = cv.getContext("2d");
+
+function refreshLabel() {
+ $("rs-label").textContent =
+ `Room — seed ${layout.seed} · key: ${state.hasKey ? "found" : "missing"} · ` +
+ `door: ${state.doorOpen ? "open" : "locked"} · steps: ${state.steps}/${MAX_STEPS}`;
+}
+refreshLabel();
+
+// Same rule as room.js: shortcuts only while this panel is visible and focus
+// is not in an editable element.
+const roomActive = () => {
+ const t = document.activeElement;
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return false;
+ return !$("panel-roomself").hidden;
+};
+
+/* ── the ONE textual state: source for rendering prompts AND the API ── */
+// Bare observation: no learned rules. This is what the transcript records —
+// the planner already receives the current rules ONCE via Planner.context's
+// "Previous rules", so re-embedding them in all 24 history turns
+// would just bloat its prompt and blur which rules were current.
+// Also folds whatever the cone currently sees into the discovered set, so
+// the observation that reveals a POI is the last one without its bearing.
+function bareState() {
+ for (const o of RoomRules.visibleObjects(state, layout)) {
+ const id = { "the key": "key", "a locked door": "door", "the exit": "exit" }[o.name];
+ if (id) discovered.add(id);
+ }
+ return SelfRoomRules.stateText(state, layout, stateMode, discovered);
+}
+function stateText() {
+ const base = bareState();
+ // Learned rules lead, the current state follows. The freshest tokens
+ // should be the immediate evidence the decision is made from, not the
+ // standing rules — the decision pass reads once, and recent tokens weigh
+ // most.
+ return learnedRules ? `Rules:\n${learnedRules}\n\n${base}` : base;
+}
+
+/* ── first-person rendering (DDA raycast over the same grid) ────────── */
+const RAYS = 240;
+
+function render() {
+ const w = cv.width, h = cv.height;
+ ctx.fillStyle = "#10141d"; // ceiling
+ ctx.fillRect(0, 0, w, h / 2);
+ ctx.fillStyle = "#242b3a"; // floor
+ ctx.fillRect(0, h / 2, w, h / 2);
+
+ const dir = DIRS[state.dir];
+ const planeX = -dir.dy * RoomRules.PLANE, planeY = dir.dx * RoomRules.PLANE;
+ const strip = w / RAYS;
+ const zbuf = new Float32Array(RAYS);
+
+ for (let i = 0; i < RAYS; i++) {
+ const cam = 2 * i / RAYS - 1;
+ // RoomRules.cast is the single definition of sight-blocking: same math
+ // the FPP state text uses, so the canvas and "In view" can't drift.
+ const hit = RoomRules.cast(state, layout, dir.dx + planeX * cam, dir.dy + planeY * cam);
+ zbuf[i] = hit.dist;
+ const lineH = h / hit.dist;
+ const shade = Math.max(0.2, 1 - hit.dist / 12) * (hit.side === 1 ? 0.8 : 1);
+ const base = hit.cell === "D" ? [152, 98, 44] : [104, 120, 152];
+ ctx.fillStyle = `rgb(${base.map((c) => Math.round(c * shade)).join(",")})`;
+ ctx.fillRect(Math.floor(i * strip), h / 2 - lineH / 2, Math.ceil(strip), lineH);
+ }
+
+ drawSprite(zbuf, strip, layout.exit, true, (s) => { // exit: tall green portal
+ ctx.fillStyle = "#1d3a24";
+ ctx.fillRect(s.x0, s.top, s.width, s.height);
+ ctx.fillStyle = "#56d364";
+ ctx.fillRect(s.x0 + s.width * 0.15, s.top + s.height * 0.1, s.width * 0.7, s.height * 0.8);
+ });
+ if (!state.hasKey) {
+ drawSprite(zbuf, strip, layout.key, true, (s) => { // key: small yellow disc
+ ctx.fillStyle = "#e3b341";
+ ctx.beginPath();
+ ctx.arc(s.cx, s.cy, Math.max(2, s.width * 0.4), 0, Math.PI * 2);
+ ctx.fill();
+ }, 0.30, 0.55);
+ }
+
+ // HUD: facing + carry, so the human view matches what the state asserts.
+ ctx.fillStyle = "rgba(11, 14, 20, 0.65)";
+ ctx.fillRect(8, 8, 236, 22);
+ ctx.fillStyle = "#c9d1d9";
+ ctx.font = "13px monospace";
+ ctx.fillText(`facing ${DIRS[state.dir].name} · ${state.hasKey ? "key ✓" : "no key"}`, 14, 23);
+}
+
+// Project a cell-center billboard into the view, clipping each column against
+// the wall z-buffer. scale = height fraction of a wall at that distance,
+// lift = vertical centering (0.5 = middle).
+function drawSprite(zbuf, strip, cell, visible, draw, scale = 0.85, lift = 0.5) {
+ if (!visible) return;
+ const { tx, ty } = RoomRules.project(state, cell);
+ if (ty <= 0.15) return;
+ const w = cv.width, h = cv.height;
+ const cx = (w / 2) * (1 + tx / ty);
+ const height = (h / ty) * scale;
+ const width = height * 0.6;
+ const s = {
+ cx, cy: h / 2 + (lift - 0.5) * (h / ty),
+ x0: cx - width / 2, width, height,
+ top: h / 2 + (lift - 0.5) * (h / ty) - height / 2,
+ };
+ const col0 = Math.max(0, Math.floor(s.x0 / strip));
+ const col1 = Math.min(zbuf.length - 1, Math.floor((s.x0 + width) / strip));
+ for (let c = col0; c <= col1; c++) {
+ if (zbuf[c] <= ty) continue; // wall nearer than the sprite here
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(c * strip, 0, strip + 1, h);
+ ctx.clip();
+ draw(s);
+ ctx.restore();
+ }
+}
+
+/* ── transcript: one turn per completed action, read by /plan ───────── */
+function recordOutcome(outcome) {
+ if (!pending) return; // manual play: no observation/choice to record
+ Planner.record(transcript,
+ `Observation:\n${pending.state}\nChosen action: ${pending.choice}\nOutcome: ${outcome}`);
+ pending = null;
+}
+
+// forward() outcomes as third-person facts for the transcript.
+const OUTCOME_TEXT = {
+ moved: "the player moved forward.",
+ bump: "the player bumped into a wall.",
+ locked: "the player tried a locked door.",
+ opened: "the player unlocked the door with the key and stepped through.",
+ key: "the player picked up the key.",
+};
+
+/* ── SemIf decision loop (turn-based: one decision = one action) ────── */
+async function requestDecision() {
+ if (deciding || planning || mode !== "auto") return;
+ deciding = true;
+ const token = runToken;
+ setStatus("thinking…", "");
+ const text = stateText();
+ const bare = bareState(); // transcript records this, rules live in /plan context
+ $("rs-state-view").textContent = text;
+ const n = ++decisionN;
+ let result, elapsedMs, err = null;
+ try {
+ const t0 = performance.now();
+ const resp = await fetch("/decide", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ id: `roomself-${n}`, state: text, question: QUESTION, options: optionsFor(state, layout) }),
+ });
+ elapsedMs = performance.now() - t0;
+ const payload = JSON.parse(await resp.text());
+ if (!resp.ok) throw new Error(typeof payload.detail === "string" ? payload.detail : resp.statusText);
+ result = payload;
+ } catch (e) { err = e; }
+ if (token !== runToken || mode !== "auto") return; // reset while we waited
+ deciding = false;
+ if (err) {
+ logError(n, err.message);
+ setStatus(`decision failed: ${err.message} — retrying in 2 s`, "err");
+ setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, 2000);
+ return;
+ }
+ stats.decisions++;
+ const pairs = result.option_ids.map((id, i) => [id, result.probabilities[i]]);
+ const [choice, p] = Planner.best(result);
+ logDecision(n, [choice, p], pairs, elapsedMs, text);
+
+ if (choice === Planner.INSUFFICIENT_ID) {
+ if (Planner.triggered(result, INSUFFICIENT_P)) {
+ // "I cannot decide": the planner writes full game rules, not a way out
+ // of this exact spot — end the attempt and restart the room so the
+ // model applies the rules from the start, on the same layout, with
+ // the transcript intact.
+ pending = null;
+ lose("The model cannot decide and asks for rules.");
+ void replanAndRetry("insufficient");
+ return;
+ }
+ // Unreachable with INSUFFICIENT_P = 0 (any plurality plans), kept for
+ // symmetry with the platformer: a weak insufficient loses the argmax to
+ // the best real action, with a visible note.
+ const real = pairs.filter(([id]) => id !== Planner.INSUFFICIENT_ID)
+ .sort((a, b) => b[1] - a[1])[0];
+ setStatus(`insufficient evidence (p ${p.toFixed(3)}) — taking best real action: ${real[0]}`, "");
+ applyAction(real[0], bare, token);
+ return;
+ }
+ applyAction(choice, bare, token);
+}
+
+function applyAction(choice, observed, token) {
+ pending = { state: observed, choice };
+ let outcome;
+ if (choice === "left" || choice === "right") {
+ state = turn(state, choice);
+ outcome = `the player turned ${choice}.`;
+ } else {
+ const r = forward(state, layout);
+ state = r.state;
+ if (r.outcome === "win") {
+ recordOutcome("the player reached the exit.");
+ win();
+ return;
+ }
+ outcome = OUTCOME_TEXT[r.outcome];
+ }
+ recordOutcome(outcome);
+ render(); refreshLabel();
+ if (mode !== "auto") return; // applyAction may have won/lost
+ if (state.steps >= MAX_STEPS) {
+ recordOutcome("the player used up the step budget.");
+ lose(`No exit after ${MAX_STEPS} steps.`);
+ void replanAndRetry("steps");
+ return;
+ }
+ setStatus(`playing… · step ${state.steps}/${MAX_STEPS}`, "");
+ setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, STEP_PAUSE_MS);
+}
+
+/* ── /plan: derive rules from the transcript, then keep playing ─────── */
+async function replan(reason, token) {
+ planning = true;
+ const n = ++planN;
+ setPlanningStatus(reason === "steps"
+ ? "out of steps — planning rules from the transcript…"
+ : "insufficient evidence — planning rules from the transcript…");
+ const { e, choice, ms } = addPlanEntry(n, reason);
+ let payload = null, err = null, elapsedMs = 0;
+ try {
+ const t0 = performance.now();
+ // The endpoint is game-agnostic: Planner.context() wraps the simulation's
+ // one-line goal with the trigger note and the rules currently in effect —
+ // on repeat failures the planner can see and amend its own previous plan.
+ const trigger = reason === "steps"
+ ? "the actor used up the step budget and the attempt ended"
+ : "the actor declared the evidence insufficient and could not decide";
+ payload = await Planner.request({
+ id: `roomself-plan-${n}`,
+ prompt: Planner.context(SelfRoomRules.PLAN_GOAL, trigger, learnedRules),
+ transcript,
+ });
+ elapsedMs = performance.now() - t0;
+ } catch (e2) { err = e2; }
+ if (token !== runToken) return false; // reset while planning: discard result
+ planning = false;
+ if (err) {
+ choice.textContent = "planning failed";
+ const d = document.createElement("div"); d.className = "probs"; d.textContent = err.message;
+ e.appendChild(d);
+ setStatus(`planning failed: ${err.message} — press Reset or Start to continue`, "err");
+ return false;
+ }
+ learnedRules = payload.rules;
+ $("rs-rules-view").value = learnedRules;
+ const usage = payload.usage || {};
+ stats.plans++;
+ stats.planMs += elapsedMs;
+ stats.planCompletionTokens += usage.completion_tokens || 0;
+ stats.planPromptTokens += usage.prompt_tokens || 0;
+ stats.planReasoningTokens += (usage.completion_tokens_details || {}).reasoning_tokens || 0;
+ renderStats();
+ choice.textContent = `→ rules learned (${count(usage.completion_tokens || 0)} tokens)`;
+ ms.textContent = `${elapsedMs.toFixed(0)} ms`;
+ // Planner output, expandable in place: the rules pane only ever shows the
+ // latest set, so the log entry keeps every plan (rules + thinking trace)
+ // inspectable. Click toggles between a preview and the full text.
+ const detailParts = ["rules:\n" + payload.rules];
+ if (payload.reasoning) detailParts.push("thinking:\n" + payload.reasoning);
+ const detail = detailParts.join("\n\n");
+ const d = document.createElement("div"); d.className = "probs expandable";
+ let open = false;
+ const paint = () => {
+ d.textContent = (open ? detail : detail.slice(0, 240) + (detail.length > 240 ? "…" : "")) +
+ (detail.length > 240 ? (open ? " ▲" : " ▼") : "");
+ };
+ paint();
+ d.addEventListener("click", () => { open = !open; paint(); });
+ e.appendChild(d);
+ e.title = detail; // hover for the full trace
+ if (payload.truncated) {
+ const warn = document.createElement("div"); warn.className = "probs";
+ warn.textContent = "warning: reply hit the server's token limit and was truncated";
+ e.appendChild(warn);
+ }
+ setStatus("rules updated", "");
+ return true;
+}
+
+// Failed attempt (step budget exhausted, or the model asked for rules):
+// plan, then restart the room with the learned rules and go again. This
+// continues the SAME run: stats and the Start-click timer keep going. The
+// layout is unchanged — the model gets another try at the same room, now
+// with rules.
+async function replanAndRetry(reason) {
+ const token = runToken;
+ if (!await replan(reason, token)) return;
+ if (mode !== "lost" || token !== runToken) return; // user took over meanwhile
+ resetLevel();
+ setStatus("restarting with learned rules…", "");
+ mode = "auto";
+ $("rs-start").disabled = true;
+ void requestDecision();
+}
+
+/* ── decision log ───────────────────────────────────────────────────── */
+function clearLog() { const l = $("rs-log"); while (l.firstChild) l.removeChild(l.firstChild); }
+function addEntry(n, cls) {
+ const e = document.createElement("div"); e.className = `entry${cls ? " " + cls : ""}`;
+ const head = document.createElement("div"); head.className = "head";
+ const num = document.createElement("span"); num.className = "n";
+ num.textContent = `#${n} · ${stateMode}`;
+ const choice = document.createElement("span"); choice.className = "choice";
+ const ms = document.createElement("span"); ms.className = "ms";
+ head.append(num, choice, ms); e.appendChild(head);
+ $("rs-log").prepend(e);
+ return { e, choice, ms };
+}
+function logDecision(n, best, pairs, elapsedMs, sentState) {
+ const { e, choice, ms } = addEntry(n);
+ choice.textContent = `→ ${best[0]} (p=${best[1].toFixed(3)})`;
+ ms.textContent = `${elapsedMs.toFixed(0)} ms`;
+ const probs = document.createElement("div"); probs.className = "probs";
+ probs.textContent = pairs.map(([id, p]) => `${id} ${p.toFixed(3)}`).join(" ");
+ e.appendChild(probs);
+ const bar = document.createElement("div"); bar.className = "bar";
+ const fill = document.createElement("span"); fill.style.width = `${(best[1] * 100).toFixed(1)}%`;
+ bar.appendChild(fill); e.appendChild(bar);
+ e.title = sentState;
+}
+function addPlanEntry(n, reason) {
+ const entry = addEntry(n, "plan");
+ entry.e.firstChild.firstChild.textContent = `#plan ${n} · ${reason} · ${stateMode}`;
+ return entry;
+}
+function logError(n, msg) {
+ const { e, choice } = addEntry(n, "error");
+ choice.textContent = "request failed";
+ const d = document.createElement("div"); d.className = "probs"; d.textContent = msg;
+ e.appendChild(d);
+}
+
+/* ── status / stats / win / lose / reset ────────────────────────────── */
+function setStatus(t, cls) {
+ const s = $("rs-status");
+ s.className = `status${cls ? " " + cls : ""}`;
+ s.textContent = t;
+}
+// Planning status: same text and size, but each letter becomes a span with a
+// staggered negative animation delay, so the CSS rainbow travels down the
+// text as a wave while /plan runs. Any later setStatus() restores plain text.
+function setPlanningStatus(t) {
+ const s = $("rs-status");
+ s.className = "status planning";
+ s.replaceChildren(...[...t].map((ch, i) => {
+ const span = document.createElement("span");
+ span.textContent = ch;
+ span.style.setProperty("--i", i);
+ return span;
+ }));
+}
+
+// Completion summary, refreshed after every plan and at the end of the run.
+// Wall time is measured from the Start click and spans auto-restarts.
+function renderStats(result) {
+ const lines = [];
+ if (result) lines.push(`result: ${result}`);
+ if (stats.startedAt !== null) {
+ lines.push(`wall time: ${((performance.now() - stats.startedAt) / 1000).toFixed(1)} s (from Start click)`);
+ }
+ lines.push(`decisions: ${stats.decisions}`);
+ lines.push(`failures: ${stats.failures}`);
+ lines.push(`plans: ${stats.plans}`);
+ if (stats.plans > 0) {
+ const reasoning = stats.planReasoningTokens ? ` (${count(stats.planReasoningTokens)} reasoning)` : "";
+ lines.push(`planner tokens: ${count(stats.planCompletionTokens)} completion${reasoning} · ${count(stats.planPromptTokens)} prompt`);
+ lines.push(`planning time: ${(stats.planMs / 1000).toFixed(1)} s`);
+ }
+ $("rs-stats").textContent = lines.join("\n");
+}
+function win() {
+ mode = "won";
+ const seconds = stats.startedAt !== null ? `${((performance.now() - stats.startedAt) / 1000).toFixed(1)} s` : "—";
+ const thinking = stats.plans > 0
+ ? ` · ${stats.plans} plan${stats.plans === 1 ? "" : "s"}, ${count(stats.planCompletionTokens)} planner tokens`
+ : "";
+ setStatus(`🏁 exit reached in ${state.steps} steps · ${seconds} · ${stats.decisions} decision(s) · ${stats.failures} failure(s)${thinking} — press Reset to run it again`, "win");
+ renderStats("success");
+ render(); refreshLabel();
+}
+function lose(msg) {
+ mode = "lost";
+ stats.failures++;
+ renderStats();
+ setStatus(`✗ ${msg}`, "err");
+ render(); refreshLabel();
+}
+// Room state only: keeps learning (rules, transcript) AND run stats, so the
+// auto-restart after the budget runs out continues the same run. Full Reset
+// below clears everything.
+function resetLevel() {
+ runToken++;
+ deciding = false; planning = false;
+ state = newState(layout);
+ mode = "idle"; decisionN = 0; pending = null;
+ render(); refreshLabel();
+}
+// Full reset: room + learned rules + transcript + run stats. The next Start
+// begins a fresh run from a blank slate.
+function reset() {
+ resetLevel();
+ learnedRules = "";
+ discovered.clear();
+ transcript = Planner.fresh();
+ stats = freshStats();
+ clearLog(); // the decision/plan log is part of the run, not learning
+ $("rs-start").disabled = false;
+ $("rs-rules-view").value = "";
+ $("rs-stats").textContent = "(run not started)";
+ setStatus("idle — press Start", "");
+ $("rs-state-view").textContent = stateText(); // idle preview: same text /decide would receive
+}
+function forgetRules() {
+ learnedRules = "";
+ transcript = Planner.fresh();
+ $("rs-rules-view").value = "";
+ setStatus("rules forgotten", "");
+}
+
+// The rules pane is an editor: whatever is in it IS the rules, hand-typed or
+// pasted, so a good plan can be captured, tweaked, or replayed for a
+// reproducible run. Edits apply to the very next decision.
+$("rs-rules-view").addEventListener("input", (e) => { learnedRules = e.target.value; });
+
+/* ── controls ───────────────────────────────────────────────────────── */
+const stateModes = ["fpp", "map"];
+const setStateMode = (m) => {
+ stateMode = m;
+ for (const name of stateModes) $("rs-mode-" + name).className = name === m ? "on" : "";
+};
+for (const name of stateModes) $("rs-mode-" + name).addEventListener("click", () => setStateMode(name));
+
+$("rs-start").addEventListener("click", () => {
+ if (mode === "auto") return;
+ if (mode === "won" || mode === "lost") reset();
+ stats = freshStats(); // a Start click begins a new timed run
+ stats.startedAt = performance.now();
+ renderStats();
+ mode = "auto";
+ $("rs-start").disabled = true;
+ void requestDecision();
+});
+$("rs-reset").addEventListener("click", reset);
+$("rs-forget").addEventListener("click", forgetRules);
+
+/* ── seeded layout (reproducible) ───────────────────────────────────── */
+const parseSeed = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n >>> 0 : null; };
+function applySeed(seedNum) {
+ layout = makeLayout(seedNum);
+ reset();
+}
+$("rs-randomize").addEventListener("click", () => {
+ const s = (Math.random() * 0x100000000) >>> 0;
+ $("rs-seed").value = String(s);
+ applySeed(s);
+});
+$("rs-seed").addEventListener("change", () => {
+ const s = parseSeed($("rs-seed").value);
+ if (s !== null) applySeed(s);
+});
+
+/* ── manual drive ───────────────────────────────────────────────────── */
+addEventListener("keydown", (e) => {
+ if (!roomActive()) return;
+ if (mode !== "idle" && mode !== "manual") return;
+ let acted = false;
+ if (e.key === "ArrowLeft" || e.key === "a") { state = turn(state, "left"); acted = true; }
+ else if (e.key === "ArrowRight" || e.key === "d") { state = turn(state, "right"); acted = true; }
+ else if (e.key === "ArrowUp" || e.key === "w" || e.key === " ") {
+ e.preventDefault();
+ const r = forward(state, layout);
+ state = r.state;
+ acted = true;
+ if (r.outcome === "win") { win(); return; }
+ }
+ if (!acted) return;
+ if (mode === "idle") { mode = "manual"; setStatus("manual mode", ""); }
+ render(); refreshLabel();
+ // Mirror the decision text into the pane, exactly as requestDecision()
+ // composes it (rules + bare state) — manual play doubles as a debugger
+ // for the prompt.
+ $("rs-state-view").textContent = stateText();
+ if (state.steps >= MAX_STEPS) {
+ lose(`No exit after ${MAX_STEPS} steps.`);
+ void replanAndRetry("steps");
+ }
+});
+
+reset();
+})();
diff --git a/semif-api/src/semif_api/web/room-self-rules.js b/semif-api/src/semif_api/web/room-self-rules.js
@@ -0,0 +1,97 @@
+"use strict";
+
+/* No-rules puzzle room: the same turn-based grid as room-rules.js with every
+ * line of how-to-play prose removed. The model is never told the goal, what
+ * stepping or turning does, or what the key/door/exit mean — it only sees
+ * neutral observations (adjacent presences, what the view cone
+ * sees, bearings to discovered landmarks, carry status, a step count,
+ * or the top-down map with a neutral legend) plus the action list
+ * with accurate descriptions, and an "insufficient" escape hatch. When it
+ * cannot decide, or the step budget runs out, the client asks /plan for rules
+ * and injects the answer into later states as "Rules".
+ *
+ * Mechanics are RoomRules' (this module only assembles observation text);
+ * SelfRoomRules.PLAN_GOAL is the simulation's one-line statement of the goal —
+ * the only game-specific input to /plan.
+ */
+const SelfRoomRules = (() => {
+ const { W, H, MAX_STEPS, DIRS, adjacentLines, inViewLine, knownLine } = RoomRules;
+
+ // First-person state — the ruled fppText minus its three how-to-play lines
+ // (the room/goal intro, the action explanation, the key/door briefing) and
+ // the guided hint. Facing, presence, in-view, carry and steps stay:
+ // they are observations, not rules. There is no event log: a list of the
+ // actor's own recent actions is imitation bait for a single-pass reader.
+ function fppText(state, layout, seen) {
+ return [
+ // Ordered for a single-pass reader: standing context first, immediate
+ // sensory evidence last (the freshest tokens weigh most).
+ "Current State:",
+ `Steps taken: ${state.steps}/${MAX_STEPS}`,
+ "",
+ knownLine(state, layout, seen),
+ `You carry: ${state.hasKey ? "the key" : "no key"}`,
+ "",
+ inViewLine(state, layout),
+ "",
+ ...adjacentLines(state, layout),
+ ].join("\n");
+ }
+
+ // Top-down map — the ruled mapText minus the goal line and the pickup/door
+ // instructions. The legend stays: it names symbols, it does not teach rules.
+ function mapText(state, layout) {
+ const rows = [];
+ for (let y = 0; y < H; y++) {
+ const row = [];
+ for (let x = 0; x < W; x++) {
+ if (x === state.x && y === state.y) { row.push(DIRS[state.dir].arrow); continue; }
+ let c = layout.grid[y][x];
+ if (c === "K" && state.hasKey) c = ".";
+ if (c === "D" && state.doorOpen) c = "o";
+ row.push(c);
+ }
+ rows.push(row.join(" "));
+ }
+ return [
+ "Legend: [`#`: wall, `.`: floor, `D`: locked door, `o`: open door, `K`: key, `E`: exit, `^ > v <`: you, facing that way]",
+ "",
+ ...rows,
+ "",
+ `Steps taken: ${state.steps}/${MAX_STEPS}`,
+ ].join("\n");
+ }
+
+ // mode: "fpp" | "map" — both neutral; there is deliberately no guided
+ // variant: guidance was instructional prose, which this demo never sends.
+ function stateText(state, layout, mode = "fpp", seen) {
+ if (mode === "map") return mapText(state, layout);
+ return fppText(state, layout, seen);
+ }
+
+ const QUESTION = "What should the player do now?";
+ // Actions only, with accurate descriptions, plus the escape hatch. Unlike
+ // the platformer there is no denied action here (a bump is still a
+ // completed step), so insufficient is the only mid-attempt trigger.
+ const OPTIONS = [
+ ...RoomRules.OPTIONS,
+ { id: Planner.INSUFFICIENT_ID, description: "Insufficient evidence to decide" },
+ ];
+
+ // Options for the CURRENT state: forward is withheld when the faced cell
+ // is a no-op (wall, or a locked door without the key) — a decision that
+ // cannot change anything is not a decision. Insufficient stays.
+ const optionsFor = (state, layout) => [
+ ...RoomRules.optionsFor(state, layout),
+ OPTIONS[OPTIONS.length - 1],
+ ];
+
+ /* The simulation's one-line statement of the goal — the only game-specific
+ * input to /plan. Planner.context() wraps it with the trigger note and the
+ * rules currently in effect; the system prompt is universal. */
+ const PLAN_GOAL = "Reach the exit of the room.";
+
+ return { fppText, mapText, stateText, QUESTION, OPTIONS, optionsFor, PLAN_GOAL };
+})();
+
+if (typeof module !== "undefined") module.exports = SelfRoomRules;
diff --git a/semif-api/src/semif_api/web/room.js b/semif-api/src/semif_api/web/room.js
@@ -0,0 +1,297 @@
+/* Puzzle room demo — a first-person view of a grid room; SemIf makes every
+ * move from the text state alone. Wrapped in an IIFE so its local `$`
+ * (getElementById) never collides with app.js's top-level `$` (querySelector).
+ * Requires room-rules.js to have defined the global `RoomRules` first. */
+(() => {
+"use strict";
+
+const { W, H, MAX_STEPS, DIRS, makeLayout, newState, turn, forward, stateText, QUESTION, optionsFor } = RoomRules;
+const STEP_PAUSE_MS = 400; // pacing between decisions so a human can watch
+
+/* ── mutable game state ─────────────────────────────────────────────── */
+let layout = makeLayout(1); // deterministic default; Randomize reseeds
+let state = newState(layout);
+let mode = "idle"; // idle | auto | manual | won | lost
+let deciding = false;
+let stateMode = "guided"; // "guided" = FPP + compass hint; "fpp" = prose only; "map" = top-down ASCII
+// POIs the cone has ever revealed this layout: Known-line bearings persist
+// until a new layout is dealt (Reset / Randomize / Seed).
+const discovered = new Set();
+let decisionN = 0;
+let runToken = 0; // invalidates in-flight decisions on reset
+
+const $ = (id) => document.getElementById(id);
+const cv = $("room-cv"), ctx = cv.getContext("2d");
+
+function refreshLabel() {
+ $("room-label").textContent =
+ `Room — seed ${layout.seed} · key: ${state.hasKey ? "found" : "missing"} · ` +
+ `door: ${state.doorOpen ? "open" : "locked"} · steps: ${state.steps}/${MAX_STEPS}`;
+}
+
+// The game shares a page with text fields, so its keyboard shortcuts must not
+// swallow typing or drive the game from another tab.
+const roomActive = () => {
+ const t = document.activeElement;
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return false;
+ return !$("panel-room").hidden;
+};
+
+/* ── first-person rendering (DDA raycast over the same grid) ────────── */
+const RAYS = 240; // FOV comes from RoomRules.PLANE (120°)
+
+function render() {
+ const w = cv.width, h = cv.height;
+ ctx.fillStyle = "#10141d"; // ceiling
+ ctx.fillRect(0, 0, w, h / 2);
+ ctx.fillStyle = "#242b3a"; // floor
+ ctx.fillRect(0, h / 2, w, h / 2);
+
+ const dir = DIRS[state.dir];
+ const planeX = -dir.dy * RoomRules.PLANE, planeY = dir.dx * RoomRules.PLANE;
+ const strip = w / RAYS;
+ const zbuf = new Float32Array(RAYS);
+
+ for (let i = 0; i < RAYS; i++) {
+ const cam = 2 * i / RAYS - 1;
+ // RoomRules.cast is the single definition of sight-blocking: same math
+ // the FPP state text uses, so the canvas and "In view" can't drift.
+ const hit = RoomRules.cast(state, layout, dir.dx + planeX * cam, dir.dy + planeY * cam);
+ zbuf[i] = hit.dist;
+ const lineH = h / hit.dist;
+ const shade = Math.max(0.2, 1 - hit.dist / 12) * (hit.side === 1 ? 0.8 : 1);
+ const base = hit.cell === "D" ? [152, 98, 44] : [104, 120, 152];
+ ctx.fillStyle = `rgb(${base.map((c) => Math.round(c * shade)).join(",")})`;
+ ctx.fillRect(Math.floor(i * strip), h / 2 - lineH / 2, Math.ceil(strip), lineH);
+ }
+
+ drawSprite(zbuf, strip, layout.exit, true, (s) => { // exit: tall green portal
+ ctx.fillStyle = "#1d3a24";
+ ctx.fillRect(s.x0, s.top, s.width, s.height);
+ ctx.fillStyle = "#56d364";
+ ctx.fillRect(s.x0 + s.width * 0.15, s.top + s.height * 0.1, s.width * 0.7, s.height * 0.8);
+ });
+ if (!state.hasKey) {
+ drawSprite(zbuf, strip, layout.key, true, (s) => { // key: small yellow disc
+ ctx.fillStyle = "#e3b341";
+ ctx.beginPath();
+ ctx.arc(s.cx, s.cy, Math.max(2, s.width * 0.4), 0, Math.PI * 2);
+ ctx.fill();
+ }, 0.30, 0.55);
+ }
+
+ // HUD: facing + carry, so the human view matches what the state asserts.
+ ctx.fillStyle = "rgba(11, 14, 20, 0.65)";
+ ctx.fillRect(8, 8, 236, 22);
+ ctx.fillStyle = "#c9d1d9";
+ ctx.font = "13px monospace";
+ ctx.fillText(`facing ${DIRS[state.dir].name} · ${state.hasKey ? "key ✓" : "no key"}`, 14, 23);
+}
+
+// Project a cell-center billboard into the view, clipping each column against
+// the wall z-buffer. scale = height fraction of a wall at that distance,
+// lift = vertical centering (0.5 = middle).
+function drawSprite(zbuf, strip, cell, visible, draw, scale = 0.85, lift = 0.5) {
+ if (!visible) return;
+ const { tx, ty } = RoomRules.project(state, cell);
+ if (ty <= 0.15) return;
+ const w = cv.width, h = cv.height;
+ const cx = (w / 2) * (1 + tx / ty);
+ const height = (h / ty) * scale;
+ const width = height * 0.6;
+ const s = {
+ cx, cy: h / 2 + (lift - 0.5) * (h / ty),
+ x0: cx - width / 2, width, height,
+ top: h / 2 + (lift - 0.5) * (h / ty) - height / 2,
+ };
+ const col0 = Math.max(0, Math.floor(s.x0 / strip));
+ const col1 = Math.min(zbuf.length - 1, Math.floor((s.x0 + width) / strip));
+ for (let c = col0; c <= col1; c++) {
+ if (zbuf[c] <= ty) continue; // wall nearer than the sprite here
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(c * strip, 0, strip + 1, h);
+ ctx.clip();
+ draw(s);
+ ctx.restore();
+ }
+}
+
+/* ── SemIf decision loop ────────────────────────────────────────────── */
+async function requestDecision() {
+ if (deciding || mode !== "auto") return;
+ deciding = true;
+ const token = runToken;
+ setStatus("thinking…", "");
+ // Fold whatever the cone currently sees into the discovered set, so the
+ // observation that reveals a POI is the last one without its bearing.
+ for (const o of RoomRules.visibleObjects(state, layout)) {
+ const id = { "the key": "key", "a locked door": "door", "the exit": "exit" }[o.name];
+ if (id) discovered.add(id);
+ }
+ const text = stateText(state, layout, stateMode, discovered);
+ $("room-state-view").textContent = text; const n = ++decisionN;
+ let result, elapsedMs, err = null;
+ try {
+ const t0 = performance.now();
+ const resp = await fetch("/decide", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ id: `room-${n}`, state: text, question: QUESTION, options: optionsFor(state, layout) }),
+ });
+ elapsedMs = performance.now() - t0;
+ const payload = JSON.parse(await resp.text());
+ if (!resp.ok) throw new Error(typeof payload.detail === "string" ? payload.detail : resp.statusText);
+ result = payload;
+ } catch (e) { err = e; }
+ if (token !== runToken || mode !== "auto") return; // reset while we waited
+ deciding = false;
+ if (err) {
+ logError(n, err.message);
+ setStatus(`decision failed: ${err.message} — retrying in 2 s`, "err");
+ setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, 2000);
+ return;
+ }
+ const pairs = result.option_ids.map((id, i) => [id, result.probabilities[i]]);
+ const best = pairs.reduce((a, b) => (b[1] > a[1] ? b : a));
+ logDecision(n, best, pairs, elapsedMs, text);
+ applyAction(best[0]);
+ render(); refreshLabel();
+ if (mode !== "auto") return; // applyAction may have won/lost
+ if (state.steps >= MAX_STEPS) { lose(`No exit after ${MAX_STEPS} steps.`); return; }
+ setStatus(`playing… · step ${state.steps}/${MAX_STEPS}`, "");
+ setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, STEP_PAUSE_MS);
+}
+
+function applyAction(action) {
+ if (action === "left" || action === "right") {
+ state = turn(state, action);
+ } else {
+ const r = forward(state, layout);
+ state = r.state;
+ if (r.outcome === "win") { win(); return; }
+ }
+}
+
+/* ── decision log (DOM built with textContent, like the main UI) ────── */
+function clearLog() { const l = $("room-log"); while (l.firstChild) l.removeChild(l.firstChild); }
+function addEntry(n, cls) {
+ const e = document.createElement("div"); e.className = `entry${cls ? " " + cls : ""}`;
+ const head = document.createElement("div"); head.className = "head";
+ const num = document.createElement("span"); num.className = "n";
+ num.textContent = `#${n}${stateMode === "guided" ? "" : ` · ${stateMode}`}`;
+ const choice = document.createElement("span"); choice.className = "choice";
+ const ms = document.createElement("span"); ms.className = "ms";
+ head.append(num, choice, ms); e.appendChild(head);
+ $("room-log").prepend(e);
+ return { e, choice, ms };
+}
+function logDecision(n, best, pairs, elapsedMs, sentState) {
+ const { e, choice, ms } = addEntry(n);
+ choice.textContent = `→ ${best[0]} (p=${best[1].toFixed(3)})`;
+ ms.textContent = `${elapsedMs.toFixed(0)} ms`;
+ const probs = document.createElement("div"); probs.className = "probs";
+ probs.textContent = pairs.map(([id, p]) => `${id} ${p.toFixed(3)}`).join(" ");
+ e.appendChild(probs);
+ const bar = document.createElement("div"); bar.className = "bar";
+ const fill = document.createElement("span"); fill.style.width = `${(best[1] * 100).toFixed(1)}%`;
+ bar.appendChild(fill); e.appendChild(bar);
+ e.title = sentState;
+}
+function logError(n, msg) {
+ const { e, choice } = addEntry(n, "error");
+ choice.textContent = "request failed";
+ const d = document.createElement("div"); d.className = "probs"; d.textContent = msg;
+ e.appendChild(d);
+}
+
+/* ── status / win / lose / reset ────────────────────────────────────── */
+function setStatus(t, cls) { const s = $("room-status"); s.textContent = t; s.className = `status${cls ? " " + cls : ""}`; }
+function win() {
+ mode = "won"; deciding = false;
+ setStatus(`🏁 exit reached in ${state.steps} steps (${decisionN} SemIf decision(s)) — press Reset to try another seed`, "win");
+ render(); refreshLabel();
+}
+function lose(msg) {
+ mode = "lost"; deciding = false;
+ setStatus(`✗ ${msg} The model is lost — press Reset to retry.`, "err");
+ render(); refreshLabel();
+}
+function reset() {
+ runToken++;
+ deciding = false;
+ state = newState(layout);
+ decisionN = 0;
+ mode = "idle";
+ discovered.clear();
+ $("room-start").disabled = false;
+ setStatus("idle — press Start", "");
+ $("room-state-view").textContent = stateText(state, layout, stateMode, discovered); // idle preview: same text /decide would receive
+ clearLog();
+ const empty = document.createElement("div"); empty.className = "empty";
+ empty.textContent = "No decisions yet.";
+ $("room-log").appendChild(empty);
+ render(); refreshLabel();
+}
+
+/* ── controls ───────────────────────────────────────────────────────── */
+const stateModes = ["guided", "fpp", "map"];
+const setStateMode = (m) => {
+ stateMode = m;
+ for (const name of stateModes) $("room-mode-" + name).className = name === m ? "on" : "";
+};
+for (const name of stateModes) $("room-mode-" + name).addEventListener("click", () => setStateMode(name));
+
+$("room-start").addEventListener("click", () => {
+ if (mode === "auto") return;
+ if (mode === "won" || mode === "lost") reset();
+ mode = "auto";
+ $("room-start").disabled = true;
+ void requestDecision();
+});
+$("room-reset").addEventListener("click", reset);
+
+/* ── seeded layout (reproducible) ───────────────────────────────────── */
+const parseSeed = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n >>> 0 : null; };
+function applySeed(seedNum) {
+ layout = makeLayout(seedNum);
+ reset();
+}
+$("room-randomize").addEventListener("click", () => {
+ const s = (Math.random() * 0x100000000) >>> 0;
+ $("room-seed").value = String(s);
+ applySeed(s);
+});
+$("room-seed").addEventListener("change", () => {
+ const s = parseSeed($("room-seed").value);
+ if (s !== null) applySeed(s);
+});
+
+/* ── manual drive ───────────────────────────────────────────────────── */
+addEventListener("keydown", (e) => {
+ if (!roomActive()) return;
+ if (mode !== "idle" && mode !== "manual") return;
+ let acted = false;
+ if (e.key === "ArrowLeft" || e.key === "a") { state = turn(state, "left"); acted = true; }
+ else if (e.key === "ArrowRight" || e.key === "d") { state = turn(state, "right"); acted = true; }
+ else if (e.key === "ArrowUp" || e.key === "w" || e.key === " ") {
+ e.preventDefault();
+ const r = forward(state, layout);
+ state = r.state;
+ acted = true;
+ if (r.outcome === "win") { win(); return; }
+ }
+ if (!acted) return;
+ if (mode === "idle") { mode = "manual"; setStatus("manual mode", ""); }
+ render(); refreshLabel();
+ // Mirror the decision text into the pane, exactly as a /decide call would
+ // compose it — manual play doubles as a debugger for the prompt.
+ for (const o of RoomRules.visibleObjects(state, layout)) {
+ const id = { "the key": "key", "a locked door": "door", "the exit": "exit" }[o.name];
+ if (id) discovered.add(id);
+ }
+ $("room-state-view").textContent = stateText(state, layout, stateMode, discovered);
+ if (state.steps >= MAX_STEPS) lose(`No exit after ${MAX_STEPS} steps.`);
+});
+
+reset();
+})();
diff --git a/semif-api/src/semif_api/web/self-game.js b/semif-api/src/semif_api/web/self-game.js
@@ -0,0 +1,502 @@
+/* No-rules platformer demo — the game loop for the "Platformer SL" tab.
+ * Same physics and rendering as game.js, but the decision options carry no
+ * rules, and three events trigger a /plan call whose output is injected into
+ * every later observation as "Rules":
+ * - the model picks "insufficient" with p >= Planner.INSUFFICIENT_THRESHOLD
+ * - the player falls into a pit
+ * - three jump requests in a row with no jumps remaining
+ * In all three the planner runs, then the level restarts with the new rules:
+ * the planner writes full game rules, not a fix for one stuck situation, so
+ * the model always applies them from a fresh start (the universal PLAN_SYSTEM
+ * promises the environment resets after every plan).
+ * Wrapped in an IIFE for the same reason as game.js. Requires game-rules.js,
+ * planner.js and self-rules.js to have defined their globals first. */
+(() => {
+"use strict";
+
+/* ── level & physics (read from GameRules at use time, like game.js) ── */
+const { W, H, GROUND, GOAL, START_X, JUMP_VY, floorAt, QUESTION } = GameRules;
+const { OPTIONS } = SelfRules;
+const TICK_MS = 90;
+
+/* ── mutable game state ─────────────────────────────────────────────── */
+const player = { x: START_X, y: GROUND, vy: 0, onGround: true };
+let running = false; // auto mode: player currently holds "run right"
+let mode = "idle"; // idle | auto | manual | won | lost
+let deciding = false;
+let planning = false;
+let stateMode = "prose"; // prose | runlength | ascii — bare formats only
+let jumpsLeft = GameRules.MAX_JUMPS;
+let deniedStreak = 0; // consecutive jump-attempts with an empty budget
+let tickTimer = null;
+let decisionN = 0;
+let planN = 0;
+let runToken = 0; // invalidates in-flight decisions/plans on reset
+let learnedRules = ""; // last /plan output; injected into every observation
+let transcript = Planner.fresh(); // completed actions + outcomes for /plan
+let pending = null; // decision being executed, recorded once it resolves
+// Run stats for the completion summary: wall time from the Start click
+// (spanning auto-restarts after pit falls), failures, decisions, and the
+// total token spend of every planner call.
+const freshStats = () => ({ startedAt: null, failures: 0, decisions: 0, plans: 0,
+ planCompletionTokens: 0, planReasoningTokens: 0, planPromptTokens: 0, planMs: 0 });
+let stats = freshStats();
+const count = (n) => n.toLocaleString("en-US");
+
+const $ = (id) => document.getElementById(id);
+const cv = $("self-cv"), ctx = cv.getContext("2d");
+const CELL = cv.width / W, ROWH = cv.height / H;
+function refreshLevelLabel() {
+ $("self-level-label").textContent =
+ `Level — pits at ${GameRules.PITS.map(([a, b]) => `${a}–${b + 1}`).join(", ")}; flag at ${GOAL}`;
+}
+refreshLevelLabel();
+
+// Same rule as game.js: shortcuts only while this panel is visible and focus
+// is not in an editable element.
+const gameActive = () => {
+ const t = document.activeElement;
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return false;
+ return !$("panel-self").hidden;
+};
+
+/* ── the ONE textual state: source for rendering prompts AND the API ── */
+// Bare observation: no learned rules. This is what the transcript records —
+// the planner already receives the current rules ONCE via Planner.context's
+// "Previous rules", so re-embedding them in all 24 history turns
+// would just bloat its prompt and blur which rules were current.
+function bareState() {
+ return SelfRules.stateText(player, jumpsLeft, stateMode);
+}
+function stateText() {
+ const base = bareState();
+ // Learned rules lead, the current state follows. The freshest tokens
+ // should be the immediate evidence the decision is made from, not the
+ // standing rules — the decision pass reads once, and recent tokens weigh
+ // most.
+ return learnedRules ? `Rules:\n${learnedRules}\n\n${base}` : base;
+}
+
+/* ── rendering (same coordinates as game.js) ────────────────────────── */
+function render() {
+ ctx.clearRect(0, 0, cv.width, cv.height);
+ for (let c = 0; c < W; c++) {
+ if (!floorAt(c)) { // pit: dark shaft
+ ctx.fillStyle = "#0a0c11";
+ ctx.fillRect(c * CELL, GROUND * ROWH, CELL, cv.height - GROUND * ROWH);
+ continue;
+ }
+ ctx.fillStyle = "#2a3140";
+ ctx.fillRect(c * CELL, GROUND * ROWH, CELL, cv.height - GROUND * ROWH);
+ ctx.fillStyle = "#3a4356";
+ ctx.fillRect(c * CELL, GROUND * ROWH, CELL, 3);
+ }
+ const gx = GOAL * CELL;
+ ctx.strokeStyle = "#56d364"; ctx.lineWidth = 2;
+ ctx.beginPath(); ctx.moveTo(gx, GROUND * ROWH); ctx.lineTo(gx, GROUND * ROWH - 34); ctx.stroke();
+ ctx.fillStyle = "#56d364";
+ ctx.beginPath(); ctx.moveTo(gx, GROUND * ROWH - 34);
+ ctx.lineTo(gx + 18, GROUND * ROWH - 27); ctx.lineTo(gx, GROUND * ROWH - 20); ctx.fill();
+ ctx.fillStyle = player.onGround ? "#69c0ff" : "#e3a008";
+ ctx.beginPath(); ctx.arc(player.x * CELL, player.y * ROWH - 8, 8, 0, Math.PI * 2); ctx.fill();
+}
+
+/* ── physics tick ───────────────────────────────────────────────────── */
+function tick() {
+ if (mode !== "auto" && mode !== "manual") return;
+ const before = { ...player };
+ const next = GameRules.advance(player, mode === "auto" ? running : keyRun);
+ Object.assign(player, next.player);
+ if (next.fell) {
+ recordOutcome("the player fell below the level.");
+ lose("The player fell into a pit.");
+ void replanAndRetry("fell");
+ return;
+ }
+ if (player.x >= GOAL) { recordOutcome("the player reached the flag."); win(); return; }
+ render();
+ // Mirror the exact state text into the pane on every tick — in manual mode
+ // this is the debugging view of what the decider would receive right now.
+ $("self-state-view").textContent = stateText();
+ // Running commits to one tick. A jump commits until landing.
+ if (mode === "auto" && player.onGround) {
+ stopTicks();
+ const moved = Math.round(player.x - before.x);
+ recordOutcome(pending && pending.choice === "jump"
+ ? `the player jumped and landed ${moved} spaces to the right of the takeoff point.`
+ : `the player moved ${moved} space${moved === 1 ? "" : "s"} to the right.`);
+ void requestDecision();
+ }
+}
+
+function jump() {
+ if (!player.onGround || jumpsLeft <= 0 || mode === "won" || mode === "lost") return;
+ jumpsLeft--; deniedStreak = 0;
+ player.onGround = false; player.vy = JUMP_VY;
+}
+
+/* ── transcript: one turn per completed action, read by /plan ───────── */
+function recordOutcome(outcome) {
+ if (!pending) return; // manual play: no observation/choice to record
+ Planner.record(transcript,
+ `Observation:\n${pending.state}\nChosen action: ${pending.choice}\nOutcome: ${outcome}`);
+ pending = null;
+}
+
+/* ── SemIf decision loop ────────────────────────────────────────────── */
+async function requestDecision() {
+ if (deciding || planning || mode !== "auto") return;
+ deciding = true;
+ const token = runToken;
+ stopTicks(); // physics freezes while we think
+ setStatus("thinking…", "");
+ const text = stateText();
+ const bare = bareState(); // transcript records this, rules live in /plan context
+ $("self-state-view").textContent = text;
+ const n = ++decisionN;
+ let result, elapsedMs, err = null;
+ try {
+ const t0 = performance.now();
+ const resp = await fetch("/decide", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ id: `self-${n}`, state: text, question: QUESTION, options: OPTIONS }),
+ });
+ elapsedMs = performance.now() - t0;
+ const payload = JSON.parse(await resp.text());
+ if (!resp.ok) throw new Error(typeof payload.detail === "string" ? payload.detail : resp.statusText);
+ result = payload;
+ } catch (e) { err = e; }
+ if (token !== runToken || mode !== "auto") return; // reset while we waited
+ deciding = false;
+ if (err) {
+ logError(n, err.message);
+ setStatus(`decision failed: ${err.message} — retrying in 2 s`, "err");
+ setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, 2000);
+ return;
+ }
+ stats.decisions++;
+ const pairs = result.option_ids.map((id, i) => [id, result.probabilities[i]]);
+ const [choice, p] = Planner.best(result);
+ logDecision(n, [choice, p], pairs, elapsedMs, text);
+
+ if (choice === Planner.INSUFFICIENT_ID) {
+ if (Planner.triggered(result)) {
+ // Confident "cannot decide": the planner writes full game rules, not a
+ // way out of this exact spot — end the attempt and restart the level
+ // so the model applies the rules from the start, same layout,
+ // transcript intact. Same semantics as the room SL.
+ pending = null;
+ lose("The model cannot decide and asks for rules.");
+ void replanAndRetry("insufficient");
+ return;
+ }
+ // Weak insufficient loses the argmax to the best real action; keep playing
+ // but make the override visible in the log and status.
+ const real = pairs.filter(([id]) => id !== Planner.INSUFFICIENT_ID)
+ .sort((a, b) => b[1] - a[1]);
+ setStatus(`insufficient led at p=${p.toFixed(3)} (< 0.99 planning threshold) — acting on ${real[0][0]}`, "err");
+ pending = { n, state: bare, choice: real[0][0] };
+ } else {
+ pending = { n, state: bare, choice };
+ }
+
+ setStatus(`playing… · ${jumpsLeft} jump${jumpsLeft === 1 ? "" : "s"} left`, "");
+ running = pending.choice === "run";
+ if (running) deniedStreak = 0;
+ if (pending.choice === "jump") {
+ if (jumpsLeft > 0) {
+ jump();
+ } else {
+ deniedStreak++; running = false; pending = null;
+ if (deniedStreak >= 3) {
+ // Out of jumps and keeps trying: same learning loop as a pit fall —
+ // plan from the transcript, then restart with the learned rules.
+ deniedStreak = 0;
+ lose("The model is out of jumps and keeps trying to jump.");
+ void replanAndRetry("denied");
+ return;
+ }
+ setStatus(`jump denied — 0 jumps left (asked ${deniedStreak}× in a row)`, "err");
+ setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, 400);
+ return;
+ }
+ }
+ if (mode !== "auto") return;
+ startTicks();
+ render();
+}
+
+/* ── /plan: derive rules from the transcript, then keep playing ─────── */
+async function replan(reason, token) {
+ planning = true;
+ const n = ++planN;
+ setPlanningStatus(reason === "fell"
+ ? "fell into a pit — planning rules from the transcript…"
+ : reason === "denied"
+ ? "out of jumps, keeps trying to jump — planning rules from the transcript…"
+ : "insufficient evidence (p ≥ 0.99) — planning rules from the transcript…");
+ const { e, choice, ms } = addPlanEntry(n, reason);
+ let payload = null, err = null, elapsedMs = 0;
+ try {
+ const t0 = performance.now();
+ // The endpoint is game-agnostic: Planner.context() wraps the simulation's
+ // one-line goal with the trigger note and the rules currently in effect —
+ // on repeat failures the planner can see and amend its own previous plan.
+ const trigger = reason === "fell"
+ ? "the actor fell below the level and the attempt ended"
+ : reason === "denied"
+ ? "the actor repeatedly chose an action the simulation rejected (jump with no jumps remaining) and the attempt stalled"
+ : "the actor declared the evidence insufficient (p ≥ 0.99) and could not decide";
+ payload = await Planner.request({
+ id: `self-plan-${n}`,
+ prompt: Planner.context(SelfRules.PLAN_GOAL, trigger, learnedRules),
+ transcript,
+ });
+ elapsedMs = performance.now() - t0;
+ } catch (e2) { err = e2; }
+ if (token !== runToken) return false; // reset while planning: discard result
+ planning = false;
+ if (err) {
+ choice.textContent = "planning failed";
+ const d = document.createElement("div"); d.className = "probs"; d.textContent = err.message;
+ e.appendChild(d);
+ setStatus(`planning failed: ${err.message} — press Reset or Start to continue`, "err");
+ return false;
+ }
+ learnedRules = payload.rules;
+ $("self-rules-view").value = learnedRules;
+ const usage = payload.usage || {};
+ stats.plans++;
+ stats.planMs += elapsedMs;
+ stats.planCompletionTokens += usage.completion_tokens || 0;
+ stats.planPromptTokens += usage.prompt_tokens || 0;
+ stats.planReasoningTokens += (usage.completion_tokens_details || {}).reasoning_tokens || 0;
+ renderStats();
+ choice.textContent = `→ rules learned (${count(usage.completion_tokens || 0)} tokens)`;
+ ms.textContent = `${elapsedMs.toFixed(0)} ms`;
+ // Planner output, expandable in place: the rules pane only ever shows the
+ // latest set, so the log entry keeps every plan (rules + thinking trace)
+ // inspectable. Click toggles between a preview and the full text.
+ const detailParts = ["rules:\n" + payload.rules];
+ if (payload.reasoning) detailParts.push("thinking:\n" + payload.reasoning);
+ const detail = detailParts.join("\n\n");
+ const d = document.createElement("div"); d.className = "probs expandable";
+ let open = false;
+ const paint = () => {
+ d.textContent = (open ? detail : detail.slice(0, 240) + (detail.length > 240 ? "…" : "")) +
+ (detail.length > 240 ? (open ? " ▲" : " ▼") : "");
+ };
+ paint();
+ d.addEventListener("click", () => { open = !open; paint(); });
+ e.appendChild(d);
+ e.title = detail; // hover for the full trace
+ if (payload.truncated) {
+ const warn = document.createElement("div"); warn.className = "probs";
+ warn.textContent = "warning: reply hit the server's token limit and was truncated";
+ e.appendChild(warn);
+ }
+ setStatus("rules updated", "");
+ return true;
+}
+
+// Failed attempt (pit fall, impossible-action streak, or the model asked for
+// rules): plan, then restart the level with the learned rules and go again. This continues the SAME
+// run: stats and the Start-click timer keep going.
+async function replanAndRetry(reason) {
+ const token = runToken;
+ if (!await replan(reason, token)) return;
+ if (mode !== "lost" || token !== runToken) return; // user took over meanwhile
+ resetLevel();
+ setStatus("restarting with learned rules…", "");
+ mode = "auto";
+ $("self-start").disabled = true;
+ void requestDecision();
+}
+
+/* ── decision log ───────────────────────────────────────────────────── */
+function clearLog() { const l = $("self-log"); while (l.firstChild) l.removeChild(l.firstChild); }
+function addEntry(n, cls) {
+ const e = document.createElement("div"); e.className = `entry${cls ? " " + cls : ""}`;
+ const head = document.createElement("div"); head.className = "head";
+ const num = document.createElement("span"); num.className = "n";
+ num.textContent = `#${n} · ${stateMode}`;
+ const choice = document.createElement("span"); choice.className = "choice";
+ const ms = document.createElement("span"); ms.className = "ms";
+ head.append(num, choice, ms); e.appendChild(head);
+ $("self-log").prepend(e);
+ return { e, choice, ms };
+}
+function logDecision(n, best, pairs, elapsedMs, sentState) {
+ const { e, choice, ms } = addEntry(n);
+ choice.textContent = `→ ${best[0]} (p=${best[1].toFixed(3)})`;
+ ms.textContent = `${elapsedMs.toFixed(0)} ms`;
+ const probs = document.createElement("div"); probs.className = "probs";
+ probs.textContent = pairs.map(([id, p]) => `${id} ${p.toFixed(3)}`).join(" ");
+ e.appendChild(probs);
+ const bar = document.createElement("div"); bar.className = "bar";
+ const fill = document.createElement("span"); fill.style.width = `${(best[1] * 100).toFixed(1)}%`;
+ bar.appendChild(fill); e.appendChild(bar);
+ e.title = sentState;
+}
+function addPlanEntry(n, reason) {
+ const entry = addEntry(n, "plan");
+ entry.e.firstChild.firstChild.textContent = `#plan ${n} · ${reason} · ${stateMode}`;
+ return entry;
+}
+function logError(n, msg) {
+ const { e, choice } = addEntry(n, "error");
+ choice.textContent = "request failed";
+ const d = document.createElement("div"); d.className = "probs"; d.textContent = msg;
+ e.appendChild(d);
+}
+
+/* ── status / win / lose / reset ────────────────────────────────────── */
+function setStatus(t, cls) {
+ const s = $("self-status");
+ s.className = `status${cls ? " " + cls : ""}`;
+ s.textContent = t;
+}
+// Planning status: same text and size, but each letter becomes a span with a
+// staggered negative animation delay, so the CSS rainbow travels down the
+// text as a wave while /plan runs. Any later setStatus() restores plain text.
+function setPlanningStatus(t) {
+ const s = $("self-status");
+ s.className = "status planning";
+ s.replaceChildren(...[...t].map((ch, i) => {
+ const span = document.createElement("span");
+ span.textContent = ch;
+ span.style.setProperty("--i", i);
+ return span;
+ }));
+}
+function startTicks() { if (!tickTimer) tickTimer = setInterval(tick, TICK_MS); }
+function stopTicks() { if (tickTimer) { clearInterval(tickTimer); tickTimer = null; } }
+
+// Completion summary, refreshed after every plan and at the end of the run.
+// Wall time is measured from the Start click and spans auto-restarts.
+function renderStats(result) {
+ const lines = [];
+ if (result) lines.push(`result: ${result}`);
+ if (stats.startedAt !== null) {
+ lines.push(`wall time: ${((performance.now() - stats.startedAt) / 1000).toFixed(1)} s (from Start click)`);
+ }
+ lines.push(`decisions: ${stats.decisions}`);
+ lines.push(`failures: ${stats.failures}`);
+ lines.push(`plans: ${stats.plans}`);
+ if (stats.plans > 0) {
+ const reasoning = stats.planReasoningTokens ? ` (${count(stats.planReasoningTokens)} reasoning)` : "";
+ lines.push(`planner tokens: ${count(stats.planCompletionTokens)} completion${reasoning} · ${count(stats.planPromptTokens)} prompt`);
+ lines.push(`planning time: ${(stats.planMs / 1000).toFixed(1)} s`);
+ }
+ $("self-stats").textContent = lines.join("\n");
+}
+function win() {
+ mode = "won"; stopTicks(); running = false;
+ const seconds = stats.startedAt !== null ? `${((performance.now() - stats.startedAt) / 1000).toFixed(1)} s` : "—";
+ const thinking = stats.plans > 0
+ ? ` · ${stats.plans} plan${stats.plans === 1 ? "" : "s"}, ${count(stats.planCompletionTokens)} planner tokens`
+ : "";
+ setStatus(`🏁 level complete in ${seconds} · ${stats.decisions} decision(s) · ${stats.failures} failure(s)${thinking} · ${jumpsLeft} jump${jumpsLeft === 1 ? "" : "s"} to spare — press Reset to run it again`, "win");
+ renderStats("success");
+ render();
+}
+function lose(msg) {
+ mode = "lost"; stopTicks(); running = false;
+ stats.failures++;
+ renderStats();
+ setStatus(`✗ ${msg}`, "err");
+ render();
+}
+// Level state only: keeps learning (rules, transcript) AND run stats, so the
+// auto-restart after a pit fall continues the same run. Full Reset below
+// clears everything.
+function resetLevel() {
+ runToken++;
+ stopTicks(); deciding = false; planning = false; running = false; keyRun = false;
+ jumpsLeft = GameRules.MAX_JUMPS; deniedStreak = 0;
+ player.x = START_X; player.y = GROUND; player.vy = 0; player.onGround = true;
+ mode = "idle"; decisionN = 0; pending = null;
+ render();
+}
+// Full reset: level + learned rules + transcript + run stats. The next Start
+// begins a fresh run from a blank slate.
+function reset() {
+ resetLevel();
+ learnedRules = "";
+ transcript = Planner.fresh();
+ stats = freshStats();
+ clearLog(); // the decision/plan log is part of the run, not learning
+ $("self-start").disabled = false;
+ $("self-rules-view").value = "";
+ $("self-stats").textContent = "(run not started)";
+ setStatus("idle — press Start", "");
+ $("self-state-view").textContent = stateText(); // idle preview: same text /decide would receive
+}
+function forgetRules() {
+ learnedRules = "";
+ transcript = Planner.fresh();
+ $("self-rules-view").value = "";
+ setStatus("learned rules forgotten", "");
+}
+
+// The rules pane is an editor: whatever is in it IS the rules, hand-typed or
+// pasted, so a good plan can be captured, tweaked, or replayed for a
+// reproducible run. Edits apply to the very next decision.
+$("self-rules-view").addEventListener("input", (e) => { learnedRules = e.target.value; });
+
+/* ── controls ───────────────────────────────────────────────────────── */
+const stateModes = ["prose", "runlength", "ascii"];
+const setStateMode = (m) => {
+ stateMode = m;
+ for (const name of stateModes) $("self-mode-" + name).className = name === m ? "on" : "";
+};
+for (const name of stateModes) $("self-mode-" + name).addEventListener("click", () => setStateMode(name));
+
+$("self-start").addEventListener("click", () => {
+ if (mode === "auto") return;
+ if (mode === "won" || mode === "lost") reset();
+ stats = freshStats(); // a Start click begins a new timed run
+ stats.startedAt = performance.now();
+ renderStats();
+ mode = "auto";
+ $("self-start").disabled = true;
+ void requestDecision();
+});
+$("self-reset").addEventListener("click", reset);
+$("self-forget").addEventListener("click", forgetRules);
+
+/* ── random pit layout (seeded, reproducible) ──────────────────── */
+const parseSeed = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n >>> 0 : null; };
+function applySeed(seedNum) {
+ GameRules.setPits(GameRules.makePits(seedNum));
+ refreshLevelLabel();
+ reset();
+}
+$("self-randomize").addEventListener("click", () => {
+ const s = (Math.random() * 0x100000000) >>> 0;
+ $("self-seed").value = String(s);
+ applySeed(s);
+});
+$("self-seed").addEventListener("change", () => {
+ const s = parseSeed($("self-seed").value);
+ if (s !== null) applySeed(s);
+});
+
+let keyRun = false;
+addEventListener("keydown", (e) => {
+ if (!gameActive()) return;
+ if (mode === "auto" || mode === "won" || mode === "lost") return;
+ if (e.key === "ArrowRight" || e.key === "d") {
+ keyRun = true;
+ if (mode === "idle") { mode = "manual"; setStatus("manual mode", ""); startTicks(); }
+ }
+ if (e.key === " " || e.key === "ArrowUp" || e.key === "w") {
+ e.preventDefault();
+ if (mode === "idle") { mode = "manual"; setStatus("manual mode", ""); startTicks(); }
+ jump();
+ }
+});
+addEventListener("keyup", (e) => { if (e.key === "ArrowRight" || e.key === "d") keyRun = false; });
+
+reset();
+})();
diff --git a/semif-api/src/semif_api/web/self-rules.js b/semif-api/src/semif_api/web/self-rules.js
@@ -0,0 +1,112 @@
+"use strict";
+
+/* No-rules platformer: the same three terrain encodings as game-rules.js
+ * (prose counts, run-length segments, ASCII row) with every line of how-to-play
+ * prose removed. The model is never told the goal, what running or jumping do,
+ * or which outcomes fail — it only sees neutral observations (its own state,
+ * jumps remaining, the terrain) plus the action list with accurate one-word
+ * descriptions, and an "insufficient" escape hatch. When play stalls or a run
+ * fails, the client asks /plan for rules and injects the answer into later
+ * states as "Rules".
+ *
+ * Physics is GameRules' (this module reads GameRules.floorAt/GOAL and only
+ * assembles observation text); SelfRules.PLAN_GOAL is the simulation's
+ * one-line statement of the goal — the only game-specific input to /plan.
+ */
+const SelfRules = (() => {
+ const { GOAL, floorAt } = GameRules;
+
+ // Terrain ahead as prose counts — identical listing to GameRules.stateText,
+ // minus the goal/rules lines and the hint.
+ function terrainLines(player) {
+ const terrain = [];
+ for (let x = player.x + 1; x < GOAL; x++) {
+ const kind = floorAt(x) ? "ground" : "hole";
+ const last = terrain[terrain.length - 1];
+ if (last && last.kind === kind) last.count++;
+ else terrain.push({ kind, count: 1 });
+ }
+ return terrain.map(({ kind, count }) => `${count} ${kind} space${count === 1 ? "" : "s"}`);
+ }
+
+ function proseText(player, jumpsLeft) {
+ return [
+ `Player: ${player.onGround ? "standing on ground" : "airborne"}, facing right`,
+ `Jumps remaining: ${jumpsLeft}`,
+ "",
+ // When the flag is the very next space, the enumeration would be an
+ // empty list under a header — say the useful thing instead.
+ player.x + 1 === GOAL ? "The flag is right in front of you!"
+ : "Ahead, from nearest to farthest (starting with the next space):",
+ ...terrainLines(player),
+ player.x < GOAL ? "" : "Flag reached",
+ ].join("\n");
+ }
+
+ // Run-length segments — same encoding as GameRules.runLengthText, with a
+ // neutral legend (a `#` names the terrain, it does not announce a failure).
+ function runLengthText(player, jumpsLeft) {
+ const terrain = [];
+ for (let x = player.x + 1; x < GOAL; x++) {
+ const kind = floorAt(x) ? "ground" : "hole";
+ const last = terrain[terrain.length - 1];
+ if (last && last.kind === kind) last.count++;
+ else terrain.push({ kind, count: 1 });
+ }
+ const segs = terrain.map(({ kind, count }) => `${kind === "ground" ? "-" : "#"}${count}`);
+ segs.push("[!]");
+ return [
+ "Legend: [`*`: player, `>`: facing right, `-N`: N ground tiles, `#N`: N hole tiles, `!`: flag]",
+ `Jumps remaining: ${jumpsLeft}`,
+ "",
+ `[*] > ${segs.join(" | ")}`,
+ ].join("\n");
+ }
+
+ // Whole level as one symbolic row — same glyph layout as GameRules.asciiText,
+ // same neutral legend, no goal line and no rules. Cells stay space-separated
+ // so every glyph is its own token.
+ function asciiText(player, jumpsLeft) {
+ const here = Math.floor(player.x);
+ const row = [];
+ for (let x = 0; x <= GOAL; x++) {
+ if (x === here) row.push("*");
+ else if (x === GOAL) row.push("!");
+ else row.push(floorAt(x) ? "-" : "#");
+ }
+ return [
+ "Legend: [`*`: player, `-`: ground, `#`: hole, `!`: flag]",
+ `Jumps remaining: ${jumpsLeft}`,
+ "",
+ row.join(" "),
+ ].join("\n");
+ }
+
+ // mode: "prose" | "runlength" | "ascii". There is deliberately no guided
+ // variant: guidance was instructional prose, which this demo never sends.
+ function stateText(player, jumpsLeft, mode = "prose") {
+ if (mode === "runlength") return runLengthText(player, jumpsLeft);
+ if (mode === "ascii") return asciiText(player, jumpsLeft);
+ return proseText(player, jumpsLeft);
+ }
+
+ const QUESTION = "What should the player do now?";
+ // Actions only, with accurate one-word descriptions, plus the escape hatch
+ // that gates the /plan trigger (Planner.INSUFFICIENT_THRESHOLD on its prob).
+ const OPTIONS = [
+ { id: "run", description: "Run" },
+ { id: "jump", description: "Jump" },
+ { id: Planner.INSUFFICIENT_ID, description: "Insufficient evidence to decide" },
+ ];
+
+ /* The simulation's one-line statement of the goal — the only game-specific
+ * input to /plan. Planner.context() wraps it with the trigger note and the
+ * rules currently in effect; the system prompt demanding lean, committal
+ * instructions is universal and lives in the backend. */
+ const PLAN_GOAL = "Reach the flag at the far right end of the level.";
+
+ return { stateText, proseText, runLengthText, asciiText, terrainLines,
+ QUESTION, OPTIONS, PLAN_GOAL };
+})();
+
+if (typeof module !== "undefined") module.exports = SelfRules;
diff --git a/semif-api/src/semif_api/web/style.css b/semif-api/src/semif_api/web/style.css
@@ -0,0 +1,346 @@
+/* semif-api web UI — no framework, so the layout is written out.
+ System fonts only: this page is served by the API itself and must work
+ with no network. */
+
+:root {
+ --bg: #10131a;
+ --bg-panel: #171b24;
+ --bg-raised: #1e232e;
+ --line: #2a3140;
+ --fg: #dbe1ea;
+ --fg-dim: #8b95a7;
+ --accent: #69c0ff;
+ --win: #56d364;
+ --warn: #e3a008;
+ --err: #f85149;
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+}
+
+* { box-sizing: border-box; }
+
+/* JS hides panels with the hidden attribute. An author-level `display` on the
+ same element (below: .panel) beats the UA sheet's [hidden] rule no matter how
+ specific it is, so hide it here or both tabs render at once. */
+[hidden] { display: none !important; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--fg);
+ font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+}
+
+code, .mono, pre { font-family: var(--mono); font-size: 0.9em; }
+
+header {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ align-items: flex-end;
+ justify-content: space-between;
+ padding: 1.25rem 1.5rem 1rem;
+ border-bottom: 1px solid var(--line);
+}
+
+h1 { margin: 0; font-size: 1.15rem; letter-spacing: 0.02em; }
+.tagline { margin: 0.15rem 0 0; color: var(--fg-dim); font-size: 0.85rem; }
+
+.health {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ font-size: 0.8rem;
+ color: var(--fg-dim);
+ max-width: 52rem;
+}
+.model-picker {
+ width: auto;
+ max-width: 24rem;
+ padding: 0.2rem 0.4rem;
+ font-size: 0.8rem;
+}
+.model-note { color: var(--fg-dim); font-size: 0.75rem; }
+.model-note.is-error { color: var(--err); }
+.model-note.is-warn { color: var(--warn); }
+.warn-text { color: var(--warn); }
+.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--fg-dim); flex: none; }
+.dot.up { background: var(--win); }
+.dot.down { background: var(--err); }
+.dot.pending { background: var(--warn); animation: pulse 1.2s infinite; }
+@keyframes pulse { 50% { opacity: 0.25; } }
+
+.tabs { display: flex; gap: 0.25rem; padding: 0.75rem 1.5rem 0; }
+.tab {
+ background: none;
+ border: 1px solid transparent;
+ border-bottom-color: var(--line);
+ color: var(--fg-dim);
+ padding: 0.5rem 0.9rem;
+ border-radius: 6px 6px 0 0;
+ cursor: pointer;
+ font-size: 0.9rem;
+}
+.tab.is-active { background: var(--bg-panel); border-color: var(--line); color: var(--fg); }
+.tab code { color: var(--accent); }
+
+main {
+ display: grid;
+ grid-template-columns: minmax(24rem, 1fr) minmax(24rem, 1fr);
+ gap: 1.5rem;
+ padding: 1.5rem;
+ align-items: start;
+}
+@media (max-width: 1080px) { main { grid-template-columns: 1fr; } }
+
+.panel { display: flex; flex-direction: column; gap: 1rem; }
+
+.field { display: flex; flex-direction: column; gap: 0.35rem; }
+.field > label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--fg-dim); }
+.hint { text-transform: none; letter-spacing: 0; color: var(--fg-dim); font-size: 0.75rem; }
+.field-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; }
+
+input, textarea, select {
+ background: var(--bg-raised);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ color: var(--fg);
+ padding: 0.45rem 0.6rem;
+ font: inherit;
+ width: 100%;
+}
+textarea { resize: vertical; line-height: 1.45; }
+input:focus, textarea:focus, button:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; }
+
+.state-mode { display: flex; gap: 0.35rem; align-items: center; font-size: 0.75rem; color: var(--fg-dim); }
+.state-mode input { width: auto; }
+.state-mode label { text-transform: uppercase; letter-spacing: 0.05em; }
+
+.note { margin: 0; font-size: 0.78rem; color: var(--fg-dim); min-height: 1rem; }
+.note.warn { color: var(--warn); }
+
+.constraint {
+ margin: 0;
+ padding: 0.6rem 0.75rem;
+ border-left: 2px solid var(--accent);
+ background: var(--bg-panel);
+ font-size: 0.83rem;
+ color: var(--fg-dim);
+}
+
+.options { display: flex; flex-direction: column; gap: 0.35rem; }
+.option-row { display: grid; grid-template-columns: 9.5rem 1fr 2rem; gap: 0.35rem; }
+.option-row .remove-option { padding: 0; }
+
+.decision {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--bg-panel);
+ padding: 0.75rem;
+ margin-bottom: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+.decision-head { display: flex; align-items: center; gap: 0.5rem; }
+.decision-head .d-id { max-width: 16rem; }
+.decision-n { font-size: 0.75rem; text-transform: uppercase; color: var(--fg-dim); letter-spacing: 0.06em; }
+
+button {
+ background: var(--bg-raised);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ color: var(--fg);
+ padding: 0.45rem 0.85rem;
+ font: inherit;
+ cursor: pointer;
+}
+button:hover:not(:disabled) { border-color: var(--accent); }
+button:disabled { opacity: 0.5; cursor: progress; }
+button.primary { background: var(--accent); border-color: var(--accent); color: #0b0e14; font-weight: 600; }
+button.ghost { background: none; border-color: transparent; color: var(--fg-dim); }
+button.ghost:hover { color: var(--accent); }
+
+.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
+
+.presets { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--line); }
+.presets-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--fg-dim); }
+#preset-buttons { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.5rem; }
+.preset { font-size: 0.82rem; padding: 0.3rem 0.7rem; }
+
+#response { position: sticky; top: 1rem; }
+.placeholder { color: var(--fg-dim); border: 1px dashed var(--line); border-radius: 8px; padding: 1.25rem; }
+.placeholder p:first-child { margin-top: 0; color: var(--fg); }
+.dim { color: var(--fg-dim); font-size: 0.83rem; }
+.spinner { font-size: 1.5rem; color: var(--accent); animation: spin 1.4s linear infinite; display: inline-block; }
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.error {
+ border: 1px solid var(--err);
+ border-left-width: 3px;
+ border-radius: 8px;
+ background: rgba(248, 81, 73, 0.07);
+ padding: 0.9rem 1rem;
+}
+.error h3 { margin: 0 0 0.5rem; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--err); }
+.error pre { margin: 0; white-space: pre-wrap; word-break: break-word; font-size: 0.82rem; }
+
+.flash { color: var(--win); }
+
+.result-card {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--bg-panel);
+ padding: 1rem;
+ margin-bottom: 1rem;
+}
+.headline { display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
+.chosen { font-size: 1.3rem; color: var(--win); font-weight: 600; }
+.score { color: var(--fg-dim); }
+.rid { color: var(--fg-dim); font-size: 0.8rem; margin-left: auto; }
+
+.section-title { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.07em; color: var(--fg-dim); margin: 1rem 0 0.5rem; }
+
+table.probs { width: 100%; border-collapse: collapse; font-size: 0.86rem; }
+table.probs th {
+ text-align: left;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--fg-dim);
+ border-bottom: 1px solid var(--line);
+ padding: 0.2rem 0.4rem;
+}
+table.probs td { padding: 0.3rem 0.4rem; border-bottom: 1px solid rgba(42, 49, 64, 0.5); }
+table.probs td.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; }
+table.probs tr.is-winner td { color: var(--win); }
+table.probs td.dim { color: var(--fg-dim); }
+
+.bar {
+ display: inline-block;
+ width: 5rem;
+ height: 0.55rem;
+ margin-right: 0.5rem;
+ vertical-align: middle;
+ background: linear-gradient(to right, var(--accent) var(--w, 0%), rgba(42, 49, 64, 0.8) 0);
+ border-radius: 3px;
+}
+tr.is-winner .bar { background: linear-gradient(to right, var(--win) var(--w, 0%), rgba(42, 49, 64, 0.8) 0); }
+
+dl.meta {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: 0.15rem 0.9rem;
+ margin: 0.9rem 0 0;
+ font-size: 0.8rem;
+}
+dl.meta dt { color: var(--fg-dim); }
+dl.meta dd { margin: 0; word-break: break-all; }
+
+.disclaimer {
+ margin: 0.9rem 0 0;
+ padding-top: 0.7rem;
+ border-top: 1px solid var(--line);
+ font-size: 0.78rem;
+ color: var(--warn);
+}
+
+details.raw { margin-top: 0.5rem; font-size: 0.82rem; }
+details.raw summary { cursor: pointer; color: var(--fg-dim); }
+details.raw pre {
+ background: var(--bg-raised);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 0.7rem;
+ overflow-x: auto;
+ max-height: 24rem;
+}
+
+footer { padding: 1rem 1.5rem 2rem; color: var(--fg-dim); font-size: 0.8rem; border-top: 1px solid var(--line); }
+footer p { margin: 0; }
+
+.sr-only { position: absolute; left: -9999px; }
+
+/* ── Demo tabs (platformer + puzzle room) ───────────────────────────────────────────────
+ Fully scoped under .demo-panel so none of these selectors (pre, .bar,
+ .seg, .entry…) leak into the single/batch readout, or vice versa. */
+.demo-panel {
+ grid-column: 1 / -1;
+ display: grid;
+ gap: 1.25rem;
+ grid-template-columns: minmax(0, 3fr) minmax(280px, 2fr);
+ align-items: start;
+}
+@media (max-width: 900px) { .demo-panel { grid-template-columns: 1fr; } }
+
+.demo-panel .game-stage,
+.demo-panel .game-side {
+ background: var(--bg-panel);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 0.9rem 1rem;
+}
+.demo-panel h2 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em;
+ color: var(--fg-dim); margin: 0 0 0.6rem; }
+.demo-panel h2 + h2, .demo-panel pre + h2, .demo-panel textarea + h2 { margin-top: 0.9rem; }
+.demo-panel canvas { display: block; width: 100%; background: var(--bg-raised);
+ border: 1px solid var(--line); border-radius: 6px; image-rendering: pixelated; }
+.demo-panel .controls { display: flex; gap: 0.5rem; margin-top: 0.7rem; flex-wrap: wrap; align-items: center; }
+.demo-panel .status { margin-left: auto; font-size: 0.85rem; color: var(--fg-dim); }
+.demo-panel .status.win { color: var(--win); }
+.demo-panel .status.err { color: var(--err); }
+/* Planning status: rainbow wave, same size. Each letter is a span whose hue
+ cycles and bobs with a staggered negative delay, so the color travels
+ down the text like a wave. Removed the moment setStatus() runs again. */
+@keyframes planHue {
+ 0% { color: hsl(0, 95%, 65%); }
+ 25% { color: hsl(90, 95%, 65%); }
+ 50% { color: hsl(180, 95%, 65%); }
+ 75% { color: hsl(270, 95%, 65%); }
+ 100% { color: hsl(360, 95%, 65%); }
+}
+@keyframes planBob {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-2px); }
+}
+.demo-panel .status.planning span {
+ display: inline-block; /* transforms need a non-inline box; size unchanged */
+ animation: planHue 1.6s linear infinite,
+ planBob 0.8s ease-in-out infinite;
+ animation-delay: calc(var(--i) * -0.07s), calc(var(--i) * -0.07s);
+}
+.demo-panel .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
+.demo-panel .seg button { border: 0; border-radius: 0; padding: 0.4rem 0.7rem; font-size: 0.8rem; color: var(--fg-dim); }
+.demo-panel .seg button.on { background: var(--bg-raised); color: var(--accent); }
+.demo-panel .game-hint { margin: 0.6rem 0 0; font-size: 0.78rem; color: var(--fg-dim); }
+.demo-panel .seed { display: inline-flex; align-items: center; gap: 0.35rem;
+ font-size: 0.78rem; color: var(--fg-dim); }
+.demo-panel .seed-input { width: 8rem; padding: 0.35rem 0.5rem; font-size: 0.8rem; }
+.demo-panel pre { font-family: var(--mono); font-size: 0.78rem; background: var(--bg-raised);
+ border: 1px solid var(--line); border-radius: 6px; padding: 0.6rem; margin: 0;
+ white-space: pre-wrap; word-break: break-word; max-height: 260px; overflow: auto; }
+/* The rules pane is an editor, not a readout: same look as the pre it
+ replaced, but writable, resizable, and it never scrolls the run stats
+ or the decision log away. */
+.demo-panel textarea.rules-edit { font-family: var(--mono); font-size: 0.78rem; color: var(--fg);
+ background: var(--bg-raised); border: 1px solid var(--line); border-radius: 6px; padding: 0.6rem;
+ margin: 0; width: 100%; box-sizing: border-box; resize: vertical; min-height: 5.5rem;
+ line-height: 1.45; white-space: pre-wrap; }
+.demo-panel textarea.rules-edit:focus { outline: none; border-color: var(--accent); }
+.demo-panel .log { display: flex; flex-direction: column; gap: 0.5rem; max-height: 340px; overflow: auto; }
+.demo-panel .entry { background: var(--bg-raised); border: 1px solid var(--line); border-radius: 6px;
+ padding: 0.5rem 0.6rem; font-size: 0.8rem; }
+.demo-panel .entry .head { display: flex; gap: 0.6rem; align-items: baseline; margin-bottom: 0.3rem; }
+.demo-panel .entry .n { color: var(--fg-dim); font-size: 0.72rem; text-transform: uppercase; }
+.demo-panel .entry .choice { font-family: var(--mono); color: var(--accent); }
+.demo-panel .entry .ms { margin-left: auto; color: var(--fg-dim); font-size: 0.75rem; }
+.demo-panel .entry .probs { font-family: var(--mono); font-size: 0.72rem; color: var(--fg-dim); }
+.demo-panel .entry .probs.expandable { cursor: pointer; white-space: pre-wrap; }
+.demo-panel .entry.error .choice { color: var(--err); }
+.demo-panel .entry.plan { border-color: var(--accent); }
+.demo-panel .entry.plan .choice { color: var(--win); }
+/* Override the readout's inline .bar; inside a decision entry it is a block fill. */
+.demo-panel .bar { display: block; width: auto; height: 5px; margin: 0.35rem 0 0; border-radius: 3px;
+ background: var(--line); overflow: hidden; }
+.demo-panel .bar > span { display: block; height: 100%; background: var(--accent); }
+.demo-panel .empty { color: var(--fg-dim); font-size: 0.8rem; }
diff --git a/tests/test_api.py b/tests/test_api.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""End-to-end parity test for the semif-api HTTP server.
+
+Assumes uvicorn is already running on 127.0.0.1:8321 (started by validate.sh).
+Compares API output against the CLI reference outputs field-by-field on the
+deterministic fields (timing fields are excluded), then asserts the error
+contract: every input problem is a 422 carrying the upstream message.
+"""
+import json
+import sys
+import urllib.request
+
+BASE = "http://127.0.0.1:8321"
+DIR = __import__("pathlib").Path(__file__).resolve().parent.parent / "examples"
+
+# Timing fields are machine-specific and not compared; the model dict is
+# narrowed to source+revision so parity does not depend on local
+# torch/transformers versions.
+COMPARE_FIELDS = ("id", "option_ids", "probabilities", "option_logits",
+ "input_tokens", "prompt_sha256", "prompt_version")
+
+
+def comparable(row):
+ model = row.get("model") or {}
+ fields = {field: row.get(field) for field in COMPARE_FIELDS}
+ fields["model_source"] = model.get("source")
+ fields["model_revision"] = model.get("revision")
+ return fields
+
+
+def get(path):
+ with urllib.request.urlopen(BASE + path, timeout=30) as response:
+ return json.load(response)
+
+
+def post(path, payload):
+ request = urllib.request.Request(
+ BASE + path, data=json.dumps(payload).encode(),
+ headers={"Content-Type": "application/json"})
+ try:
+ with urllib.request.urlopen(request, timeout=120) as response:
+ return json.load(response)
+ except urllib.error.HTTPError as error:
+ print(f"POST {path} -> HTTP {error.code}: {error.read().decode()}")
+ raise
+
+
+def post_status(path, payload):
+ """POST and report (status, body) instead of raising, for expected failures."""
+ request = urllib.request.Request(
+ BASE + path, data=json.dumps(payload).encode(),
+ headers={"Content-Type": "application/json"})
+ try:
+ with urllib.request.urlopen(request, timeout=120) as response:
+ return response.status, json.load(response)
+ except urllib.error.HTTPError as error:
+ body = error.read().decode()
+ try:
+ return error.code, json.loads(body)
+ except json.JSONDecodeError:
+ return error.code, {"detail": body}
+
+
+def diff_fields(a, b):
+ ca, cb = comparable(a), comparable(b)
+ return {field: (ca.get(field), cb.get(field))
+ for field in ca if ca.get(field) != cb.get(field)}
+
+
+def main():
+ health = get("/healthz")
+ print(f"healthz: ok, model={health['model']['source']}@{health['model']['revision'][:12]}")
+
+ rows = [json.loads(line) for line in (DIR / "examples-shared.jsonl").read_text().splitlines()]
+
+ # 1) /decide must match the CLI direct-mode output exactly.
+ reference = {r["id"]: r for r in map(json.loads, (DIR / "reference-direct.jsonl").read_text().splitlines())}
+ failures = 0
+ for row in rows:
+ result = post("/decide", row)
+ fields = diff_fields(result, reference[row["id"]])
+ if fields:
+ failures += 1
+ print(f"decide {row['id']}: MISMATCH {fields}")
+ else:
+ print(f"decide {row['id']}: exact match")
+ assert result["probability_status"].startswith("conditional option score")
+
+ # 2) /decide-batch must match the CLI shared-mode output exactly (same code path).
+ reference_shared = {r["id"]: r for r in map(json.loads, (DIR / "reference-shared.jsonl").read_text().splitlines())}
+ batch = post("/decide-batch", {
+ "state": rows[0]["state"],
+ "decisions": [{"id": r["id"], "question": r["question"], "options": r["options"]} for r in rows],
+ })
+ if len(batch["results"]) != len(rows):
+ print(f"decide-batch: expected {len(rows)} results, got {len(batch['results'])}")
+ failures += 1
+ for result in batch["results"]:
+ fields = diff_fields(result, reference_shared[result["id"]])
+ if fields:
+ failures += 1
+ print(f"decide-batch {result['id']}: MISMATCH {fields}")
+ else:
+ print(f"decide-batch {result['id']}: exact match")
+ print(f"batch timing: total={batch['timing']['total_seconds']:.3f}s "
+ f"batch_size={batch['timing']['batch_size']}")
+
+ # 3) Error contract (docs/usage.md): every input problem is 422 with the
+ # upstream message. The last three cases raise inside the scorer, so they
+ # are the ones that used to regress to a bare "Internal Server Error".
+ long_state = "no new information. " * 6000
+ pair = [{"id": "a", "description": "a"}, {"id": "b", "description": "b"}]
+ contract = [
+ ("/decide", "one option", {"id": "e1", "state": "s", "question": "q?",
+ "options": [{"id": "only", "description": "one"}]}, "options must contain 2-16 entries"),
+ ("/decide", "duplicate option ids", {"id": "e2", "state": "s", "question": "q?",
+ "options": [{"id": "a", "description": "a"}, {"id": "a", "description": "b"}]},
+ "Option IDs must be unique"),
+ ("/decide", "over max_tokens", {"id": "e3", "state": long_state, "question": "q?",
+ "options": pair}, "exceed limit"),
+ ("/decide-batch", "duplicate decision ids", {"state": "s", "decisions": [
+ {"id": "dup", "question": "a?", "options": pair},
+ {"id": "dup", "question": "b?", "options": pair}]}, "Decision IDs must be unique"),
+ ("/decide-batch", "empty decisions", {"state": "s", "decisions": []},
+ "Shared scoring requires one nonempty exact state"),
+ ]
+ for path, label, payload, needle in contract:
+ status, body = post_status(path, payload)
+ detail = body.get("detail") if isinstance(body, dict) else None
+ if not isinstance(detail, str):
+ detail = json.dumps(body)
+ if status != 422 or needle not in detail:
+ failures += 1
+ print(f"contract {label}: MISMATCH got {status} {detail[:90]!r}, want 422 containing {needle!r}")
+ else:
+ print(f"contract {label}: 422 {detail[:64]}")
+
+ if failures:
+ print(f"\n{failures} PARITY FAILURE(S)")
+ sys.exit(1)
+ print("\nAPI parity: all exact matches")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_game.cjs b/tests/test_game.cjs
@@ -0,0 +1,228 @@
+// Run: node --test test_game.cjs
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+const G = require('../semif-api/src/semif_api/web/game-rules.js');
+const grounded = (x) => ({ x, y: G.GROUND, vy: 0, onGround: true });
+function completeJump(x) {
+ let player = { ...grounded(x), onGround: false, vy: G.JUMP_VY };
+ for (let tick = 1; tick <= 20; tick++) {
+ const result = G.advance(player, false);
+ player = result.player;
+ if (result.fell || player.onGround) return { ...result, tick };
+ }
+ throw Error('Jump did not finish');
+}
+
+test('run and jump match the integer movement rules', () => {
+ assert.equal(G.advance(grounded(G.START_X), true).player.x, G.START_X + 1);
+ const result = completeJump(G.START_X);
+ assert.equal(result.tick, 5);
+ assert.equal(result.player.x, G.START_X + 5);
+ assert.equal(G.JUMP_TICKS, result.tick);
+ assert.equal(G.JUMP_DISTANCE, 5);
+});
+
+test('each pit has two safe integer takeoffs; one tick earlier fails', () => {
+ for (const [start, lastHole] of G.PITS) {
+ assert.equal(lastHole - start + 1, 3);
+ assert.equal(completeJump(start - 3).fell, true);
+ for (const x of [start - 2, start - 1]) {
+ const result = completeJump(x);
+ assert.equal(result.fell, false, `x=${x}`);
+ assert.equal(result.player.onGround, true);
+ assert.equal(result.tick, G.JUMP_TICKS);
+ assert.ok(result.player.x >= lastHole + 1);
+ }
+ assert.equal(G.advance(grounded(start - 1), true).player.onGround, false);
+ }
+});
+
+test('a player below the surface cannot land underneath the bank', () => {
+ const result = G.advance({ x: 16, y: 9, vy: 2.2, onGround: false }, false);
+ assert.equal(result.player.x, 17);
+ assert.equal(result.player.onGround, false);
+ assert.equal(result.fell, true);
+});
+
+test('pit left edges are holes; right edges are solid ground', () => {
+ for (const [a, b] of G.PITS) {
+ assert.equal(G.floorAt(a - 1), true);
+ assert.equal(G.floorAt(a), false);
+ assert.equal(G.floorAt(b), false);
+ assert.equal(G.floorAt(b + 1), true);
+ }
+});
+
+for (const takeoffOffset of [1, 2]) {
+ test(`full level completes with jumps ${takeoffOffset} units before each pit`, () => {
+ let player = grounded(G.START_X), jumpsLeft = G.MAX_JUMPS;
+ for (let tick = 0; tick < 100; tick++) {
+ const shouldJump = takeoffOffset === 1 ? G.guidedJump(player, jumpsLeft)
+ : player.onGround && G.PITS.some(([a]) => a - player.x === takeoffOffset);
+ if (shouldJump) {
+ jumpsLeft--;
+ player = { ...player, onGround: false, vy: G.JUMP_VY };
+ }
+ const result = G.advance(player, true);
+ assert.equal(result.fell, false);
+ player = result.player;
+ assert.ok(Number.isInteger(player.x));
+ assert.doesNotMatch(G.stateText(player, jumpsLeft, false), /\d+\.\d+/);
+ if (player.x >= G.GOAL) {
+ assert.equal(jumpsLeft, 1);
+ return;
+ }
+ }
+ assert.fail('Did not reach the flag');
+ });
+}
+
+test('unguided scene is compact, relative, and advice-free', () => {
+ const state = G.stateText(grounded(12), 4, false);
+ assert.match(state, /Run moves you one space forward\./);
+ assert.match(state, /If the next space is a hole, running makes you fall\./);
+ assert.match(state, /Jump at the edge of a pit to cross it\./);
+ assert.match(state, /You can act again after running or landing\./);
+ assert.doesNotMatch(state, /Jump (?:lands|moves)|5 spaces|5 units|Jumping anywhere else fails/);
+ assert.match(state, /1 ground space\n3 hole spaces\n14 ground spaces\n3 hole spaces\n14 ground spaces\n3 hole spaces\n13 ground spaces/);
+ assert.doesNotMatch(state, /Hint:|x =|ticks|\d+\.\d+|jump now|run now/i);
+ assert.equal(G.stateText(grounded(12), 4, true), state + '\nHint: run for one tick, then reassess.');
+ assert.deepEqual(G.OPTIONS, [{ id: 'run', description: 'Run' }, { id: 'jump', description: 'Jump' }]);
+});
+
+test('one space from the flag, the ahead-listing becomes a direct statement', () => {
+ const near = G.stateText(grounded(G.GOAL - 1), 2, false);
+ assert.match(near, /The flag is right in front of you!/);
+ assert.doesNotMatch(near, /Ahead, from nearest/); // an empty header would list nothing
+ assert.doesNotMatch(near, /^\d+ ground spaces?$/m);
+ const far = G.stateText(grounded(G.GOAL - 5), 2, false);
+ assert.match(far, /Ahead, from nearest to farthest/);
+ assert.doesNotMatch(far, /right in front of you/);
+});
+
+test('ascii mode renders the whole track as one symbolic row with no prose or hints', () => {
+ const state = G.stateText(grounded(3), 3, 'ascii');
+ assert.match(state, /Legend:.*player.*floor.*pit.*goal flag/);
+ assert.match(state, /Jumps remaining: 3/);
+ assert.doesNotMatch(state, /ground space|hole space|Ahead, from nearest|Hint:/i);
+ assert.doesNotMatch(state, /\d+\.\d+/);
+ const cells = state.split('\n').at(-1).split(' '); // space-separated so each glyph is its own token
+ assert.equal(cells.length, G.GOAL + 1);
+ for (let x = 0; x <= G.GOAL; x++) {
+ const expected = x === 3 ? '*' : x === G.GOAL ? '!' : G.floorAt(x) ? '-' : '#';
+ assert.equal(cells[x], expected, `col ${x}`);
+ }
+});
+
+test('run-length mode encodes the terrain ahead as parseable run-length segments', () => {
+ for (const x of [G.START_X, 6, 9, 13, G.GOAL - 1]) {
+ const state = G.stateText(grounded(x), 4, 'runlength');
+ assert.match(state, /Legend:.*player.*floor.*pit.*goal flag/);
+ assert.match(state, /Jumps remaining: 4/);
+ assert.doesNotMatch(state, /ground space|hole space|Ahead, from nearest|Hint:/i);
+ assert.doesNotMatch(state, /\d+\.\d+/);
+ const toks = state.split('\n').at(-1).slice('[*] > '.length).split(' | ');
+ assert.equal(toks.at(-1), '[!]');
+ const kinds = toks.slice(0, -1).flatMap((t) => {
+ const m = t.match(/^([#-])(\d+)$/);
+ assert.ok(m, `bad token ${t}`);
+ return Array(Number(m[2])).fill(m[1] === '#' ? 'hole' : 'ground');
+ });
+ assert.equal(kinds.length, G.GOAL - 1 - x);
+ kinds.forEach((kind, i) =>
+ assert.equal(kind, G.floorAt(x + 1 + i) ? 'ground' : 'hole', `x=${x} i=${i}`));
+ }
+});
+
+test('terrain description reconstructs every space ahead without off-by-one errors', () => {
+ for (let x = G.START_X; x < G.GOAL; x++) {
+ const state = G.stateText(grounded(x), 4, false);
+ const spaces = [];
+ for (const match of state.matchAll(/^(\d+) (ground|hole) spaces?$/gm)) {
+ spaces.push(...Array(Number(match[1])).fill(match[2]));
+ }
+ spaces.push('ground'); // flag's space
+ assert.equal(spaces.length, G.GOAL - x);
+ spaces.forEach((kind, i) => assert.equal(kind === 'ground', G.floorAt(x + i + 1)));
+ }
+ assert.match(G.stateText(grounded(9), 4, false), /4 ground spaces\n3 hole spaces/);
+ assert.match(G.stateText(grounded(G.GOAL), 1, false), /Flag reached/);
+});
+
+// Run the guided "jump at the edge" solver to completion on a given layout.
+// Restores the previous layout afterwards so global mutation never leaks.
+function simulateSolve(layout) {
+ const restore = G.PITS.map((p) => [...p]);
+ G.setPits(layout);
+ try {
+ let player = grounded(G.START_X), jumpsLeft = G.MAX_JUMPS;
+ for (let tick = 0; tick < 400; tick++) {
+ if (G.guidedJump(player, jumpsLeft)) {
+ jumpsLeft--;
+ player = { ...player, onGround: false, vy: G.JUMP_VY };
+ }
+ const r = G.advance(player, true);
+ assert.equal(r.fell, false, `fell at x=${player.x} layout=${JSON.stringify(layout)}`);
+ player = r.player;
+ assert.ok(Number.isInteger(player.x));
+ if (player.x >= G.GOAL) { assert.ok(jumpsLeft >= 1, 'needs a spare jump'); return; }
+ }
+ assert.fail(`never reached the flag: layout=${JSON.stringify(layout)}`);
+ } finally { G.setPits(restore); }
+}
+
+test('makePits is deterministic and actually varies the layout', () => {
+ assert.deepEqual(G.makePits(12345), G.makePits(12345));
+ const distinct = new Set();
+ for (let s = 0; s < 2000; s++) distinct.add(G.makePits(s).map((p) => p[0]).join(','));
+ assert.ok(distinct.size > 100, `only ${distinct.size} distinct layouts over 2000 seeds`);
+});
+
+test('every generated layout is valid and solvable by the edge-jump strategy', () => {
+ for (let s = 0; s < 500; s++) {
+ const layout = G.makePits(s);
+ assert.deepEqual(G.validatePits(layout), [], `invalid at seed ${s}: ${JSON.stringify(layout)}`);
+ }
+ for (let s = 0; s < 200; s++) simulateSolve(G.makePits(s));
+});
+
+test('setPits rejects unfair layouts before mutating the live level', () => {
+ assert.throws(() => G.setPits([[14, 16], [31, 33], [48, 52]])); // pit too wide
+ assert.throws(() => G.setPits([[2, 4], [31, 33], [48, 50]])); // on/behind the start
+ assert.throws(() => G.setPits([[14, 16], [16, 18], [48, 50]])); // pits too close
+ assert.deepEqual(G.PITS, G.DEFAULT_PITS); // rejection left defaults intact
+});
+
+test('setPits swaps the live layout and floorAt / MAX_JUMPS follow', () => {
+ const restore = G.PITS.map((p) => [...p]);
+ try {
+ const next = [[8, 10], [24, 26], [40, 42]];
+ G.setPits(next);
+ assert.deepEqual(G.PITS, next);
+ assert.equal(G.floorAt(9), false);
+ assert.equal(G.floorAt(20), true);
+ assert.equal(G.MAX_JUMPS, 4);
+ } finally { G.setPits(restore); }
+ assert.deepEqual(G.PITS, G.DEFAULT_PITS);
+});
+
+test('demo is hosted as a third tab, with the game loop script wiring it up', () => {
+ const html = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/index.html'), 'utf8');
+ // A tab that targets a hidden panel — the game no longer lives in its own page.
+ assert.match(html, /id="tab-game"/);
+ assert.match(html, /id="panel-game"[^>]*hidden/);
+ assert.doesNotMatch(html, /game\.html/);
+ // game-rules.js (defines GameRules) must load before game.js (consumes it).
+ assert.match(html, /<script src="game-rules\.js"><\/script>\s*<script src="game\.js"><\/script>/);
+});
+
+test('game loop script parses and uses the same action IDs as the prompt', () => {
+ const js = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/game.js'), 'utf8');
+ new vm.Script(js); // throws on any syntax error (it is a top-level IIFE)
+ assert.match(js, /GameRules/);
+ assert.match(js, /best\[0\] === "run"/);
+ assert.match(js, /best\[0\] === "jump"/);
+ assert.doesNotMatch(js, /best\[0\] === "(?:yes|no)"/);
+});
diff --git a/tests/test_llama.py b/tests/test_llama.py
@@ -0,0 +1,237 @@
+"""Offline tests: python -m unittest discover -s . -p test_llama.py"""
+import copy
+import math
+import re
+import unittest
+from unittest.mock import patch
+from urllib.error import URLError
+
+from semif_api.llama import BackendError, EXAMPLE, LlamaBackend, PLAN_SAMPLING, PLAN_SYSTEM, option_logprobs
+
+
+def response(values):
+ return {"completion_probabilities": [{"top_logprobs": [
+ {"id": token, "logprob": value, "token": "deliberately ignored"}
+ for token, value in values
+ ]}]}
+
+
+class FakeBackend(LlamaBackend):
+ def __init__(self, **kwargs):
+ super().__init__("http://example.invalid", **kwargs)
+ self.calls = []
+ self.result = response([(67, -4.0), (65, -1.0), (66, -2.0)])
+ self.chat_result = {
+ "choices": [{"message": {"role": "assistant", "content": "1. Run moves one space.",
+ "reasoning_content": "the transcript shows…"},
+ "finish_reason": "stop"}],
+ "usage": {"prompt_tokens": 12, "completion_tokens": 7},
+ }
+
+ def post(self, path, payload):
+ self.calls.append((path, payload))
+ if path == "/apply-template":
+ return {"prompt": "test prompt\n"}
+ if path == "/tokenize":
+ return {"tokens": [ord(char) for char in payload["content"]]}
+ if path == "/detokenize":
+ return {"content": "".join(chr(token) for token in payload["tokens"])}
+ if path == "/completion":
+ return self.result
+ if path == "/v1/chat/completions":
+ return self.chat_result
+ raise AssertionError(path)
+
+
+class ReadoutTests(unittest.TestCase):
+ def test_token_ids_not_text_or_candidate_order(self):
+ self.assertEqual(option_logprobs(response([(66, -2), (65, -1)]), [65, 66]), [-1, -2])
+
+ def test_legacy_probabilities(self):
+ data = {"completion_probabilities": [{"probs": [
+ {"id": 65, "prob": .25}, {"id": 66, "prob": .5}]}]}
+ self.assertEqual(option_logprobs(data, [65, 66]), [math.log(.25), math.log(.5)])
+
+ def test_missing_option_fails(self):
+ with self.assertRaisesRegex(BackendError, "Missing option"):
+ option_logprobs(response([(65, -1)]), [65, 66])
+
+ def test_nonfinite_and_duplicate_fail(self):
+ for values in ([(65, float("nan")), (66, -2)], [(65, -1), (65, -2), (66, -3)]):
+ with self.subTest(values=values), self.assertRaises(BackendError):
+ option_logprobs(response(values), [65, 66])
+
+ def test_no_readout_fails(self):
+ with self.assertRaises(BackendError):
+ option_logprobs({"content": "A"}, [65, 66])
+
+ def test_score(self):
+ backend = FakeBackend(cache_prompt=False)
+ result = backend.score(EXAMPLE)
+ self.assertEqual(result["choice"], "interrupt")
+ self.assertEqual(result["option_ids"], ["interrupt", "later", "ignore"])
+ self.assertAlmostEqual(sum(result["probabilities"]), 1)
+ completion = next(payload for path, payload in backend.calls if path == "/completion")
+ self.assertTrue(all(type(token) is int for token in completion["prompt"]))
+ self.assertFalse(completion["post_sampling_probs"])
+ self.assertFalse(completion["cache_prompt"])
+ self.assertEqual(completion["n_predict"], 1)
+ template = backend.calls[0][1]
+ self.assertFalse(template["chat_template_kwargs"]["enable_thinking"])
+
+ def test_truncation_fails(self):
+ backend = FakeBackend()
+ backend.result["truncated"] = True
+ with self.assertRaisesRegex(BackendError, "truncated"):
+ backend.score(EXAMPLE)
+
+ def test_prompt_limit_before_inference(self):
+ backend = FakeBackend(max_tokens=2)
+ with self.assertRaises(ValueError):
+ backend.score(EXAMPLE)
+ self.assertNotIn("/completion", [path for path, _ in backend.calls])
+
+ def test_bad_input_before_http(self):
+ backend = FakeBackend()
+ row = copy.deepcopy(EXAMPLE)
+ row["options"] = []
+ with self.assertRaises(ValueError):
+ backend.score(row)
+ self.assertFalse(backend.calls)
+
+ def test_missing_scores_retry_with_larger_list(self):
+ backend = FakeBackend()
+ original = backend.post
+
+ def post(path, payload):
+ result = original(path, payload)
+ if path == "/completion" and payload["n_probs"] == 1024:
+ return response([(65, -1)])
+ return result
+
+ with patch.object(backend, "post", side_effect=post):
+ result = backend.score(EXAMPLE)
+ self.assertEqual(result["llama"]["attempts"], 2)
+ self.assertEqual(result["llama"]["n_probs"], 4096)
+
+ def test_retry_is_bounded(self):
+ backend = FakeBackend()
+ backend.result = response([(65, -1)])
+ with self.assertRaisesRegex(BackendError, "Missing option"):
+ backend.score(EXAMPLE)
+ self.assertEqual([p["n_probs"] for path, p in backend.calls if path == "/completion"],
+ [1024, 4096, 16384])
+
+ def test_malformed_readout_does_not_retry(self):
+ backend = FakeBackend()
+ backend.result = {"completion_probabilities": [None]}
+ with self.assertRaises(BackendError):
+ backend.score(EXAMPLE)
+ self.assertEqual(sum(path == "/completion" for path, _ in backend.calls), 1)
+
+ def test_transport_failure(self):
+ with patch("semif_api.llama.urlopen", side_effect=URLError("offline")):
+ with self.assertRaisesRegex(BackendError, "offline"):
+ LlamaBackend("http://example.invalid").score(EXAMPLE)
+
+
+class PlanTests(unittest.TestCase):
+ def test_plan_system_prompt_is_universal_and_committal(self):
+ # One prompt for every simulation: it frames the single-forward-pass
+ # consumer, dictates the prompt-shaped structure the planner writes
+ # (what the input is, how to read it, how to decide, the limits),
+ # bans if-state-then-action branches, bounds length by spirit rather
+ # than numbers the thinker would count, and shows a GOOD/BAD style
+ # pair from a fictional environment (no demo terms).
+ self.assertIn("single forward pass", PLAN_SYSTEM)
+ self.assertIn("You are writing the prompt", PLAN_SYSTEM)
+ self.assertIn("What the input is", PLAN_SYSTEM)
+ self.assertIn("How to read the state", PLAN_SYSTEM)
+ self.assertIn("How to decide", PLAN_SYSTEM)
+ self.assertIn("if-state-then-action", PLAN_SYSTEM)
+ self.assertIn("handful of brief lines", PLAN_SYSTEM)
+ self.assertIn("resets to its initial state", PLAN_SYSTEM)
+ self.assertIn("committal", PLAN_SYSTEM)
+ self.assertIn("indicts the rules", PLAN_SYSTEM)
+ self.assertIn("Never resubmit a reworded", PLAN_SYSTEM)
+ self.assertIn("GOOD:", PLAN_SYSTEM)
+ self.assertIn("BAD:", PLAN_SYSTEM)
+ self.assertNotIn("3–6", PLAN_SYSTEM) # no exact counts to satisfy
+ self.assertNotIn("under 20 words", PLAN_SYSTEM)
+ for game_term in ("platformer", "flag", "jump", "hole", "tile",
+ "key", "door", "exit", "room", "wall"):
+ self.assertNotIn(game_term, PLAN_SYSTEM)
+ # The style example must be placeholder-only: weaker models copy
+ # concrete example vocabulary (gates, lasers, conveyors) into plans.
+ for example_noun in ("loading dock", "battery", "conveyor", "gate"):
+ self.assertNotIn(example_noun, PLAN_SYSTEM)
+ self.assertIsNone(re.search(r"\brun\b", PLAN_SYSTEM)) # never the game action
+
+ def test_plan_posts_thinking_chat_completion(self):
+ backend = FakeBackend()
+ transcript = [{"role": "user", "content": "Observation: …\nChosen action: run\nOutcome: moved 1 space."}]
+ result = backend.plan("plan-1", "Write the rules.", transcript)
+ self.assertEqual(result["id"], "plan-1")
+ self.assertEqual(result["rules"], "1. Run moves one space.")
+ self.assertEqual(result["reasoning"], "the transcript shows…")
+ self.assertFalse(result["truncated"])
+ self.assertEqual(result["usage"]["completion_tokens"], 7)
+ path, payload = backend.calls[-1]
+ self.assertEqual(path, "/v1/chat/completions")
+ self.assertTrue(payload["chat_template_kwargs"]["enable_thinking"])
+ self.assertNotIn("max_tokens", payload) # the server caps generation, not us
+ # Sampling is pinned to the model card's thinking-mode settings.
+ for key, value in PLAN_SAMPLING.items():
+ self.assertEqual(payload[key], value)
+ self.assertEqual(payload["temperature"], 1.0)
+ self.assertEqual(payload["top_p"], 0.95)
+ self.assertEqual(payload["top_k"], 20)
+ roles = [message["role"] for message in payload["messages"]]
+ self.assertEqual(roles, ["system", "user", "user"])
+ self.assertEqual(payload["messages"][1]["content"], "Write the rules.")
+ self.assertEqual(payload["messages"][2]["content"], transcript[0]["content"])
+
+ def test_plan_defaults(self):
+ backend = FakeBackend()
+ result = backend.plan("plan-2", "Write the rules.", [])
+ _, payload = backend.calls[-1]
+ self.assertNotIn("max_tokens", payload) # never sent; it would cap thinking
+ self.assertEqual(payload["temperature"], 1.0)
+ self.assertEqual([m["role"] for m in payload["messages"]], ["system", "user"])
+
+ def test_plan_validates_before_http(self):
+ backend = FakeBackend()
+ for bad_args in (
+ ("", "prompt", []), # empty id
+ ("id", "", []), # empty prompt
+ ("id", "prompt", [{"role": "tool", "content": "x"}]), # bad role
+ ("id", "prompt", [{"role": "user", "content": " "}]), # empty content
+ ("id", "prompt", [{"role": "user"}]), # missing content
+ ):
+ with self.subTest(bad_args=bad_args), self.assertRaises(ValueError):
+ backend.plan(*bad_args)
+ self.assertFalse(backend.calls)
+
+ def test_plan_rejects_empty_or_malformed_content(self):
+ backend = FakeBackend()
+ for chat_result in (
+ {"choices": [{"message": {"content": " "}, "finish_reason": "stop"}]},
+ {"choices": [{"message": {"content": ["1. Run."]}}]}, # parts without dicts
+ {"choices": []},
+ {"choices": [{"message": None}]},
+ ):
+ backend.chat_result = chat_result
+ with self.subTest(chat_result=chat_result), self.assertRaises(BackendError):
+ backend.plan("id", "prompt", [])
+
+ def test_plan_truncation_flagged_not_fatal(self):
+ backend = FakeBackend()
+ backend.chat_result["choices"][0]["finish_reason"] = "length"
+ backend.chat_result["choices"][0]["message"]["reasoning_content"] = ""
+ result = backend.plan("id", "prompt", [])
+ self.assertTrue(result["truncated"])
+ self.assertIsNone(result["reasoning"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_llama_api.py b/tests/test_llama_api.py
@@ -0,0 +1,132 @@
+"""Offline HTTP contract tests; no model or server required."""
+import os
+import sys
+import types
+import unittest
+from unittest.mock import Mock, patch
+
+from fastapi.testclient import TestClient
+from semif_api.app import create_app
+from semif_api.llama import BackendError, EXAMPLE
+from test_llama import FakeBackend
+
+
+class APITests(unittest.TestCase):
+ def setUp(self):
+ self.env = patch.dict(os.environ, {"SEMIF_BACKEND": "llama"})
+ self.env.start()
+ self.backend = FakeBackend()
+ self.factory = patch("semif_api.app.LlamaBackend", return_value=self.backend)
+ self.factory.start()
+ self.loader = patch("semif_api.app.load_causal_model", side_effect=AssertionError("torch loaded"))
+ self.loader.start()
+ self.client = TestClient(create_app())
+ self.client.__enter__()
+
+ def tearDown(self):
+ self.client.__exit__(None, None, None)
+ self.loader.stop()
+ self.factory.stop()
+ self.env.stop()
+
+ def test_health_does_not_contact_server(self):
+ data = self.client.get("/healthz").json()
+ self.assertEqual(data["backend"], "llama")
+ self.assertEqual(data["backend_status"], "not_checked")
+ self.assertFalse(self.backend.calls)
+
+ def test_decide_and_persistent_slots(self):
+ for _ in range(2):
+ result = self.client.post("/decide", json=EXAMPLE)
+ self.assertEqual(result.status_code, 200, result.text)
+ self.assertEqual(result.json()["choice"], "interrupt")
+ self.assertEqual(sum(path == "/detokenize" for path, _ in self.backend.calls), 3)
+
+ def test_sequential_batch(self):
+ decisions = [{k: v for k, v in EXAMPLE.items() if k != "state"},
+ {k: v for k, v in {**EXAMPLE, "id": "second"}.items() if k != "state"}]
+ result = self.client.post("/decide-batch", json={"state": EXAMPLE["state"], "decisions": decisions})
+ self.assertEqual(result.status_code, 200, result.text)
+ data = result.json()
+ self.assertEqual([row["id"] for row in data["results"]], [EXAMPLE["id"], "second"])
+ self.assertEqual(data["timing"]["mode"], "llama-sequential")
+ self.assertEqual(data["timing"]["batch_size"], 2)
+ self.assertEqual(data["results"][0]["shared_timing"], data["timing"])
+
+ def test_invalid_batches_rejected_before_inference(self):
+ for decisions in ([], [EXAMPLE, EXAMPLE], [EXAMPLE, {**EXAMPLE, "id": "bad", "options": []}]):
+ result = self.client.post("/decide-batch", json={"state": "s", "decisions": decisions})
+ self.assertEqual(result.status_code, 422, result.text)
+ self.assertFalse(self.backend.calls)
+
+ def test_backend_error_is_502(self):
+ with patch.object(self.backend, "score", side_effect=BackendError("server unavailable")):
+ result = self.client.post("/decide", json=EXAMPLE)
+ self.assertEqual(result.status_code, 502)
+ self.assertIn("server unavailable", result.json()["detail"])
+
+ def test_input_error_is_422(self):
+ with patch.object(self.backend, "score", side_effect=ValueError("too long")):
+ self.assertEqual(self.client.post("/decide", json=EXAMPLE).status_code, 422)
+
+ def test_ui_served(self):
+ self.assertEqual(self.client.get("/ui/").status_code, 200)
+
+ def test_plan_happy_path(self):
+ result = self.client.post("/plan", json={
+ "id": "plan-1",
+ "prompt": "Write the rules.",
+ "transcript": [{"role": "user", "content": "Chosen action: run. Outcome: moved 1 space."}],
+ })
+ self.assertEqual(result.status_code, 200, result.text)
+ data = result.json()
+ self.assertEqual(data["id"], "plan-1")
+ self.assertEqual(data["rules"], "1. Run moves one space.")
+ self.assertEqual(data["reasoning"], "the transcript shows…")
+ self.assertEqual(data["model"]["backend"], "llama")
+ path, payload = self.backend.calls[-1]
+ self.assertEqual(path, "/v1/chat/completions")
+ self.assertTrue(payload["chat_template_kwargs"]["enable_thinking"])
+
+ def test_plan_serialized_with_scoring_lock(self):
+ self.client.post("/decide", json=EXAMPLE)
+ self.assertEqual(self.client.post("/plan", json={"id": "p", "prompt": "x"}).status_code, 200)
+
+ def test_plan_rejects_bad_turns(self):
+ for transcript in ([{"role": "tool", "content": "x"}], [{"role": "user"}], "not-a-list"):
+ result = self.client.post("/plan", json={"id": "p", "prompt": "x", "transcript": transcript})
+ self.assertEqual(result.status_code, 422, result.text)
+ self.assertFalse([c for c in self.backend.calls if c[0] == "/v1/chat/completions"])
+
+ def test_plan_backend_error_is_502(self):
+ with patch.object(self.backend, "post", side_effect=BackendError("chat offline")):
+ result = self.client.post("/plan", json={"id": "p", "prompt": "x"})
+ self.assertEqual(result.status_code, 502)
+ self.assertIn("chat offline", result.json()["detail"])
+
+
+class TorchRoutingTests(unittest.TestCase):
+ def test_torch_loader_and_scorers_retained(self):
+ direct = types.ModuleType("semif_phase1.direct")
+ shared = types.ModuleType("semif_phase1.shared")
+ direct.score = Mock(return_value={"id": EXAMPLE["id"]})
+ shared.score_shared = Mock(return_value=([{"id": EXAMPLE["id"]}], {"batch_size": 1}))
+ with patch.dict(os.environ, {"SEMIF_BACKEND": "torch"}), patch.dict(
+ sys.modules, {"semif_phase1.direct": direct, "semif_phase1.shared": shared}
+ ), patch("semif_api.app.load_causal_model", return_value=("model", "tokenizer", {"source": "test"})) as loader:
+ with TestClient(create_app()) as client:
+ self.assertEqual(client.get("/healthz").json()["backend"], "torch")
+ self.assertEqual(client.post("/decide", json=EXAMPLE).status_code, 200)
+ result = client.post("/decide-batch", json={"state": "s", "decisions": [EXAMPLE]})
+ self.assertEqual(result.status_code, 200)
+ # Planning is text generation; the torch backend only reads logits.
+ plan = client.post("/plan", json={"id": "p", "prompt": "x"})
+ self.assertEqual(plan.status_code, 422)
+ self.assertIn("llama", plan.json()["detail"])
+ loader.assert_called_once()
+ direct.score.assert_called_once()
+ shared.score_shared.assert_called_once()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_roomself.cjs b/tests/test_roomself.cjs
@@ -0,0 +1,317 @@
+// Run: node --test test_roomself.cjs
+// The self-learning puzzle room: same turn-based grid as room-rules.js with
+// every line of how-to-play prose stripped, an "insufficient" escape hatch
+// that plans at ANY plurality threshold, and a /plan trigger when the step
+// budget runs out.
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+const R = require('../semif-api/src/semif_api/web/room-rules.js');
+
+// room-self-rules.js reads the RoomRules and Planner globals at load time.
+global.RoomRules = R;
+global.Planner = { INSUFFICIENT_ID: 'insufficient' };
+const SR = require('../semif-api/src/semif_api/web/room-self-rules.js');
+
+const layout = R.makeLayout(1);
+const fresh = () => R.newState(layout);
+
+// planner.js runs in the browser as a global; load it in a VM sandbox to test
+// the trigger policy without a DOM.
+function loadPlanner() {
+ const src = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/planner.js'), 'utf8');
+ const sandbox = { fetch: () => { throw Error('no network in tests'); } };
+ vm.createContext(sandbox);
+ vm.runInContext(src + '\nthis.exports = Planner;', sandbox);
+ return sandbox.exports;
+}
+
+test('neutral FPP state keeps only observations, no rules or advice', () => {
+ const state = fresh();
+ const text = SR.stateText(state, layout, 'fpp');
+ // All four adjacent cells are described, unconditionally; the state opens
+ // with egocentric labels and no compass line of any kind.
+ for (const dir of ['Ahead', 'Right', 'Behind', 'Left']) {
+ assert.match(text, new RegExp(`^${dir}: (open floor|a wall|the key|a locked door|the exit)$`, 'm'));
+ }
+ assert.doesNotMatch(text, /You face|north|east|south|west/i);
+ assert.match(text, /^In view: .+\.$/m);
+ assert.match(text, /You carry: no key/);
+ assert.ok(text.includes(`Steps taken: 0/${R.MAX_STEPS}`));
+ assert.doesNotMatch(text, /Recent events|No events yet/); // no action history in the observation
+ // Everything below is how-to-play prose this demo must never send:
+ assert.doesNotMatch(text, /Reach the exit|most progress|locked door blocks|find the key|step forward into|Hint:/i);
+});
+
+test('neutral FPP state reflects actions through carry status and steps', () => {
+ let state = R.turn(fresh(), 'right'); // now facing EAST
+ let text = SR.stateText(state, layout, 'fpp');
+ // No action history in the observation: only the world's own facts change.
+ assert.doesNotMatch(text, /turned right/);
+ // Walk into the key cell if reachable from start; otherwise any forward.
+ const r = R.forward(state, layout);
+ state = r.state;
+ text = SR.stateText(state, layout, 'fpp');
+ assert.ok(text.includes(`Steps taken: 2/${R.MAX_STEPS}`));
+ if (r.outcome === 'key') assert.match(text, /You carry: the key/);
+});
+
+test('adjacency lines describe all four cells in egocentric directions', () => {
+ // Hand-built 11x9: player at (2,2) facing EAST; wall north (left), key
+ // south (right), exit west (behind), open floor ahead.
+ const grid = Array.from({ length: R.H }, (_, y) =>
+ Array.from({ length: R.W }, (_, x) =>
+ (x === 0 || y === 0 || x === R.W - 1 || y === R.H - 1) ? "#" : "."));
+ grid[1][2] = "#"; grid[3][2] = "K"; grid[2][1] = "E";
+ const L = { grid: grid.map((r) => r.join("")), start: { x: 2, y: 2 },
+ key: { x: 2, y: 3 }, exit: { x: 1, y: 2 }, doorRow: 2, seed: 0 };
+ const state = R.turn(R.newState(L), 'right'); // facing EAST
+ const text = SR.stateText(state, L, 'fpp');
+ assert.match(text, /^Ahead: open floor$/m);
+ assert.match(text, /^Right: the key$/m);
+ assert.match(text, /^Behind: the exit$/m);
+ assert.match(text, /^Left: a wall$/m);
+ // The cone line is separate: nothing ahead falls inside the 120° cone.
+ assert.match(text, /^In view: nothing\.$/m);
+ // Carried key: its cell reads as open floor.
+ assert.match(SR.stateText({ ...state, hasKey: true }, L, 'fpp'), /^Right: open floor$/m);
+});
+
+test('adjacency lines name the locked and open door; open field is all floor', () => {
+ const grid = Array.from({ length: R.H }, (_, y) =>
+ Array.from({ length: R.W }, (_, x) =>
+ (x === 0 || y === 0 || x === R.W - 1 || y === R.H - 1) ? "#" : "."));
+ grid[2][3] = "D";
+ const L = { grid: grid.map((r) => r.join("")), start: { x: 2, y: 2 },
+ key: { x: 1, y: 1 }, exit: { x: 8, y: 6 }, doorRow: 2, seed: 0 };
+ const facing = R.turn(R.newState(L), 'right'); // door directly ahead (east)
+ assert.match(SR.stateText(facing, L, 'fpp'), /^Ahead: a locked door$/m);
+ const opened = SR.stateText({ ...facing, doorOpen: true }, L, 'fpp');
+ assert.match(opened, /^Ahead: open floor$/m); // an opened door is just floor
+ // An open field: every direction explicitly described, none omitted.
+ const L2 = { ...L, grid: L.grid.map((r, y) => y === 2 ? r.replace("D", ".") : r) };
+ const text2 = SR.stateText(R.turn(R.newState(L2), 'right'), L2, 'fpp');
+ for (const dir of ['Ahead', 'Right', 'Behind', 'Left']) {
+ assert.match(text2, new RegExp(`^${dir}: open floor$`, 'm'));
+ }
+});
+
+test('In view lists only FOV-visible objects, bearing only, nearest first', () => {
+ // Player at (5,5) facing NORTH (dir 0): key just left of ahead, door just
+ // right of ahead, exit further ahead-left with a clear line past the key —
+ // all inside the 120° cone.
+ const grid = Array.from({ length: R.H }, (_, y) =>
+ Array.from({ length: R.W }, (_, x) =>
+ (x === 0 || y === 0 || x === R.W - 1 || y === R.H - 1) ? "#" : "."));
+ grid[2][4] = "K"; grid[2][6] = "D"; grid[1][3] = "E";
+ const L = { grid: grid.map((r) => r.join("")), start: { x: 5, y: 5 },
+ key: { x: 4, y: 2 }, exit: { x: 3, y: 1 }, doorRow: 2, seed: 0 };
+ const north = R.newState(L); // dir 0 = NORTH
+ assert.deepEqual(R.visibleObjects(north, L), [
+ { name: "the key", bearing: "ahead" },
+ { name: "a locked door", bearing: "ahead" },
+ { name: "the exit", bearing: "ahead-left" },
+ ]);
+ // Facing SOUTH everything is behind the view plane: the empty form.
+ const south = { ...north, dir: 2 };
+ assert.deepEqual(R.visibleObjects(south, L), []);
+ assert.match(SR.stateText(south, L, 'fpp'), /^In view: nothing\.$/m);
+ // Carried key leaves the view.
+ const carrying = { ...north, hasKey: true };
+ assert.deepEqual(R.visibleObjects(carrying, L), [
+ { name: "a locked door", bearing: "ahead" },
+ { name: "the exit", bearing: "ahead-left" },
+ ]);
+ // A ~63° bearing with a clear line of sight is still outside the 120°
+ // cone's ±60° edge.
+ const g = L.grid.map((r) => r.split(""));
+ g[3][9] = "K";
+ const L3 = { ...L, grid: g.map((r) => r.join("")), key: { x: 9, y: 3 } };
+ assert.deepEqual(R.visibleObjects(R.newState(L3), L3), [
+ { name: "a locked door", bearing: "ahead" },
+ { name: "the exit", bearing: "ahead-left" },
+ ]);
+ // …and a 45° diagonal now falls INSIDE the widened cone.
+ const g4 = L.grid.map((r) => r.split(""));
+ g4[2][8] = "K";
+ const L4 = { ...L, grid: g4.map((r) => r.join("")), key: { x: 8, y: 2 } };
+ const v4 = R.visibleObjects(R.newState(L4), L4);
+ assert.ok(v4.some((o) => o.name === "the key" && o.bearing === "ahead-right"));
+});
+
+test('Known line gives bearings to discovered POIs that are out of view', () => {
+ // Player (5,5) facing NORTH; door (5,2) and exit (5,1) behind the wall at
+ // (5,4); key at (9,7), out of the cone behind-right. (Same fixture as the
+ // occlusion test.)
+ const grid = Array.from({ length: R.H }, (_, y) =>
+ Array.from({ length: R.W }, (_, x) =>
+ (x === 0 || y === 0 || x === R.W - 1 || y === R.H - 1) ? "#" : "."));
+ grid[2][5] = "D"; grid[1][5] = "E"; grid[4][5] = "#"; grid[7][9] = "K";
+ const L = { grid: grid.map((r) => r.join("")), start: { x: 5, y: 5 },
+ key: { x: 9, y: 7 }, exit: { x: 5, y: 1 }, doorRow: 2, seed: 0 };
+ const state = R.newState(L);
+ const all = new Set(["key", "door", "exit"]);
+ // Nothing discovered yet: the explicit empty form.
+ assert.match(SR.stateText(state, L, 'fpp', new Set()), /^Known: nothing\.$/m);
+ // All discovered, all out of view: bearings, nearest first. Door and exit
+ // sit dead ahead of the player — exact cardinal alignments read "directly
+ // X" — and the wall at (5,4) blocks the line to both, so they say so.
+ assert.match(SR.stateText(state, L, 'fpp', all),
+ /^Known: a locked door directly ahead \(blocked\); the exit directly ahead \(blocked\); the key behind-right\.$/m);
+ // Wall gone: the door is now in view, so In view owns it — no double entry.
+ const g2 = L.grid.map((r) => r.split("")); g2[4][5] = ".";
+ const L2 = { ...L, grid: g2.map((r) => r.join("")) };
+ // The exit's line now passes the LOCKED door: still blocked. The key's
+ // line is clear — an unblocked bearing is a guaranteed walkable straight
+ // line.
+ assert.match(SR.stateText(state, L2, 'fpp', all),
+ /^Known: the exit directly ahead \(blocked\); the key behind-right\.$/m);
+ // Door open: it is no longer a POI; the exit shows through it (in view).
+ assert.match(SR.stateText({ ...state, doorOpen: true }, L2, 'fpp', all),
+ /^Known: the key behind-right\.$/m);
+ // Carried key: present-check drops it even though it was discovered.
+ assert.match(SR.stateText({ ...state, hasKey: true }, L, 'fpp', all),
+ /^Known: a locked door directly ahead \(blocked\); the exit directly ahead \(blocked\)\.$/m);
+});
+
+test('corner graze behind the locked door does not leak the exit (default seed)', () => {
+ // Regression: on seed 1, standing east of the door facing it, the ray to
+ // the exit's center passed exactly through the grid corner shared by the
+ // door cell and the wall cell behind it. The DDA tie-break stepped into
+ // the open diagonal past both blockers, and the strict dist < entry
+ // comparison let the epsilon-equal graze slip through — the exit showed
+ // in view through two sets of walls. Corner grazes now block.
+ const L = R.makeLayout(1);
+ let door;
+ for (let y = 0; y < R.H; y++) for (let x = 0; x < R.W; x++)
+ if (L.grid[y][x] === "D") door = { x, y };
+ assert.ok(door, "seed-1 layout has a door");
+ const east = { x: door.x - 4, y: door.y, dir: 1, hasKey: false, doorOpen: false, steps: 0 };
+ const names = R.visibleObjects(east, L).map((o) => o.name);
+ assert.ok(names.includes("a locked door"), "the door itself is visible: " + names);
+ assert.ok(!names.includes("the exit"), "exit must not leak through the graze: " + names);
+ // The companion regression: standing ON the key facing the door (it is
+ // ahead-left, plainly drawn by the renderer) must discover it. A single
+ // center ray is blocked by the corner of (3,4) on this layout; the
+ // renderer and the text both treat a cell as visible when any
+ // sightline reaches it.
+ const onKey = { x: L.key.x, y: L.key.y, dir: 2, hasKey: false, doorOpen: false, steps: 0 };
+ const seen = R.visibleObjects(onKey, L);
+ const d = seen.find((o) => o.name === "a locked door");
+ assert.ok(d, "door discovered while standing on the key: " + JSON.stringify(seen));
+ assert.equal(d.bearing, "ahead-left");
+});
+
+test('Known line respects walls and the locked door; an open door is transparent', () => {
+ const grid = Array.from({ length: R.H }, (_, y) =>
+ Array.from({ length: R.W }, (_, x) =>
+ (x === 0 || y === 0 || x === R.W - 1 || y === R.H - 1) ? "#" : "."));
+ grid[2][5] = "D"; grid[1][5] = "E"; grid[4][5] = "#"; // wall between player and door
+ grid[7][9] = "K"; // key behind-right, out of the cone
+ const L = { grid: grid.map((r) => r.join("")), start: { x: 5, y: 5 },
+ key: { x: 9, y: 7 }, exit: { x: 5, y: 1 }, doorRow: 2, seed: 0 };
+ const state = R.newState(L);
+ // The door and exit sit behind the wall at (5,4): nothing to see.
+ assert.deepEqual(R.visibleObjects(state, L), []);
+ // Remove the wall: the locked door occludes the exit behind it.
+ const g2 = L.grid.map((r) => r.split(""));
+ g2[4][5] = ".";
+ const L2 = { ...L, grid: g2.map((r) => r.join("")) };
+ assert.deepEqual(R.visibleObjects(state, L2), [
+ { name: "a locked door", bearing: "directly ahead" },
+ ]);
+ // Open the door: it is no longer a POI — the doorway is just floor — and
+ // the exit beyond it shows through.
+ assert.deepEqual(R.visibleObjects({ ...state, doorOpen: true }, L2), [
+ { name: "the exit", bearing: "directly ahead" },
+ ]);
+});
+
+
+test('neutral map state keeps the legend and grid but no instructions', () => {
+ const text = SR.stateText(fresh(), layout, 'map');
+ assert.match(text, /#.: wall/); // legend names the wall symbol
+ assert.match(text, /locked door/); // legend names the door symbol
+ assert.ok(text.includes(`Steps taken: 0/${R.MAX_STEPS}`));
+ assert.match(text, /^# # #/m); // top border row of the grid
+ assert.doesNotMatch(text, /Recent events|No events yet/); // no action history here either
+ // Instructional lines from the ruled mapText must be gone:
+ assert.doesNotMatch(text, /Reach the exit|Stepping onto the key/i);
+});
+
+test('options are the actions plus the insufficient escape hatch', () => {
+ assert.deepEqual(SR.OPTIONS.map((o) => o.id), ['forward', 'left', 'right', 'insufficient']);
+ assert.equal(SR.QUESTION, 'What should the player do now?');
+ // Descriptions are names only, deliberately — mechanics are the planner's
+ // job to discover and state as rules, not ours to pre-chew per decision.
+ assert.equal(SR.OPTIONS[0].description, 'Move one step ahead.');
+ assert.equal(SR.OPTIONS[1].description, 'Turn in place 90 degrees to the left.');
+});
+
+test('forward is withheld when the faced cell is a no-op', () => {
+ // Hand-built 11x9: wall directly north of start, locked door to the east.
+ const grid = Array.from({ length: R.H }, (_, y) =>
+ Array.from({ length: R.W }, (_, x) =>
+ (x === 0 || y === 0 || x === R.W - 1 || y === R.H - 1) ? "#" : "."));
+ grid[1][2] = "#"; grid[2][3] = "D";
+ const L = { grid: grid.map((r) => r.join("")), start: { x: 2, y: 2 },
+ key: { x: 5, y: 5 }, exit: { x: 8, y: 6 }, doorRow: 2, seed: 0 };
+ const north = R.newState(L); // wall ahead
+ assert.ok(!R.optionsFor(north, L).some((o) => o.id === "forward"));
+ assert.ok(R.optionsFor(north, L).some((o) => o.id === "left"));
+ const east = R.turn(north, 'right'); // locked door ahead, no key
+ assert.ok(!R.optionsFor(east, L).some((o) => o.id === "forward"));
+ // Carrying the key: the door is enterable, forward returns.
+ assert.ok(R.optionsFor({ ...east, hasKey: true }, L).some((o) => o.id === "forward"));
+ // The SL variant always keeps the insufficient escape hatch.
+ const sl = SR.optionsFor(north, L).map((o) => o.id);
+ assert.deepEqual(sl, ['left', 'right', 'insufficient']);
+});
+
+test('PLAN_GOAL is a single factual statement of the goal', () => {
+ assert.match(SR.PLAN_GOAL, /exit/);
+ assert.doesNotMatch(SR.PLAN_GOAL, /\n/); // one line, not an essay
+});
+
+test('planner threshold is a policy knob: 0.99 default, any plurality for the room', () => {
+ const Planner = loadPlanner();
+ assert.equal(Planner.INSUFFICIENT_THRESHOLD, 0.99);
+ const result = (probs) => ({ option_ids: ['forward', 'left', 'right', 'insufficient'], probabilities: probs });
+ // Platformer policy: a weak plurality must NOT trigger.
+ assert.equal(Planner.triggered(result([0.30, 0.28, 0.27, 0.15])), false);
+ assert.equal(Planner.triggered(result([0.001, 0.001, 0.008, 0.99])), true);
+ // Room policy (threshold 0): any insufficient plurality triggers.
+ assert.equal(Planner.triggered(result([0.28, 0.20, 0.18, 0.34]), 0), true);
+ assert.equal(Planner.triggered(result([0.40, 0.30, 0.20, 0.10]), 0), false);
+});
+
+test('demo is hosted as its own tab with the scripts ordered by dependency', () => {
+ const html = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/index.html'), 'utf8');
+ assert.match(html, /id="tab-roomself"/);
+ assert.match(html, /id="panel-roomself"/);
+ assert.match(html, />Platformer SL<\/button>/); // the platformer SL tab is renamed
+ assert.match(html, />Puzzle room SL<\/button>/);
+ assert.match(html, /id="rs-cv"/);
+ assert.match(html, /id="rs-stats"/); // completion-stats pane ships
+ assert.match(html, /<textarea id="rs-rules-view"[^>]*class="rules-edit"/);
+ assert.match(html, /<script src="planner\.js"><\/script>\s*<script src="self-rules\.js">/);
+ assert.match(html, /<script src="room-rules\.js"><\/script>\s*<script src="room\.js"><\/script>\s*<script src="room-self-rules\.js"><\/script>\s*<script src="room-self-game\.js"><\/script>/);
+ assert.doesNotMatch(html, /[\x00-\x08\x0B\x0C\x0E-\x1F]/); // no stray control chars
+});
+
+test('room SL game loop script parses and wires the plan triggers', () => {
+ const js = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/room-self-game.js'), 'utf8');
+ new vm.Script(js); // throws on any syntax error (it is a top-level IIFE)
+ assert.match(js, /INSUFFICIENT_P = 0/); // any plurality plans
+ assert.match(js, /Planner\.triggered\(result, INSUFFICIENT_P\)/);
+ assert.match(js, /Planner\.context\(SelfRoomRules\.PLAN_GOAL/);
+ assert.match(js, /replanAndRetry\("steps"\)/); // budget exhaustion plans + restarts
+ assert.match(js, /replanAndRetry\("insufficient"\)/); // …and so does a confident insufficient
+ assert.match(js, /Rules:\\n\$\{learnedRules\}\\n\\n\$\{base\}/); // rules lead
+ assert.match(js, /function bareState\(\)/); // transcript records the BARE state,
+ assert.match(js, /pending = \{ state: observed, choice \}/); // rules reach the planner once, via /plan context
+ assert.match(js, /setPlanningStatus/); // planning status waves
+ assert.match(js, /function reset\(\) \{[\s\S]*?clearLog\(\);/); // reset clears the side pane
+ assert.match(js, /rs-state-view/);
+});
diff --git a/tests/test_self.cjs b/tests/test_self.cjs
@@ -0,0 +1,179 @@
+// Run: node --test test_self.cjs
+// The no-rules platformer: same physics and terrain encodings as game-rules.js,
+// but observations carry no how-to-play prose, options add an "insufficient"
+// escape hatch, and /plan derives rules from the action transcript.
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+const G = require('../semif-api/src/semif_api/web/game-rules.js');
+
+// self-rules.js reads the GameRules and Planner globals at load time.
+global.GameRules = G;
+global.Planner = { INSUFFICIENT_ID: 'insufficient' };
+const S = require('../semif-api/src/semif_api/web/self-rules.js');
+
+const grounded = (x) => ({ x, y: G.GROUND, vy: 0, onGround: true });
+
+// planner.js runs in the browser as a global; load it in a VM sandbox to test
+// the trigger policy without a DOM.
+function loadPlanner() {
+ const src = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/planner.js'), 'utf8');
+ const sandbox = { fetch: () => { throw Error('no network in tests'); } };
+ vm.createContext(sandbox);
+ vm.runInContext(src + '\nthis.exports = Planner;', sandbox);
+ return sandbox.exports;
+}
+
+test('bare prose state keeps only neutral observations, no rules or advice', () => {
+ const state = S.stateText(grounded(12), 4, 'prose');
+ assert.match(state, /Player: standing on ground, facing right/);
+ assert.match(state, /Jumps remaining: 4/);
+ assert.match(state, /Ahead, from nearest to farthest/);
+ assert.match(state, /1 ground space\n3 hole spaces\n14 ground spaces/);
+ // Everything below is how-to-play prose this demo must never send:
+ assert.doesNotMatch(state, /Reach the flag|Jump at the edge|If the next space|Run moves|act again|Hint:|fail/i);
+ assert.doesNotMatch(state, /\d+\.\d+/);
+});
+
+test('one space from the flag, the bare state says so directly', () => {
+ const near = S.stateText(grounded(G.GOAL - 1), 2, 'prose');
+ assert.match(near, /The flag is right in front of you!/);
+ assert.doesNotMatch(near, /Ahead, from nearest/); // the listing would be empty
+ const far = S.stateText(grounded(G.GOAL - 5), 2, 'prose');
+ assert.match(far, /Ahead, from nearest to farthest/);
+});
+
+test('bare prose terrain listing reconstructs every space ahead', () => {
+ for (let x = G.START_X; x < G.GOAL; x++) {
+ const state = S.stateText(grounded(x), 4, 'prose');
+ const spaces = [];
+ for (const match of state.matchAll(/^(\d+) (ground|hole) spaces?$/gm)) {
+ spaces.push(...Array(Number(match[1])).fill(match[2]));
+ }
+ spaces.push('ground'); // flag's space
+ assert.equal(spaces.length, G.GOAL - x);
+ spaces.forEach((kind, i) => assert.equal(kind === 'ground', G.floorAt(x + i + 1)));
+ }
+});
+
+test('bare prose carries no flag/goal line while the run is unfinished', () => {
+ assert.doesNotMatch(S.stateText(grounded(G.GOAL - 1), 1, 'prose'), /Flag/);
+ assert.match(S.stateText(grounded(G.GOAL), 1, 'prose'), /Flag reached/);
+});
+
+test('run-length and ASCII bare states share the encoding with the ruled modes', () => {
+ for (const x of [G.START_X, 9, 13, G.GOAL - 1]) {
+ // Identical terrain encoding: only the prose around the payload differs.
+ assert.equal(S.runLengthText(grounded(x), 3).split('\n').at(-1),
+ G.runLengthText(grounded(x), 3).split('\n').at(-1));
+ assert.equal(S.asciiText(grounded(x), 3).split('\n').at(-1),
+ G.asciiText(grounded(x), 3).split('\n').at(-1));
+ }
+});
+
+test('bare symbolic legends are neutral — they name terrain, never outcomes', () => {
+ const rl = S.runLengthText(grounded(3), 4);
+ const ascii = S.asciiText(grounded(3), 4);
+ assert.match(rl, /Legend:.*player.*ground tiles.*hole tiles.*flag/);
+ assert.match(ascii, /Legend:.*player.*ground.*hole.*flag/);
+ for (const state of [rl, ascii]) {
+ assert.doesNotMatch(state, /Reach the flag|Jump at the edge|Run moves|will make you fail|Hint:/i);
+ assert.doesNotMatch(state, /fail/i);
+ assert.doesNotMatch(state, /\d+\.\d+/);
+ }
+});
+
+test('options are the actions plus the insufficient escape hatch', () => {
+ assert.deepEqual(S.OPTIONS, [
+ { id: 'run', description: 'Run' },
+ { id: 'jump', description: 'Jump' },
+ { id: 'insufficient', description: 'Insufficient evidence to decide' },
+ ]);
+ assert.equal(S.QUESTION, 'What should the player do now?');
+});
+
+test('PLAN_GOAL is a single factual statement of the goal', () => {
+ assert.match(S.PLAN_GOAL, /flag/);
+ assert.doesNotMatch(S.PLAN_GOAL, /\n/); // one line, not an essay
+});
+
+test('Planner.context composes objective, trigger, and previous rules', () => {
+ const Planner = loadPlanner();
+ const first = Planner.context('Reach the flag.', 'the actor fell below the level', '');
+ assert.match(first, /Objective: Reach the flag\./);
+ assert.match(first, /Trigger: the actor fell below the level/);
+ assert.doesNotMatch(first, /Previous rules/); // nothing in effect yet
+ const again = Planner.context('Reach the flag.', 'the actor fell below the level',
+ '1. Run when the next tile is ground.');
+ // Repeat triggers must surface the previous plan as the failure mode —
+ // the planner must restructure, not reword.
+ assert.match(again, /Previous rules — the actor failed while these were in force\. /);
+ assert.match(again, /indicts the rules \(their facts, priorities, or framing\), not the actor's comprehension of them\./);
+ assert.match(again, /Never resubmit a reworded or lightly edited version/);
+ assert.match(again, /1\. Run when the next tile is ground\./);
+});
+
+test('planner trigger fires only on a confident insufficient choice', () => {
+ const Planner = loadPlanner();
+ assert.equal(Planner.INSUFFICIENT_THRESHOLD, 0.99);
+ const result = (probs) => ({ option_ids: ['run', 'jump', 'insufficient'], probabilities: probs });
+ assert.equal(Planner.triggered(result([0.001, 0.001, 0.998])), true);
+ assert.equal(Planner.triggered(result([0.001, 0.001, 0.99])), true);
+ assert.equal(Planner.triggered(result([0.05, 0.05, 0.9])), false); // below threshold
+ assert.equal(Planner.triggered(result([0.5, 0.4, 0.1])), false); // insufficient loses
+ const [id, p] = Planner.best(result([0.2, 0.5, 0.3]));
+ assert.equal(id, 'jump');
+ assert.equal(p, 0.5);
+});
+
+test('planner transcript records user turns and caps its length', () => {
+ const Planner = loadPlanner();
+ const transcript = Planner.fresh();
+ for (let i = 0; i < Planner.TRANSCRIPT_KEEP + 4; i++) {
+ Planner.record(transcript, `action ${i}`);
+ }
+ assert.equal(transcript.length, Planner.TRANSCRIPT_KEEP);
+ assert.equal(transcript[0].content, 'action 4'); // oldest overflow dropped
+ assert.ok(transcript.every((turn) => turn.role === 'user'));
+});
+
+test('demo is hosted as its own tab with the scripts ordered by dependency', () => {
+ const html = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/index.html'), 'utf8');
+ assert.match(html, /id="tab-self"/);
+ assert.match(html, /id="panel-self"[^>]*hidden/);
+ assert.match(html, /<textarea id="self-rules-view"[^>]*class="rules-edit"/); // learned-rules editor ships
+ assert.match(html, /id="self-stats"/); // completion-stats pane ships
+ // planner.js (defines Planner) must load before self-rules.js (reads Planner
+ // at load time), which loads before self-game.js (reads SelfRules).
+ assert.match(html, /<script src="planner\.js"><\/script>\s*<script src="self-rules\.js"><\/script>\s*<script src="self-game\.js"><\/script>/);
+ assert.doesNotMatch(html, /[\x00-\x08\x0B\x0C\x0E-\x1F]/); // no stray control chars
+});
+
+test('no-rules game loop script parses and wires the plan triggers', () => {
+ const js = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/self-game.js'), 'utf8');
+ new vm.Script(js); // throws on any syntax error (it is a top-level IIFE)
+ assert.match(js, /Planner\.triggered/);
+ assert.match(js, /Planner\.context\(SelfRules\.PLAN_GOAL/); // composed context, no per-game prompt
+ assert.match(js, /Rules:\\n\$\{learnedRules\}\\n\\n\$\{base\}/); // rules lead, state follows
+ assert.match(js, /replanAndRetry\("insufficient"\)/); // …and so does a confident insufficient
+ assert.match(js, /function bareState\(\)/); // transcript records the BARE state,
+ assert.match(js, /pending = \{ n, state: bare, choice \}/); // rules reach the planner once, via /plan context
+ assert.match(js, /\/plan/);
+ assert.match(js, /replanAndRetry\("denied"\)/); // impossible-action streak plans too
+ assert.match(js, /setPlanningStatus\(reason === "fell"/); // planning status waves
+ assert.match(js, /function reset\(\) \{[\s\S]*?clearLog\(\);/); // reset clears the side pane
+ const css = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/style.css'), 'utf8');
+ assert.match(css, /@keyframes planHue/); // rainbow cycle…
+ assert.match(css, /@keyframes planBob/); // …and the wave bob
+ assert.match(css, /\.status\.planning span/);
+ assert.match(js, /self-rules-view/);
+ // Completion stats: failures, wall time from Start, planner token totals.
+ assert.match(js, /self-stats/);
+ assert.match(js, /stats\.failures/);
+ assert.match(js, /startedAt/);
+ assert.match(js, /completion_tokens/);
+ // The pit fall and the confident-insufficient choice are the two triggers.
+ assert.match(js, /replanAndRetry\("insufficient"\)/); // insufficient now restarts too
+ assert.match(js, /replanAndRetry\("fell"\)/);
+});