Skip to content
Draft
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
2 changes: 2 additions & 0 deletions src/app/runtime-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ export interface CoreRuntimeConfig {
queryAugmentationMinSimilarity: number;
queryExpansionEnabled: boolean;
queryExpansionMinSimilarity: number;
recencyBinBoostEnabled: boolean;
recencyBinBoostWeight: number;
repairConfidenceFloor: number;
repairDeltaThreshold: number;
repairLoopEnabled: boolean;
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ export interface RuntimeConfig {
literalListProtectionMaxProtected: number;
temporalQueryConstraintEnabled: boolean;
temporalQueryConstraintBoost: number;
recencyBinBoostEnabled: boolean;
recencyBinBoostWeight: number;
deferredAudnEnabled: boolean;
deferredAudnBatchSize: number;
compositeGroupingEnabled: boolean;
Expand Down Expand Up @@ -374,6 +376,8 @@ export const config: RuntimeConfig = {
literalListProtectionMaxProtected: parsePositiveIntEnv('LITERAL_LIST_PROTECTION_MAX_PROTECTED', 3),
temporalQueryConstraintEnabled: (optionalEnv('TEMPORAL_QUERY_CONSTRAINT_ENABLED') ?? 'false') === 'true',
temporalQueryConstraintBoost: parseFloat(optionalEnv('TEMPORAL_QUERY_CONSTRAINT_BOOST') ?? '2'),
recencyBinBoostEnabled: (optionalEnv('RECENCY_BIN_BOOST_ENABLED') ?? 'false') === 'true',
recencyBinBoostWeight: parseFloat(optionalEnv('RECENCY_BIN_BOOST_WEIGHT') ?? '0.10'),
deferredAudnEnabled: (optionalEnv('DEFERRED_AUDN_ENABLED') ?? 'false') === 'true',
deferredAudnBatchSize: parseInt(optionalEnv('DEFERRED_AUDN_BATCH_SIZE') ?? '20', 10),
compositeGroupingEnabled: (optionalEnv('COMPOSITE_GROUPING_ENABLED') ?? 'true') === 'true',
Expand Down Expand Up @@ -514,6 +518,8 @@ export const INTERNAL_POLICY_CONFIG_FIELDS = [
'literalListProtectionEnabled', 'literalListProtectionMaxProtected',
// Temporal query selection
'temporalQueryConstraintEnabled', 'temporalQueryConstraintBoost',
// Recency-bin boost (EXP-12)
'recencyBinBoostEnabled', 'recencyBinBoostWeight',
// Fast AUDN
'fastAudnEnabled', 'fastAudnDuplicateThreshold',
// Observation / deferred
Expand Down
2 changes: 2 additions & 0 deletions src/db/repository-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export const RESERVED_METADATA_KEYS = new Set<string>([
'clarification_note',
'target_memory_id',
'contradiction_confidence',
// Recency bin breadcrumb — `src/services/memory-storage.ts` (EXP-12)
'recency_bin',
]);

/**
Expand Down
149 changes: 149 additions & 0 deletions src/services/__tests__/recency-bin-ranking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Unit tests for the recency-bin boost stage (EXP-12).
*/

import { describe, expect, it } from 'vitest';
import { applyRecencyBinBoost } from '../recency-bin-ranking.js';
import { createSearchResult } from './test-fixtures.js';

const NOW = new Date('2026-04-29T12:00:00.000Z');

function aged(id: string, ageMs: number, score: number) {
return createSearchResult({
id,
score,
similarity: score,
created_at: new Date(NOW.getTime() - ageMs),
observed_at: new Date(NOW.getTime() - ageMs),
});
}

describe('applyRecencyBinBoost', () => {
it('boosts the matching bin and re-sorts ties', () => {
const candidates = [
aged('old', 100 * 86_400_000, 1.0),
aged('hours', 2 * 3_600_000, 0.95),
aged('minutes', 5 * 60_000, 0.9),
];

const result = applyRecencyBinBoost({
query: 'what did I do recently',
candidates,
weight: 0.5,
referenceTime: NOW,
currentStateTriggered: false,
});

expect(result.applied).toBe(true);
expect(result.queryBin).toBe('1d');
expect(result.results[0]?.id).toBe('hours');
});

it('is a no-op when the flag is off (caller skips by not calling)', () => {
// The pipeline gates on `recencyBinBoostEnabled` before calling. This
// test asserts the function still preserves order when called with
// weight 0, which the pipeline can use as a defense-in-depth fallback.
const candidates = [
aged('a', 5 * 60_000, 0.9),
aged('b', 100 * 86_400_000, 0.8),
];
const result = applyRecencyBinBoost({
query: 'recently',
candidates,
weight: 0,
referenceTime: NOW,
currentStateTriggered: false,
});
expect(result.applied).toBe(false);
expect(result.results.map((r) => r.id)).toEqual(['a', 'b']);
});

it('returns no-op when the query bin is not inferable', () => {
const candidates = [
aged('a', 5 * 60_000, 0.9),
aged('b', 5 * 86_400_000, 0.8),
];
const result = applyRecencyBinBoost({
query: 'what database does the project use',
candidates,
weight: 0.5,
referenceTime: NOW,
currentStateTriggered: false,
});
expect(result.applied).toBe(false);
expect(result.queryBin).toBe(null);
expect(result.results.map((r) => r.id)).toEqual(['a', 'b']);
});

it('short-circuits when current-state-ranking already triggered', () => {
const candidates = [
aged('a', 5 * 60_000, 0.9),
aged('b', 5 * 86_400_000, 0.8),
];
const result = applyRecencyBinBoost({
query: 'recently',
candidates,
weight: 0.5,
referenceTime: NOW,
currentStateTriggered: true,
});
expect(result.applied).toBe(false);
expect(result.results.map((r) => r.id)).toEqual(['a', 'b']);
});

it('applies configured weight on exact match and adjacent bins', () => {
const candidates = [
aged('exact', 12 * 3_600_000, 1.0), // 12h ⇒ 1d bin (adjacent to 10h? 10h max=36M ms; 12*3.6M=43.2M ⇒ 1d)
aged('adjacent', 5 * 3_600_000, 1.0), // 5h ⇒ 10h bin (adjacent to 1d)
aged('far', 1 * 60_000, 1.0), // 1m bin (non-adjacent)
];
const result = applyRecencyBinBoost({
query: 'yesterday',
candidates,
weight: 0.4,
referenceTime: NOW,
currentStateTriggered: false,
});

expect(result.applied).toBe(true);
expect(result.queryBin).toBe('1d');
const byId = new Map(result.results.map((r) => [r.id, r.score]));
// Exact-match (1d) gets full weight; adjacent (10h) gets half; far (1m) gets nothing.
expect(byId.get('exact')).toBeCloseTo(1.4, 5);
expect(byId.get('adjacent')).toBeCloseTo(1.2, 5);
expect(byId.get('far')).toBeCloseTo(1.0, 5);
});

it('recomputes bins from created_at, ignoring stale persisted hints', () => {
// Fact stored with metadata.recency_bin='1m' but actually 5 days old.
const stale = createSearchResult({
id: 'stale',
score: 1.0,
created_at: new Date(NOW.getTime() - 5 * 86_400_000),
metadata: { recency_bin: '1m' },
});
const result = applyRecencyBinBoost({
query: 'last week',
candidates: [stale],
weight: 1.0,
referenceTime: NOW,
currentStateTriggered: false,
});
expect(result.applied).toBe(true);
expect(result.queryBin).toBe('10d');
// 5 days ⇒ '10d' bin ⇒ exact match ⇒ +1.0.
expect(result.results[0]?.score).toBeCloseTo(2.0, 5);
});

it('handles empty candidates without throwing', () => {
const result = applyRecencyBinBoost({
query: 'recently',
candidates: [],
weight: 0.5,
referenceTime: NOW,
currentStateTriggered: false,
});
expect(result.applied).toBe(false);
expect(result.results).toEqual([]);
});
});
81 changes: 81 additions & 0 deletions src/services/__tests__/temporal-fingerprint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Unit tests for log-spaced recency bin assignment (EXP-12).
*
* Bin ladder: 1m | 10m | 1h | 10h | 1d | 10d | 100d | older.
* Bins are an upper-bound classification — exactly-on-the-boundary ages
* land in the corresponding bin, ages just past the boundary land in the
* next rung.
*/

import { describe, expect, it } from 'vitest';
import {
assignRecencyBin,
computeBinAffinity,
RECENCY_BIN_LABELS,
type RecencyBin,
} from '../temporal-fingerprint.js';

const NOW = new Date('2026-04-29T12:00:00.000Z');

function ageMs(ms: number): Date {
return new Date(NOW.getTime() - ms);
}

describe('assignRecencyBin — bin boundary table', () => {
const cases: ReadonlyArray<{ label: string; ageMs: number; expected: RecencyBin }> = [
{ label: 'now (0ms)', ageMs: 0, expected: '1m' },
{ label: '1 minute exactly', ageMs: 60_000, expected: '1m' },
{ label: 'just past 1m (61s)', ageMs: 61_000, expected: '10m' },
{ label: '9 minutes', ageMs: 9 * 60_000, expected: '10m' },
{ label: '10 minutes exactly', ageMs: 10 * 60_000, expected: '10m' },
{ label: '11 minutes', ageMs: 11 * 60_000, expected: '1h' },
{ label: '59 minutes', ageMs: 59 * 60_000, expected: '1h' },
{ label: '1 hour exactly', ageMs: 3_600_000, expected: '1h' },
{ label: 'just past 1h', ageMs: 3_600_001, expected: '10h' },
{ label: '10 hours exactly', ageMs: 36_000_000, expected: '10h' },
{ label: '23 hours', ageMs: 23 * 3_600_000, expected: '1d' },
{ label: '1 day exactly', ageMs: 86_400_000, expected: '1d' },
{ label: 'just past 1 day', ageMs: 86_400_001, expected: '10d' },
{ label: '9 days', ageMs: 9 * 86_400_000, expected: '10d' },
{ label: '10 days exactly', ageMs: 10 * 86_400_000, expected: '10d' },
{ label: '11 days', ageMs: 11 * 86_400_000, expected: '100d' },
{ label: '99 days', ageMs: 99 * 86_400_000, expected: '100d' },
{ label: '100 days exactly', ageMs: 100 * 86_400_000, expected: '100d' },
{ label: '101 days', ageMs: 101 * 86_400_000, expected: 'older' },
{ label: '1 year', ageMs: 365 * 86_400_000, expected: 'older' },
];

for (const c of cases) {
it(c.label, () => {
expect(assignRecencyBin(ageMs(c.ageMs), NOW)).toBe(c.expected);
});
}

it('clamps future-dated facts to the youngest bin', () => {
const future = new Date(NOW.getTime() + 60_000);
expect(assignRecencyBin(future, NOW)).toBe('1m');
});
});

describe('computeBinAffinity', () => {
it('exact match returns 1', () => {
expect(computeBinAffinity('1h', '1h')).toBe(1);
});

it('adjacent bins return 0.5', () => {
expect(computeBinAffinity('1h', '10h')).toBe(0.5);
expect(computeBinAffinity('10h', '1h')).toBe(0.5);
expect(computeBinAffinity('100d', 'older')).toBe(0.5);
expect(computeBinAffinity('1m', '10m')).toBe(0.5);
});

it('non-adjacent bins return 0', () => {
expect(computeBinAffinity('1m', '1h')).toBe(0);
expect(computeBinAffinity('1h', '1d')).toBe(0);
expect(computeBinAffinity('1m', 'older')).toBe(0);
});

it('exposes the canonical bin order for callers', () => {
expect(RECENCY_BIN_LABELS).toEqual(['1m', '10m', '1h', '10h', '1d', '10d', '100d', 'older']);
});
});
8 changes: 7 additions & 1 deletion src/services/memory-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { generateL1Overview } from './tiered-context.js';
import { emitAuditEvent } from './audit-events.js';
import { derivePersistedClaimSlot } from './memory-crud.js';
import { emitLineageEvent } from './memory-lineage.js';
import { assignRecencyBin } from './temporal-fingerprint.js';
import type {
AudnFactContext,
ClaimTarget,
Expand Down Expand Up @@ -89,11 +90,16 @@ export async function storeProjection(

const overview = generateL1Overview(fact.fact);
const network = fact.network ?? classifyNetwork(fact as any).network;
const ingestNow = new Date();
const createdAt = options.logicalTimestamp ?? ingestNow;
const recencyBin = assignRecencyBin(createdAt, ingestNow);
const baseMetadata: Record<string, unknown> = { recency_bin: recencyBin };
if (options.cmoId) baseMetadata.cmo_id = options.cmoId;
const memoryId = await deps.stores.memory.storeMemory({
userId, content: fact.fact, embedding,
memoryType: fact.type === 'knowledge' ? 'semantic' : 'episodic',
importance: fact.importance, sourceSite, sourceUrl, episodeId,
metadata: options.cmoId ? { cmo_id: options.cmoId } : undefined,
metadata: baseMetadata,
keywords: fact.keywords.join(' '),
namespace: namespace ?? undefined,
summary: fact.headline,
Expand Down
69 changes: 69 additions & 0 deletions src/services/recency-bin-ranking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Log-spaced recency-bin boost stage (EXP-12).
*
* Reads the inferred query bin from `temporal-query-expansion.inferQueryBin`,
* recomputes each candidate's bin from `result.created_at` against the
* provided reference time, and adds `weight * computeBinAffinity(...)` to
* each result's `score`. Re-sorts and returns the new order.
*
* Recomputation is deliberate: persisted `metadata.recency_bin` is a debug
* breadcrumb only — it goes stale the moment a fact ages past its bin
* boundary. We always recompute from `created_at` at retrieval time so
* the boost matches the query's "feel" of recency at the moment of the
* search call.
*
* The stage is wired in `search-pipeline.ts` after `applyCurrentStateRanking`
* and short-circuits when current-state-ranking already triggered, to
* avoid double-counting two recency-flavored signals on the same query.
*/

import type { SearchResult } from '../db/repository-types.js';
import { assignRecencyBin, computeBinAffinity, type RecencyBin } from './temporal-fingerprint.js';
import { inferQueryBin } from './temporal-query-expansion.js';

export interface RecencyBinBoostInput {
query: string;
candidates: SearchResult[];
weight: number;
referenceTime: Date;
/**
* When `applyCurrentStateRanking.triggered` is true the current-state
* stage has already added a recency-flavored signal; layering the bin
* boost on top double-counts. The pipeline passes that flag through so
* this stage can no-op cleanly.
*/
currentStateTriggered: boolean;
}

export interface RecencyBinBoostResult {
applied: boolean;
queryBin: RecencyBin | null;
results: SearchResult[];
}

const NO_OP = (candidates: SearchResult[], queryBin: RecencyBin | null): RecencyBinBoostResult => ({
applied: false,
queryBin,
results: candidates,
});

export function applyRecencyBinBoost(input: RecencyBinBoostInput): RecencyBinBoostResult {
const { query, candidates, weight, referenceTime, currentStateTriggered } = input;
if (currentStateTriggered) return NO_OP(candidates, null);
if (candidates.length === 0) return NO_OP(candidates, null);
if (!Number.isFinite(weight) || weight === 0) return NO_OP(candidates, null);

const queryBin = inferQueryBin(query, referenceTime);
if (queryBin === null) return NO_OP(candidates, queryBin);

const rescored = candidates
.map((result) => {
const factBin = assignRecencyBin(result.created_at, referenceTime);
const affinity = computeBinAffinity(queryBin, factBin);
if (affinity === 0) return result;
return { ...result, score: result.score + weight * affinity };
})
.sort((left, right) => right.score - left.score);

return { applied: true, queryBin, results: rescored };
}
Loading
Loading