Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[];
}
Expand Down Expand Up @@ -175,20 +175,20 @@ export interface MetadataStore {
getVolumesByBook(bookId: Hash): Promise<Volume[]>;
getShelvesByVolume(volumeId: Hash): Promise<Shelf[]>;

// --- Metroid NN radius index ---
putMetroidNeighbors(pageId: Hash, neighbors: MetroidNeighbor[]): Promise<void>;
getMetroidNeighbors(pageId: Hash, maxDegree?: number): Promise<MetroidNeighbor[]>;
// --- Semantic neighbor radius index ---
putSemanticNeighbors(pageId: Hash, neighbors: SemanticNeighbor[]): Promise<void>;
getSemanticNeighbors(pageId: Hash, maxDegree?: number): Promise<SemanticNeighbor[]>;

/** 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<MetroidSubgraph>;
): Promise<SemanticNeighborSubgraph>;

// --- Dirty-volume recalc flags ---
needsMetroidRecalc(volumeId: Hash): Promise<boolean>;
flagVolumeForMetroidRecalc(volumeId: Hash): Promise<void>;
clearMetroidRecalcFlag(volumeId: Hash): Promise<void>;
needsNeighborRecalc(volumeId: Hash): Promise<boolean>;
flagVolumeForNeighborRecalc(volumeId: Hash): Promise<void>;
clearNeighborRecalcFlag(volumeId: Hash): Promise<void>;

// --- Hotpath index ---
putHotpathEntry(entry: HotpathEntry): Promise<void>;
Expand Down
42 changes: 22 additions & 20 deletions storage/IndexedDbMetadataStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import type {
Hash,
HotpathEntry,
MetadataStore,
MetroidNeighbor,
MetroidSubgraph,
SemanticNeighbor,
SemanticNeighborSubgraph,
Page,
PageActivity,
Shelf,
Expand All @@ -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 = {
Expand All @@ -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",
Expand Down Expand Up @@ -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" });
}
Expand All @@ -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" });
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -328,30 +330,30 @@ export class IndexedDbMetadataStore implements MetadataStore {
}

// -------------------------------------------------------------------------
// Metroid NN radius index
// Semantic neighbor radius index
// -------------------------------------------------------------------------

putMetroidNeighbors(pageId: Hash, neighbors: MetroidNeighbor[]): Promise<void> {
return this._put(STORE.metroidNeighbors, { pageId, neighbors });
putSemanticNeighbors(pageId: Hash, neighbors: SemanticNeighbor[]): Promise<void> {
return this._put(STORE.neighborGraph, { pageId, neighbors });
}

async getMetroidNeighbors(
async getSemanticNeighbors(
pageId: Hash,
maxDegree?: number,
): Promise<MetroidNeighbor[]> {
const row = await this._get<{ pageId: Hash; neighbors: MetroidNeighbor[] }>(
STORE.metroidNeighbors,
): Promise<SemanticNeighbor[]> {
const row = await this._get<{ pageId: Hash; neighbors: SemanticNeighbor[] }>(
STORE.neighborGraph,
pageId,
);
if (!row) return [];
const list = row.neighbors;
return maxDegree !== undefined ? list.slice(0, maxDegree) : list;
}

async getInducedMetroidSubgraph(
async getInducedNeighborSubgraph(
seedPageIds: Hash[],
maxHops: number,
): Promise<MetroidSubgraph> {
): Promise<SemanticNeighborSubgraph> {
const visited = new Set<Hash>(seedPageIds);
const nodeSet = new Set<Hash>(seedPageIds);
const edgeMap = new Map<string, { from: Hash; to: Hash; distance: number }>();
Expand All @@ -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)) {
Expand Down Expand Up @@ -393,19 +395,19 @@ export class IndexedDbMetadataStore implements MetadataStore {
// Dirty-recalc flags
// -------------------------------------------------------------------------

async needsMetroidRecalc(volumeId: Hash): Promise<boolean> {
async needsNeighborRecalc(volumeId: Hash): Promise<boolean> {
const row = await this._get<{ volumeId: Hash; needsRecalc: boolean }>(
STORE.flags,
volumeId,
);
return row?.needsRecalc === true;
}

flagVolumeForMetroidRecalc(volumeId: Hash): Promise<void> {
flagVolumeForNeighborRecalc(volumeId: Hash): Promise<void> {
return this._put(STORE.flags, { volumeId, needsRecalc: true });
}

clearMetroidRecalcFlag(volumeId: Hash): Promise<void> {
clearNeighborRecalcFlag(volumeId: Hash): Promise<void> {
return this._put(STORE.flags, { volumeId, needsRecalc: false });
}

Expand Down
72 changes: 36 additions & 36 deletions tests/Persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type {
Book,
Edge,
HotpathEntry,
MetroidNeighbor,
SemanticNeighbor,
Page,
PageActivity,
Shelf,
Expand Down Expand Up @@ -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 },
];
Expand Down Expand Up @@ -415,95 +415,95 @@ 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());
});

// --- 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 ---
Expand Down
12 changes: 6 additions & 6 deletions tests/SalienceEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,12 @@ class MockMetadataStore implements MetadataStore {
async getBooksByPage(): Promise<never[]> { return []; }
async getVolumesByBook(): Promise<never[]> { return []; }
async getShelvesByVolume(): Promise<never[]> { return []; }
async putMetroidNeighbors(): Promise<void> { /* stub */ }
async getMetroidNeighbors(): Promise<never[]> { return []; }
async getInducedMetroidSubgraph() { return { nodes: [], edges: [] }; }
async needsMetroidRecalc(): Promise<boolean> { return false; }
async flagVolumeForMetroidRecalc(): Promise<void> { /* stub */ }
async clearMetroidRecalcFlag(): Promise<void> { /* stub */ }
async putSemanticNeighbors(): Promise<void> { /* stub */ }
async getSemanticNeighbors(): Promise<never[]> { return []; }
async getInducedNeighborSubgraph() { return { nodes: [], edges: [] }; }
async needsNeighborRecalc(): Promise<boolean> { return false; }
async flagVolumeForNeighborRecalc(): Promise<void> { /* stub */ }
async clearNeighborRecalcFlag(): Promise<void> { /* stub */ }
}

// ---------------------------------------------------------------------------
Expand Down
Loading