Files
Oxicloud/frontend/src/lib/utils/resourceSections.bench.test.ts
T
Claude 3be85fa9f0 perf: round 15 — grouped-listing O(N²) rebucket, exif/reseed allocs, tantivy zero-hit snippet skip
Benchmark-gated, same rule as rounds 2–14: every change ships with a
BEFORE/AFTER benchmark and an equivalence/safety gate; an AFTER that doesn't
beat its BEFORE is rolled back. The rule is encoded per harness (GATE FAIL
non-zero exit in the Rust examples, threshold expect() in vitest).

F1 — Grouped listings (trash / recent / favorites / shared-with-me)
re-bucketed the WHOLE accumulated list on every infinite-scroll page.
ResourceSectionsBuilder (new, off the reactive graph) re-buckets only the
fresh page and hands VirtualList the same rows array reference for untouched
buckets. 50×50 (2 500-item) drain: 63 750 → 2 500 bucketOf calls (25.5×),
12.5 → 1.3 ms wall (9.9×); O(N²/page) → O(N). Deep-equal to the full-rebuild
reference at every page for both a contiguous (date) and a non-contiguous
(trash-by-drive) group-by; reference-stability + fallback gated.

B1 — exif Make/Model: the display String was thrown away to allocate the
trimmed copy; display_value_trimmed trims in place (drain + truncate), 2 → 1
alloc per field (8 → 4 allocs/op, 1.26×).

B2 — content-index worker: text_extractor::supports (lowercases MIME +
extension) was called twice per file per drain batch; classify once into a
Vec<bool> and thread it through both uses. 256-file batch: 704 → 353 allocs,
34.5 → 16.7 µs (2.07×).

B3 — tantivy: skip SnippetGenerator::create on a zero-hit content search
(return Ok(vec![]) once top_docs.is_empty()); the per-hit loop was empty.
400-doc index: 1 575.6 → 1 237.2 ns (1.27×), widens with index size.

Harnesses: examples/bench_round15_micro.rs, examples/bench_round15_tantivy.rs,
frontend resourceSections.bench.test.ts; writeup in benches/ROUND15.md. Also
normalizes two round14 bench examples that were committed unformatted
(cargo fmt --all).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o47jSrtL7xuNGTHXmtiYL
2026-07-19 11:36:47 +00:00

