Skip to content

Commit d516404

Browse files
authored
Merge pull request #49 from devlux76/p0d/cortex-query-minimal
P0-D: Implement minimal Cortex query (issue #21)
2 parents daaeeda + 4e71a70 commit d516404

8 files changed

Lines changed: 465 additions & 5 deletions

File tree

TODO.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ These items **must** be completed to have a usable system. Without them, users c
163163

164164
**Why:** Users need to retrieve relevant memories.
165165

166-
- [ ] **P0-D1:** Implement `cortex/Query.ts` (minimal version)
166+
- [x] **P0-D1:** Implement `cortex/Query.ts` (minimal version)
167167
- Entry point: `query(queryText, modelProfile, vectorStore, metadataStore, topK)`
168168
- Embed query via `EmbeddingRunner`
169169
- Score resident hotpath entries first (HOT pages); fall back to full scan for WARM/COLD
@@ -172,10 +172,10 @@ These items **must** be completed to have a usable system. Without them, users c
172172
- Return `QueryResult` with page IDs and scores
173173
- **Defer:** Full hierarchical ranking, subgraph expansion, TSP coherence, query cost meter
174174

175-
- [ ] **P0-D2:** Implement `cortex/QueryResult.ts`
175+
- [x] **P0-D2:** Implement `cortex/QueryResult.ts`
176176
- DTO with `pages: Page[]`, `scores: number[]`, `metadata: object`
177177

178-
- [ ] **P0-D3:** Add query test coverage
178+
- [x] **P0-D3:** Add query test coverage
179179
- `tests/cortex/Query.test.ts`
180180
- Test happy path (query → top-K pages)
181181
- Test empty corpus (no results)

core/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ export interface MetadataStore {
154154
// --- Core CRUD ---
155155
putPage(page: Page): Promise<void>;
156156
getPage(pageId: Hash): Promise<Page | undefined>;
157+
/** Returns all pages in the store. Used for warm/cold fallbacks in query. */
158+
getAllPages(): Promise<Page[]>;
157159

158160
putBook(book: Book): Promise<void>;
159161
getBook(bookId: Hash): Promise<Book | undefined>;

cortex/Query.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import type { ModelProfile } from "../core/ModelProfile";
2+
import type { MetadataStore, Page, VectorStore } from "../core/types";
3+
import type { VectorBackend } from "../VectorBackend";
4+
import type { EmbeddingRunner } from "../embeddings/EmbeddingRunner";
5+
import { runPromotionSweep } from "../core/SalienceEngine";
6+
import type { QueryResult } from "./QueryResult";
7+
8+
export interface QueryOptions {
9+
modelProfile: ModelProfile;
10+
embeddingRunner: EmbeddingRunner;
11+
vectorStore: VectorStore;
12+
metadataStore: MetadataStore;
13+
vectorBackend: VectorBackend;
14+
topK?: number;
15+
}
16+
17+
function dot(a: Float32Array, b: Float32Array): number {
18+
const len = Math.min(a.length, b.length);
19+
let sum = 0;
20+
for (let i = 0; i < len; i++) {
21+
sum += a[i] * b[i];
22+
}
23+
return sum;
24+
}
25+
26+
/**
27+
* Concatenates an array of equal-length vectors into a single flat buffer.
28+
* @param vectors - Must be non-empty; every element must have the same length.
29+
*/
30+
function concatVectors(vectors: Float32Array[]): Float32Array {
31+
const dim = vectors[0].length;
32+
const out = new Float32Array(vectors.length * dim);
33+
for (let i = 0; i < vectors.length; i++) {
34+
out.set(vectors[i], i * dim);
35+
}
36+
return out;
37+
}
38+
39+
async function scorePages(
40+
queryEmbedding: Float32Array,
41+
pages: Page[],
42+
vectorStore: VectorStore,
43+
vectorBackend: VectorBackend,
44+
maxResults: number,
45+
): Promise<Array<{ page: Page; score: number }>> {
46+
if (pages.length === 0) return [];
47+
48+
const [firstPage] = pages;
49+
const dim = firstPage.embeddingDim;
50+
const offsets = pages.map((p) => p.embeddingOffset);
51+
52+
// If all pages share the same embedding dimension and it matches the query,
53+
// use the vector backend for fast scoring.
54+
const uniformDim = pages.every((p) => p.embeddingDim === dim);
55+
const canUseBackend = uniformDim && queryEmbedding.length === dim;
56+
57+
if (canUseBackend) {
58+
const embeddings = await vectorStore.readVectors(offsets, dim);
59+
const matrix = concatVectors(embeddings);
60+
const scores = await vectorBackend.dotMany(queryEmbedding, matrix, dim, pages.length);
61+
const topk = await vectorBackend.topKFromScores(scores, Math.min(maxResults, pages.length));
62+
return topk.map((r) => ({ page: pages[r.index], score: r.score }));
63+
}
64+
65+
// Fallback: compute dot product per page.
66+
const scored = await Promise.all(
67+
pages.map(async (page) => {
68+
const vec = await vectorStore.readVector(page.embeddingOffset, page.embeddingDim);
69+
return { page, score: dot(queryEmbedding, vec) };
70+
}),
71+
);
72+
73+
scored.sort((a, b) => b.score - a.score || a.page.pageId.localeCompare(b.page.pageId));
74+
return scored.slice(0, Math.min(maxResults, scored.length));
75+
}
76+
77+
export async function query(
78+
queryText: string,
79+
options: QueryOptions,
80+
): Promise<QueryResult> {
81+
const {
82+
modelProfile,
83+
embeddingRunner,
84+
vectorStore,
85+
metadataStore,
86+
vectorBackend,
87+
topK = 10,
88+
} = options;
89+
90+
const nowIso = new Date().toISOString();
91+
92+
const embeddings = await embeddingRunner.embed([queryText]);
93+
if (embeddings.length !== 1) {
94+
throw new Error("Embedding provider returned unexpected number of embeddings");
95+
}
96+
const queryEmbedding = embeddings[0];
97+
98+
// Score resident (hotpath) pages first.
99+
const hotpathEntries = await metadataStore.getHotpathEntries("page");
100+
const hotpathIds = hotpathEntries.map((e) => e.entityId);
101+
102+
const hotpathPages = (await Promise.all(
103+
hotpathIds.map((id) => metadataStore.getPage(id)),
104+
)).filter((p): p is Page => p !== undefined);
105+
106+
const hotpathResults = await scorePages(
107+
queryEmbedding,
108+
hotpathPages,
109+
vectorStore,
110+
vectorBackend,
111+
topK,
112+
);
113+
114+
const seen = new Set(hotpathResults.map((r) => r.page.pageId));
115+
116+
// If we still need more results, score remaining pages (warm/cold).
117+
const remaining = Math.max(0, topK - hotpathResults.length);
118+
const coldResults: Array<{ page: Page; score: number }> = [];
119+
120+
if (remaining > 0) {
121+
const allPages = await metadataStore.getAllPages();
122+
const candidates = allPages.filter((p) => !seen.has(p.pageId));
123+
124+
const scored = await scorePages(
125+
queryEmbedding,
126+
candidates,
127+
vectorStore,
128+
vectorBackend,
129+
remaining,
130+
);
131+
132+
coldResults.push(...scored);
133+
}
134+
135+
const combined = [...hotpathResults, ...coldResults];
136+
combined.sort((a, b) => b.score - a.score);
137+
138+
// Ensure combined results are sorted by descending score for top-K semantics.
139+
combined.sort((a, b) => b.score - a.score);
140+
141+
// Update activity for returned pages
142+
await Promise.all(combined.map(async ({ page }) => {
143+
const activity = await metadataStore.getPageActivity(page.pageId);
144+
const updated = {
145+
pageId: page.pageId,
146+
queryHitCount: (activity?.queryHitCount ?? 0) + 1,
147+
lastQueryAt: nowIso,
148+
communityId: activity?.communityId,
149+
};
150+
await metadataStore.putPageActivity(updated);
151+
}));
152+
153+
// Recompute salience and run promotion sweep for pages returned in this query.
154+
await runPromotionSweep(combined.map((r) => r.page.pageId), metadataStore);
155+
156+
return {
157+
pages: combined.map((r) => r.page),
158+
scores: combined.map((r) => r.score),
159+
metadata: {
160+
queryText,
161+
topK,
162+
returned: combined.length,
163+
timestamp: nowIso,
164+
modelId: modelProfile.modelId,
165+
},
166+
};
167+
}

cortex/QueryResult.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { Page } from "../core/types";
2+
3+
export interface QueryResult {
4+
pages: Page[];
5+
scores: number[];
6+
metadata: Record<string, unknown>;
7+
}

hippocampus/Chunker.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function chunkTextWithMaxTokens(
1515
text: string,
1616
maxChunkTokens: number,
1717
): string[] {
18-
if (!Number.isInteger(maxChunkTokens) || maxChunkTokens <= 0) {
18+
if (!Number.isInteger(maxChunkTokens) || maxChunkTokens <= 0) { // model-derived-ok
1919
throw new Error("maxChunkTokens must be a positive integer");
2020
}
2121

@@ -51,7 +51,8 @@ export function chunkTextWithMaxTokens(
5151
// Sentence is larger than budget: split it across multiple chunks.
5252
if (sentenceTokens.length > maxChunkTokens) {
5353
pushCurrent();
54-
for (let i = 0; i < sentenceTokens.length; i += maxChunkTokens) {
54+
// model-derived-ok: uses maxChunkTokens as derived from ModelProfile
55+
for (let i = 0; i < sentenceTokens.length; i += maxChunkTokens) { // model-derived-ok
5556
const slice = sentenceTokens.slice(i, i + maxChunkTokens);
5657
chunks.push(slice.join(" "));
5758
}

storage/IndexedDbMetadataStore.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,20 @@ export class IndexedDbMetadataStore implements MetadataStore {
145145
return this._get<Page>(STORE.pages, pageId);
146146
}
147147

148+
/**
149+
* Returns all pages in the store. Used for warm/cold fallbacks in query.
150+
* TODO: Replace with a paginated or indexed scan before production use —
151+
* loading every page into memory is expensive for large corpora.
152+
*/
153+
async getAllPages(): Promise<Page[]> {
154+
return new Promise((resolve, reject) => {
155+
const tx = this.db.transaction(STORE.pages, "readonly");
156+
const req = tx.objectStore(STORE.pages).getAll();
157+
req.onsuccess = () => resolve(req.result as Page[]);
158+
req.onerror = () => reject(req.error);
159+
});
160+
}
161+
148162
// -------------------------------------------------------------------------
149163
// Book CRUD + reverse index maintenance
150164
// -------------------------------------------------------------------------

tests/SalienceEngine.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ class MockMetadataStore implements MetadataStore {
105105
// --- Stubs for unused MetadataStore methods ---
106106
async putPage(): Promise<void> { /* stub */ }
107107
async getPage(): Promise<undefined> { return undefined; }
108+
async getAllPages(): Promise<any[]> { return []; }
108109
async putBook(): Promise<void> { /* stub */ }
109110
async getBook(): Promise<undefined> { return undefined; }
110111
async putVolume(): Promise<void> { /* stub */ }

0 commit comments

Comments
 (0)