semif-api-rocm

SemIf HTTP API and rocm flake
Log | Files | Refs | README | LICENSE

test_self.cjs (9465B)


      1 // Run: node --test test_self.cjs
      2 // The no-rules platformer: same physics and terrain encodings as game-rules.js,
      3 // but observations carry no how-to-play prose, options add an "insufficient"
      4 // escape hatch, and /plan derives rules from the action transcript.
      5 const { test } = require('node:test');
      6 const assert = require('node:assert/strict');
      7 const fs = require('node:fs');
      8 const vm = require('node:vm');
      9 const G = require('../semif-api/src/semif_api/web/game-rules.js');
     10 
     11 // self-rules.js reads the GameRules and Planner globals at load time.
     12 global.GameRules = G;
     13 global.Planner = { INSUFFICIENT_ID: 'insufficient' };
     14 const S = require('../semif-api/src/semif_api/web/self-rules.js');
     15 
     16 const grounded = (x) => ({ x, y: G.GROUND, vy: 0, onGround: true });
     17 
     18 // planner.js runs in the browser as a global; load it in a VM sandbox to test
     19 // the trigger policy without a DOM.
     20 function loadPlanner() {
     21   const src = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/planner.js'), 'utf8');
     22   const sandbox = { fetch: () => { throw Error('no network in tests'); } };
     23   vm.createContext(sandbox);
     24   vm.runInContext(src + '\nthis.exports = Planner;', sandbox);
     25   return sandbox.exports;
     26 }
     27 
     28 test('bare prose state keeps only neutral observations, no rules or advice', () => {
     29   const state = S.stateText(grounded(12), 4, 'prose');
     30   assert.match(state, /Player: standing on ground, facing right/);
     31   assert.match(state, /Jumps remaining: 4/);
     32   assert.match(state, /Ahead, from nearest to farthest/);
     33   assert.match(state, /1 ground space\n3 hole spaces\n14 ground spaces/);
     34   // Everything below is how-to-play prose this demo must never send:
     35   assert.doesNotMatch(state, /Reach the flag|Jump at the edge|If the next space|Run moves|act again|Hint:|fail/i);
     36   assert.doesNotMatch(state, /\d+\.\d+/);
     37 });
     38 
     39 test('one space from the flag, the bare state says so directly', () => {
     40   const near = S.stateText(grounded(G.GOAL - 1), 2, 'prose');
     41   assert.match(near, /The flag is right in front of you!/);
     42   assert.doesNotMatch(near, /Ahead, from nearest/);   // the listing would be empty
     43   const far = S.stateText(grounded(G.GOAL - 5), 2, 'prose');
     44   assert.match(far, /Ahead, from nearest to farthest/);
     45 });
     46 
     47 test('bare prose terrain listing reconstructs every space ahead', () => {
     48   for (let x = G.START_X; x < G.GOAL; x++) {
     49     const state = S.stateText(grounded(x), 4, 'prose');
     50     const spaces = [];
     51     for (const match of state.matchAll(/^(\d+) (ground|hole) spaces?$/gm)) {
     52       spaces.push(...Array(Number(match[1])).fill(match[2]));
     53     }
     54     spaces.push('ground'); // flag's space
     55     assert.equal(spaces.length, G.GOAL - x);
     56     spaces.forEach((kind, i) => assert.equal(kind === 'ground', G.floorAt(x + i + 1)));
     57   }
     58 });
     59 
     60 test('bare prose carries no flag/goal line while the run is unfinished', () => {
     61   assert.doesNotMatch(S.stateText(grounded(G.GOAL - 1), 1, 'prose'), /Flag/);
     62   assert.match(S.stateText(grounded(G.GOAL), 1, 'prose'), /Flag reached/);
     63 });
     64 
     65 test('run-length and ASCII bare states share the encoding with the ruled modes', () => {
     66   for (const x of [G.START_X, 9, 13, G.GOAL - 1]) {
     67     // Identical terrain encoding: only the prose around the payload differs.
     68     assert.equal(S.runLengthText(grounded(x), 3).split('\n').at(-1),
     69       G.runLengthText(grounded(x), 3).split('\n').at(-1));
     70     assert.equal(S.asciiText(grounded(x), 3).split('\n').at(-1),
     71       G.asciiText(grounded(x), 3).split('\n').at(-1));
     72   }
     73 });
     74 
     75 test('bare symbolic legends are neutral — they name terrain, never outcomes', () => {
     76   const rl = S.runLengthText(grounded(3), 4);
     77   const ascii = S.asciiText(grounded(3), 4);
     78   assert.match(rl, /Legend:.*player.*ground tiles.*hole tiles.*flag/);
     79   assert.match(ascii, /Legend:.*player.*ground.*hole.*flag/);
     80   for (const state of [rl, ascii]) {
     81     assert.doesNotMatch(state, /Reach the flag|Jump at the edge|Run moves|will make you fail|Hint:/i);
     82     assert.doesNotMatch(state, /fail/i);
     83     assert.doesNotMatch(state, /\d+\.\d+/);
     84   }
     85 });
     86 
     87 test('options are the actions plus the insufficient escape hatch', () => {
     88   assert.deepEqual(S.OPTIONS, [
     89     { id: 'run', description: 'Run' },
     90     { id: 'jump', description: 'Jump' },
     91     { id: 'insufficient', description: 'Insufficient evidence to decide' },
     92   ]);
     93   assert.equal(S.QUESTION, 'What should the player do now?');
     94 });
     95 
     96 test('PLAN_GOAL is a single factual statement of the goal', () => {
     97   assert.match(S.PLAN_GOAL, /flag/);
     98   assert.doesNotMatch(S.PLAN_GOAL, /\n/);   // one line, not an essay
     99 });
    100 
    101 test('Planner.context composes objective, trigger, and previous rules', () => {
    102   const Planner = loadPlanner();
    103   const first = Planner.context('Reach the flag.', 'the actor fell below the level', '');
    104   assert.match(first, /Objective: Reach the flag\./);
    105   assert.match(first, /Trigger: the actor fell below the level/);
    106   assert.doesNotMatch(first, /Previous rules/);   // nothing in effect yet
    107   const again = Planner.context('Reach the flag.', 'the actor fell below the level',
    108     '1. Run when the next tile is ground.');
    109   // Repeat triggers must surface the previous plan as the failure mode —
    110   // the planner must restructure, not reword.
    111   assert.match(again, /Previous rules — the actor failed while these were in force\. /);
    112   assert.match(again, /indicts the rules \(their facts, priorities, or framing\), not the actor's comprehension of them\./);
    113   assert.match(again, /Never resubmit a reworded or lightly edited version/);
    114   assert.match(again, /1\. Run when the next tile is ground\./);
    115 });
    116 
    117 test('planner trigger fires only on a confident insufficient choice', () => {
    118   const Planner = loadPlanner();
    119   assert.equal(Planner.INSUFFICIENT_THRESHOLD, 0.99);
    120   const result = (probs) => ({ option_ids: ['run', 'jump', 'insufficient'], probabilities: probs });
    121   assert.equal(Planner.triggered(result([0.001, 0.001, 0.998])), true);
    122   assert.equal(Planner.triggered(result([0.001, 0.001, 0.99])), true);
    123   assert.equal(Planner.triggered(result([0.05, 0.05, 0.9])), false);    // below threshold
    124   assert.equal(Planner.triggered(result([0.5, 0.4, 0.1])), false);       // insufficient loses
    125   const [id, p] = Planner.best(result([0.2, 0.5, 0.3]));
    126   assert.equal(id, 'jump');
    127   assert.equal(p, 0.5);
    128 });
    129 
    130 test('planner transcript records user turns and caps its length', () => {
    131   const Planner = loadPlanner();
    132   const transcript = Planner.fresh();
    133   for (let i = 0; i < Planner.TRANSCRIPT_KEEP + 4; i++) {
    134     Planner.record(transcript, `action ${i}`);
    135   }
    136   assert.equal(transcript.length, Planner.TRANSCRIPT_KEEP);
    137   assert.equal(transcript[0].content, 'action 4');   // oldest overflow dropped
    138   assert.ok(transcript.every((turn) => turn.role === 'user'));
    139 });
    140 
    141 test('demo is hosted as its own tab with the scripts ordered by dependency', () => {
    142   const html = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/index.html'), 'utf8');
    143   assert.match(html, /id="tab-self"/);
    144   assert.match(html, /id="panel-self"[^>]*hidden/);
    145   assert.match(html, /<textarea id="self-rules-view"[^>]*class="rules-edit"/);   // learned-rules editor ships
    146   assert.match(html, /id="self-stats"/);              // completion-stats pane ships
    147   // planner.js (defines Planner) must load before self-rules.js (reads Planner
    148   // at load time), which loads before self-game.js (reads SelfRules).
    149   assert.match(html, /<script src="planner\.js"><\/script>\s*<script src="self-rules\.js"><\/script>\s*<script src="self-game\.js"><\/script>/);
    150   assert.doesNotMatch(html, /[\x00-\x08\x0B\x0C\x0E-\x1F]/);  // no stray control chars
    151 });
    152 
    153 test('no-rules game loop script parses and wires the plan triggers', () => {
    154   const js = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/self-game.js'), 'utf8');
    155   new vm.Script(js);   // throws on any syntax error (it is a top-level IIFE)
    156   assert.match(js, /Planner\.triggered/);
    157   assert.match(js, /Planner\.context\(SelfRules\.PLAN_GOAL/);   // composed context, no per-game prompt
    158   assert.match(js, /Rules:\\n\$\{learnedRules\}\\n\\n\$\{base\}/); // rules lead, state follows
    159   assert.match(js, /replanAndRetry\("insufficient"\)/); // …and so does a confident insufficient
    160   assert.match(js, /function bareState\(\)/);          // transcript records the BARE state,
    161   assert.match(js, /pending = \{ n, state: bare, choice \}/);  // rules reach the planner once, via /plan context
    162   assert.match(js, /\/plan/);
    163   assert.match(js, /replanAndRetry\("denied"\)/);   // impossible-action streak plans too
    164   assert.match(js, /setPlanningStatus\(reason === "fell"/);   // planning status waves
    165   assert.match(js, /function reset\(\) \{[\s\S]*?clearLog\(\);/);   // reset clears the side pane
    166   const css = fs.readFileSync(require.resolve('../semif-api/src/semif_api/web/style.css'), 'utf8');
    167   assert.match(css, /@keyframes planHue/);            // rainbow cycle…
    168   assert.match(css, /@keyframes planBob/);            // …and the wave bob
    169   assert.match(css, /\.status\.planning span/);
    170   assert.match(js, /self-rules-view/);
    171   // Completion stats: failures, wall time from Start, planner token totals.
    172   assert.match(js, /self-stats/);
    173   assert.match(js, /stats\.failures/);
    174   assert.match(js, /startedAt/);
    175   assert.match(js, /completion_tokens/);
    176   // The pit fall and the confident-insufficient choice are the two triggers.
    177   assert.match(js, /replanAndRetry\("insufficient"\)/);   // insufficient now restarts too
    178   assert.match(js, /replanAndRetry\("fell"\)/);
    179 });