diff --git a/src/agent/mcts.js b/src/agent/mcts.js index 7de481e..0d2df4b 100644 --- a/src/agent/mcts.js +++ b/src/agent/mcts.js @@ -1,3 +1,8 @@ +/* Statistical-memo MCTS: this tree records which (state, action) pairs have been + explored and their cumulative rewards, but does NOT re-navigate the browser to + match the selected node before action execution. The tree bounds exploration via + UCB1 scores but does not enforce state-graph consistency. Full re-navigation + is deferred — it increases per-step cost 3-10x. See DECISION_LOG #021. */ import { selectChild } from './policy.js'; export class MctsNode { diff --git a/src/index.js b/src/index.js index 4d81dbd..7013dcd 100644 --- a/src/index.js +++ b/src/index.js @@ -306,7 +306,7 @@ async function runArm({ role, page, seed, config, rng, tracer, breadcrumbs, step const macroFireProb = config.macros?.fireProbability ?? 0; const macroList = config.macros?.list ?? []; const stepTimeoutMs = config.run?.stepTimeoutMs ?? 10000; - const recentStateIds = []; + const recentStateIds = new Set(); const noveltyDenylist = (config.novelty?.nameDenylist ?? []).map((s) => new RegExp(s, 'i')); let prevA11y = null; let prevUrl = null; @@ -321,7 +321,7 @@ async function runArm({ role, page, seed, config, rng, tracer, breadcrumbs, step const fileInputs = await getFileInputs(page.raw); const forms = await detectFillableForms(page.raw); const stateId = clusterId(a11y, config.mcts.abstractionGranularity); - recentStateIds.push(stateId); + recentStateIds.add(stateId); const cands = candidateActions(a11y, { weights: config.actions.weights, blockedSelectors: config.target.blockedSelectors, diff --git a/src/llm/prompts.js b/src/llm/prompts.js index 9b17a77..cc7b6cd 100644 --- a/src/llm/prompts.js +++ b/src/llm/prompts.js @@ -23,3 +23,67 @@ export function buildPredictPrompt({ a11yTree, action, recentActions = [] }) { .filter(Boolean) .join('\n'); } + +export function buildSurprisePrompt({ + prediction, + observed, + hardSignals = [], + recentActions = [], + recentStateIds = new Set(), + currentStateId = null, +}) { + const observedJson = JSON.stringify(observed).slice(0, 4000); + const recentList = recentActions.length + ? recentActions.map((a, i) => `- ${i + 1}. ${a}`).join('\n') + : '- (none)'; + const recentIdsView = recentStateIds.size + ? JSON.stringify([...recentStateIds].slice(-8)) + : '[]'; + const repeated = Boolean(currentStateId) && recentStateIds.has(currentStateId); + + return [ + '# QA Surprise Evaluator', + '', + 'You score how surprising a UI action result is. Pick exactly one bucket from the rubric below. Do not blend, average, or interpolate.', + '', + '## SCORE RUBRIC — pick exactly one of {0.0, 0.2, 0.5, 0.8, 1.0}', + '', + '| Score | Use when |', + '|---|---|', + '| 0.0 | Observed state matches (or is a near-duplicate of) a recent state, OR there is no visible change beyond text/timestamps. Refresh / scroll / no-op loops are ALWAYS 0.0. |', + '| 0.2 | Predicted change happened but with no novelty: same interactive roles, same URL fragment, only inner text shifted within the same widgets. |', + '| 0.5 | New widgets appeared or disappeared — a role/name not present in any recent state. Same screen, different controls. |', + '| 0.8 | Distinct new screen reached: URL/route changed, a dialog/modal/drawer opened, or a new section is now interactive. |', + '| 1.0 | Page broke, threw, froze, or rendered an unexpected error visible from the tree alone (hard signals are scored separately by the harness). |', + '', + '## ANTI-LOOP RULES (strict — violating them is a wrong answer)', + '', + '1. **Repetition is never surprising.** If the current state cluster id appears in the recent state ids list, you MUST return 0.0.', + '2. **No hedging.** The only legal scores are 0.0, 0.2, 0.5, 0.8, 1.0. Never invent intermediate values to feel "safe".', + '3. **Novelty must be evidenced.** Any score ≥ 0.5 must name, in `reason`, the specific role / accessible name / URL fragment that was not present in recent states.', + '4. **Ignore prediction quality.** A vague or stub prediction (e.g. "the page will change") cannot justify surprise. Only the observed tree counts.', + '5. **Same-cluster refreshes score 0.0**, even if numeric counters, timestamps, or analytics pixels changed.', + '', + '## INPUTS', + '', + `Prediction: ${prediction}`, + '', + 'Recent actions (oldest → newest):', + recentList, + '', + `Recent state cluster ids (most recent last): ${recentIdsView}`, + `Current state cluster id: ${currentStateId ?? 'unknown'}`, + `Repeated state detected by harness: ${repeated ? 'YES — score MUST be 0.0' : 'no'}`, + `Page-level hard signals fired: ${JSON.stringify(hardSignals)}`, + '', + 'Observed accessibility tree (pruned, truncated to 4000 chars):', + observedJson, + '', + '## OUTPUT', + '', + 'Reply with JSON on a single line, no markdown fence, no surrounding prose. Schema:', + '{"score": <0.0|0.2|0.5|0.8|1.0>, "reason": "<≤ 14 words; name the new element or write \\"repeat\\">"}', + '', + 'JSON:', + ].join('\n'); +} diff --git a/tests/unit/mcts.test.js b/tests/unit/mcts.test.js index 1e75127..a820086 100644 --- a/tests/unit/mcts.test.js +++ b/tests/unit/mcts.test.js @@ -124,3 +124,22 @@ describe('descend', () => { expect(result).toBe(childA); }); }); + +describe('recentStateIds Set contract', () => { + it('does not duplicate on repeated add', () => { + const s = new Set(); + s.add('state-a'); + s.add('state-a'); + expect(s.size).toBe(1); + }); + it('.has() returns true for added items', () => { + const s = new Set(); + s.add('state-1'); + expect(s.has('state-1')).toBe(true); + }); + it('.has() returns false for non-added items', () => { + const s = new Set(); + s.add('state-1'); + expect(s.has('state-99')).toBe(false); + }); +});