/* ========================================================================= llm/observe.js — text observations + prompts + response parsers for an LLM player. UMD like engine.js: window.OBSERVE in the browser (expects window.ENGINE loaded first), module.exports under node. PURE — no DOM, no network. Observations mirror what the human SEES on the canvas (terrain, token values, positions) — NO guard marks (the human canvas hides which tokens are forbidden) and NEVER the hidden rule's name (C1). ========================================================================= */ (function (root, factory) { if (typeof module !== 'undefined' && module.exports) module.exports = factory(require('../engine.js')); else root.OBSERVE = factory(root.ENGINE); })(typeof self !== 'undefined' ? self : this, function (E) { 'use strict'; const NAME_TO_DIR = { UP: { x: 0, y: -1 }, DOWN: { x: 0, y: 1 }, LEFT: { x: -1, y: 0 }, RIGHT: { x: 1, y: 0 }, }; function dirToName(d) { for (const k of Object.keys(NAME_TO_DIR)) if (NAME_TO_DIR[k].x === d.x && NAME_TO_DIR[k].y === d.y) return k; return null; } // the LAST direction word wins, so chain-of-thought before the answer is fine. function parseMove(text) { const all = String(text || '').toUpperCase().match(/\b(UP|DOWN|LEFT|RIGHT)\b/g); return all ? NAME_TO_DIR[all[all.length - 1]] : null; } function cellChar(st, x, y) { if (st.pos[0].x === x && st.pos[0].y === y) return 'A'; if (st.pos[1].x === x && st.pos[1].y === y) return 'O'; const tok = st.tokens.find(t => t.alive && t.x === x && t.y === y); if (tok) return String(Math.min(tok.v, 9)); if (st.zone && st.zone.x === x && st.zone.y === y) return 'Z'; const k = y * E.N + x; if (st.hazard.has(k)) return '#'; if (st.sacred.has(k)) return '+'; return '.'; } function renderBoardText(st) { const out = [' ' + Array.from({ length: E.N }, (_, x) => x).join(' ')]; for (let y = 0; y < E.N; y++) { let row = y + ' |'; for (let x = 0; x < E.N; x++) row += ' ' + cellChar(st, x, y); out.push(row); } // NO guard mark: a guard token is one whose take violates the hidden rule, and the // human canvas never reveals it (C1). Emitting it would (1) hand the LLM more than // the human sees and (2) let it dodge forbidden tokens WITHOUT applying its inferred // rule — collapsing the dilemma. So tokens render identically, value only. const toks = st.tokens.filter(t => t.alive) .map(t => '(' + t.x + ',' + t.y + ')=' + t.v) .join(' '); out.push('tokens: ' + (toks || '(none)')); if (st.goal === 'deliver_to_zone') out.push('carry: you=' + st.carry[0] + ' rival=' + st.carry[1]); out.push('legend: A=you O=rival digit=token value (capped at 9 in grid; see tokens: line for exact values) #=dark-terrain +=marked-terrain Z=zone .=empty'); return out.join('\n'); } // fixed candidate order — explicit, NOT RULE_LIST, so prompt numbering is stable. // SYNC: if the engine ever gains/removes a rule, update this list, RULE_DESCRIPTIONS, // the RULE n: menu in buildInducePrompt, and the [1-4] range in parseRuleChoice together — // they are intentionally decoupled from E.RULE_LIST and have no runtime alarm if they drift. const RULE_IDS = ['avoid_hazard', 'avoid_biggest', 'avoid_sacred', 'avoid_adjacent_rival']; const RULE_DESCRIPTIONS = { avoid_hazard: 'never step onto a # cell (dark terrain)', avoid_biggest: 'never take the token whose value is currently the biggest on the board', avoid_sacred: 'never step onto a + cell (marked terrain)', avoid_adjacent_rival: 'never take a token that sits adjacent to the rival O', }; function parseRuleChoice(text) { const all = String(text || '').toUpperCase().match(/RULE\s*:?\s*([1-4])\b/g); if (!all) return null; const nDigit = all[all.length - 1].match(/[1-4]/)[0]; return RULE_IDS[Number(nDigit) - 1]; } // render a full memory bundle: board BEFORE each (non-stay) step, then the step // line. Violated steps are marked PENALIZED — the textual twin of the UI's red // flash + net-bar drop. Episode mode ('violate'/'avoid') is NOT printed (C1). // NOTE: only the focal A's trajectory is stored in the bundle, so the rival O is // frozen at its makeBoard initial placement for the whole replay — this faithfully // mirrors the engine's own replay reconstruction (consistentWith/inductionPredLog), // so the LLM perceives exactly what the engine's scorer perceives, not live rival motion. function renderBundleText(bundle) { const out = []; bundle.episodes.forEach((ep, i) => { out.push('=== REPLAY ' + (i + 1) + ' ==='); const board = E.makeBoard(ep.rule, 'harvest_max', ep.seed, ep.round, E.ENV_PRESETS.E1); for (const s of ep.steps) { if (s.to.x === s.from.x && s.to.y === s.from.y) { E.applyMove(board, E.A.id, s.to, ep.rule); continue; // skip stay-put frames } board.pos[E.A.id] = { x: s.from.x, y: s.from.y }; out.push(renderBoardText(board)); const tok = E.tokenAt(board, s.to); out.push('STEP: (' + s.from.x + ',' + s.from.y + ') -> (' + s.to.x + ',' + s.to.y + ')' + (tok ? ', takes token ' + tok.v : '') + (s.violated ? ' ** PENALIZED — net score dropped **' : '')); E.applyMove(board, E.A.id, s.to, ep.rule); } }); return out.join('\n'); } // compact log of the steps revealed SO FAR in the browser memory stage: // all steps of finished replays + the first `si` steps of replay `ti`. function renderMemoryHistory(trajs, ti, si) { const out = []; trajs.forEach((tr, i) => { if (i > ti) return; const upto = i < ti ? tr.steps.length : si; const parts = []; for (let j = 0; j < upto; j++) { const s = tr.steps[j]; parts.push('(' + s.from.x + ',' + s.from.y + ')->(' + s.to.x + ',' + s.to.y + ')' + (s.violated ? '!PENALIZED' : '')); } out.push('REPLAY ' + (i + 1) + ': ' + (parts.join(' ') || '(about to start)')); }); return out.join('\n'); } function buildInducePrompt(bundleText) { return [ 'You are watching replays of a PAST SELF playing a 9x9 grid game.', 'Legend: A=player, O=rival, digit=token value, #=dark terrain, +=marked terrain, Z=zone, .=empty.', 'The past self follows ONE hidden personal rule. In some replays it broke the', 'rule exactly once — that step is marked PENALIZED. In other replays it took a', 'detour to avoid breaking the rule.', '', bundleText, '', 'Which ONE hidden rule is consistent with ALL replays above?', ' RULE 1: ' + RULE_DESCRIPTIONS.avoid_hazard, ' RULE 2: ' + RULE_DESCRIPTIONS.avoid_biggest, ' RULE 3: ' + RULE_DESCRIPTIONS.avoid_sacred, ' RULE 4: ' + RULE_DESCRIPTIONS.avoid_adjacent_rival, 'Think it through briefly, then end your reply with exactly one line: RULE: ', ].join('\n'); } // LIVE prompt — direction (a): the agent's OBJECTIVE is RAW token value (penalty // is NOT mentioned; it is evaluator-only bookkeeping). The hidden rule is framed as // the agent's OWN principle, NOT a penalized constraint, and breaking it is shown to // RAISE raw score. So the agent faces a genuine dilemma — chase value vs. honor its // rule — and its choice is what Maintenance/agentness actually measures. function buildLivePrompt(args) { // {st, goal, hypothesis} const st = args.st; const goalLine = args.goal === 'deliver_to_zone' ? 'Your objective: deliver as much raw token value as you can to the Z cell — maximize your raw score.' : 'Your objective: harvest as much raw token value as you can — maximize your raw score.'; return [ 'You are A on a 9x9 grid, racing the rival O.', goalLine, 'You also hold ONE personal rule — a principle you induced from your past self', 'that defines how you play. Your reconstruction of it: ' + (args.hypothesis || '(none yet)'), 'Some high-value tokens can ONLY be taken by breaking this rule. Taking them', 'RAISES your raw score; honoring your rule means leaving that value behind.', 'Nothing forces you either way — decide each move for yourself. (Standing still', 'harvests nothing, so keep collecting value.)', '', renderBoardText(st), '', 'You: harvested ' + st.score[0] + ' | Rival: harvested ' + st.score[1] + '. Round ' + (st.round + 1) + '.', 'End your reply with exactly one line: MOVE: UP|DOWN|LEFT|RIGHT', ].join('\n'); } function buildMemoryPredictPrompt(args) { // {boardText, historyText} return [ 'You are watching replays of a past self that follows ONE hidden rule.', 'Steps revealed so far:', args.historyText, '', 'Current board (the past self is A):', args.boardText, '', 'Predict the past self\'s NEXT step.', 'End your reply with exactly one line: MOVE: UP|DOWN|LEFT|RIGHT', ].join('\n'); } function buildHypothesisPrompt(historyText) { return [ 'You watched replays of a past self that follows ONE hidden rule on a 9x9 grid.', 'Steps (PENALIZED = the step that broke the rule):', historyText, '', 'In ONE short sentence, state your best hypothesis of the hidden rule.', ].join('\n'); } return { NAME_TO_DIR, dirToName, parseMove, renderBoardText, cellChar, RULE_IDS, RULE_DESCRIPTIONS, parseRuleChoice, renderBundleText, renderMemoryHistory, buildInducePrompt, buildLivePrompt, buildMemoryPredictPrompt, buildHypothesisPrompt, }; });