jatin.blog ~ $
$ cat ai-engineering/temporal-reasoning-provenance.md

Temporal Reasoning and Memory Provenance

Temporal memory retrieval with as-of queries, staleness scoring, and evidence provenance.

Jatin Bansal@blog:~/ai-engineering$ open temporal-reasoning-provenance

A retrieval policy can rank an old claim highly even after a later event invalidates it. Temporal reasoning determines when a claim was valid, while provenance records the episodes and derived claims that support it. Both are required for historical queries, contradiction handling, and source explanations.

The as-of query; temporal intent classification

A lightweight intent classifier extracts the query’s reference time before retrieval. Common temporal intents include:

Explicit absolute time; “what did we decide on March 15?” The reference time is 2026-03-15. The retrieval filters by valid_from ≤ 2026-03-15 ≤ valid_to, drops the recency term entirely (α = 0), and may also filter by transaction_time ≤ as-of-date if the user is asking about historical system belief rather than historical world state.

Explicit relative time; “what did we decide three months ago?” Resolve to absolute (now - 90d) and proceed as above. The trap is locale-dependence: “yesterday” at 23:55 means a different date in UTC than in PST; the classifier should resolve relative times against the user’s locale or fail explicitly. The Test of Time benchmark (Fatemi et al., 2024) measures exactly this kind of relative-time resolution and finds that LLMs lose 23-35% accuracy when shifting from “in 2020” to “4 years ago,” even though the two refer to the same absolute date; the classifier carries non-trivial difficulty and should be measured.

Implicit temporal intent; “what’s the user’s job?” carries no explicit time but reads against a fact that has known turnover. The classifier needs to recognize the fact category (job, manager, address, preferences-known-to-change) and resolve the reference time to “current”; which means the filter is valid_from ≤ now ≤ valid_to, dropping any fact whose valid_to is in the past. Without the classifier, an old “user works at Acme” fact and the current “user works at Beta Corp” fact both retrieve, and the rerank picks whichever has higher cosine similarity to the query.

Before/after queries; “what did we believe before the pivot?” requires identifying the pivot event in the timeline and filtering by valid_from ≤ pivot_time. The pivot itself is typically an episode in the store; the classifier resolves “before the pivot” to “valid_to < pivot_episode.ts” by walking back to find the canonical episode that the user is referencing.

No temporal intent; most queries fall here. The default is “as of now,” and the temporal filter is permissive (valid_from ≤ now ≤ valid_to) but doesn’t override the rerank’s recency weighting. The query proceeds through normal retrieval; the temporal filter just prunes facts whose validity windows have explicitly expired.

The implementation is a tiered classifier: cheap regex catches explicit dates and relative-time phrases; a small-model call (Haiku-class, ~50ms) catches the implicit and before/after cases; if both miss, fall through to “no temporal intent.” Mem0’s temporal-intent flag and Zep’s valid_at parameter both expose the resolved reference time to downstream retrieval, and both report measurable wins on temporal-RAG benchmarks (the ChronoQA and TEMPRAGEVAL datasets are the published yardsticks).

Staleness in retrieval

The retrieval-policies article blended three signals: recency, importance, similarity. Temporal-aware retrieval adds a fourth: staleness, the inverse of “the fact is believed to still be true as of the query’s reference time.”

The signal is binary in the strict version (stale or not) and continuous in the soft version (probability the fact is still current). The strict version is a gate: a stale fact gets score = 0 regardless of other signals. The soft version is a down-weight: a fact whose validity is about to expire (or whose category has high turnover) gets multiplied by a freshness ∈ [0, 1] factor. Both are defensible; the right choice depends on the fact category.

Strict gating for tagged-with-validity facts. Any fact with an explicit valid_to timestamp is gated: if valid_to < query_reference_time, exclude. The knowledge-graph world has this for free; every edge carries the interval. The vector-memory world has to add it as metadata on writes, which most retrofitted systems don’t do, which is why the staleness failure mode is more visible in vector-only stacks than in graph-augmented ones.

