From 2234ae46de0772b7a203d3ee2d7b5d706bfe2745 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 8 Jul 2026 14:00:02 -0700 Subject: [PATCH] [eric] browser: interactives render in DOCUMENT order (rank still picks who survives the cap, but display is top-to-bottom page order); the scrambled list forced the model to burn orientation turns reading page text to recover position on ordinal tasks ('the 4th story' landed at [48]), the real cause of the slow HN planning --- .../src/shared/interactiveRanking.test.ts | 43 +++++++++++-------- frontend/src/shared/interactiveRanking.ts | 29 +++++++------ 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/frontend/src/shared/interactiveRanking.test.ts b/frontend/src/shared/interactiveRanking.test.ts index 48cc06ab..b8f18d9f 100644 --- a/frontend/src/shared/interactiveRanking.test.ts +++ b/frontend/src/shared/interactiveRanking.test.ts @@ -37,14 +37,26 @@ test('non-consecutive same name is preserved (real list items)', () => { assert.equal(carts.length, 3); }); -test('ranks by role priority: input > button/link > toggle > option', () => { +test('display order is DOCUMENT order, not rank order (so ordinals can be counted)', () => { const { shown } = rankAndCapInteractives([ mk('option', 'opt', 1), mk('checkbox', 'agree', 2), mk('button', 'Go', 3), mk('textbox', 'email', 4), ]); - assert.deepEqual(shown.map((x) => x.role), ['textbox', 'button', 'checkbox', 'option']); + // all survive the cap; they render top-to-bottom as they appear on the page, + // NOT re-sorted by role (which would scramble "the 4th thing") + assert.deepEqual(shown.map((x) => x.backendNodeId), [1, 2, 3, 4]); +}); + +test('rank still decides cap SURVIVAL: a high-priority input buried in options is kept', () => { + const items = [ + ...Array.from({ length: 40 }, (_, i) => mk('option', `opt${i}`, i)), + mk('textbox', 'email', 900), + ...Array.from({ length: 40 }, (_, i) => mk('option', `optb${i}`, 100 + i)), + ]; + const { shown } = rankAndCapInteractives(items, { cap: 30 }); + assert.ok(shown.some((x) => x.backendNodeId === 900), 'the input survives the cap by rank'); }); test('preserves document order within the same priority tier', () => { @@ -71,31 +83,23 @@ test('cap of 0 means no cap', () => { assert.equal(truncated, 0); }); -test('goal-matched elements float to the top, above role priority', () => { - const { shown } = rankAndCapInteractives([ - mk('textbox', 'Search', 1), - mk('link', 'Account settings', 2), - mk('button', 'Save', 3), - ], { goal: 'open the settings page' }); - // "Account settings" matches "settings" and jumps ahead of the textbox - assert.equal(shown[0].backendNodeId, 2); -}); - -test('goal match survives the cap even when buried deep', () => { +test('goal-matched element survives the cap and displays IN PLACE (document order)', () => { const items = Array.from({ length: 100 }, (_, i) => mk('link', `Item ${i}`, i)); - items.push(mk('button', 'Checkout now', 999)); + items.push(mk('button', 'Checkout now', 999)); // last on the page const { shown } = rankAndCapInteractives(items, { cap: 30, goal: 'click checkout' }); + // it survives the cap (rank kept it)... assert.ok(shown.some((x) => x.backendNodeId === 999), 'checkout should be retained'); - assert.equal(shown[0].backendNodeId, 999); + // ...and renders at its real position (last), not floated to [0] + assert.equal(shown[shown.length - 1].backendNodeId, 999); }); -test('no goal leaves pure role-priority ordering', () => { +test('display order stays document order with no goal', () => { const { shown } = rankAndCapInteractives([ mk('option', 'opt', 1), mk('button', 'Go', 2), mk('textbox', 'email', 3), ]); - assert.deepEqual(shown.map((x) => x.role), ['textbox', 'button', 'option']); + assert.deepEqual(shown.map((x) => x.backendNodeId), [1, 2, 3]); }); test('goalKeywords strips stopwords, action verbs, and short tokens', () => { @@ -110,11 +114,12 @@ test('empty input yields empty result', () => { assert.equal(truncated, 0); }); -test('unknown role falls into the middle tier, not dropped', () => { +test('unknown role is not dropped (survives the cap), displayed in document order', () => { const { shown } = rankAndCapInteractives([ mk('option', 'opt', 1), mk('weirdrole', 'mystery', 2), mk('textbox', 'field', 3), ]); - assert.deepEqual(shown.map((x) => x.role), ['textbox', 'weirdrole', 'option']); + assert.deepEqual(shown.map((x) => x.backendNodeId), [1, 2, 3]); + assert.ok(shown.some((x) => x.role === 'weirdrole')); }); diff --git a/frontend/src/shared/interactiveRanking.ts b/frontend/src/shared/interactiveRanking.ts index e778481e..db6432de 100644 --- a/frontend/src/shared/interactiveRanking.ts +++ b/frontend/src/shared/interactiveRanking.ts @@ -77,17 +77,22 @@ export function rankAndCapInteractives( const cap = opts.cap ?? DEFAULT_INTERACTIVE_CAP; const keywords = opts.goal ? goalKeywords(opts.goal) : []; const deduped = dedupeConsecutive(items); - // Sort: goal-matched first, then role priority, tiebroken on original document order so the result is deterministic regardless of engine sort. - const ranked = deduped - .map((it, i) => ({ it, i, m: matchesGoal(it.name, keywords) ? 0 : 1 })) - .sort((a, b) => { - if (a.m !== b.m) return a.m - b.m; - const pa = rolePriority(a.it.role); - const pb = rolePriority(b.it.role); - if (pa !== pb) return pa - pb; - return a.i - b.i; - }) - .map((x) => x.it); - const shown = cap > 0 ? ranked.slice(0, cap) : ranked; + const scored = deduped.map((it, i) => ({ it, i, m: matchesGoal(it.name, keywords) ? 0 : 1 })); + // Rank picks WHICH items survive the cap (goal-matched first, then role priority), + // so the thing the model wants is never truncated away. + const ranked = [...scored].sort((a, b) => { + if (a.m !== b.m) return a.m - b.m; + const pa = rolePriority(a.it.role); + const pb = rolePriority(b.it.role); + if (pa !== pb) return pa - pb; + return a.i - b.i; + }); + const selected = cap > 0 ? ranked.slice(0, cap) : ranked; + // But the DISPLAY order is document order (by original index), so the numbered + // list reads top-to-bottom the way the page looks. A rank-sorted list scrambled + // ordinals ("the 4th story" landed at [48], out of order), forcing the model to + // burn a turn reading page text just to recover position; document order lets it + // count directly. The high-signal-subset win (from the cap) is untouched. + const shown = selected.sort((a, b) => a.i - b.i).map((x) => x.it); return { shown, truncated: Math.max(0, ranked.length - shown.length) }; }