From e8268baf60a764f91455dab7af525112bdae53aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 20:11:54 -0700 Subject: [PATCH 1/2] fix: sync only the rows that changed when the learnings fingerprint misses (GIT-98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a Pro store the fingerprint misses on nearly every session_start: refresh_scar_behavioral_scores() rewrites each scar with >=3 recent usage rows and bumps its updated_at. Each miss re-downloaded the whole index, every row with its 1536-dim embedding. - A miss with a cache from this store downloads the manifest (id, updated_at; no vectors) with the bulk load's filters, order and limit. - Rows whose id and updated_at match are reused from disk; changed and new ids are fetched in full, 100 ids per request; rows gone from the manifest are dropped. The result is in manifest order, what a full download returns. - Any failure, an empty manifest, or no cache from this store: the full download, as before. cache-flush still always takes the full download. - The bulk load, fingerprint, manifest and delta fetch share one filter set. Venue (v1.8.0 setup.sql, 250 learnings, --usage 5): the start after a session_start drops from 921,780 B to 42,823 B — 245 reused, 5 fetched. +8 unit tests. Co-Authored-By: Claude Opus 5 --- src/services/startup.ts | 55 +++++- src/services/supabase-client.ts | 56 +++++- src/services/vector-disk-cache.ts | 111 +++++++++-- tests/unit/services/vector-delta-sync.test.ts | 180 ++++++++++++++++++ 4 files changed, 377 insertions(+), 25 deletions(-) create mode 100644 tests/unit/services/vector-delta-sync.test.ts diff --git a/src/services/startup.ts b/src/services/startup.ts index 3cd2232..f9bee20 100644 --- a/src/services/startup.ts +++ b/src/services/startup.ts @@ -15,8 +15,9 @@ import * as fs from "fs"; import * as path from "path"; -import { isConfigured, loadScarsWithEmbeddings, getLearningsFingerprint, getSupabaseUrl } from "./supabase-client.js"; -import { computeStoreKey, readVectorCache, writeVectorCache, tryAcquireDownloadLock, waitForLeaderCache } from "./vector-disk-cache.js"; +import { isConfigured, loadScarsWithEmbeddings, loadLearningsManifest, loadLearningsByIds, getLearningsFingerprint, getSupabaseUrl } from "./supabase-client.js"; +import { computeStoreKey, readVectorCache, readVectorCacheBase, planDelta, assembleDelta, writeVectorCache, tryAcquireDownloadLock, waitForLeaderCache } from "./vector-disk-cache.js"; +import type { StoreFingerprint } from "./vector-disk-cache.js"; import { getTableName } from "./tier.js"; import { getGitmemDir } from "./gitmem-dir.js"; import { @@ -103,6 +104,38 @@ interface ScarWithEmbedding { [key: string]: unknown; } +/** Rows the bulk load and the delta manifest both cap at. */ +const INDEX_ROW_LIMIT = 500; + +/** + * GIT-98 per-row delta: bring a cache from this store up to date by fetching + * only the rows whose updated_at moved. Returns null — and the caller does the + * full download — when there is no base to reuse or anything goes wrong. + */ +async function deltaSyncFromCache(storeKey: string, fingerprint: StoreFingerprint): Promise { + const base = readVectorCacheBase(storeKey); + if (!base || base.length === 0) return null; + const startTime = Date.now(); + try { + const manifest = await loadLearningsManifest(INDEX_ROW_LIMIT); + // An empty manifest is more likely a transient failure than an emptied + // store; let the full download decide. + if (manifest.length === 0) return null; + const plan = planDelta(base, manifest); + const fetched = plan.fetchIds.length > 0 ? await loadLearningsByIds(plan.fetchIds) : []; + const rows = assembleDelta(manifest, plan, fetched); + console.error( + `[startup] Vector cache DELTA: ${rows.length} learnings in ${Date.now() - startTime}ms — ` + + `${plan.reuse.size} reused from disk, ${fetched.length} fetched, ${plan.dropped} dropped (bulk download skipped)` + ); + if (rows.length > 0) writeVectorCache(storeKey, fingerprint, rows); + return rows; + } catch (error) { + console.error("[startup] Delta sync failed, falling back to full download:", error instanceof Error ? error.message : error); + return null; + } +} + /** * Load all learnings with embeddings from Supabase * @@ -112,7 +145,8 @@ interface ScarWithEmbedding { * NOTE: Now loads all learning types (scars, patterns, wins, anti-patterns), * not just scars. This fixes the issue where ~64 patterns were being ignored. */ -async function loadScarsFromSupabase(options: { bypassDiskCache?: boolean } = {}): Promise<{ +/** @internal exported for tests (GIT-98 delta sync). */ +export async function loadScarsFromSupabase(options: { bypassDiskCache?: boolean } = {}): Promise<{ scars: ScarWithEmbedding[]; latestUpdatedAt: string | null; source: "disk" | "network"; @@ -155,11 +189,20 @@ async function loadScarsFromSupabase(options: { bypassDiskCache?: boolean } = {} } releaseLock = releaseLock ?? (() => {}); - console.error(`[startup] Loading ALL learnings with embeddings from Supabase (cross-project)`); - try { + // GIT-98: a miss is usually a handful of changed rows, not a new store. + // cache-flush (bypassDiskCache) always takes the full download. + if (!options.bypassDiskCache && fingerprint.count >= 0) { + const synced = await deltaSyncFromCache(storeKey, fingerprint); + if (synced) { + return { scars: synced, latestUpdatedAt: synced[0]?.updated_at || null, source: "network" }; + } + } + + console.error(`[startup] Loading ALL learnings with embeddings from Supabase (cross-project)`); + // Load ALL learnings across projects — semantic similarity handles relevance - const learnings = await loadScarsWithEmbeddings(undefined, 500); + const learnings = await loadScarsWithEmbeddings(undefined, INDEX_ROW_LIMIT); const elapsed = Date.now() - startTime; console.error(`[startup] Loaded ${learnings.length} learnings in ${elapsed}ms`); diff --git a/src/services/supabase-client.ts b/src/services/supabase-client.ts index a420f69..ca1855a 100644 --- a/src/services/supabase-client.ts +++ b/src/services/supabase-client.ts @@ -518,6 +518,18 @@ export async function directPatch( * * NOTE: Changed from "scars only" to "all learning types" */ +/** + * The learnings the local index is built from. The bulk load, the fingerprint, + * the delta manifest and the delta fetch MUST all use these, or they describe + * different sets (GIT-98). + */ +const INDEXED_LEARNING_FILTERS: Record = { + learning_type: "in.(scar,pattern,win,anti_pattern)", + is_active: "eq.true", +}; +const INDEXED_LEARNING_COLUMNS = + "id,title,description,severity,counter_arguments,applies_when,source_linear_issue,project,embedding,updated_at,learning_type,decay_multiplier"; + export async function loadScarsWithEmbeddings( project?: string, limit = 500 @@ -526,16 +538,13 @@ export async function loadScarsWithEmbeddings( const startTime = Date.now(); try { - const filters: Record = { - learning_type: "in.(scar,pattern,win,anti_pattern)", - is_active: "eq.true", - }; + const filters: Record = { ...INDEXED_LEARNING_FILTERS }; if (project) { filters.project = project; } const learnings = await directQuery(getTableName("learnings"), { - select: "id,title,description,severity,counter_arguments,applies_when,source_linear_issue,project,embedding,updated_at,learning_type,decay_multiplier", + select: INDEXED_LEARNING_COLUMNS, filters, order: "updated_at.desc", limit, @@ -551,6 +560,40 @@ export async function loadScarsWithEmbeddings( } } +/** + * Delta manifest (GIT-98): the id and updated_at of every row the bulk load + * would return — same filters, order and limit — and nothing else. About 70 + * bytes a row, versus ~20 KB a row with its 1536-dim embedding. + */ +export async function loadLearningsManifest(limit = 500): Promise> { + return directQuery<{ id: string; updated_at: string | null }>(getTableName("learnings"), { + select: "id,updated_at", + filters: { ...INDEXED_LEARNING_FILTERS }, + order: "updated_at.desc", + limit, + }); +} + +/** Ids per delta request. A UUID is 36 characters, so this keeps URLs near 4 KB. */ +const DELTA_FETCH_CHUNK = 100; + +/** + * Delta fetch (GIT-98): full rows, embedding included, for the given ids only. + * The indexed-learning filters still apply, so a row deactivated since the + * manifest was read is simply absent. + */ +export async function loadLearningsByIds(ids: string[]): Promise { + const rows: T[] = []; + for (let i = 0; i < ids.length; i += DELTA_FETCH_CHUNK) { + const chunk = ids.slice(i, i + DELTA_FETCH_CHUNK); + rows.push(...await directQuery(getTableName("learnings"), { + select: INDEXED_LEARNING_COLUMNS, + filters: { ...INDEXED_LEARNING_FILTERS, id: `in.(${chunk.join(",")})` }, + })); + } + return rows; +} + /** * Cheap fingerprint of the learnings the local index is built from (GIT-98). * @@ -569,8 +612,7 @@ export async function getLearningsFingerprint(): Promise<{ count: number; latest try { const url = new URL(`${SUPABASE_REST_URL}/${getTableName("learnings")}`); url.searchParams.set("select", "updated_at"); - url.searchParams.set("learning_type", "in.(scar,pattern,win,anti_pattern)"); - url.searchParams.set("is_active", "eq.true"); + for (const [key, value] of Object.entries(INDEXED_LEARNING_FILTERS)) url.searchParams.set(key, value); url.searchParams.set("order", "updated_at.desc"); url.searchParams.set("limit", "1"); diff --git a/src/services/vector-disk-cache.ts b/src/services/vector-disk-cache.ts index 91853b9..490a4ba 100644 --- a/src/services/vector-disk-cache.ts +++ b/src/services/vector-disk-cache.ts @@ -12,7 +12,21 @@ * 1. Ask the store for a FINGERPRINT — row count + newest updated_at. One * request, one row, a few hundred bytes (PostgREST `Prefer: count=exact`). * 2. If a cache file with the same fingerprint exists, load vectors from disk. - * 3. Otherwise download as before and rewrite the cache. + * 3. Otherwise sync the difference (below) and rewrite the cache. + * + * ## Per-row delta sync + * + * A fingerprint miss used to mean a full download, and on a Pro store it + * misses on nearly every session_start: refresh_scar_behavioral_scores() + * rewrites every scar with enough recent usage and bumps its updated_at. So + * a handful of changed rows cost the whole index. + * + * On a miss with a usable cache from this store, only the manifest (id, + * updated_at; no vectors) is downloaded. A row whose id and updated_at match + * the cache is reused from disk; only changed and new ids are fetched in full; + * rows no longer in the manifest are dropped. The result is in manifest order, + * exactly what a full download would have returned, given that updated_at + * moves on every edit (the same assumption the fingerprint already makes). * * ## What the fingerprint can and cannot see * @@ -105,31 +119,104 @@ export function readVectorCache(storeKey: string, expected: StoreFingerprint) // failures would otherwise "agree" and serve whatever is on disk. if (expected.count < 0) return { hit: false, reason: "store fingerprint unavailable" }; + const file = readCacheFile(storeKey); + if (!file.ok) return { hit: false, reason: file.reason }; + if (!file.parsed.fingerprint || !fingerprintsMatch(file.parsed.fingerprint, expected)) { + return { hit: false, reason: "store changed since cache was written" }; + } + return { hit: true, rows: file.parsed.rows, bytesOnDisk: file.bytesOnDisk }; +} + +/** + * The cached rows of this store, whatever fingerprint they were written under: + * the base a delta sync reuses unchanged rows from. Never served as-is — every + * row is checked against the store's manifest first. + */ +export function readVectorCacheBase(storeKey: string): T[] | null { + if (!isVectorDiskCacheEnabled()) return null; + const file = readCacheFile(storeKey); + return file.ok ? file.parsed.rows : null; +} + +export interface ManifestRow { + id: string; + updated_at: string | null; +} + +export interface DeltaPlan { + /** Cached rows still current in the store, by id. */ + reuse: Map; + /** Ids to fetch in full: changed since the cache was written, or new. */ + fetchIds: string[]; + /** Cached rows the store no longer returns. */ + dropped: number; +} + +/** + * Compare the store's manifest with the cached rows. A row is reused only when + * its id AND updated_at match; a cached row without updated_at is refetched. + */ +export function planDelta( + cached: T[], + manifest: ManifestRow[] +): DeltaPlan { + const cachedById = new Map(cached.map((r) => [r.id, r])); + const reuse = new Map(); + const fetchIds: string[] = []; + for (const m of manifest) { + const c = cachedById.get(m.id); + if (c && m.updated_at != null && c.updated_at === m.updated_at) reuse.set(m.id, c); + else fetchIds.push(m.id); + } + const inManifest = new Set(manifest.map((m) => m.id)); + const dropped = cached.filter((r) => !inManifest.has(r.id)).length; + return { reuse, fetchIds, dropped }; +} + +/** + * Rows in manifest order: reused where current, fetched otherwise. A manifest + * id that is in neither (deactivated between the manifest and the fetch) is + * left out, as a full download at that moment would have. + */ +export function assembleDelta( + manifest: ManifestRow[], + plan: DeltaPlan, + fetched: T[] +): T[] { + const fetchedById = new Map(fetched.map((r) => [r.id, r])); + const rows: T[] = []; + for (const m of manifest) { + const row = fetchedById.get(m.id) ?? plan.reuse.get(m.id); + if (row) rows.push(row); + } + return rows; +} + +function readCacheFile(storeKey: string): + | { ok: true; parsed: VectorCacheFile; bytesOnDisk: number } + | { ok: false; reason: string } { const file = getVectorCachePath(storeKey); let raw: string; try { - if (!fs.existsSync(file)) return { hit: false, reason: "no cache file" }; + if (!fs.existsSync(file)) return { ok: false, reason: "no cache file" }; raw = fs.readFileSync(file, "utf-8"); } catch (error) { - return { hit: false, reason: `unreadable: ${error instanceof Error ? error.message : String(error)}` }; + return { ok: false, reason: `unreadable: ${error instanceof Error ? error.message : String(error)}` }; } let parsed: VectorCacheFile; try { parsed = JSON.parse(raw) as VectorCacheFile; } catch { - return { hit: false, reason: "corrupt JSON" }; + return { ok: false, reason: "corrupt JSON" }; } - if (!parsed || typeof parsed !== "object") return { hit: false, reason: "corrupt shape" }; - if (parsed.format !== VECTOR_CACHE_FORMAT) return { hit: false, reason: `format ${parsed.format} != ${VECTOR_CACHE_FORMAT}` }; - if (parsed.storeKey !== storeKey) return { hit: false, reason: "written for a different store" }; - if (!Array.isArray(parsed.rows)) return { hit: false, reason: "corrupt rows" }; - if (!parsed.fingerprint || !fingerprintsMatch(parsed.fingerprint, expected)) { - return { hit: false, reason: "store changed since cache was written" }; - } + if (!parsed || typeof parsed !== "object") return { ok: false, reason: "corrupt shape" }; + if (parsed.format !== VECTOR_CACHE_FORMAT) return { ok: false, reason: `format ${parsed.format} != ${VECTOR_CACHE_FORMAT}` }; + if (parsed.storeKey !== storeKey) return { ok: false, reason: "written for a different store" }; + if (!Array.isArray(parsed.rows)) return { ok: false, reason: "corrupt rows" }; - return { hit: true, rows: parsed.rows, bytesOnDisk: Buffer.byteLength(raw) }; + return { ok: true, parsed, bytesOnDisk: Buffer.byteLength(raw) }; } /** diff --git a/tests/unit/services/vector-delta-sync.test.ts b/tests/unit/services/vector-delta-sync.test.ts new file mode 100644 index 0000000..332521b --- /dev/null +++ b/tests/unit/services/vector-delta-sync.test.ts @@ -0,0 +1,180 @@ +/** + * GIT-98: per-row delta sync. + * + * A fingerprint miss used to cost the whole index. On a Pro store it misses on + * nearly every session_start, because refresh_scar_behavioral_scores() bumps + * updated_at on every scar with enough recent usage. Now a miss with a cache + * from this store downloads the manifest (id, updated_at) and only the rows + * whose updated_at moved. + * + * Real filesystem cache, isolated GITMEM_DIR; the store is mocked at the + * supabase-client boundary so each call can be counted. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +const store = vi.hoisted(() => ({ + rows: [] as Array<{ id: string; title: string; updated_at: string; embedding: number[] }>, + manifestFails: false, + calls: { fingerprint: 0, manifest: 0, byIds: [] as string[][], bulk: 0 }, +})); + +const active = () => [...store.rows].sort((a, b) => b.updated_at.localeCompare(a.updated_at)); + +vi.mock("../../../src/services/supabase-client.js", () => ({ + isConfigured: () => true, + getSupabaseUrl: () => "https://venue.example.supabase.co", + getLearningsFingerprint: vi.fn(async () => { + store.calls.fingerprint++; + const rows = active(); + return { count: rows.length, latestUpdatedAt: rows[0]?.updated_at ?? null }; + }), + loadLearningsManifest: vi.fn(async () => { + store.calls.manifest++; + if (store.manifestFails) throw new Error("manifest 500"); + return active().map(({ id, updated_at }) => ({ id, updated_at })); + }), + loadLearningsByIds: vi.fn(async (ids: string[]) => { + store.calls.byIds.push(ids); + return active().filter((r) => ids.includes(r.id)).map((r) => ({ ...r })); + }), + loadScarsWithEmbeddings: vi.fn(async () => { + store.calls.bulk++; + return active().map((r) => ({ ...r })); + }), +})); + +import { loadScarsFromSupabase } from "../../../src/services/startup.js"; +import { planDelta, assembleDelta } from "../../../src/services/vector-disk-cache.js"; + +let dir: string; +const ts = (n: number) => new Date(Date.UTC(2026, 8, 21, 0, 0, n)).toISOString(); +const vec = (i: number) => Array.from({ length: 8 }, (_, k) => i + k / 10); + +function seed(n: number) { + store.rows = Array.from({ length: n }, (_, i) => ({ id: `id-${String(i).padStart(3, "0")}`, title: `t${i}`, updated_at: ts(i), embedding: vec(i) })); +} +/** What refresh_scar_behavioral_scores() does to a scar: new score, new updated_at. */ +function rewrite(ids: string[], at: number) { + for (const r of store.rows) if (ids.includes(r.id)) { r.updated_at = ts(at); r.title = `${r.title}*`; r.embedding = vec(1000 + at); } +} +const resetCalls = () => { store.calls = { fingerprint: 0, manifest: 0, byIds: [], bulk: 0 }; }; + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "gitmem-vdelta-")); + vi.stubEnv("GITMEM_DIR", dir); + vi.stubEnv("GITMEM_VECTOR_DISK_CACHE", ""); + store.manifestFails = false; + seed(250); + resetCalls(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("planDelta / assembleDelta", () => { + const cached = [ + { id: "a", updated_at: "1", v: "a-old" }, + { id: "b", updated_at: "1", v: "b-old" }, + { id: "gone", updated_at: "1", v: "gone" }, + { id: "no-ts", v: "no-ts" }, + ]; + const manifest = [ + { id: "new", updated_at: "3" }, + { id: "b", updated_at: "2" }, + { id: "a", updated_at: "1" }, + { id: "no-ts", updated_at: "1" }, + ]; + + it("reuses only id+updated_at matches, fetches changed, new and timestamp-less rows, counts dropped ones", () => { + const plan = planDelta(cached as never[], manifest); + expect([...plan.reuse.keys()]).toEqual(["a"]); + expect(plan.fetchIds).toEqual(["new", "b", "no-ts"]); + expect(plan.dropped).toBe(1); + }); + + it("assembles in manifest order and omits ids that vanished before the fetch", () => { + const plan = planDelta(cached as never[], manifest); + const rows = assembleDelta(manifest, plan, [{ id: "b", v: "b-new" }, { id: "no-ts", v: "no-ts-new" }] as never[]); + expect(rows.map((r: { v: string }) => r.v)).toEqual(["b-new", "a-old", "no-ts-new"]); + }); +}); + +describe("loadScarsFromSupabase delta sync (GIT-98)", () => { + it("cold start: one full download, then a warm start costs only the fingerprint", async () => { + const cold = await loadScarsFromSupabase(); + expect(cold.scars).toHaveLength(250); + expect(store.calls.bulk).toBe(1); + + resetCalls(); + const warm = await loadScarsFromSupabase(); + expect(warm.source).toBe("disk"); + expect(store.calls).toEqual({ fingerprint: 1, manifest: 0, byIds: [], bulk: 0 }); + }); + + it("5 rows rewritten: fetches exactly those 5 vectors, not 250, and matches a full download", async () => { + await loadScarsFromSupabase(); + const changed = ["id-007", "id-042", "id-099", "id-150", "id-249"]; + rewrite(changed, 5000); + resetCalls(); + + const synced = await loadScarsFromSupabase(); + + expect(store.calls.bulk).toBe(0); + expect(store.calls.manifest).toBe(1); + expect(store.calls.byIds.flat().sort()).toEqual([...changed].sort()); + // Identical to what a full download would have returned, order included. + expect(synced.scars).toEqual(active()); + expect(synced.latestUpdatedAt).toBe(ts(5000)); + + // The rewritten cache serves the next start from disk. + resetCalls(); + expect((await loadScarsFromSupabase()).source).toBe("disk"); + expect(store.calls.byIds).toEqual([]); + }); + + it("inserts and deactivations: fetches the new row, drops the gone one", async () => { + await loadScarsFromSupabase(); + store.rows = store.rows.filter((r) => r.id !== "id-010"); + store.rows.push({ id: "id-new", title: "fresh", updated_at: ts(6000), embedding: vec(6000) }); + resetCalls(); + + const synced = await loadScarsFromSupabase(); + expect(store.calls.bulk).toBe(0); + expect(store.calls.byIds.flat()).toEqual(["id-new"]); + expect(synced.scars.map((r) => r.id)).toEqual(active().map((r) => r.id)); + expect(synced.scars.some((r) => r.id === "id-010")).toBe(false); + }); + + it("a failed manifest falls back to the full download", async () => { + await loadScarsFromSupabase(); + rewrite(["id-001"], 7000); + store.manifestFails = true; + resetCalls(); + + const loaded = await loadScarsFromSupabase(); + expect(store.calls.bulk).toBe(1); + expect(loaded.scars).toEqual(active()); + }); + + it("cache-flush (bypassDiskCache) always takes the full download", async () => { + await loadScarsFromSupabase(); + rewrite(["id-001"], 8000); + resetCalls(); + + await loadScarsFromSupabase({ bypassDiskCache: true }); + expect(store.calls.manifest).toBe(0); + expect(store.calls.bulk).toBe(1); + }); + + it("no cache from this store: full download, no manifest", async () => { + const loaded = await loadScarsFromSupabase(); + expect(store.calls.manifest).toBe(0); + expect(store.calls.bulk).toBe(1); + expect(loaded.scars).toHaveLength(250); + }); +}); From fb2893fb8354e86ac712deff77cb963e9b19a2ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 20:11:54 -0700 Subject: [PATCH 2/2] test: egress mode fails on any full index download after the cold start (GIT-98) Exits 1 on 1.10.0 (the start after each session_start re-downloads ~922 KB); exits 0 with per-row delta sync. Co-Authored-By: Claude Opus 5 --- tests/e2e/blank-supabase.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/e2e/blank-supabase.mjs b/tests/e2e/blank-supabase.mjs index 84b343b..0787fd4 100644 --- a/tests/e2e/blank-supabase.mjs +++ b/tests/e2e/blank-supabase.mjs @@ -21,6 +21,8 @@ * egress bytes received from the venue on a cold start, a warm start, and around * two session_starts (GIT-98 disk cache). --usage N seeds N scars with * >=3 usage rows so refresh_scar_behavioral_scores() has work to do. + * Fails if any start after the cold one downloads the whole index again + * (GIT-98 per-row delta). * * VENUE_ENV is a KEY=VALUE file OUTSIDE the repo defining SUPABASE_URL, * SUPABASE_SERVICE_ROLE_KEY and VENUE_REF. Credentials are never committed. @@ -520,6 +522,15 @@ const failureCheck = checkFailures(allVenueRequests); const edgeCalls = allVenueRequests.filter((r) => r.path.startsWith("/functions/v1/")) .map((r) => `${r.method} ${r.path} -> ${r.status}`); if (edgeCalls.length) failureCheck.unexpected.push(...edgeCalls.map((c) => `edge function called (GIT-97): ${c}`)); +// GIT-98 per-row delta: after the cold start, a changed store costs the changed +// rows, never the whole index again. +if (mode === "egress") { + for (const st of result.starts.slice(1)) { + if (st.cache_log.some((l) => /Loading ALL learnings/.test(l))) { + failureCheck.unexpected.push(`full index download after the cold start (GIT-98): ${st.start}, ${st.total_bytes} B`); + } + } +} if (mode === "flow" && !result.session_close_persisted) { failureCheck.unexpected.push(`session_close did not persist session ${result.session_id} (closing_reflection missing)`); }