Soft down-weighting for category-typed facts without explicit validity. A fact tagged as "job_title" has no valid_to written at ingest time, but the category has a known half-life; average job tenure is roughly 4 years. A "food_preference" fact has a half-life closer to 5-10 years. A "name" fact (the user’s own name) is effectively immortal. The down-weight is exp(-Δt / category_half_life), exactly the same shape as the recency decay but tuned per category instead of globally. The signal is empirically powerful; the temporal-validity literature finds that LLM-based fact-retrieval systems with category-conditional staleness gates outperform uniform-decay systems by 6-15% on temporal benchmarks.

The verification boost. A fact that’s been re-confirmed by the user recently (the last_verified clock) gets a freshness boost; freshness *= 1 + δ · 1[recently_verified]. The signal captures the behavioral component of staleness: a fact the user re-mentioned last week is current regardless of when it was first ingested. The mechanic shows up in MemoryBank’s read-driven salience update (the Ebbinghaus-curve-inspired pattern from the retrieval-policies piece) and applies to staleness for the same reason it applies to importance: reactivation is evidence.

The contradiction penalty. A fact that has been explicitly contradicted by a later episode; the user said “I switched stacks” after the agent previously believed “user uses Postgres”; gets freshness = 0 even if its valid_to is null. The contradiction-detection step is itself an LLM call (pattern-match for “X used to be Y but is now Z” or “actually, X” or “we pivoted on X”); the Zep/Graphiti contradiction-handling pipeline is the cleanest documented example, and the memory conflict and forgetting article works the full ADD/UPDATE/DELETE/NOOP resolver pattern in detail. The mechanic is the agent-memory port of the stamp-the-row-as-deleted-but-keep-it pattern from event-sourced systems: the contradicted fact stays in the store (for audit) but is excluded from retrieval.

The combined scoring formula:

text
1
score = (α·recency + β·importance + γ·similarity) · staleness_gate · freshness_factor

Where staleness_gate is binary (0 or 1) and freshness_factor ∈ [0, 1] captures the soft signals. The multiplicative structure is intentional: a stale fact (gate = 0) is excluded regardless of the rest, while a borderline-stale fact (freshness = 0.3) is down-weighted but still retrievable if the other signals are strong enough.

Provenance; the back-pointer chain

The provenance chain is an inverse index over writes: every higher-order claim carries source_episode_ids, and reverse lookup finds dependent claims. It supports these operations:

Walk-down: claim → sources. Given a returned fact, list the episodes that produced it. The chain is read at answer-rendering time; when the agent quotes “the user is vegetarian,” the harness can append “(based on episodes from March 3, March 17, and April 2)” or expose the source IDs through an API for downstream UI. The shape is exactly the citation pattern every production RAG system already ships, ported to memory: don’t return a fact without its sources.

Walk-up: source → dependents. Given an episode that’s been corrected or invalidated, find the higher-order claims that derived from it. The chain is read at write time when a contradiction lands; the contradiction-detection step marks the new episode as superseding the old, and the propagation pass walks every reflection/summary that cited the old episode and either revalidates or marks-as-stale each one. Without the walk-up, contradictions are content-local; the old episode is stamped invalid, but the reflection built on it is still confidently citable in retrieval. The chain is what makes contradictions systemic.

Walk-graph: claim → claim. A reflection cites episodes; a meta-reflection cites reflections; the chain can be arbitrarily deep. The walk-graph operation is a transitive closure over the back-pointer graph and gets called when the agent is asked “explain why you believe this” or when a debugging pass is reconstructing the reasoning behind a confident-but-wrong answer. The Generative Agents paper does this implicitly through its reflection tree; production frameworks like Letta and Mem0 expose it as an API.

The discipline that separates a useful provenance chain from a heavyweight audit fixture is bounded depth. A reflection chain that grows linearly with session count becomes expensive to walk, expensive to render, and (worse) expensive to invalidate. The mitigation is depth-bounded provenance; each claim points back at most 2-3 layers, with deeper provenance reachable via the sleep-time consolidator but not surfaced by default. The full chain stays in the durable store for audit; the runtime walk is shallow.

Code: Python; as-of retrieval with provenance walk

