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
3 changes: 3 additions & 0 deletions src/app/runtime-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ export interface CoreRuntimeConfig {
queryExpansionMinSimilarity: number;
recencyBinBoostEnabled: boolean;
recencyBinBoostWeight: number;
/** EXP-21: per-entity temporal linkage (ingest write + retrieval boost). */
perEntityTemporalLinkageEnabled: boolean;
perEntityTemporalLinkageBoostWeight: number;
repairConfidenceFloor: number;
repairDeltaThreshold: number;
repairLoopEnabled: boolean;
Expand Down
15 changes: 15 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@ export interface RuntimeConfig {
temporalQueryConstraintBoost: number;
recencyBinBoostEnabled: boolean;
recencyBinBoostWeight: number;
/**
* EXP-21: when true, ingest writes per-entity temporal linkage rows for
* every fact stored, and retrieval traverses the per-entity timeline to
* boost candidates by their chronological position. Defaults-off per
* Sprint 2 rule. See `src/services/entity-temporal-linkage.ts`.
*/
perEntityTemporalLinkageEnabled: boolean;
/** EXP-21: additive score boost applied per linkage-list rank. */
perEntityTemporalLinkageBoostWeight: number;
eventBoundaryExtractionEnabled: boolean;
eventBoundaryRetrievalBoost: number;
retrievalConfidenceGateEnabled: boolean;
Expand Down Expand Up @@ -398,6 +407,10 @@ export const config: RuntimeConfig = {
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'),
perEntityTemporalLinkageEnabled:
(optionalEnv('PER_ENTITY_TEMPORAL_LINKAGE_ENABLED') ?? 'false') === 'true',
perEntityTemporalLinkageBoostWeight:
parseFloat(optionalEnv('PER_ENTITY_TEMPORAL_LINKAGE_BOOST_WEIGHT') ?? '0.15'),
eventBoundaryExtractionEnabled: (optionalEnv('EVENT_BOUNDARY_EXTRACTION_ENABLED') ?? 'false') === 'true',
eventBoundaryRetrievalBoost: parseFloat(optionalEnv('EVENT_BOUNDARY_RETRIEVAL_BOOST') ?? '0.4'),
retrievalConfidenceGateEnabled: (optionalEnv('RETRIEVAL_CONFIDENCE_GATE_ENABLED') ?? 'false') === 'true',
Expand Down Expand Up @@ -549,6 +562,8 @@ export const INTERNAL_POLICY_CONFIG_FIELDS = [
'temporalQueryConstraintEnabled', 'temporalQueryConstraintBoost',
// Recency-bin boost (EXP-12)
'recencyBinBoostEnabled', 'recencyBinBoostWeight',
// Per-entity temporal linkage (EXP-21)
'perEntityTemporalLinkageEnabled', 'perEntityTemporalLinkageBoostWeight',
// Event boundary extraction (EXP-13)
'eventBoundaryExtractionEnabled', 'eventBoundaryRetrievalBoost',
// Retrieval confidence gate (EXP-14)
Expand Down
13 changes: 13 additions & 0 deletions src/db/memory-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ import {
type StoreAtomicFactInput,
type StoreForesightInput,
} from './repository-representations.js';
import {
listEntityTemporalLinks,
storeEntityTemporalLinks,
type StoreTemporalLinkInput,
} from './repository-entity-temporal-links.js';
export type {
AgentScope,
AtomicFactRow,
Expand Down Expand Up @@ -299,6 +304,14 @@ export class MemoryRepository {
return listForesightForMemory(this.pool, userId, parentMemoryId);
}

async storeEntityTemporalLinks(links: StoreTemporalLinkInput[]) {
return storeEntityTemporalLinks(this.pool, links);
}

async listEntityTemporalLinks(userId: string, entityId: string, limit: number) {
return listEntityTemporalLinks(this.pool, userId, entityId, limit);
}

async deleteBySource(userId: string, sourceSite: string) {
return deleteBySource(this.pool, userId, sourceSite);
}
Expand Down
9 changes: 9 additions & 0 deletions src/db/pg-representation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import {
type StoreAtomicFactInput,
type StoreForesightInput,
} from './repository-representations.js';
import {
listEntityTemporalLinks,
storeEntityTemporalLinks,
type StoreTemporalLinkInput,
} from './repository-entity-temporal-links.js';

export class PgRepresentationStore implements RepresentationStore {
constructor(private pool: pg.Pool) {}
Expand All @@ -25,4 +30,8 @@ export class PgRepresentationStore implements RepresentationStore {
async listForesightForMemory(userId: string, parentMemoryId: string) { return listForesightForMemory(this.pool, userId, parentMemoryId); }
async replaceAtomicFactsForMemory(userId: string, parentMemoryId: string, facts: StoreAtomicFactInput[]) { return replaceAtomicFactsForMemory(this.pool, userId, parentMemoryId, facts); }
async replaceForesightForMemory(userId: string, parentMemoryId: string, entries: StoreForesightInput[]) { return replaceForesightForMemory(this.pool, userId, parentMemoryId, entries); }
async storeEntityTemporalLinks(links: StoreTemporalLinkInput[]) { return storeEntityTemporalLinks(this.pool, links); }
async listEntityTemporalLinks(userId: string, entityId: string, limit: number) {
return listEntityTemporalLinks(this.pool, userId, entityId, limit);
}
}
81 changes: 81 additions & 0 deletions src/db/repository-entity-temporal-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* EXP-21: Per-entity temporal linkage repository.
*
* Insert one row per (entity, fact) pair so retrieval can walk a sparse
* per-entity timeline ordered by `created_at`. The table is keyed by
* lowercase entity name (TEXT) so the writer doesn't need to consult the
* `entities` table — entity extraction in `extraction.ts` already produces
* canonical names, and we want the linkage to work even when the entity
* graph is disabled.
*/

import type pg from 'pg';

type Queryable = Pick<pg.Pool, 'query'> | pg.PoolClient;

export interface StoreTemporalLinkInput {
userId: string;
entityId: string;
factId: string;
createdAt?: Date;
}

export interface EntityTemporalLinkRow {
fact_id: string;
parent_memory_id: string;
created_at: Date;
}

/** Insert one linkage row per input. Caller dedupes (entity, fact) pairs. */
export async function storeEntityTemporalLinks(
queryable: Queryable,
links: StoreTemporalLinkInput[],
): Promise<number> {
if (links.length === 0) return 0;
let inserted = 0;
for (const link of links) {
const result = await queryable.query(
`INSERT INTO atomic_entity_temporal_links (user_id, entity_id, fact_id, created_at)
VALUES ($1, $2, $3, $4)`,
[
link.userId,
link.entityId,
link.factId,
(link.createdAt ?? new Date()).toISOString(),
],
);
inserted += result.rowCount ?? 0;
}
return inserted;
}

/**
* Fetch the per-entity temporal link list for a single entity. Joined to
* `memory_atomic_facts` to surface the `parent_memory_id`, which is the
* id used as the SearchResult key by the rest of the pipeline.
*
* Ordered by `created_at ASC` so position 0 is the chronologically first
* fact mentioning this entity. Callers that want most-recent-first can
* reverse — the index supports both directions.
*/
export async function listEntityTemporalLinks(
queryable: Queryable,
userId: string,
entityId: string,
limit: number,
): Promise<EntityTemporalLinkRow[]> {
const result = await queryable.query(
`SELECT l.fact_id, f.parent_memory_id, l.created_at
FROM atomic_entity_temporal_links l
JOIN memory_atomic_facts f ON f.id = l.fact_id
WHERE l.user_id = $1 AND l.entity_id = $2
ORDER BY l.created_at ASC
LIMIT $3`,
[userId, entityId, limit],
);
return result.rows.map((row) => ({
fact_id: String(row.fact_id),
parent_memory_id: String(row.parent_memory_id),
created_at: row.created_at instanceof Date ? row.created_at : new Date(row.created_at),
}));
}
30 changes: 30 additions & 0 deletions src/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -432,3 +432,33 @@ CREATE INDEX IF NOT EXISTS idx_memory_atomic_facts_workspace
ON memory_atomic_facts (workspace_id) WHERE workspace_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_memory_foresight_workspace
ON memory_foresight (workspace_id) WHERE workspace_id IS NOT NULL;

-- ---------------------------------------------------------------------------
-- EXP-21: Per-entity temporal linkage list.
--
-- Sparse linked-list per entity sorted by `created_at`. At ingest time, when
-- the per-entity temporal linkage flag is on, every fact stored emits one row
-- per entity it mentions. At retrieval time, the search pipeline walks this
-- list in chronological order to boost facts by their position so event-
-- ordering (BEAM EO) and multi-session reasoning (BEAM MR) queries surface
-- entity-scoped chronology.
--
-- `entity_id` is the lowercase canonical entity name (matching the extraction
-- stage's casing). `fact_id` references `memory_atomic_facts(id)` — the
-- atomic-fact projection produced by the same ingest path. ON DELETE CASCADE
-- keeps the linkage in sync with fact lifecycle (UPDATE/SUPERSEDE deletes
-- replace the projection rows, and so the linkage rows too).
-- ---------------------------------------------------------------------------

CREATE TABLE IF NOT EXISTS atomic_entity_temporal_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
entity_id TEXT NOT NULL,
fact_id UUID NOT NULL REFERENCES memory_atomic_facts(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_atomic_entity_temporal_links_traversal
ON atomic_entity_temporal_links (user_id, entity_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_atomic_entity_temporal_links_fact
ON atomic_entity_temporal_links (fact_id);
5 changes: 5 additions & 0 deletions src/db/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
} from './repository-types.js';
import type { CandidateRow } from './repository-vector-search.js';
import type { StoreAtomicFactInput, StoreForesightInput } from './repository-representations.js';
import type { StoreTemporalLinkInput, EntityTemporalLinkRow } from './repository-entity-temporal-links.js';
import type { MemoryLink } from './repository-links.js';

// StoreMemoryInput is shared with the repository write path; re-exported
Expand Down Expand Up @@ -114,6 +115,10 @@ export interface RepresentationStore {
listForesightForMemory(userId: string, parentMemoryId: string): Promise<ForesightRow[]>;
replaceAtomicFactsForMemory(userId: string, parentMemoryId: string, facts: StoreAtomicFactInput[]): Promise<string[]>;
replaceForesightForMemory(userId: string, parentMemoryId: string, entries: StoreForesightInput[]): Promise<string[]>;
/** EXP-21: append per-entity temporal linkage rows. */
storeEntityTemporalLinks(links: StoreTemporalLinkInput[]): Promise<number>;
/** EXP-21: fetch the per-entity link list ordered by created_at ASC. */
listEntityTemporalLinks(userId: string, entityId: string, limit: number): Promise<EntityTemporalLinkRow[]>;
}

// ---------------------------------------------------------------------------
Expand Down
Loading