diff --git a/core/types.ts b/core/types.ts index 7271e8a..d8cc52b 100644 --- a/core/types.ts +++ b/core/types.ts @@ -64,16 +64,16 @@ export interface Edge { } // --------------------------------------------------------------------------- -// Metroid nearest-neighbour graph (project term; medoid-inspired) +// Semantic nearest-neighbour graph // --------------------------------------------------------------------------- -export interface MetroidNeighbor { +export interface SemanticNeighbor { neighborPageId: Hash; cosineSimilarity: number; // threshold is defined by runtime policy distance: number; // 1 - cosineSimilarity (ready for TSP) } -export interface MetroidSubgraph { +export interface SemanticNeighborSubgraph { nodes: Hash[]; edges: { from: Hash; to: Hash; distance: number }[]; } @@ -175,20 +175,20 @@ export interface MetadataStore { getVolumesByBook(bookId: Hash): Promise; getShelvesByVolume(volumeId: Hash): Promise; - // --- Metroid NN radius index --- - putMetroidNeighbors(pageId: Hash, neighbors: MetroidNeighbor[]): Promise; - getMetroidNeighbors(pageId: Hash, maxDegree?: number): Promise; + // --- Semantic neighbor radius index --- + putSemanticNeighbors(pageId: Hash, neighbors: SemanticNeighbor[]): Promise; + getSemanticNeighbors(pageId: Hash, maxDegree?: number): Promise; - /** BFS expansion of the Metroid subgraph up to `maxHops` levels deep. */ - getInducedMetroidSubgraph( + /** BFS expansion of the semantic neighbor subgraph up to `maxHops` levels deep. */ + getInducedNeighborSubgraph( seedPageIds: Hash[], maxHops: number, - ): Promise; + ): Promise; // --- Dirty-volume recalc flags --- - needsMetroidRecalc(volumeId: Hash): Promise; - flagVolumeForMetroidRecalc(volumeId: Hash): Promise; - clearMetroidRecalcFlag(volumeId: Hash): Promise; + needsNeighborRecalc(volumeId: Hash): Promise; + flagVolumeForNeighborRecalc(volumeId: Hash): Promise; + clearNeighborRecalcFlag(volumeId: Hash): Promise; // --- Hotpath index --- putHotpathEntry(entry: HotpathEntry): Promise; diff --git a/storage/IndexedDbMetadataStore.ts b/storage/IndexedDbMetadataStore.ts index 8cd21e0..212523d 100644 --- a/storage/IndexedDbMetadataStore.ts +++ b/storage/IndexedDbMetadataStore.ts @@ -4,8 +4,8 @@ import type { Hash, HotpathEntry, MetadataStore, - MetroidNeighbor, - MetroidSubgraph, + SemanticNeighbor, + SemanticNeighborSubgraph, Page, PageActivity, Shelf, @@ -16,7 +16,7 @@ import type { // Schema constants // --------------------------------------------------------------------------- -const DB_VERSION = 2; +const DB_VERSION = 3; /** Object-store names used across the schema. */ const STORE = { @@ -25,7 +25,7 @@ const STORE = { volumes: "volumes", shelves: "shelves", edges: "edges_hebbian", - metroidNeighbors: "metroid_neighbors", + neighborGraph: "neighbor_graph", flags: "flags", pageToBook: "page_to_book", bookToVolume: "book_to_volume", @@ -72,9 +72,6 @@ function applyUpgrade(db: IDBDatabase): void { edgeStore.createIndex("by-from", "fromPageId"); } - if (!db.objectStoreNames.contains(STORE.metroidNeighbors)) { - db.createObjectStore(STORE.metroidNeighbors, { keyPath: "pageId" }); - } if (!db.objectStoreNames.contains(STORE.flags)) { db.createObjectStore(STORE.flags, { keyPath: "volumeId" }); } @@ -97,6 +94,11 @@ function applyUpgrade(db: IDBDatabase): void { if (!db.objectStoreNames.contains(STORE.pageActivity)) { db.createObjectStore(STORE.pageActivity, { keyPath: "pageId" }); } + + // v3 stores — neighbor_graph (replaces the old metroid_neighbors name) + if (!db.objectStoreNames.contains(STORE.neighborGraph)) { + db.createObjectStore(STORE.neighborGraph, { keyPath: "pageId" }); + } } // --------------------------------------------------------------------------- @@ -328,19 +330,19 @@ export class IndexedDbMetadataStore implements MetadataStore { } // ------------------------------------------------------------------------- - // Metroid NN radius index + // Semantic neighbor radius index // ------------------------------------------------------------------------- - putMetroidNeighbors(pageId: Hash, neighbors: MetroidNeighbor[]): Promise { - return this._put(STORE.metroidNeighbors, { pageId, neighbors }); + putSemanticNeighbors(pageId: Hash, neighbors: SemanticNeighbor[]): Promise { + return this._put(STORE.neighborGraph, { pageId, neighbors }); } - async getMetroidNeighbors( + async getSemanticNeighbors( pageId: Hash, maxDegree?: number, - ): Promise { - const row = await this._get<{ pageId: Hash; neighbors: MetroidNeighbor[] }>( - STORE.metroidNeighbors, + ): Promise { + const row = await this._get<{ pageId: Hash; neighbors: SemanticNeighbor[] }>( + STORE.neighborGraph, pageId, ); if (!row) return []; @@ -348,10 +350,10 @@ export class IndexedDbMetadataStore implements MetadataStore { return maxDegree !== undefined ? list.slice(0, maxDegree) : list; } - async getInducedMetroidSubgraph( + async getInducedNeighborSubgraph( seedPageIds: Hash[], maxHops: number, - ): Promise { + ): Promise { const visited = new Set(seedPageIds); const nodeSet = new Set(seedPageIds); const edgeMap = new Map(); @@ -362,7 +364,7 @@ export class IndexedDbMetadataStore implements MetadataStore { const nextFrontier: Hash[] = []; for (const pageId of frontier) { - const neighbors = await this.getMetroidNeighbors(pageId); + const neighbors = await this.getSemanticNeighbors(pageId); for (const n of neighbors) { const key = `${pageId}\x00${n.neighborPageId}`; if (!edgeMap.has(key)) { @@ -393,7 +395,7 @@ export class IndexedDbMetadataStore implements MetadataStore { // Dirty-recalc flags // ------------------------------------------------------------------------- - async needsMetroidRecalc(volumeId: Hash): Promise { + async needsNeighborRecalc(volumeId: Hash): Promise { const row = await this._get<{ volumeId: Hash; needsRecalc: boolean }>( STORE.flags, volumeId, @@ -401,11 +403,11 @@ export class IndexedDbMetadataStore implements MetadataStore { return row?.needsRecalc === true; } - flagVolumeForMetroidRecalc(volumeId: Hash): Promise { + flagVolumeForNeighborRecalc(volumeId: Hash): Promise { return this._put(STORE.flags, { volumeId, needsRecalc: true }); } - clearMetroidRecalcFlag(volumeId: Hash): Promise { + clearNeighborRecalcFlag(volumeId: Hash): Promise { return this._put(STORE.flags, { volumeId, needsRecalc: false }); } diff --git a/tests/Persistence.test.ts b/tests/Persistence.test.ts index e38ea29..360bc04 100644 --- a/tests/Persistence.test.ts +++ b/tests/Persistence.test.ts @@ -19,7 +19,7 @@ import type { Book, Edge, HotpathEntry, - MetroidNeighbor, + SemanticNeighbor, Page, PageActivity, Shelf, @@ -286,7 +286,7 @@ const EDGE_B: Edge = { lastUpdatedAt: "2026-03-11T00:00:00.000Z", }; -const NEIGHBORS: MetroidNeighbor[] = [ +const NEIGHBORS: SemanticNeighbor[] = [ { neighborPageId: "page-def", cosineSimilarity: 0.9, distance: 0.1 }, { neighborPageId: "page-ghi", cosineSimilarity: 0.7, distance: 0.3 }, ]; @@ -415,72 +415,72 @@ describe("IndexedDbMetadataStore", () => { expect(neighbors).toEqual([]); }); - // --- MetroidNeighbors --- + // --- SemanticNeighbors --- - it("putMetroidNeighbors / getMetroidNeighbors round-trips neighbor list", async () => { + it("putSemanticNeighbors / getSemanticNeighbors round-trips neighbor list", async () => { const store = await IndexedDbMetadataStore.open(freshDbName()); - await store.putMetroidNeighbors("page-abc", NEIGHBORS); - const result = await store.getMetroidNeighbors("page-abc"); + await store.putSemanticNeighbors("page-abc", NEIGHBORS); + const result = await store.getSemanticNeighbors("page-abc"); expect(result).toEqual(NEIGHBORS); }); - it("getMetroidNeighbors respects maxDegree", async () => { + it("getSemanticNeighbors respects maxDegree", async () => { const store = await IndexedDbMetadataStore.open(freshDbName()); - await store.putMetroidNeighbors("page-abc", NEIGHBORS); - const result = await store.getMetroidNeighbors("page-abc", 1); + await store.putSemanticNeighbors("page-abc", NEIGHBORS); + const result = await store.getSemanticNeighbors("page-abc", 1); expect(result).toHaveLength(1); expect(result[0].neighborPageId).toBe("page-def"); }); - it("getMetroidNeighbors returns empty array for unknown page", async () => { + it("getSemanticNeighbors returns empty array for unknown page", async () => { const store = await IndexedDbMetadataStore.open(freshDbName()); - const result = await store.getMetroidNeighbors("no-such-page"); + const result = await store.getSemanticNeighbors("no-such-page"); expect(result).toEqual([]); }); - it("putMetroidNeighbors overwrites existing list", async () => { + it("putSemanticNeighbors overwrites existing list", async () => { const store = await IndexedDbMetadataStore.open(freshDbName()); - await store.putMetroidNeighbors("page-abc", NEIGHBORS); - const updated: MetroidNeighbor[] = [ + await store.putSemanticNeighbors("page-abc", NEIGHBORS); + const updated: SemanticNeighbor[] = [ { neighborPageId: "page-new", cosineSimilarity: 0.95, distance: 0.05 }, ]; - await store.putMetroidNeighbors("page-abc", updated); - const result = await store.getMetroidNeighbors("page-abc"); + await store.putSemanticNeighbors("page-abc", updated); + const result = await store.getSemanticNeighbors("page-abc"); expect(result).toHaveLength(1); expect(result[0].neighborPageId).toBe("page-new"); }); - // --- Induced Metroid subgraph (BFS) --- + // --- Induced semantic neighbor subgraph (BFS) --- - it("getInducedMetroidSubgraph returns seed nodes with zero hops", async () => { + it("getInducedNeighborSubgraph returns seed nodes with zero hops", async () => { const store = await IndexedDbMetadataStore.open(freshDbName()); - await store.putMetroidNeighbors("page-abc", NEIGHBORS); - const subgraph = await store.getInducedMetroidSubgraph(["page-abc"], 0); + await store.putSemanticNeighbors("page-abc", NEIGHBORS); + const subgraph = await store.getInducedNeighborSubgraph(["page-abc"], 0); expect(subgraph.nodes).toEqual(["page-abc"]); expect(subgraph.edges).toHaveLength(0); }); - it("getInducedMetroidSubgraph expands one hop correctly", async () => { + it("getInducedNeighborSubgraph expands one hop correctly", async () => { const store = await IndexedDbMetadataStore.open(freshDbName()); - await store.putMetroidNeighbors("page-abc", NEIGHBORS); + await store.putSemanticNeighbors("page-abc", NEIGHBORS); // page-def and page-ghi have no further neighbors - const subgraph = await store.getInducedMetroidSubgraph(["page-abc"], 1); + const subgraph = await store.getInducedNeighborSubgraph(["page-abc"], 1); expect(subgraph.nodes.sort()).toEqual( ["page-abc", "page-def", "page-ghi"].sort(), ); expect(subgraph.edges).toHaveLength(2); }); - it("getInducedMetroidSubgraph does not revisit nodes", async () => { + it("getInducedNeighborSubgraph does not revisit nodes", async () => { const store = await IndexedDbMetadataStore.open(freshDbName()); // Triangle: abc → def → abc (cycle) - await store.putMetroidNeighbors("page-abc", [ + await store.putSemanticNeighbors("page-abc", [ { neighborPageId: "page-def", cosineSimilarity: 0.9, distance: 0.1 }, ]); - await store.putMetroidNeighbors("page-def", [ + await store.putSemanticNeighbors("page-def", [ { neighborPageId: "page-abc", cosineSimilarity: 0.9, distance: 0.1 }, ]); - const subgraph = await store.getInducedMetroidSubgraph(["page-abc"], 5); + const subgraph = await store.getInducedNeighborSubgraph(["page-abc"], 5); const uniqueNodes = new Set(subgraph.nodes); expect(uniqueNodes.size).toBe(subgraph.nodes.length); // no duplicates expect(subgraph.nodes.sort()).toEqual(["page-abc", "page-def"].sort()); @@ -488,22 +488,22 @@ describe("IndexedDbMetadataStore", () => { // --- Dirty-recalc flags --- - it("needsMetroidRecalc returns false before any flag is set", async () => { + it("needsNeighborRecalc returns false before any flag is set", async () => { const store = await IndexedDbMetadataStore.open(freshDbName()); - expect(await store.needsMetroidRecalc("vol-001")).toBe(false); + expect(await store.needsNeighborRecalc("vol-001")).toBe(false); }); - it("flagVolumeForMetroidRecalc / needsMetroidRecalc round-trips", async () => { + it("flagVolumeForNeighborRecalc / needsNeighborRecalc round-trips", async () => { const store = await IndexedDbMetadataStore.open(freshDbName()); - await store.flagVolumeForMetroidRecalc("vol-001"); - expect(await store.needsMetroidRecalc("vol-001")).toBe(true); + await store.flagVolumeForNeighborRecalc("vol-001"); + expect(await store.needsNeighborRecalc("vol-001")).toBe(true); }); - it("clearMetroidRecalcFlag resets the flag", async () => { + it("clearNeighborRecalcFlag resets the flag", async () => { const store = await IndexedDbMetadataStore.open(freshDbName()); - await store.flagVolumeForMetroidRecalc("vol-001"); - await store.clearMetroidRecalcFlag("vol-001"); - expect(await store.needsMetroidRecalc("vol-001")).toBe(false); + await store.flagVolumeForNeighborRecalc("vol-001"); + await store.clearNeighborRecalcFlag("vol-001"); + expect(await store.needsNeighborRecalc("vol-001")).toBe(false); }); // --- HotpathEntry CRUD --- diff --git a/tests/SalienceEngine.test.ts b/tests/SalienceEngine.test.ts index 0618a33..22dfc96 100644 --- a/tests/SalienceEngine.test.ts +++ b/tests/SalienceEngine.test.ts @@ -115,12 +115,12 @@ class MockMetadataStore implements MetadataStore { async getBooksByPage(): Promise { return []; } async getVolumesByBook(): Promise { return []; } async getShelvesByVolume(): Promise { return []; } - async putMetroidNeighbors(): Promise { /* stub */ } - async getMetroidNeighbors(): Promise { return []; } - async getInducedMetroidSubgraph() { return { nodes: [], edges: [] }; } - async needsMetroidRecalc(): Promise { return false; } - async flagVolumeForMetroidRecalc(): Promise { /* stub */ } - async clearMetroidRecalcFlag(): Promise { /* stub */ } + async putSemanticNeighbors(): Promise { /* stub */ } + async getSemanticNeighbors(): Promise { return []; } + async getInducedNeighborSubgraph() { return { nodes: [], edges: [] }; } + async needsNeighborRecalc(): Promise { return false; } + async flagVolumeForNeighborRecalc(): Promise { /* stub */ } + async clearNeighborRecalcFlag(): Promise { /* stub */ } } // ---------------------------------------------------------------------------