212 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import {
ResourceSectionsBuilder,
buildResourceSections,
type SectionGrouping
} from './resourceSections';
/**
* Benchmark gate for the incremental swimlane builder (ResourceSectionsBuilder)
* that replaced ResourceList's `sections` `$derived.by`.
*
* Audit finding (ROUND14 deferred flagship): every grouped listing (trash,
* recent, favorites, shared-with-me) pages in via `raw = [...raw, ...page]`,
* and `sections` re-bucketed the WHOLE accumulated list on every page — Σ ≈
* O(N²/page) `bucketOf` + `ctxOf` calls during an infinite-scroll drain, and a
* brand-new rows array for EVERY bucket each page (so VirtualList re-diffed
* every swimlane every page). The builder re-buckets only the fresh page and
* hands back the same array reference for untouched buckets.
*
* Gates:
* 1. Equivalence — at EVERY page of the drain, the incremental output is
* deep-equal to the verbatim full-rebuild reference (buildResourceSections),
* for a contiguous group-by (date, bucket aligned with order) AND a
* non-contiguous one (trash-by-drive: name-ordered, drive-bucketed); plus
* group-by switch, deletion and the flat pass-through fall back correctly.
* 2. Reference stability — untouched buckets keep their exact array reference
* across a page append (the property VirtualList relies on to skip them),
* while a grown bucket gets a fresh one.
* 3. Perf — bucketing work collapses from Σ O(N²/page) to O(N) across the
* drain (deterministic `bucketOf`-call count) and wall drops ≥3x.
*/
interface Item {
id: string;
name: string;
driveId: string;
/** ms epoch; descending with index (newest-first, like the server pages). */
date: number;
}
interface Ctx {
date: number;
driveId: string;
}
const DAY = 86_400_000;
/** Item `i`: newest-first date, name in a fixed lexical order, round-robin drive. */
function item(i: number): Item {
return {
id: `it-${i.toString().padStart(6, '0')}`,
// Zero-padded so lexical name order is a stable, well-defined sequence.
name: `file-${i.toString().padStart(6, '0')}`,
driveId: `drive-${i % 4}`,
date: 1_700_000_000_000 - i * (DAY / 2)
};
}
const contextMap = new Map<string, Ctx>();
function ctxOf(it: Item): Ctx | undefined {
let c = contextMap.get(it.id);
if (!c) {
c = { date: it.date, driveId: it.driveId };
contextMap.set(it.id, c);
}
return c;
}
/** Month bucket key from a ctx date (contiguous under date order). */
function monthKey(d: number): string {
const dt = new Date(d);
return `${dt.getUTCFullYear()}-${`${dt.getUTCMonth() + 1}`.padStart(2, '0')}`;
}
/** Contiguous group-by: date-ordered pages, date buckets. Counts bucketOf calls. */
function dateGrouping(counter?: { n: number }): SectionGrouping<Item, Ctx> {
return {
bucketOf: (_it, ctx) => {
if (counter) counter.n++;
return ctx ? monthKey(ctx.date) : null;
},
labelOf: (k) => `📅 ${k}`,
ctxOf
};
}
/**
* Non-contiguous group-by mirroring trash "by drive": pages arrive in NAME
* order but bucket by driveId, so a fresh page sprays items across every
* already-emitted drive bucket. Equivalence must still hold.
*/
function driveGrouping(counter?: { n: number }): SectionGrouping<Item, Ctx> {
return {
bucketOf: (_it, ctx) => {
if (counter) counter.n++;
return ctx ? ctx.driveId : null;
},
labelOf: (k) => `💾 ${k}`,
ctxOf
};
}
const PAGE = 50;
const PAGES = 50; // 2 500-item drain
describe('incremental resource sections (benchmark gate)', () => {
for (const [name, mk] of [
['contiguous date buckets', dateGrouping],
['non-contiguous drive buckets', driveGrouping]
] as const) {
it(`stays deep-equal to the full rebuild at every page — ${name}`, () => {
const all = Array.from({ length: PAGE * PAGES }, (_, i) => item(i));
const builder = new ResourceSectionsBuilder<Item, Ctx>();
// ONE stable grouping across the drain — mirrors the component, where
// `activeGroup.bucketOf` is a fixed closure from the page's once-defined
// `groupBys`. This is what lets the builder take its incremental path,
// so this loop genuinely exercises it (not the rebuild fallback).
const g = mk();
for (let p = 1; p <= PAGES; p++) {
const cumulative = all.slice(0, p * PAGE);
const incremental = builder.sync(cumulative, g);
const reference = buildResourceSections(cumulative, g);
expect(incremental, `page ${p}`).toEqual(reference);
}
});
}
it('keeps untouched bucket arrays reference-stable and refreshes grown ones', () => {
const all = Array.from({ length: 600 }, (_, i) => item(i));
const builder = new ResourceSectionsBuilder<Item, Ctx>();
const g = dateGrouping();
const first = builder.sync(all.slice(0, 300), g);
const refBefore = new Map(first.map((s) => [s.key, s.rows]));
const second = builder.sync(all.slice(0, 350), g);
let stable = 0;
let refreshed = 0;
for (const s of second) {
const prev = refBefore.get(s.key);
if (prev === undefined) continue; // brand-new bucket
if (prev === s.rows) stable++;
else refreshed++;
}
// Date-ordered append only grows the boundary bucket(s): most earlier
// buckets must be handed back by the SAME reference (VirtualList skips
// them), and at least one bucket must be refreshed (it grew).
expect(stable).toBeGreaterThan(0);
expect(refreshed).toBeGreaterThan(0);
expect(stable).toBeGreaterThan(refreshed);
});
it('falls back to a correct full rebuild on group-by switch, deletion and flat', () => {
const all = Array.from({ length: 600 }, (_, i) => item(i));
const builder = new ResourceSectionsBuilder<Item, Ctx>();
const byDate = dateGrouping();
const byDrive = driveGrouping();
// Drain a few pages under date grouping, then switch to drive grouping
// (a different bucketOf reference → rebuild).
builder.sync(all.slice(0, 300), byDate);
expect(builder.sync(all.slice(0, 300), byDrive)).toEqual(
buildResourceSections(all.slice(0, 300), byDrive)
);
// Deletion under the SAME grouping (list shrinks / prefix changes) →
// rebuild via the append check, not a grouping-ref change.
const shrunk = all.slice(0, 300).filter((_, i) => i % 7 !== 0);
expect(builder.sync(shrunk, byDrive)).toEqual(buildResourceSections(shrunk, byDrive));
// Flat pass-through (no bucketOf) yields one section and doesn't wedge the
// next grouped sync.
const flat: SectionGrouping<Item, Ctx> = { ctxOf };
const flatOut = builder.sync(shrunk, flat);
expect(flatOut).toEqual([{ key: '', label: '', rows: shrunk }]);
expect(flatOut[0].rows).toBe(shrunk); // pass-through, no copy
expect(builder.sync(shrunk, byDate)).toEqual(buildResourceSections(shrunk, byDate));
});
it('collapses bucketing work from Σ O(N²/page) to O(N) and runs ≥3x faster', () => {
const N = PAGE * PAGES;
const all = Array.from({ length: N }, (_, i) => item(i));
// AFTER: incremental — each item is bucketed exactly once across the drain.
// ONE stable grouping (fixed bucketOf), exactly as the component supplies.
const afterCounter = { n: 0 };
const gAfter = dateGrouping(afterCounter);
const builder = new ResourceSectionsBuilder<Item, Ctx>();
const t1 = performance.now();
for (let p = 1; p <= PAGES; p++) builder.sync(all.slice(0, p * PAGE), gAfter);
const afterMs = performance.now() - t1;
// BEFORE: full rebuild per page — re-buckets the whole cumulative list.
const beforeCounter = { n: 0 };
const gBefore = dateGrouping(beforeCounter);
const t0 = performance.now();
for (let p = 1; p <= PAGES; p++) buildResourceSections(all.slice(0, p * PAGE), gBefore);
const beforeMs = performance.now() - t0;
console.info(
`resource sections ${PAGES}×${PAGE}: before ${beforeCounter.n} bucketOf calls / ${beforeMs.toFixed(1)} ms — after ${afterCounter.n} calls / ${afterMs.toFixed(1)} ms (${(beforeCounter.n / afterCounter.n).toFixed(1)}x fewer calls, ${(beforeMs / afterMs).toFixed(1)}x wall)`
);
// Incremental buckets each item once: exactly N calls.
expect(afterCounter.n).toBe(N);
// Full rebuild is quadratic: Σ_{p=1..P} p·PAGE.
expect(beforeCounter.n).toBe((PAGES * (PAGES + 1) * PAGE) / 2);
expect(afterCounter.n).toBeLessThan(beforeCounter.n / 5);
expect(afterMs).toBeLessThan(beforeMs / 3);
});
});