Measure refresh latency independently and retain intended result selection

This commit is contained in:
karl committed 2026-10-13 06:50:38 +02:00
1 parent 373ed1ed49
commit 8b993b23d7
7 files changed
+188 -11

No files matched your search

+19 -9
View File
@@ -25,6 +25,7 @@ import {
} from "./search-performance";
import type { MeasuredResponse, SearchLane } from "./search-performance";
import { displayPath } from "./path-display";
import { ResultSelection } from "./selection-state";
import {
AiRevision,
aiConfigurationProblem,
@@ -146,6 +147,7 @@ let displayedLiteralQuery = literalQuery("");
let displayedCodeScope = false;
let explainedSelection = "";
let selected = -1;
const resultSelection = new ResultSelection();
let requestId = 0;
let timer: ReturnType<typeof setTimeout>;
let indexingActive = false;
@@ -700,10 +702,11 @@ element("ai-citation").addEventListener("click", () => {
block: "nearest",
});
});
function select(index: number, focus = false) {
function select(index: number, focus = false, userChoice = true) {
if (!results.length) return;
selected = Math.max(0, Math.min(index, results.length - 1));
const result = results[selected];
if (userChoice) resultSelection.choose(result.path);
list.querySelectorAll<HTMLButtonElement>("button").forEach((button, i) => {
button.classList.toggle("selected", i === selected);
button.setAttribute("aria-current", String(i === selected));
@@ -923,7 +926,6 @@ function applyResults(
)
return;
const renderStarted = performance.now();
const selectedPath = results[selected]?.path;
const resultsHadFocus = list.contains(document.activeElement);
shownLane = lane;
displayedRequestId = request.id;
@@ -932,9 +934,10 @@ function applyResults(
results = found;
selected = -1;
render();
const preserved = results.findIndex((result) => result.path === selectedPath);
if (preserved >= 0) select(preserved, resultsHadFocus);
else if (results.length) select(0, resultsHadFocus);
const preserved = resultSelection.resolve(
results.map((result) => result.path),
);
if (preserved >= 0) select(preserved, resultsHadFocus, false);
if (!found.length) refreshEmptySearchState();
request.measurement.displayed(
lane,
@@ -1064,14 +1067,18 @@ function refreshIndexedSearch() {
query.value.trim() !== previous.query
)
return;
const refreshStarted = performance.now();
clearTimeout(hybridTimer);
lexicalLane.clear();
hybridLane.clear();
querySemanticFailed = false;
shownLane = "lexical";
// Keep the original input/first-response timing while rejecting responses
// captured before the index changed, even when the query text stays identical.
const refreshed: SearchRequest = { ...previous, id: ++requestId };
const refreshed: SearchRequest = {
...previous,
id: ++requestId,
started: refreshStarted,
measurement: previous.measurement.freshForRefresh(refreshStarted),
};
latestSearch = refreshed;
submitSearch(refreshed, "lexical");
if (canSearchSemantically()) {
@@ -1126,6 +1133,7 @@ async function search(inputAt = performance.now()) {
shownLane = "lexical";
const value = query.value.trim();
if (!value) {
resultSelection.begin("");
element("performance-panel").hidden = true;
results = [];
displayedLiteralQuery = literalQuery("");
@@ -1140,10 +1148,12 @@ async function search(inputAt = performance.now()) {
return;
}
try {
const filters = captureFilters();
resultSelection.begin(JSON.stringify([value, filters]));
const request: SearchRequest = {
id: requestId,
query: value,
filters: captureFilters(),
filters,
started: performance.now(),
measurement: new SearchMeasurement(
inputAt,
+27
View File
@@ -35,8 +35,15 @@ interface Attempt {
failed: boolean;
ended_at: number;
}
interface InputTimingSummary {
debounce_ms: number;
first_results_ms: number | null;
latest_displayed_ms: number | null;
hybrid_final_results_ms: number | null;
}
export class SearchMeasurement {
private originalInput: Readonly<InputTimingSummary> | null = null;
debug: boolean;
inputAt: number;
scheduledAt: number;
@@ -67,6 +74,24 @@ export class SearchMeasurement {
this.debug = debug;
this.context = { ...context };
}
/** Filesystem updates are independent requests, not elapsed user idle time. */
freshForRefresh(now: number): SearchMeasurement {
const refreshed = new SearchMeasurement(now, now, this.debug, this.context);
// Preserve one fixed-size numeric snapshot, never an ancestor trace/history.
// Pending responses on the old request cannot rewrite this snapshot.
refreshed.originalInput = Object.freeze({
...(this.originalInput ?? {
debounce_ms: Math.max(0, this.scheduledAt - this.inputAt),
first_results_ms: this.firstDisplayMs,
latest_displayed_ms: this.displays.at(-1)?.elapsed_ms ?? null,
hybrid_final_results_ms:
[...this.displays]
.reverse()
.find((display) => display.lane === "hybrid")?.elapsed_ms ?? null,
}),
});
return refreshed;
}
submitted(lane: SearchLane, at: number) {
if (!this.submissions.has(lane)) this.submissions.set(lane, at);
this.latestSubmission.set(lane, at);
@@ -140,6 +165,8 @@ export class SearchMeasurement {
export() {
return {
version: 1,
trigger: this.originalInput ? "filesystem_refresh" : "user_search",
original_input: this.originalInput ? { ...this.originalInput } : null,
context: this.context,
clock_note:
"UI monotonic elapsed times; native command-relative spans. Nested spans and parallel lanes must not be summed.",
+24
View File
@@ -0,0 +1,24 @@
/** Preserve the user's result choice while progressive indexing hides it briefly. */
export class ResultSelection {
private scope: string | null = null;
private intendedPath: string | null = null;
begin(scope: string) {
if (scope !== this.scope) {
this.scope = scope;
this.intendedPath = null;
}
}
choose(path: string) {
this.intendedPath = path;
}
resolve(paths: readonly string[]): number {
if (!paths.length) return -1;
if (this.intendedPath === null) this.intendedPath = paths[0];
const intended = paths.indexOf(this.intendedPath);
// A temporary fallback is presentation, not a new user choice.
return intended >= 0 ? intended : 0;
}
}
@@ -100,3 +100,64 @@ test('an in-flight attempt keeps its captured submission when a refresh queues',
assert.equal(attempts[1].retry_wait_ms, 0);
assert.notEqual(attempts[0].submission, attempts[1].submission);
});
test('filesystem refresh excludes long idle time and retains original input timings separately', () => {
const original = new SearchMeasurement(100, 160, true, { device: 'cpu' });
original.submitted('lexical', 160);
finish(original, 'lexical', 165, 177, native, false);
original.displayed('lexical', 177, 180, 2);
original.submitted('hybrid', 510);
finish(original, 'hybrid', 510, 522, native, false);
original.displayed('hybrid', 522, 524, 3);
const before = original.export();
const refreshed = original.freshForRefresh(83500);
refreshed.submitted('lexical', 83500);
finish(refreshed, 'lexical', 83502, 83514, native, false);
refreshed.displayed('lexical', 83514, 83515, 3);
const report = refreshed.export();
assert.equal(report.trigger, 'filesystem_refresh');
assert.equal(report.first_results_ms, 15);
assert.equal(report.latest_displayed_ms, 15);
assert.equal(report.debounce_ms, 0);
assert.equal(report.hybrid_final_results_ms, null);
assert.deepEqual(report.original_input, {
debounce_ms: 60, first_results_ms: 80, latest_displayed_ms: 424,
hybrid_final_results_ms: 424,
});
assert.equal(report.attempts.length, 1);
assert.equal(report.attempts[0].attempt, 1);
assert.equal(report.attempts[0].queued_ms, 2);
assert.deepEqual(original.export(), before);
});
test('repeated refreshes retain a bounded immutable numeric origin without replaying failures or private spans', () => {
const original = new SearchMeasurement(0, 60, false);
original.submitted('lexical', 60);
finish(original, 'lexical', 60, 70, {
...native, trace: [{ name: 'private-path-and-snippet', start_ms: 0, duration_ms: 1, parent: null, counters: {} }],
}, true);
original.displayed('lexical', 70, 72, 1);
let refreshed = original.freshForRefresh(10000);
const origin = refreshed.export().original_input;
original.displayed('hybrid', 10001, 10002, 5);
for (let i = 0; i < 100; i++) {
const now = 20000 + i * 10000;
refreshed = refreshed.freshForRefresh(now);
assert.equal(refreshed.export().attempts.length, 0);
assert.equal(refreshed.export().displays.length, 0);
refreshed.submitted('lexical', now);
finish(refreshed, 'lexical', now + 2, now + 14, native, false);
refreshed.displayed('lexical', now + 14, now + 15, 2);
const report = refreshed.export();
assert.equal(report.latest_displayed_ms, 15);
assert.equal(report.attempts[0].retry_wait_ms, 0);
assert.equal(report.attempts[0].attempt, 1);
assert.deepEqual(report.original_input, origin);
report.original_input.first_results_ms = 999;
assert.deepEqual(refreshed.export().original_input, origin);
assert.doesNotMatch(JSON.stringify(report), /private-path-and-snippet|ended_at/);
assert.ok(JSON.stringify(report).length < 1500);
}
assert.equal(origin.first_results_ms, 72);
assert.equal(origin.hybrid_final_results_ms, null);
});
@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ResultSelection } from '../src/selection-state.ts';
test('restores intended result after temporary absence across empty and fallback responses', () => {
const selection = new ResultSelection();
selection.begin('supplier/all');
assert.equal(selection.resolve(['supplier', 'retry']), 0);
assert.equal(selection.resolve(['retry']), 0);
assert.equal(selection.resolve([]), -1);
assert.equal(selection.resolve(['retry', 'supplier']), 1);
});
test('explicit user navigation supersedes the hidden result even when choosing the fallback', () => {
const selection = new ResultSelection();
selection.begin('supplier/all');
selection.resolve(['supplier', 'retry']);
selection.resolve(['retry']);
selection.choose('retry');
assert.equal(selection.resolve(['supplier', 'retry']), 1);
});
test('changed query or filters reset intent, while same-scope retries retain it', () => {
const selection = new ResultSelection();
selection.begin('supplier/all');
selection.resolve(['supplier', 'retry']);
selection.choose('retry');
selection.begin('supplier/all');
assert.equal(selection.resolve(['supplier', 'retry']), 1);
selection.begin('supplier/code');
assert.equal(selection.resolve(['code', 'retry']), 0);
selection.begin('garden/code');
assert.equal(selection.resolve(['garden', 'code']), 0);
selection.begin('');
selection.begin('garden/code');
assert.equal(selection.resolve(['code', 'garden']), 0);
});
test('initially empty search establishes its first choice only when results arrive', () => {
const selection = new ResultSelection();
selection.begin('new/all');
assert.equal(selection.resolve([]), -1);
assert.equal(selection.resolve(['new', 'other']), 0);
assert.equal(selection.resolve(['other', 'new']), 1);
});
+4 -2
View File
@@ -24,8 +24,10 @@ Scans own separate SQLite connections. They do not hold the foreground search
mutex during traversal or extraction. A changed scan prepares its reader and
briefly swaps it into the desktop. UI events refresh counts and the active query,
reject results from before the change, and request normal semantic indexing.
Refresh timing must start at the change event rather than include idle time since
the original input; native Linux interaction exposed a regression in this clock.
Refresh timing starts at the change event rather than include idle time since
the original input. A bounded numeric snapshot retains the original input
timings separately. Intended selection survives temporary result absence;
explicit navigation changes that intent, and a new query or filter clears it.
## Targeted reconciliation
+8
View File
@@ -83,6 +83,14 @@ model loading must not be silently included in a warm-query claim.
## Reproducible performance validation
Automatic filesystem refreshes start a new frontend measurement at the refresh
event. `trigger: "filesystem_refresh"` distinguishes them from user searches;
`SHOWN` and `latest_displayed_ms` exclude time spent idle before that event.
`original_input` retains a fixed-size snapshot of the original debounce and
display timings. Refreshes do not replay previous attempts or append ancestor
traces. A refresh before the first response leaves that original response timing
null rather than inventing a first-input measurement.
Record build profile, CPU/device and thread budget, VM/host resources, corpus and
active generation, filters, cold/warm state, concurrent indexing and setup costs.
Use multiple queries and p50/p95 distributions; retain raw reports. Compare basic