Skip to content
Open
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
23 changes: 13 additions & 10 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -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/<default> 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`
Expand All @@ -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 \
Expand Down
17 changes: 14 additions & 3 deletions src/memory/__tests__/atomicmemory-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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');
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
"importance": 0.6
}
},
"score": 4.3938328690927975
"score": 4.3938328690927975,
"similarity": 0.14691642279927353,
"rankingScore": 4.3938328690927975,
"relevance": 0.14691642279927353
},
{
"memory": {
Expand All @@ -31,6 +34,9 @@
"importance": 0.6
}
},
"score": 4.06119768667802
"score": 4.06119768667802,
"similarity": 0.6055988308430426,
"rankingScore": 4.06119768667802,
"relevance": 0.6055988308430426
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
"importance": 0.6
}
},
"score": 4.393832796958154
"score": 4.393832796958154,
"similarity": 0.14691642279927353,
"rankingScore": 4.393832796958154,
"relevance": 0.14691642279927353
},
{
"memory": {
Expand All @@ -31,6 +34,9 @@
"importance": 0.6
}
},
"score": 4.061197412672656
"score": 4.061197412672656,
"similarity": 0.6055988308430426,
"rankingScore": 4.061197412672656,
"relevance": 0.6055988308430426
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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 },
Expand Down Expand Up @@ -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,
Expand All @@ -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,
},
],
Expand All @@ -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,
Expand All @@ -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);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/memory/atomicmemory-provider/atomicmemory-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
Expand Down
13 changes: 11 additions & 2 deletions src/memory/atomicmemory-provider/handle-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
10 changes: 8 additions & 2 deletions src/memory/atomicmemory-provider/handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
11 changes: 10 additions & 1 deletion src/memory/atomicmemory-provider/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 } : {}),
};
}

Expand Down
15 changes: 10 additions & 5 deletions src/memory/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading