Measure refresh latency independently and retain intended result selection
This commit is contained in:
1 parent
373ed1ed49
commit
8b993b23d7
7 files changed
+188
-11
No files matched your search
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "./search-performance";
|
} from "./search-performance";
|
||||||
import type { MeasuredResponse, SearchLane } from "./search-performance";
|
import type { MeasuredResponse, SearchLane } from "./search-performance";
|
||||||
import { displayPath } from "./path-display";
|
import { displayPath } from "./path-display";
|
||||||
|
import { ResultSelection } from "./selection-state";
|
||||||
import {
|
import {
|
||||||
AiRevision,
|
AiRevision,
|
||||||
aiConfigurationProblem,
|
aiConfigurationProblem,
|
||||||
@@ -146,6 +147,7 @@ let displayedLiteralQuery = literalQuery("");
|
|||||||
let displayedCodeScope = false;
|
let displayedCodeScope = false;
|
||||||
let explainedSelection = "";
|
let explainedSelection = "";
|
||||||
let selected = -1;
|
let selected = -1;
|
||||||
|
const resultSelection = new ResultSelection();
|
||||||
let requestId = 0;
|
let requestId = 0;
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
let indexingActive = false;
|
let indexingActive = false;
|
||||||
@@ -700,10 +702,11 @@ element("ai-citation").addEventListener("click", () => {
|
|||||||
block: "nearest",
|
block: "nearest",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
function select(index: number, focus = false) {
|
function select(index: number, focus = false, userChoice = true) {
|
||||||
if (!results.length) return;
|
if (!results.length) return;
|
||||||
selected = Math.max(0, Math.min(index, results.length - 1));
|
selected = Math.max(0, Math.min(index, results.length - 1));
|
||||||
const result = results[selected];
|
const result = results[selected];
|
||||||
|
if (userChoice) resultSelection.choose(result.path);
|
||||||
list.querySelectorAll<HTMLButtonElement>("button").forEach((button, i) => {
|
list.querySelectorAll<HTMLButtonElement>("button").forEach((button, i) => {
|
||||||
button.classList.toggle("selected", i === selected);
|
button.classList.toggle("selected", i === selected);
|
||||||
button.setAttribute("aria-current", String(i === selected));
|
button.setAttribute("aria-current", String(i === selected));
|
||||||
@@ -923,7 +926,6 @@ function applyResults(
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const renderStarted = performance.now();
|
const renderStarted = performance.now();
|
||||||
const selectedPath = results[selected]?.path;
|
|
||||||
const resultsHadFocus = list.contains(document.activeElement);
|
const resultsHadFocus = list.contains(document.activeElement);
|
||||||
shownLane = lane;
|
shownLane = lane;
|
||||||
displayedRequestId = request.id;
|
displayedRequestId = request.id;
|
||||||
@@ -932,9 +934,10 @@ function applyResults(
|
|||||||
results = found;
|
results = found;
|
||||||
selected = -1;
|
selected = -1;
|
||||||
render();
|
render();
|
||||||
const preserved = results.findIndex((result) => result.path === selectedPath);
|
const preserved = resultSelection.resolve(
|
||||||
if (preserved >= 0) select(preserved, resultsHadFocus);
|
results.map((result) => result.path),
|
||||||
else if (results.length) select(0, resultsHadFocus);
|
);
|
||||||
|
if (preserved >= 0) select(preserved, resultsHadFocus, false);
|
||||||
if (!found.length) refreshEmptySearchState();
|
if (!found.length) refreshEmptySearchState();
|
||||||
request.measurement.displayed(
|
request.measurement.displayed(
|
||||||
lane,
|
lane,
|
||||||
@@ -1064,14 +1067,18 @@ function refreshIndexedSearch() {
|
|||||||
query.value.trim() !== previous.query
|
query.value.trim() !== previous.query
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
const refreshStarted = performance.now();
|
||||||
clearTimeout(hybridTimer);
|
clearTimeout(hybridTimer);
|
||||||
lexicalLane.clear();
|
lexicalLane.clear();
|
||||||
hybridLane.clear();
|
hybridLane.clear();
|
||||||
querySemanticFailed = false;
|
querySemanticFailed = false;
|
||||||
shownLane = "lexical";
|
shownLane = "lexical";
|
||||||
// Keep the original input/first-response timing while rejecting responses
|
const refreshed: SearchRequest = {
|
||||||
// captured before the index changed, even when the query text stays identical.
|
...previous,
|
||||||
const refreshed: SearchRequest = { ...previous, id: ++requestId };
|
id: ++requestId,
|
||||||
|
started: refreshStarted,
|
||||||
|
measurement: previous.measurement.freshForRefresh(refreshStarted),
|
||||||
|
};
|
||||||
latestSearch = refreshed;
|
latestSearch = refreshed;
|
||||||
submitSearch(refreshed, "lexical");
|
submitSearch(refreshed, "lexical");
|
||||||
if (canSearchSemantically()) {
|
if (canSearchSemantically()) {
|
||||||
@@ -1126,6 +1133,7 @@ async function search(inputAt = performance.now()) {
|
|||||||
shownLane = "lexical";
|
shownLane = "lexical";
|
||||||
const value = query.value.trim();
|
const value = query.value.trim();
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
resultSelection.begin("");
|
||||||
element("performance-panel").hidden = true;
|
element("performance-panel").hidden = true;
|
||||||
results = [];
|
results = [];
|
||||||
displayedLiteralQuery = literalQuery("");
|
displayedLiteralQuery = literalQuery("");
|
||||||
@@ -1140,10 +1148,12 @@ async function search(inputAt = performance.now()) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const filters = captureFilters();
|
||||||
|
resultSelection.begin(JSON.stringify([value, filters]));
|
||||||
const request: SearchRequest = {
|
const request: SearchRequest = {
|
||||||
id: requestId,
|
id: requestId,
|
||||||
query: value,
|
query: value,
|
||||||
filters: captureFilters(),
|
filters,
|
||||||
started: performance.now(),
|
started: performance.now(),
|
||||||
measurement: new SearchMeasurement(
|
measurement: new SearchMeasurement(
|
||||||
inputAt,
|
inputAt,
|
||||||
|
|||||||
@@ -35,8 +35,15 @@ interface Attempt {
|
|||||||
failed: boolean;
|
failed: boolean;
|
||||||
ended_at: number;
|
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 {
|
export class SearchMeasurement {
|
||||||
|
private originalInput: Readonly<InputTimingSummary> | null = null;
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
inputAt: number;
|
inputAt: number;
|
||||||
scheduledAt: number;
|
scheduledAt: number;
|
||||||
@@ -67,6 +74,24 @@ export class SearchMeasurement {
|
|||||||
this.debug = debug;
|
this.debug = debug;
|
||||||
this.context = { ...context };
|
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) {
|
submitted(lane: SearchLane, at: number) {
|
||||||
if (!this.submissions.has(lane)) this.submissions.set(lane, at);
|
if (!this.submissions.has(lane)) this.submissions.set(lane, at);
|
||||||
this.latestSubmission.set(lane, at);
|
this.latestSubmission.set(lane, at);
|
||||||
@@ -140,6 +165,8 @@ export class SearchMeasurement {
|
|||||||
export() {
|
export() {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
trigger: this.originalInput ? "filesystem_refresh" : "user_search",
|
||||||
|
original_input: this.originalInput ? { ...this.originalInput } : null,
|
||||||
context: this.context,
|
context: this.context,
|
||||||
clock_note:
|
clock_note:
|
||||||
"UI monotonic elapsed times; native command-relative spans. Nested spans and parallel lanes must not be summed.",
|
"UI monotonic elapsed times; native command-relative spans. Nested spans and parallel lanes must not be summed.",
|
||||||
|
|||||||
@@ -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.equal(attempts[1].retry_wait_ms, 0);
|
||||||
assert.notEqual(attempts[0].submission, attempts[1].submission);
|
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);
|
||||||
|
});
|
||||||
@@ -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
|
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,
|
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.
|
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
|
Refresh timing starts at the change event rather than include idle time since
|
||||||
the original input; native Linux interaction exposed a regression in this clock.
|
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
|
## Targeted reconciliation
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ model loading must not be silently included in a warm-query claim.
|
|||||||
|
|
||||||
## Reproducible performance validation
|
## 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
|
Record build profile, CPU/device and thread budget, VM/host resources, corpus and
|
||||||
active generation, filters, cold/warm state, concurrent indexing and setup costs.
|
active generation, filters, cold/warm state, concurrent indexing and setup costs.
|
||||||
Use multiple queries and p50/p95 distributions; retain raw reports. Compare basic
|
Use multiple queries and p50/p95 distributions; retain raw reports. Compare basic
|
||||||
|
|||||||
Reference in new issue
Block a user