The smallest interesting build: a retrieval pass that takes a query plus a reference time, filters by bi-temporal validity, computes the four-signal score (recency + importance + similarity + staleness/freshness), and returns each hit with its provenance chain attached. Substrate: Chroma for vectors, a sidecar SQLite table for the validity intervals and provenance edges (because Chroma’s metadata filtering doesn’t support range queries on multiple keys efficiently). Install: pip install chromadb.

python
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# pip install chromadb
import math
import sqlite3
import time
import uuid
from dataclasses import dataclass

import chromadb

chroma = chromadb.PersistentClient(path="./memory_store")
episodes = chroma.get_or_create_collection("episodes")

# Sidecar SQLite for bi-temporal metadata and provenance edges.
meta = sqlite3.connect("memory_meta.db")
meta.executescript("""
CREATE TABLE IF NOT EXISTS facts (
  id TEXT PRIMARY KEY,
  user TEXT NOT NULL,
  category TEXT,
  text TEXT NOT NULL,
  importance REAL DEFAULT 0.5,
  valid_from REAL NOT NULL,
  valid_to REAL,             -- NULL = open-ended (still believed true)
  transaction_time REAL NOT NULL,
  last_verified REAL,
  superseded_by TEXT         -- ID of contradicting fact, if any
);
CREATE TABLE IF NOT EXISTS provenance (
  claim_id TEXT NOT NULL,
  source_episode_id TEXT NOT NULL,
  PRIMARY KEY (claim_id, source_episode_id)
);
CREATE INDEX IF NOT EXISTS provenance_source_idx
  ON provenance(source_episode_id);
""")


# Category-conditional half-lives (seconds). Tuned empirically per workload.
CATEGORY_HALF_LIFE = {
    "name": float("inf"),       # immortal
    "food_preference": 5 * 365 * 86_400,
    "job_title": 4 * 365 * 86_400,
    "address": 3 * 365 * 86_400,
    "preference": 2 * 365 * 86_400,
    "event": 30 * 86_400,
    "fact": 365 * 86_400,
}


@dataclass
class Policy:
    recall_k: int = 30
    final_n: int = 5
    alpha: float = 1.0
    beta: float = 1.0
    gamma: float = 1.0
    recency_half_life_s: float = 14 * 86_400
    verification_boost: float = 0.3   # δ in the freshness formula


def _decay(age_s: float, half_life_s: float) -> float:
    if half_life_s == float("inf"):
        return 1.0
    return math.pow(0.5, age_s / half_life_s)


def _minmax(xs: list[float]) -> list[float]:
    if not xs:
        return []
    lo, hi = min(xs), max(xs)
    if hi - lo < 1e-9:
        return [0.5 for _ in xs]
    return [(x - lo) / (hi - lo) for x in xs]


