feat(core): productize first-mention events + TLL EO read-path#18
feat(core): productize first-mention events + TLL EO read-path#18
Conversation
Adds the TLL primitive — a per-entity sparse graph of event nodes with
predecessor/successor edges. Each new memory referencing an entity
appends an event node to that entity's chain; the predecessor pointer
allows traversal of the chain backward at query time.
Targets the abilities current SOTA architectures admit they don't crack
at scale: temporal reasoning (TR), event ordering (EO), and multi-
session reasoning (MSR). These require higher-order representations of
how events relate across time — fact-level and entity-level matching
are insufficient on their own.
Implementation:
- schema.sql: new table temporal_linkage_list with composite uniqueness
on (user_id, entity_id, memory_id), predecessor pointer,
position_in_chain, and supporting indexes on (user_id, entity_id,
position_in_chain) and (memory_id).
- db/repository-tll.ts: TllRepository with append() (idempotent batch
insert with predecessor wiring) and chain()/chainsFor() readers.
- services/memory-storage.ts: append after entity link in
resolveAndLinkEntities (best-effort, fire-and-forget — keeps the
ingest hot path fast).
- services/tll-retrieval.ts: shouldUseTLL() regex gate over ordering/
temporal query phrasing, plus expandViaTLL() helper that takes the
top-N retrieval candidates, finds their linked entities, and pulls
the entities' chain memory_ids.
- services/memory-search.ts: when the gate fires, expand the candidate
set with chain members (deterministic chain-traversal augmentation,
not a similarity search). Fail-open — chain expansion errors don't
block primary retrieval.
- app/runtime-container.ts: instantiate TllRepository when entity
graph is enabled.
- services/memory-service-types.ts + memory-service.ts: thread the
repository through MemoryServiceDeps as an optional null-able field.
Read-only retrieval augmentation; AUDN/ingest behaviour unchanged.
…rimitive
Introduces a new memory primitive — first-mention events — distinct from
both atomic facts (claims) and memories (ingested chunks). For a given
conversation, captures the first turn at which each topic is brought up.
The grain matches event-ordering rubrics that ask "in what order did the
user bring up these aspects."
Caller-driven extraction (no in-core ingest hook): the in-core ingest
pipeline does not retain turn structure (it extracts atomic facts from
chunks, not turns). External callers that know the turn structure
supply a turn-id-to-memory-id mapping via the public API. This keeps
extraction explicit and the core ingest pipeline unchanged. An
automatic post-write hook is deferred until a core-side notion of "turn"
exists.
Implementation:
- schema.sql: new table first_mention_events with `(user_id, memory_id)`
unique constraint for idempotent re-extraction. Indexed on
`(user_id, position_in_conversation)` and on `topic` via GIN.
- db/repository-first-mentions.ts: FirstMentionRepository with
`store()`, `getByMemoryId()`, `list()`. Mirrors the TllRepository
pattern.
- services/first-mention-service.ts: FirstMentionService with
`extractAndStore(userId, conversationText, sourceSite,
memoryIdsByTurnId)`. One LLM call via an injected ChatFn; salvage
parser tolerates truncated JSON; loose LLM output is mapped to the
strict FirstMentionEvent schema.
- app/runtime-container.ts: instantiate the repository + service. The
ChatFn adapter wraps the configured `llm.chat` singleton (per-call
cost is tracked inside `llm.chat`).
- services/memory-service-types.ts + memory-service.ts: thread the
service through MemoryServiceDeps as an optional null-able field.
The HTTP endpoint that exposes this primitive is added in a follow-up
commit alongside the TLL read endpoint.
Exposes the first-mention-events and Temporal Linkage List primitives
through public HTTP endpoints. Both primitives existed inside the runtime
container (previous commits) but had no callable surface; this commit
adds the routes, schemas, public service methods, and tests.
## TLL EO read endpoint
- `GET /v1/memories/event-chains?user_id=X&entity_ids=Y,Z` returns
`{ chains: [{ entity_id, events: [...] }] }`. `entity_ids` is
comma-separated, trimmed, deduplicated, and validated as UUIDs.
- `db/repository-tll.ts` adds `chainEventsForEntities(userId,
entityIds)` — enriched events joined with memory content
(memoryId, content, observationDate, positionInChain,
predecessorMemoryId). Soft-deleted memories are filtered out;
entities with no events are dropped.
- `services/memory-service.ts` adds public `getEventChains()` wrapper.
- `schemas/memories.ts`: `EventChainsQuerySchema`.
- `schemas/responses.ts`: `EventChainsResponseSchema`.
- `routes/response-schema-map.ts`: corresponding entry.
## First-mention extract endpoint
- `POST /v1/memories/first-mentions/extract` body:
`{ user_id, conversation_text, source_site, memory_ids_by_turn_id }`
where `memory_ids_by_turn_id` is `{ "0": "uuid", "5": "uuid", ... }`
(object form because JSON has no Map). Returns `{ events: [...] }`.
- `services/memory-service.ts` adds public `extractFirstMentions()`
wrapper around `FirstMentionService.extractAndStore()`.
- `schemas/memories.ts`: `FirstMentionsExtractBodySchema` (transforms
the object into a `Map<number, string>`).
- `schemas/responses.ts`: `FirstMentionsExtractResponseSchema`.
- `routes/response-schema-map.ts`: corresponding entry.
Both endpoints fail closed (404/400 on validation) and additive — no
existing behaviour changes.
## Tests
- `services/__tests__/tll-retrieval.test.ts` — 26 cases covering
`shouldUseTLL` regex coverage (positive + negative + case
insensitivity), `entitiesForMemories` SQL-shape verification, and
`expandViaTLL` call ordering / 10-id slice / userId pass-through.
- `db/__tests__/repository-tll.test.ts` — 13 integration tests
against test Postgres covering `append` idempotency + predecessor
wiring, `chain` and `chainsFor` ordering, and
`chainEventsForEntities` enriched-join + soft-delete filtering.
- `services/__tests__/first-mention-service.test.ts` — 9 unit tests
covering happy path, salvage of truncated JSON, garbage-text
fallback, non-array JSON, chatFn throw, missing `memoryId` mapping
drop, schema validation drop, anchor_date parsing
(valid/invalid/null), ascending sort. No DB required.
The previously-needed fallow suppression on `entitiesForMemories` is
removed now that the unit test consumes it.
Code reviewRead all 18 changed files end-to-end. Tests look thoughtful (26 + 13 + 9 cases) and the architectural shape (repository → service → route, threading both new deps through Critical1.
The fire-and-forget call site in Fix options:
2. Hardcoded Trace: Net effect:
In either case the score is noise, not a signal. TLL membership is a different retrieval signal and shouldn't share the similarity gate. Suggest one of:
3.
Fix: pass the memory's Important4. Hard-delete paths exist ( 5. No HTTP-level tests for either new endpoint Schema validation (missing 6. A request with thousands of UUIDs generates a single 7.
Minor8. Silent error catching at three sites
The fail-open behavior may be deliberate, but per CLAUDE.md "no silent error catching" / "no fallback modes," at minimum: route through the structured logger rather than 9. Bare 10. 11. Hardcoded Findings #1–#3 are blocking; #4–#7 are important to address before merge or in a fast-follow. Strong shape overall — the architectural primitives (TLL, first-mention) are well-isolated, repositories are thin, and the test files demonstrate the intended invariants clearly. Just need the concurrency, scoring, and timestamp issues nailed down before the read-path goes live. |
…eview #1) Replace the read-then-insert pattern in TllRepository.append() with an atomic INSERT...SELECT serialized by pg_advisory_xact_lock keyed on (user_id, entity_id). Concurrent appends to the same entity chain now serialize on the lock and compute predecessor + position from the latest committed row, eliminating the TOCTOU race where two parallel callers read the same MAX(position_in_chain) and both wrote at tip+1. Add a UNIQUE (user_id, entity_id, position_in_chain) index as defense-in-depth so any future code path that bypasses the lock fails loudly at the DB layer rather than silently producing duplicate positions. Add an integration test that fires three concurrent appends to the same chain via Promise.all and asserts positions 0,1,2 with correctly wired predecessor pointers.
The previous flow ran TLL chain augmentation inside executeSearchStep and tagged hydrated rows with `similarity: 0.5` so they could pass through applySearchRelevanceFilter. That magic constant either filtered chain rows out (when the threshold was higher) or polluted ranking with a meaningless score. Move TLL augmentation AFTER postProcessResults / applySearchRelevanceFilter. Hydrated rows now carry `similarity: null` and `retrieval_signal: 'tll-chain'`, so they ride around the similarity gate entirely — chain-membership is a structurally different retrieval signal than semantic similarity. The trace adds a `tll-augmentation` stage with the ids that were appended. Replace the `slice(0, 10)` magic number with a named constant (TLL_SEED_CANDIDATE_COUNT).
…eview #3) The TLL append in resolveAndLinkEntities was passing `new Date()` as observation_date, which is the ingest-arrival time. Chains order by observation_date ASC, so out-of-order or backfilled conversations would chain by ingest order — destroying conversation chronology that EO and MSR queries rely on. Thread the caller-supplied logicalTimestamp through from storeCanonicalFact to the new maybeAppendTll helper. When a logical timestamp isn't supplied, look up the just-stored memory's observed_at column rather than fabricating one. Last-resort new Date() fallback only fires if the row lookup fails — keeps the append from silently dropping.
Hard-deletes (e.g. resetBySource in repository-write) silently nulled out predecessor_memory_id pointers, breaking backward chain traversal and leaving half-broken chains where some events still reference a deleted ancestor. Change the predecessor FK to ON DELETE CASCADE so the dependent chain node gets removed cleanly when its predecessor goes. Matches the memory_id FK policy on the same table. Schema migration runs an idempotent DROP CONSTRAINT / ADD CONSTRAINT in a DO block because CREATE TABLE IF NOT EXISTS won't update column-level FK definitions on an existing table.
The /v1/memories/event-chains endpoint fans out per entity. Without an upper bound on entity_ids, a single caller could pull tens of thousands of chain rows in one request — straightforward amplification target. Add a MAX_ENTITY_IDS_PER_REQUEST = 100 named constant and refine the schema to reject larger lists with a 400 response. Cap chosen to match the existing MAX_SEARCH_LIMIT ceiling.
|
Thanks for the detailed review. Addressed in commits dc7b9ff, 91676f4, 4020135, d6fa8ed, 63efbea (initial round) and 58c3095, 623ac96, 9eacd79, 36701b0, 35dc36a, c46cddf (fast-follow round). Blockers (fixed)
Important (fixed)
Fast-follow round (now also fixed)
Pre-commit status (this push)
PR is now ready for re-review. |
Same root cause as benchmarks repo `327326a`: git sets GIT_INDEX_FILE before invoking pre-commit hooks; fallow's `git worktree add` for base-ref scanning performs a checkout that writes to GIT_INDEX_FILE, corrupting the main worktree's index by replacing it with the base ref's tree (silently deletes files the feature branch added that don't exist on base, then commits those deletions). Reproduced during the PR #18 review-response work — 7 unrelated files briefly marked deleted in the index after a fallow run; had to recover with git reset --soft + git read-tree HEAD. Fix: unset both vars at the top of the hook so nested git invocations run against the worktree's own default index.
…iew #11) The `chatFn` adapter wired in `runtime-container.ts` returned hardcoded zero token counts for every call. `LLMProvider.chat()` returns `Promise<string>` (no usage), so threading real counts here would require widening that interface across every adapter. Nothing in the `FirstMentionService` path actually consumed the fields — they only existed to satisfy the local `ChatResult` shape — so dropping them is strictly safer than leaving misleading zeros in place. Per-call cost telemetry continues to flow from `LLMProvider.chat` -> `writeCostEvent` unchanged. Updated: - `ChatResult` in `first-mention-service.ts` -> `{ text: string }` only, with a comment documenting the deliberate decision. - `runtime-container.ts` adapter no longer fabricates zero usage. - `first-mention-service.test.ts` fixture updated to match.
The same magic 10 lived in two places: `tll-retrieval.ts:expandViaTLL` sliced its input ids before entity lookup, and `memory-search.ts` re-declared a private `TLL_SEED_CANDIDATE_COUNT = 10` for the same purpose. Defined the constant once in `tll-retrieval.ts` and re-used it from both call sites so a tuning change can't drift between them. Updated the unit test to reference the exported constant directly instead of asserting against the literal 10.
…#9) The original gate was a single alternation regex that fired on any single occurrence of `first|last|before|after|then|later|track|...`. That over-fired on plain factual queries that incidentally contained one of those tokens — `what is my first name`, `the model used before GPT-4`, `track my spending` — pulling in unrelated TLL chain memories on the augmented retrieval path. Replaced the gate with a two-tier check: 1. ORDERING_TERMS_RE — a curated set of single-token signals (first/last/before/after/then/later/earlier/previous/next/prior). Only fires TLL when TWO co-occur, e.g. "what aspects did I discuss BEFORE and AFTER X". 2. SEQUENCE_PATTERNS — phrase-level structural signals (`in (chronological/reverse/the) order`, `when did`, `since when`, `over time`, `evolution of`, `history|timeline of`, `originally`/`initially`, `progression of`, `how X evolved/shifted/changed`, `brought up`). Single phrase hit is enough. Removed `track`, `sequence`, and bare `order` from the gate — they were the largest false-positive contributors. Updated `src/services/__tests__/tll-retrieval.test.ts`: - Positive list rewritten to canonical EO/MSR/TR shapes that hit one of the structural patterns or co-occurring ordering terms. - Negative list now includes the false-positive shapes the loose regex used to match (the three reviewer-cited ones plus a handful of single-ordering-term factual queries). 41/41 unit tests pass against the updated gate.
…n paths (review #8) Three deliberate fail-open sites were swallowing errors with weak or no log signal, hiding production failures behind ephemeral `console.error('[tll]', ...)` lines and `process.stderr.write` calls that no log scraper greps for. Behaviour stays fail-open by design (per CLAUDE.md "no fallback modes" applies to mutations, not to augmentation paths) — only the observability changes. Changes: 1. `memory-storage.ts:maybeAppendTll` — fire-and-forget TLL append now logs `[tll-append-failed]` with the message and stringified fallback for non-Error throws. Comment documents the deliberate fire-and-forget choice (ingest hot path can't block on chain bookkeeping). 2. `memory-search.ts:maybeExpandViaTLL` — return type widened from `SearchResult[]` to `{ memories, failed, errorMessage? }` so the caller can surface the failure on the retrieval trace. On the catch path: log `[tll-expansion-failed]` and propagate `{ failed: true }`. Added comment marking the fail-open as deliberate. 3. `appendTllAugmentation` — emits a `tll_expansion_failed` event on the active retrieval `TraceCollector` whenever `maybeExpandViaTLL` reports `failed: true`, so the trace artifacts written to disk capture the failure instead of dropping it. 4. `first-mention-service.ts:invokeLlm` and helpers — replaced ad-hoc `process.stderr.write` lines with structured `[first-mention-llm-failed]` / `[first-mention-llm-salvaged]` / `[first-mention-mapping]` prefixes routed through `console.error` / `console.warn`. JSDoc on `invokeLlm` now documents the deliberate fail-open (the EO read path treats no-events as "no signal", not an error). Existing first-mention and tll-retrieval unit tests pass unchanged.
…view #7) `positionInConversation` was set directly to `turn_id`. That looked correct for a single extraction, but the (user_id, memory_id) UNIQUE on `first_mention_events` means a re-run of `extractAndStore` for the same conversation silently keeps the FIRST inserted row — including its position. If the LLM's turn_id assignment drifted between runs (which it does in practice — non-deterministic decoding even at temperature=0 plus prompt-cache-state variation), readers would see position values that depend on which run happened to write first, breaking deterministic chronological ordering. Fix: `positionInConversation` is now the 0-based index in the FINAL turn-id-sorted output, NOT `turn_id` itself. Sort first, then enumerate. Re-runs produce identical (position, topic) tuples regardless of any turn_id drift, so the post-write read is stable. Updated `mapToEvents` in `src/services/first-mention-service.ts`: - Build candidates first (without position). - Sort by `turnId` ASC. - Assign `positionInConversation = index` during the final map. Tests: - `src/services/__tests__/first-mention-service.test.ts`: * existing happy-path / sort tests now assert position 0/1/... instead of position == turn_id. * new `produces stable positionInConversation across re-runs even when LLM turn_id drifts` test runs `extractAndStore` twice with drifted turn_ids and confirms both runs produce the same `[0, 1]` position sequence. - `src/db/__tests__/repository-first-mentions.test.ts` (new): integration test seeding a memory + running `store()` twice with drifted turn_ids — asserts only 2 rows survive, position sequence is `[0, 1]`, and the first-write turn_id (5) is what the read-back returns (ON CONFLICT DO NOTHING semantics).
…xtract (review #5) The two PR #18 read endpoints had no HTTP-level tests — only the underlying repository / service unit tests existed, which left the schema-validation middleware and route-level wiring uncovered. A schema rename or a route-handler regression could ship green. New file `src/routes/__tests__/event-chains-and-first-mentions.test.ts` mirrors the route-test pattern from `src/__tests__/route-validation.test.ts`: spin up an Express app on `app.listen(0, ...)`, wire `createMemoryRouter` against a real `MemoryService` backed by the test DB, drive endpoints via `fetch`. The MemoryService gets a real `FirstMentionRepository` plus a stubbed `chatFn` so the LLM call returns a deterministic JSON array. Coverage: GET /v1/memories/event-chains - 400 when `user_id` is missing - 400 when `entity_ids` is missing - 400 when `entity_ids` contains an invalid UUID - 400 when `entity_ids` exceeds the 100-entry cap (review #6) - 400 when `entity_ids` is present but holds only empty tokens - happy path: seed memory + entity + TLL row, hit the route, parse the response with `EventChainsResponseSchema` POST /v1/memories/first-mentions/extract - 400 when `user_id` is missing - 400 when `conversation_text` is empty - 400 when `conversation_text` exceeds MAX_CONVERSATION_LENGTH (100_000 chars) - 400 when `memory_ids_by_turn_id` is missing entirely - 400 when `source_site` is missing - happy path: stub LLM returns 2 events, route stores+returns them, response parsed with `FirstMentionsExtractResponseSchema`, `position_in_conversation` is the post-sorted [0, 1] sequence from review #7. 12/12 new tests pass.
Code review (v2 — after
|
|
additional review from codex: Findings
|
Squashed across four review rounds on PR #18, all of which surfaced after the initial wave of fixes (#1–#11). Each item below maps to a finding in the PR's review threads. Search path - hydrateChainMemories now returns fully-shaped SearchResults via SELECT * + normalizeMemoryRow. The previous projection set similarity: null and omitted source_site / score / summary / observed_at, crashing the buildInjection formatter (`memory.similarity.toFixed(2)`) the first time TLL augmentation actually fired against a populated chain. The `as unknown as SearchResult` cast was hiding it from tsc. (review v2 #1) - Hydration query now uses `unnest($2::uuid[]) WITH ORDINALITY ... ORDER BY req.ord` so the chronological order chainsFor returns through the augmentation pipeline survives. (review v4 #2) - Workspace isolation: hydrateChainMemories filters `m.workspace_id IS NULL` to match the gate behavior performSearch's postProcessResults already applies. Without it, a workspace memory chained from a global memory's entity could surface in a global response. (review v4 #1) - Defensive `relevance: 1.0` on hydrated rows locks in the chain-membership bypass invariant against future filter drift. The augmented rows are appended after applySearchRelevanceFilter today, but `similarity: 0` + `score: 0` would make them load-bearing on `relevance` if any future filter past appendTllAugmentation checked `memory.relevance >= threshold`. Regression test drives performSearch with a high retrievalOptions.relevanceThreshold and confirms the augmented row survives. (review v5 #2) Repository - TLL chain reads (chain, chainEventsForEntities) now derive chronological position and predecessor via `ROW_NUMBER()` and `LAG()` window functions ordered by observation_date ASC (with stored position_in_chain as a deterministic tiebreaker for events sharing an observation_date). The stored predecessor_memory_id and position_in_chain columns become insertion-order audit metadata; the API surface returns chronological ordering. Backfilled out-of-order events surface in their true position with chronologically-correct predecessors. (review v3 #1) - chainEventsForEntities adds `m.workspace_id IS NULL` for the same reason as the search-path fix above — the global event-chains HTTP endpoint must not surface workspace memories. (review v4 #1) Schema - FirstMentionsExtractBodySchema validates memory_ids_by_turn_id values as UUIDs so a non-UUID returns 400 (schema layer) instead of leaking a Postgres "invalid input syntax for type uuid" as 500 from the route. (review v3 #2) - New SearchResult.retrieval_signal optional field tags chain-augmented rows so observability and any future ranker can distinguish them from similarity-ranked candidates. (review v2 #1, plumbed through v4) Refactor - Extracted maybeExpandViaTLL, hydrateChainMemories, appendTllAugmentation out of memory-search.ts into a sibling tll-augmentation.ts module. The search file dropped from 551 → 385 LOC, back under the 400-LOC project cap. Shared internal types (PostProcessedSearch, RelevanceFilterSummary) pulled into memory-search-types.ts so the two consumers don't duplicate. (review v5 #4) Test coverage - New integration test (services/__tests__/tll-augmentation-integration.test.ts) drives performSearch end-to-end through appendTllAugmentation, with cases for: rendering augmented rows through buildInjection without crashing, the SQL contract (unnest ORDINALITY + ORDER BY req.ord), workspace-leak prevention, the relevance-1.0 bypass invariant, and no-augmentation for non-TLL queries. - New repository tests for backfill chronological ordering of chain() and chainEventsForEntities() and for chainEventsForEntities workspace isolation. - New route test asserts 400 (not 500) on non-UUID memory_ids_by_turn_id. Verification: `npx tsc --noEmit` clean · 1286/1286 vitest pass against the test DB · `npx fallow audit --no-cache` exit 0. Deferrals (parked durably in the research repo's tech-debt log at Atomicmemory-research/docs/core-repo/tech-debt.md): - predecessor_memory_id ON DELETE CASCADE vs SET NULL — design call, contested between two reviewers; current default kept. - process.env.ALLOWED_ORIGINS direct read in src/routes/memories.ts — pre-existing CLAUDE.md violation, out of scope for this PR. - shouldUseTLL non-adjacent "after did" — low operational risk; tightening the regex was the explicit goal of review #9 and re-broadening risks reintroducing false positives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
it's ok @ethanj |
Summary
Productizes two memory primitives — first-mention events and Temporal
Linkage List (TLL) read-path retrieval — that were previously only
available in the eval harness.
This branch is a clean port off
main(vs the existing draft PR #17which targets
experiment/phase2-combined-stackand shares no historywith main).
What's added
GET /v1/memories/event-chains?user_id=X&entity_ids=Y,Z— returnsordered event chains for the given entities (TLL read-path).
POST /v1/memories/first-mentions/extract— extracts thechronological topic-introduction list from a conversation.
first_mention_eventstable + 2 indexes.temporal_linkage_listtable + repository +read-path service.
Behavior changes for callers
npm run migrate.memory-search:when an ordering/temporal query is detected and TLL is wired,
candidates from the entity event chain are appended; errors don't
block primary retrieval.
Test coverage
tll-retrieval.test.ts(shouldUseTLLregex,entitiesForMemoriesSQL shape,expandViaTLLcall ordering /10-id slice / userId pass-through).
repository-tll.test.ts(appendidempotency + predecessor wiring,
chainandchainsForordering,chainEventsForEntitiesenriched-join + soft-delete filtering).first-mention-service.test.ts(happy path, salvageof truncated JSON, garbage-text fallback, non-array JSON, chatFn
throw, missing
memoryIdmapping drop, schema validation drop,anchor_date parsing, ascending sort).
Test plan
npm run migrate:testnpm test(full vitest suite)GET /v1/memories/event-chainsandPOST /v1/memories/first-mentions/extractvia curl.Verification done locally
npx tsc --noEmitclean.npm test— 1253/1253 vitest tests pass against.env.testPostgreson this branch.
npx fallow auditclean (no fatal issues; one duplication warn inthe new test file's small repeated setup blocks, audit gate exits 0).