[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

This commit is contained in:
ciregenz
2026-07-08 14:00:02 -07:00
parent a308444487
commit 2234ae46de
2 changed files with 41 additions and 31 deletions
+24 -19
View File
@@ -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'));
});
+17 -12
View File
@@ -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) };
}