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
6 changes: 3 additions & 3 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ These items **must** be completed to have a usable system. Without them, users c

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

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

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

- [ ] **P0-D3:** Add query test coverage
- [x] **P0-D3:** Add query test coverage
- `tests/cortex/Query.test.ts`
- Test happy path (query → top-K pages)
- Test empty corpus (no results)
Expand Down
2 changes: 2 additions & 0 deletions core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ export interface MetadataStore {
// --- Core CRUD ---
putPage(page: Page): Promise<void>;
getPage(pageId: Hash): Promise<Page | undefined>;
/** Returns all pages in the store. Used for warm/cold fallbacks in query. */
getAllPages(): Promise<Page[]>;

putBook(book: Book): Promise<void>;
getBook(bookId: Hash): Promise<Book | undefined>;
Expand Down
167 changes: 167 additions & 0 deletions cortex/Query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import type { ModelProfile } from "../core/ModelProfile";
import type { MetadataStore, Page, VectorStore } from "../core/types";
import type { VectorBackend } from "../VectorBackend";
import type { EmbeddingRunner } from "../embeddings/EmbeddingRunner";
import { runPromotionSweep } from "../core/SalienceEngine";
import type { QueryResult } from "./QueryResult";

export interface QueryOptions {
modelProfile: ModelProfile;
embeddingRunner: EmbeddingRunner;
vectorStore: VectorStore;
metadataStore: MetadataStore;
vectorBackend: VectorBackend;
topK?: number;
}

function dot(a: Float32Array, b: Float32Array): number {
const len = Math.min(a.length, b.length);
let sum = 0;
for (let i = 0; i < len; i++) {
sum += a[i] * b[i];
}
return sum;
}

/**
* Concatenates an array of equal-length vectors into a single flat buffer.
* @param vectors - Must be non-empty; every element must have the same length.
*/
function concatVectors(vectors: Float32Array[]): Float32Array {
const dim = vectors[0].length;
const out = new Float32Array(vectors.length * dim);
for (let i = 0; i < vectors.length; i++) {
out.set(vectors[i], i * dim);
}
return out;
}
Comment on lines +30 to +37

async function scorePages(
queryEmbedding: Float32Array,
pages: Page[],
vectorStore: VectorStore,
vectorBackend: VectorBackend,
maxResults: number,
): Promise<Array<{ page: Page; score: number }>> {
if (pages.length === 0) return [];

const [firstPage] = pages;
const dim = firstPage.embeddingDim;
const offsets = pages.map((p) => p.embeddingOffset);

// If all pages share the same embedding dimension and it matches the query,
// use the vector backend for fast scoring.
const uniformDim = pages.every((p) => p.embeddingDim === dim);
const canUseBackend = uniformDim && queryEmbedding.length === dim;

if (canUseBackend) {
const embeddings = await vectorStore.readVectors(offsets, dim);
const matrix = concatVectors(embeddings);
const scores = await vectorBackend.dotMany(queryEmbedding, matrix, dim, pages.length);
const topk = await vectorBackend.topKFromScores(scores, Math.min(maxResults, pages.length));
return topk.map((r) => ({ page: pages[r.index], score: r.score }));
}

// Fallback: compute dot product per page.
const scored = await Promise.all(
pages.map(async (page) => {
const vec = await vectorStore.readVector(page.embeddingOffset, page.embeddingDim);
return { page, score: dot(queryEmbedding, vec) };
}),
);

scored.sort((a, b) => b.score - a.score || a.page.pageId.localeCompare(b.page.pageId));
return scored.slice(0, Math.min(maxResults, scored.length));
}

export async function query(
queryText: string,
options: QueryOptions,
): Promise<QueryResult> {
const {
modelProfile,
embeddingRunner,
vectorStore,
metadataStore,
vectorBackend,
topK = 10,
} = options;

const nowIso = new Date().toISOString();

const embeddings = await embeddingRunner.embed([queryText]);
if (embeddings.length !== 1) {
throw new Error("Embedding provider returned unexpected number of embeddings");
}
const queryEmbedding = embeddings[0];

// Score resident (hotpath) pages first.
const hotpathEntries = await metadataStore.getHotpathEntries("page");
const hotpathIds = hotpathEntries.map((e) => e.entityId);

const hotpathPages = (await Promise.all(
hotpathIds.map((id) => metadataStore.getPage(id)),
)).filter((p): p is Page => p !== undefined);

const hotpathResults = await scorePages(
queryEmbedding,
hotpathPages,
vectorStore,
vectorBackend,
topK,
);

const seen = new Set(hotpathResults.map((r) => r.page.pageId));

// If we still need more results, score remaining pages (warm/cold).
const remaining = Math.max(0, topK - hotpathResults.length);
const coldResults: Array<{ page: Page; score: number }> = [];

if (remaining > 0) {
const allPages = await metadataStore.getAllPages();
const candidates = allPages.filter((p) => !seen.has(p.pageId));

const scored = await scorePages(
queryEmbedding,
candidates,
vectorStore,
vectorBackend,
remaining,
);

coldResults.push(...scored);
}

const combined = [...hotpathResults, ...coldResults];
combined.sort((a, b) => b.score - a.score);

// Ensure combined results are sorted by descending score for top-K semantics.
combined.sort((a, b) => b.score - a.score);

Comment thread
devlux76 marked this conversation as resolved.
// Update activity for returned pages
await Promise.all(combined.map(async ({ page }) => {
const activity = await metadataStore.getPageActivity(page.pageId);
const updated = {
pageId: page.pageId,
queryHitCount: (activity?.queryHitCount ?? 0) + 1,
lastQueryAt: nowIso,
communityId: activity?.communityId,
};
await metadataStore.putPageActivity(updated);
}));

// Recompute salience and run promotion sweep for pages returned in this query.
await runPromotionSweep(combined.map((r) => r.page.pageId), metadataStore);

return {
pages: combined.map((r) => r.page),
scores: combined.map((r) => r.score),
metadata: {
queryText,
topK,
returned: combined.length,
timestamp: nowIso,
modelId: modelProfile.modelId,
},
};
}
7 changes: 7 additions & 0 deletions cortex/QueryResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { Page } from "../core/types";

export interface QueryResult {
pages: Page[];
scores: number[];
metadata: Record<string, unknown>;
}
5 changes: 3 additions & 2 deletions hippocampus/Chunker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function chunkTextWithMaxTokens(
text: string,
maxChunkTokens: number,
): string[] {
if (!Number.isInteger(maxChunkTokens) || maxChunkTokens <= 0) {
if (!Number.isInteger(maxChunkTokens) || maxChunkTokens <= 0) { // model-derived-ok
throw new Error("maxChunkTokens must be a positive integer");
}

Expand Down Expand Up @@ -51,7 +51,8 @@ export function chunkTextWithMaxTokens(
// Sentence is larger than budget: split it across multiple chunks.
if (sentenceTokens.length > maxChunkTokens) {
pushCurrent();
for (let i = 0; i < sentenceTokens.length; i += maxChunkTokens) {
// model-derived-ok: uses maxChunkTokens as derived from ModelProfile
for (let i = 0; i < sentenceTokens.length; i += maxChunkTokens) { // model-derived-ok
const slice = sentenceTokens.slice(i, i + maxChunkTokens);
chunks.push(slice.join(" "));
}
Expand Down
14 changes: 14 additions & 0 deletions storage/IndexedDbMetadataStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,20 @@ export class IndexedDbMetadataStore implements MetadataStore {
return this._get<Page>(STORE.pages, pageId);
}

/**
* Returns all pages in the store. Used for warm/cold fallbacks in query.
* TODO: Replace with a paginated or indexed scan before production use —
* loading every page into memory is expensive for large corpora.
*/
async getAllPages(): Promise<Page[]> {
return new Promise((resolve, reject) => {
const tx = this.db.transaction(STORE.pages, "readonly");
const req = tx.objectStore(STORE.pages).getAll();
req.onsuccess = () => resolve(req.result as Page[]);
req.onerror = () => reject(req.error);
});
}

// -------------------------------------------------------------------------
// Book CRUD + reverse index maintenance
// -------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions tests/SalienceEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ class MockMetadataStore implements MetadataStore {
// --- Stubs for unused MetadataStore methods ---
async putPage(): Promise<void> { /* stub */ }
async getPage(): Promise<undefined> { return undefined; }
async getAllPages(): Promise<any[]> { return []; }
async putBook(): Promise<void> { /* stub */ }
async getBook(): Promise<undefined> { return undefined; }
async putVolume(): Promise<void> { /* stub */ }
Expand Down
Loading
Loading