From 9362f52722a2e264572e9ace5d35641050db8292 Mon Sep 17 00:00:00 2001 From: Philippe Mortelette Date: Tue, 28 Apr 2026 17:29:49 -0400 Subject: [PATCH 1/2] feat: expose atomicmemory search score semantics --- .../__tests__/atomicmemory-provider.test.ts | 17 ++++++++++++++--- .../__tests__/fixtures/search-fast.mapped.json | 10 ++++++++-- .../__tests__/fixtures/search-fast.raw.json | 6 ++++++ .../__tests__/fixtures/search.mapped.json | 10 ++++++++-- .../__tests__/fixtures/search.raw.json | 6 ++++++ .../__tests__/namespace-base-routes.test.ts | 17 +++++++++++++---- .../__tests__/to-search-result.fixtures.test.ts | 12 ++++++++++++ .../atomicmemory-provider.ts | 3 +++ src/memory/atomicmemory-provider/handle-impl.ts | 13 +++++++++++-- src/memory/atomicmemory-provider/handle.ts | 10 ++++++++-- src/memory/atomicmemory-provider/mappers.ts | 11 ++++++++++- src/memory/types.ts | 15 ++++++++++----- 12 files changed, 109 insertions(+), 21 deletions(-) diff --git a/src/memory/__tests__/atomicmemory-provider.test.ts b/src/memory/__tests__/atomicmemory-provider.test.ts index 51b52c2..04028a4 100644 --- a/src/memory/__tests__/atomicmemory-provider.test.ts +++ b/src/memory/__tests__/atomicmemory-provider.test.ts @@ -110,12 +110,19 @@ describe('search', () => { const provider = createProvider(); mockFetch.mockResolvedValueOnce( jsonResponse({ - memories: [{ id: 's1', content: 'fact', score: 0.95 }], + memories: [{ + id: 's1', + content: 'fact', + semantic_similarity: 0.84, + ranking_score: 1.25, + relevance: 0.84, + score: 1.25, + }], count: 1, }) ); - const request: SearchRequest = { query: 'test', scope: VALID_SCOPE, limit: 5 }; + const request: SearchRequest = { query: 'test', scope: VALID_SCOPE, limit: 5, threshold: 0.8 }; const page = await provider.search(request); const [url, init] = mockFetch.mock.calls[0]; @@ -125,8 +132,12 @@ describe('search', () => { expect(body.query).toBe('test'); expect(body.user_id).toBe('u1'); expect(body.limit).toBe(5); + expect(body.threshold).toBe(0.8); expect(page.results).toHaveLength(1); - expect(page.results[0].score).toBe(0.95); + expect(page.results[0].score).toBe(1.25); + expect(page.results[0].similarity).toBe(0.84); + expect(page.results[0].rankingScore).toBe(1.25); + expect(page.results[0].relevance).toBe(0.84); expect(page.results[0].memory.id).toBe('s1'); }); }); diff --git a/src/memory/atomicmemory-provider/__tests__/fixtures/search-fast.mapped.json b/src/memory/atomicmemory-provider/__tests__/fixtures/search-fast.mapped.json index 20e9641..f3af381 100644 --- a/src/memory/atomicmemory-provider/__tests__/fixtures/search-fast.mapped.json +++ b/src/memory/atomicmemory-provider/__tests__/fixtures/search-fast.mapped.json @@ -14,7 +14,10 @@ "importance": 0.6 } }, - "score": 4.3938328690927975 + "score": 4.3938328690927975, + "similarity": 0.14691642279927353, + "rankingScore": 4.3938328690927975, + "relevance": 0.14691642279927353 }, { "memory": { @@ -31,6 +34,9 @@ "importance": 0.6 } }, - "score": 4.06119768667802 + "score": 4.06119768667802, + "similarity": 0.6055988308430426, + "rankingScore": 4.06119768667802, + "relevance": 0.6055988308430426 } ] diff --git a/src/memory/atomicmemory-provider/__tests__/fixtures/search-fast.raw.json b/src/memory/atomicmemory-provider/__tests__/fixtures/search-fast.raw.json index 260d3d6..b24f98a 100644 --- a/src/memory/atomicmemory-provider/__tests__/fixtures/search-fast.raw.json +++ b/src/memory/atomicmemory-provider/__tests__/fixtures/search-fast.raw.json @@ -10,7 +10,10 @@ "id": "FIXTURE-MEM-2", "content": "user's passport expires in March 2027.", "similarity": 0.14691642279927353, + "semantic_similarity": 0.14691642279927353, "score": 4.3938328690927975, + "ranking_score": 4.3938328690927975, + "relevance": 0.14691642279927353, "importance": 0.6, "source_site": "fixture-quick-ingest", "created_at": "2026-04-24T10:00:00.000Z" @@ -19,7 +22,10 @@ "id": "FIXTURE-MEM-1", "content": "User prefers aisle seats on flights longer than four hours.", "similarity": 0.6055988308430426, + "semantic_similarity": 0.6055988308430426, "score": 4.06119768667802, + "ranking_score": 4.06119768667802, + "relevance": 0.6055988308430426, "importance": 0.6, "source_site": "fixture-full-ingest", "created_at": "2026-04-24T10:00:00.000Z" diff --git a/src/memory/atomicmemory-provider/__tests__/fixtures/search.mapped.json b/src/memory/atomicmemory-provider/__tests__/fixtures/search.mapped.json index 31c6484..30211ce 100644 --- a/src/memory/atomicmemory-provider/__tests__/fixtures/search.mapped.json +++ b/src/memory/atomicmemory-provider/__tests__/fixtures/search.mapped.json @@ -14,7 +14,10 @@ "importance": 0.6 } }, - "score": 4.393832796958154 + "score": 4.393832796958154, + "similarity": 0.14691642279927353, + "rankingScore": 4.393832796958154, + "relevance": 0.14691642279927353 }, { "memory": { @@ -31,6 +34,9 @@ "importance": 0.6 } }, - "score": 4.061197412672656 + "score": 4.061197412672656, + "similarity": 0.6055988308430426, + "rankingScore": 4.061197412672656, + "relevance": 0.6055988308430426 } ] diff --git a/src/memory/atomicmemory-provider/__tests__/fixtures/search.raw.json b/src/memory/atomicmemory-provider/__tests__/fixtures/search.raw.json index 4c1e7a1..8eef97d 100644 --- a/src/memory/atomicmemory-provider/__tests__/fixtures/search.raw.json +++ b/src/memory/atomicmemory-provider/__tests__/fixtures/search.raw.json @@ -10,7 +10,10 @@ "id": "FIXTURE-MEM-2", "content": "user's passport expires in March 2027.", "similarity": 0.14691642279927353, + "semantic_similarity": 0.14691642279927353, "score": 4.393832796958154, + "ranking_score": 4.393832796958154, + "relevance": 0.14691642279927353, "importance": 0.6, "source_site": "fixture-quick-ingest", "created_at": "2026-04-24T10:00:00.000Z" @@ -19,7 +22,10 @@ "id": "FIXTURE-MEM-1", "content": "User prefers aisle seats on flights longer than four hours.", "similarity": 0.6055988308430426, + "semantic_similarity": 0.6055988308430426, "score": 4.061197412672656, + "ranking_score": 4.061197412672656, + "relevance": 0.6055988308430426, "importance": 0.6, "source_site": "fixture-full-ingest", "created_at": "2026-04-24T10:00:00.000Z" diff --git a/src/memory/atomicmemory-provider/__tests__/namespace-base-routes.test.ts b/src/memory/atomicmemory-provider/__tests__/namespace-base-routes.test.ts index 18935f5..f4b0609 100644 --- a/src/memory/atomicmemory-provider/__tests__/namespace-base-routes.test.ts +++ b/src/memory/atomicmemory-provider/__tests__/namespace-base-routes.test.ts @@ -205,6 +205,7 @@ describe('atomicmemory.search', () => { const request: AtomicMemorySearchRequest = { query: 'q', limit: 5, + threshold: 0.72, asOf: new Date('2026-04-01T00:00:00Z'), retrievalMode: 'flat', configOverride: { hybridSearchEnabled: true, mmrLambda: 0.8 }, @@ -217,6 +218,7 @@ describe('atomicmemory.search', () => { user_id: 'u1', query: 'q', limit: 5, + threshold: 0.72, as_of: '2026-04-01T00:00:00.000Z', retrieval_mode: 'flat', config_override: { hybridSearchEnabled: true, mmrLambda: 0.8 }, @@ -570,7 +572,7 @@ describe('list() rejects user-scope-only options on workspace scope', () => { }); describe('search result field population', () => { - it('populates similarity + importance + score on each result (not just memory.metadata)', async () => { + it('populates explicit score semantics on each result (not just memory.metadata)', async () => { mockFetch.mockResolvedValueOnce( jsonResponse({ count: 1, @@ -580,7 +582,10 @@ describe('search result field population', () => { id: 'm1', content: 'x', similarity: 0.42, - score: 1.23, + semantic_similarity: 0.43, + score: 1.11, + ranking_score: 1.23, + relevance: 0.43, importance: 0.7, }, ], @@ -590,11 +595,13 @@ describe('search result field population', () => { const page = await handle.search({ query: 'q' }, USER_SCOPE); const result = page.results[0]; expect(result.score).toBe(1.23); - expect(result.similarity).toBe(0.42); + expect(result.similarity).toBe(0.43); + expect(result.rankingScore).toBe(1.23); + expect(result.relevance).toBe(0.43); expect(result.importance).toBe(0.7); }); - it('leaves similarity and importance undefined when core omits them', async () => { + it('leaves explicit optional fields undefined when core omits them', async () => { mockFetch.mockResolvedValueOnce( jsonResponse({ count: 1, @@ -605,6 +612,8 @@ describe('search result field population', () => { const handle = createHandle(); const page = await handle.search({ query: 'q' }, USER_SCOPE); expect(page.results[0].similarity).toBeUndefined(); + expect(page.results[0].rankingScore).toBe(0.5); + expect(page.results[0].relevance).toBeUndefined(); expect(page.results[0].importance).toBeUndefined(); expect(page.results[0].score).toBe(0.5); }); diff --git a/src/memory/atomicmemory-provider/__tests__/to-search-result.fixtures.test.ts b/src/memory/atomicmemory-provider/__tests__/to-search-result.fixtures.test.ts index e26bab1..240122b 100644 --- a/src/memory/atomicmemory-provider/__tests__/to-search-result.fixtures.test.ts +++ b/src/memory/atomicmemory-provider/__tests__/to-search-result.fixtures.test.ts @@ -27,7 +27,10 @@ interface RawSearchMemory { id: string; content: string; similarity?: number; + semantic_similarity?: number; score?: number; + ranking_score?: number; + relevance?: number; importance?: number; source_site?: string; created_at?: string; @@ -69,6 +72,15 @@ describe.each(fixtures)('toSearchResult — fixture replay ($label)', ({ raw, ma } }); + it('semantic contract: explicit score semantics are exposed when core emits them', () => { + for (const row of rawResponse.memories) { + const result = toSearchResult(row, SCOPE); + expect(result.similarity).toBe(row.semantic_similarity ?? row.similarity); + expect(result.rankingScore).toBe(row.ranking_score ?? row.score); + expect(result.relevance).toBe(row.relevance); + } + }); + it('semantic contract: result.memory.id and .content are set', () => { for (const row of rawResponse.memories) { const result = toSearchResult(row, SCOPE); diff --git a/src/memory/atomicmemory-provider/atomicmemory-provider.ts b/src/memory/atomicmemory-provider/atomicmemory-provider.ts index 8ff8eef..f8c0215 100644 --- a/src/memory/atomicmemory-provider/atomicmemory-provider.ts +++ b/src/memory/atomicmemory-provider/atomicmemory-provider.ts @@ -138,6 +138,7 @@ export class AtomicMemoryProvider user_id: request.scope.user, query: request.query, limit: request.limit, + threshold: request.threshold, namespace_scope: request.scope.namespace, }; @@ -292,6 +293,7 @@ export class AtomicMemoryProvider user_id: request.scope.user, query: request.query, limit: request.limit, + threshold: request.threshold, namespace_scope: request.scope.namespace, retrieval_mode: mapPackageFormat(request.format), token_budget: request.tokenBudget, @@ -330,6 +332,7 @@ export class AtomicMemoryProvider user_id: request.scope.user, query: request.query, limit: request.limit, + threshold: request.threshold, as_of: request.asOf.toISOString(), namespace_scope: request.scope.namespace, }; diff --git a/src/memory/atomicmemory-provider/handle-impl.ts b/src/memory/atomicmemory-provider/handle-impl.ts index 681f6f7..9f82fc0 100644 --- a/src/memory/atomicmemory-provider/handle-impl.ts +++ b/src/memory/atomicmemory-provider/handle-impl.ts @@ -297,6 +297,7 @@ async function postSearch( query: request.query, }; if (request.limit !== undefined) body.limit = request.limit; + if (request.threshold !== undefined) body.threshold = request.threshold; if (request.asOf) body.as_of = request.asOf.toISOString(); if (request.retrievalMode) body.retrieval_mode = request.retrievalMode; if (request.tokenBudget !== undefined) body.token_budget = request.tokenBudget; @@ -321,7 +322,10 @@ interface RawMemoryResponse { id: string; content?: string; similarity?: number; + semantic_similarity?: number; score?: number; + ranking_score?: number; + relevance?: number; importance?: number; source_site?: string; source_url?: string; @@ -395,11 +399,16 @@ function toAtomicMemorySearchResult( raw: RawMemoryResponse, scope: MemoryScope, ): AtomicMemorySearchResult { + const similarity = raw.semantic_similarity ?? raw.similarity; + const rankingScore = raw.ranking_score ?? raw.score; + const relevance = raw.relevance; const result: AtomicMemorySearchResult = { memory: toAtomicMemoryMemory(raw, scope), - score: raw.score ?? raw.similarity ?? 0, + score: rankingScore ?? similarity ?? 0, }; - if (raw.similarity !== undefined) result.similarity = raw.similarity; + if (similarity !== undefined) result.similarity = similarity; + if (rankingScore !== undefined) result.rankingScore = rankingScore; + if (relevance !== undefined) result.relevance = relevance; if (raw.importance !== undefined) result.importance = raw.importance; return result; } diff --git a/src/memory/atomicmemory-provider/handle.ts b/src/memory/atomicmemory-provider/handle.ts index 25c051b..1ca1c49 100644 --- a/src/memory/atomicmemory-provider/handle.ts +++ b/src/memory/atomicmemory-provider/handle.ts @@ -81,6 +81,8 @@ export interface AtomicMemoryIngestInput { export interface AtomicMemorySearchRequest { query: string; limit?: number; + /** Normalized relevance floor forwarded to core's `threshold` request field. */ + threshold?: number; /** Temporal filter. Honored by `/memories/search` full path; NOT by fast. */ asOf?: Date; retrievalMode?: 'flat' | 'tiered' | 'abstract-aware'; @@ -126,10 +128,14 @@ export interface AtomicMemoryMemory { export interface AtomicMemorySearchResult { memory: AtomicMemoryMemory; - /** Composite ranking score from core's retrieval pipeline. */ + /** Backward-compatible alias for `rankingScore` when core emits it. */ score: number; - /** Raw cosine similarity (when emitted by core). */ + /** Semantic/vector similarity when emitted by core. */ similarity?: number; + /** Composite ranking/debug score from core's retrieval pipeline. Not normalized. */ + rankingScore?: number; + /** Normalized injection relevance in [0, 1]. */ + relevance?: number; /** AtomicMemory's 0–1 importance weighting on the source memory. */ importance?: number; } diff --git a/src/memory/atomicmemory-provider/mappers.ts b/src/memory/atomicmemory-provider/mappers.ts index f69162f..00d6c1b 100644 --- a/src/memory/atomicmemory-provider/mappers.ts +++ b/src/memory/atomicmemory-provider/mappers.ts @@ -20,7 +20,10 @@ interface RawMemory { id: string; content: string; similarity?: number; + semantic_similarity?: number; score?: number; + ranking_score?: number; + relevance?: number; importance?: number; source_site?: string; /** Present on list responses; not on search responses today. */ @@ -102,9 +105,15 @@ function buildMetadata(raw: RawMemory): Memory['metadata'] { } export function toSearchResult(raw: RawMemory, scope: Scope): SearchResult { + const similarity = raw.semantic_similarity ?? raw.similarity; + const rankingScore = raw.ranking_score ?? raw.score; + const relevance = raw.relevance; return { memory: toMemory(raw, scope), - score: raw.score ?? raw.similarity ?? 0, + score: rankingScore ?? similarity ?? 0, + ...(similarity !== undefined ? { similarity } : {}), + ...(rankingScore !== undefined ? { rankingScore } : {}), + ...(relevance !== undefined ? { relevance } : {}), }; } diff --git a/src/memory/types.ts b/src/memory/types.ts index d885ab0..e6b4ec6 100644 --- a/src/memory/types.ts +++ b/src/memory/types.ts @@ -123,13 +123,18 @@ export interface SearchRequest { export interface SearchResult { memory: Memory; /** - * Raw backend score, passed through without transformation. - * Semantics depend on the provider: - * - Mem0 OSS (local): distance-like — lower is better (0 = exact match). - * - Mem0 hosted: similarity-like — higher is better. - * Consumers that need a uniform semantic should apply their own normalization. + * Backward-compatible provider score. + * For AtomicMemory this is the composite ranking score (`rankingScore`) and + * is not normalized. New consumers should prefer the explicit fields below. + * Other providers preserve their historical score semantics. */ score: number; + /** Semantic/vector similarity when the provider exposes it. Higher is better. */ + similarity?: number; + /** Composite ranking/debug score. Not guaranteed to be normalized. */ + rankingScore?: number; + /** Normalized injection relevance in [0, 1], suitable for threshold checks. */ + relevance?: number; } export interface SearchResultPage { From 8c1c3d9c3ad6fad200fdebe1155424574f18eb39 Mon Sep 17 00:00:00 2001 From: Philippe Mortelette Date: Tue, 28 Apr 2026 18:02:12 -0400 Subject: [PATCH 2/2] fix: use stable fallow pre-commit base --- .husky/pre-commit | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index e9a37dc..1634950 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,14 +1,12 @@ #!/usr/bin/env sh # Fallow gate: block commits that introduce new dead code, complexity, # or duplication beyond the frozen baselines in .fallow/. Mirrors CI's -# --base selection by following origin/HEAD (the repo's actual default -# branch), so the hook adapts if the default changes and doesn't -# silently diverge from the CI step's `github.base_ref || -# default_branch` fallback. Non-default-target PRs (e.g. a branch -# targeting staging while origin defaults to main) will diff -# differently here than CI will — that's inherent to pre-commit since -# the eventual PR target isn't known yet. If origin/ is -# behind, run `git fetch` first. +# --base selection by using origin/main by default. Set FALLOW_BASE_REF +# for an intentionally different local target. The hook intentionally +# does not trust origin/HEAD because local clones can cache it to a +# branch unrelated to this repo's PR target, which makes fallow fail +# before it can audit the staged change. If origin/main is behind, run +# `git fetch` first. # Regenerate Istanbul coverage first so CRAP scores match the baseline # (baseline was saved with coverage enabled; running audit without # coverage would produce false regressions). `pnpm test:coverage` @@ -17,8 +15,13 @@ # To lower a baseline, refactor the flagged code and regenerate: # pnpm dlx fallow health --save-baseline=.fallow/health-baseline.json # pnpm dlx fallow dupes --save-baseline=.fallow/dupes-baseline.json -BASE=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null) -BASE=${BASE:-origin/main} +BASE=${FALLOW_BASE_REF:-origin/main} +if ! git rev-parse --verify "$BASE" >/dev/null 2>&1 || ! git merge-base HEAD "$BASE" >/dev/null 2>&1; then + echo "fallow pre-commit: '$BASE' is not available or has no merge-base with HEAD." >&2 + echo "fallow pre-commit: run 'git fetch origin' or set FALLOW_BASE_REF to a valid base ref." >&2 + exit 1 +fi + pnpm test:coverage FALLOW_COVERAGE=coverage/coverage-final.json pnpm dlx fallow audit \ --health-baseline=.fallow/health-baseline.json \