Metadata-Version: 2.4
Name: tempora-memory
Version: 0.1.0
Summary: Deterministic temporal operators for AI agent memory: answer previous/count/history questions over conflicting facts, not just 'what is the current value'.
Author: Nitin Gupta
License: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: rank-bm25>=0.2
Provides-Extra: bench
Requires-Dist: pandas>=2.0; extra == "bench"
Requires-Dist: pyarrow>=14; extra == "bench"
Requires-Dist: huggingface_hub>=0.20; extra == "bench"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Dynamic: license-file

<div align="center">

# tempora

**Deterministic temporal operators for AI agent memory.**

*LLMs understand. Code keeps the books.*

[![python](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
[![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)

</div>

---

Agent memory breaks when facts change. Given

```
#0    "User lives in Delhi"
#41   "User lives in Bangalore"
#306  "User lives in Mumbai"
```

ask *"Where did I live before Mumbai?"* and production memory systems guess: on the public
conflict benchmark ([MemoryAgentBench, ICLR 2026](https://github.com/HUST-AI-HYZ/MemoryAgentBench))
**Mem0 scores 18% and Zep/Graphiti 7%** — because every version of the fact lands in the prompt
and one model call must untangle ordering, meaning, and answer at once.

[*Don't Ask the LLM to Track Freshness*](https://arxiv.org/abs/2606.01435) (May 2026) showed the
fix for the simplest case: let the LLM only *find* relevant facts, then pick the newest with
Python `max()` — jumping single-hop accuracy to ~95%. But `max()` answers exactly one question
shape. Real questions about changing facts have four:

| question | operator | resolver |
|---|---|---|
| "Where do I live?" | `current` | `timeline[-1]` |
| "Where did I live **before**?" | `previous` | `timeline[-2]` |
| "**How many** cities have I lived in?" | `count` | `len(timeline)` |
| "List **everywhere** I've lived" | `history` | `", ".join(timeline)` |

tempora implements the missing three, plus the one-call router that picks the right one.

## Architecture

```
question ─────────────────────────────────────────────┐
   │                                                  │
   ▼                                                  ▼
route()                 LLM sees only the question:   extract()   LLM sees facts + question:
"what KIND of           returns one label             "which facts are about this
question is this?"      current|previous|             subject?" Told explicitly NOT to
                        count|history                 judge recency. Returns ALL versions.
   │                                                  │
   └────────────────────┬─────────────────────────────┘
                        ▼
                   resolve()          pure Python: sort candidates by serial,
                                      dedupe, apply the one-line operator.
                                      No LLM. Cannot hallucinate ordering,
                                      cannot miscount, fully auditable.
```

Two constrained LLM calls for understanding; zero LLM involvement in the temporal logic.

## Results

Both arms below share identical BM25 retrieval and identical candidate extraction — the only
difference is whether the deterministic step knows more operations than `max()`. Questions are
generated mechanically from MemoryAgentBench's own fact conflicts, so gold answers are computed
from data, never by an LLM.

| operator | n | newest-only baseline | **tempora** | router accuracy |
|---|--:|--:|--:|--:|
| current  | 12 | 83% | 83% | 100% |
| previous | 12 | 8% | **83%** | 92% |
| count    | 12 | 0% | **83%** | 100% |
| history  | 12 | 0% | **83%** | 100% |
| **total** | **48** | **23%** | **83%** | |

The residual gap is candidate-extraction misses on a small open model, not resolver error — the
resolvers are pure functions with unit tests.

We also reproduce the freshness paper's single-hop pipeline at **91%**
(`openai/gpt-oss-120b`, n=100, 6k context), against published memory-system baselines of
54% (HippoRAG-v2), 18% (Mem0), and 7% (Zep/Graphiti).

## Install

```bash
pip install git+https://github.com/nitininhouse/tempora.git
```

Core has a single dependency (`rank-bm25`, used only by the benchmarks). No LLM SDK: you pass
any callable `(system: str, user: str) -> str`.

## Quickstart

```python
import os
from tempora import answer
from tempora.llm_helpers import openai_compatible   # optional convenience

llm = openai_compatible(api_key=os.environ["GROQ_API_KEY"])   # any OpenAI-compatible endpoint

facts = [                     # (serial, text) — higher serial = recorded later
    (0,   "User lives in Delhi."),
    (41,  "User lives in Bangalore."),
    (306, "User lives in Mumbai."),
]

answer("Where did the user live before Mumbai?", facts, llm)
# {'answer': 'Bangalore', 'operator': 'previous', 'candidates': [...]}
```

Run `python examples/quickstart.py` for all four operators against the same facts.

## API

| function | does |
|---|---|
| `answer(question, facts, llm)` | full pipeline; returns `{answer, operator, candidates}` |
| `route(question, llm)` | classify the question into an operator name |
| `extract(question, facts, llm)` | LLM candidate extraction, all fact versions |
| `resolve(operator, candidates)` | pure-Python resolution; no LLM |
| `build_timeline(candidates)` | serial-ordered, deduplicated value list |

## Reproduce the numbers

```bash
pip install -e ".[bench]"
export GROQ_API_KEY=...                  # or any OpenAI-compatible endpoint via base_url
cd benchmarks
python generate_operator_set.py          # 160 questions from real MAB conflicts
python run_operators.py                  # baseline vs tempora, per-operator table
python run_freshness.py                  # single-hop reproduction
```

## Prior art, honestly

- **[Don't Ask the LLM to Track Freshness](https://arxiv.org/abs/2606.01435)** proved
  deterministic-newest beats LLM judgment and named non-freshness operators as open future work.
  tempora is that future-work section, built.
- **Temporal KGQA** ([TEQUILA](https://arxiv.org/abs/1908.03650), CronKGQA) used operator
  decomposition — before/after/first/last — over structured temporal knowledge graphs years ago.
  Agent memory forgot this; tempora re-applies the idea to serial-ordered conversational facts.
- **Zep/Graphiti** stores bitemporal history and **Mem0** exposes a `history()` API — storage
  without question routing, which is how a system that stores history still scores 7% answering
  questions about it.

## Limitations

- Operator set derives from one benchmark's synthetic conflicts; phrasing diversity is limited.
- Serial order stands in for time. Inferring order from language ("after I graduated…") is the
  next milestone.
- Four operators. Real usage will want `as-of(date)`, `still-valid`, `duration`.
- Multi-hop conflict chains remain open here as everywhere (published best ≤7%; the freshness
  paper's chain extension reaches ~30%).

## License

MIT © Nitin Gupta