def retrieve_as_of(
    user: str,
    query: str,
    reference_time: float | None = None,   # None = "as of now"
    system_time: float | None = None,      # None = "current system belief"
    policy: Policy | None = None,
) -> list[dict]:
    """As-of retrieval with bi-temporal filter and provenance."""
    policy = policy or Policy()
    now = time.time()
    ref = reference_time if reference_time is not None else now
    sys = system_time if system_time is not None else now

    # ----- 1. Coarse recall: cosine top-K, tenant-scoped -----
    hits = episodes.query(
        query_texts=[query],
        n_results=policy.recall_k,
        where={"user": user},
    )
    if not hits["ids"][0]:
        return []

    ids = hits["ids"][0]
    distances = hits["distances"][0]
    similarity_raw = [1.0 / (1.0 + d) for d in distances]

    # ----- 2. Sidecar bi-temporal lookup -----
    placeholders = ",".join("?" * len(ids))
    rows = meta.execute(
        f"SELECT id, category, text, importance, valid_from, valid_to, "
        f"transaction_time, last_verified, superseded_by "
        f"FROM facts WHERE id IN ({placeholders})",
        ids,
    ).fetchall()
    by_id = {r[0]: r for r in rows}

    scored: list[dict] = []
    recency_raw: list[float] = []
    importance_raw: list[float] = []
    freshness_raw: list[float] = []
    keep: list[int] = []

    for i, eid in enumerate(ids):
        row = by_id.get(eid)
        if row is None:
            continue
        (
            _id,
            category,
            text,
            importance,
            valid_from,
            valid_to,
            txn_time,
            last_verified,
            superseded_by,
        ) = row

        # ----- 3. Hard staleness gate (bi-temporal filter) -----
        # Business-time check: was the fact valid at the query's reference time?
        if valid_from > ref:
            continue   # fact wasn't true yet
        if valid_to is not None and valid_to < ref:
            continue   # fact was no longer true
        # System-time check: did the system know about the fact at the requested
        # system time? (Used for "what did we believe on X?" audits.)
        if txn_time > sys:
            continue
        # Contradiction gate.
        if superseded_by is not None and sys >= _txn_time_of(superseded_by):
            continue

        # ----- 4. Per-signal raw values -----
        recency_raw.append(_decay(now - txn_time, policy.recency_half_life_s))
        importance_raw.append(importance)
        # Category-conditional freshness, scaled by verification boost.
        half_life = CATEGORY_HALF_LIFE.get(category, 365 * 86_400)
        fresh = _decay(ref - (valid_from or txn_time), half_life)
        if last_verified is not None:
            time_since_verify = ref - last_verified
            if time_since_verify < 30 * 86_400:
                fresh *= 1 + policy.verification_boost
        freshness_raw.append(min(fresh, 1.0))
        scored.append({"id": eid, "text": text, "row": row})
        keep.append(i)

    if not scored:
        return []

    # ----- 5. Min-max normalize and blend -----
    recency = _minmax(recency_raw)
    importance = _minmax(importance_raw)
    similarity = _minmax([similarity_raw[i] for i in keep])
    # freshness stays in [0, 1] absolute, not min-max'd (it's a gate-shaped signal)

    for j, item in enumerate(scored):
        base = (
            policy.alpha * recency[j]
            + policy.beta * importance[j]
            + policy.gamma * similarity[j]
        )
        item["score"] = base * freshness_raw[j]

    scored.sort(key=lambda x: x["score"], reverse=True)
    top = scored[: policy.final_n]

    # ----- 6. Attach provenance chain (depth 1 by default) -----
    for item in top:
        sources = meta.execute(
            "SELECT source_episode_id FROM provenance WHERE claim_id = ?",
            (item["id"],),
        ).fetchall()
        item["provenance"] = [s[0] for s in sources]

    return top


def _txn_time_of(fact_id: str) -> float:
    row = meta.execute(
        "SELECT transaction_time FROM facts WHERE id = ?", (fact_id,)
    ).fetchone()
    return row[0] if row else float("inf")


# ----- Write-side helpers -----
def write_fact(
    user: str,
    text: str,
    category: str,
    valid_from: float,
    valid_to: float | None = None,
    importance: float = 0.5,
    sources: list[str] | None = None,
) -> str:
    fid = str(uuid.uuid4())
    now = time.time()
    episodes.add(
        ids=[fid], documents=[text], metadatas=[{"user": user}]
    )
    meta.execute(
        "INSERT INTO facts (id, user, category, text, importance, valid_from, "
        "valid_to, transaction_time, last_verified) "
        "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
        (fid, user, category, text, importance, valid_from, valid_to, now, now),
    )
    for sid in sources or []:
        meta.execute(
            "INSERT OR IGNORE INTO provenance (claim_id, source_episode_id) "
            "VALUES (?, ?)",
            (fid, sid),
        )
    meta.commit()
    return fid


def supersede(old_id: str, new_text: str, category: str, **kwargs) -> str:
    """Write a new fact that supersedes an old one. Old fact stays in store."""
    now = time.time()
    new_id = write_fact(text=new_text, category=category, **kwargs)
    meta.execute(
        "UPDATE facts SET valid_to = COALESCE(valid_to, ?), superseded_by = ? "
        "WHERE id = ?",
        (now, new_id, old_id),
    )
    meta.commit()
    return new_id

The staleness gate excludes facts whose validity interval does not cover the reference time. Business time and transaction time are checked separately for historical-belief queries. Freshness then down-weights aging facts that remain valid. Provenance walks run only for the final top-N results, and supersede closes the old validity interval while linking to the replacement fact.