diff --git a/.gitignore b/.gitignore
index 1505587..cdd4c4c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,3 +42,7 @@ paper/*.out
# Keep curated benchmark logs (committable evidence)
!results/
results/*.tmp
+
+# 第三方评测数据集:按 eval/locomo.py 的文档下载到此路径,2.8MB,不入库。
+# .gitignore 里原本只有 data/,挡住的是备选路径 eval/data/locomo10.json,主路径漏网。
+eval/locomo10.json
diff --git a/API.md b/API.md
index 79fe34c..7057def 100644
--- a/API.md
+++ b/API.md
@@ -38,6 +38,50 @@ Authorization: Bearer <你的key>
Content-Type: application/json
```
+密钥有两种来源,解析顺序为「运行时签发的密钥 → `ENGRAM_API_KEYS` 静态映射 → `ENGRAM_OPEN` 开放模式」。
+运行时密钥优先,所以吊销不会被残留的环境变量条目复活。
+
+### 1.1 运行时签发密钥(不用重启)
+
+静态映射要改环境变量再重启。托管部署可以用管理端点在运行中签发和吊销。**管理面默认不存在**——
+不设 `ENGRAM_ADMIN_TOKEN` 时这几个端点一律 403,所以开放模式部署不会被路人签发租户。
+
+```bash
+export ENGRAM_ADMIN_TOKEN=<强随机串> # 与租户 key 不同,是独立的管理凭据
+
+# 签发(明文密钥只在这一次响应里出现,服务端只存 SHA-256 摘要,丢了只能重签不能找回)
+curl -s -X POST $B/v1/admin/keys -H "Authorization: Bearer $ENGRAM_ADMIN_TOKEN" \
+ -H 'Content-Type: application/json' -d '{"user":"alice","label":"laptop"}'
+# -> {"id":"key_...","user":"alice","label":"laptop","created_at":...,"revoked":false,"key":"sk-engram-..."}
+
+curl -s $B/v1/admin/keys -H "Authorization: Bearer $ENGRAM_ADMIN_TOKEN" # 列出(不含密钥与摘要)
+curl -s -X DELETE $B/v1/admin/keys/key_xxx -H "Authorization: Bearer $ENGRAM_ADMIN_TOKEN" # 立即吊销
+```
+
+密钥文件(数据目录下的 `api_keys.json`)仅属主可读写,且只保存摘要。若该文件损坏,服务**拒绝启动
+密钥库并对请求返回 503**,而不是以空状态继续——后者会先让所有已签发密钥失效,再在下次签发时覆盖掉
+那些只是读不出来的记录。
+
+### 1.2 重试安全(Idempotency-Key)
+
+`/v1/remember` 与 `/v1/import` 支持 `Idempotency-Key` 请求头。客户端超时重试时,服务端按
+「租户 + key」返回首次的响应而不重跑:首次请求其实成功了,丢的只是响应。
+
+```bash
+curl -s -X POST $B/v1/remember -H "Authorization: Bearer $KEY" \
+ -H 'Idempotency-Key: 2026-08-16-abc' -H 'Content-Type: application/json' \
+ -d '{"content":"..."}'
+```
+
+缓存是进程内的,默认保留 `ENGRAM_IDEMPOTENCY_TTL_S`(86400)秒。多副本部署下,重试若路由到另一个
+副本仍会重跑——需要跨副本幂等就换成共享存储。
+
+### 1.3 限流
+
+`ENGRAM_RATE_LIMIT_PER_MIN`(默认 0 = 关闭)按租户做滑动窗口限流,超限返回 `429` 并带
+`Retry-After`。`/health`、`/ready`、`/metrics` 不鉴权也不限流——租户被限流时探针必须还能用。
+同样是进程内的:多副本下有效限额是 `每分钟配额 × 副本数`。
+
健康检查不需要鉴权:
```bash
curl -s $B/health
@@ -164,9 +208,51 @@ agent session。
| POST | `/v1/conflicts/{id}/resolve` | `{"keep":"newer\|older\|both"}` 让冲突由人确认 |
| PATCH | `/v1/facts/{id}` | 改事实 `{"object":"...","sensitive":true}` |
| DELETE | `/v1/facts/{id}` | 删一条 |
-| GET | `/v1/export?include_sensitive=false` | 安全结构化导出:仅非敏感 facts + graph;不含画像、摘要、原始对话 |
+| GET | `/v1/export?include_sensitive=false` | 安全结构化导出:仅非敏感 facts + graph;`include_sensitive=true` 为完整可迁移导出(双时间戳、supersedes 链、provenance、原始对话、摘要、focus) |
+| POST | `/v1/import` | 批量导入:`{"data":..., "format":"chatgpt\|messages\|records\|jsonl\|transcript\|engram\|auto"}`;`engram` 格式(自动嗅探)直接还原一份 `/v1/export` 导出——跨实例迁移路径 |
| POST | `/v1/forget` | 需 `{"confirm":true}`;清空该 key 的全部记忆(不可逆) |
+### 运行指标(`GET /metrics`,不鉴权)
+
+与 `/health` 一样开放,因为载荷**按构造只含聚合量**:没有命名空间名、没有查询、没有正文,所以它
+无法暴露某个租户是否存在。用于回答「写路径 <50ms、读路径 <100ms 这两个目标现在还成立吗」——
+没有实时分位数的话,那两个数字只是断言而非测量。
+
+```bash
+curl -s $B/metrics
+```
+
+```json
+{
+ "uptime_s": 1234.5,
+ "ops": {"remember": {"n": 42, "p50_ms": 8.1, "p95_ms": 22.4, "avg_ms": 9.7, "max_ms": 31.0, "window": 42}},
+ "counts": {"rate_limited": 3, "idempotent_replays": 1, "auth_rejected": 0, "remember_degraded": 0},
+ "tokens": {"context_total": 9600, "calls_with_baseline": 12, "savings_ratio": 8.2}
+}
+```
+
+- `ops` 是每个操作的滑动窗口分位数,反映**当前**表现,不会被历史均值稀释。
+- `counts` 里 `rate_limited` / `idempotent_replays` / `auth_rejected` 让三层防护可见——否则无法判断
+ 它们是否在生效。`auth_misconfigured`(503)与 `auth_rejected`(401)分开计:前者是配置坏了,
+ 后者是调用方拿错了密钥。
+- `savings_ratio` **只用两侧都测量过的调用**计算(即带 `answer=true` 的召回),没有配对样本时为
+ `null` 而不是编一个数。
+
+### 跨实例迁移(换服务器 / 换账号)
+
+记忆属于用户,不属于某一个部署。把一个 namespace 从实例 A 搬到实例 B:
+
+```bash
+# 1. 从 A 完整导出(含敏感事实与原始对话)
+curl -s "$A/v1/export?include_sensitive=true" -H "Authorization: Bearer $KEY_A" > export.json
+# 2. 导入 B(事实保留原 id/双时间戳/supersedes 链;目标端用自己的 embedder 重新向量化)
+curl -s -X POST "$B/v1/import" -H "Authorization: Bearer $KEY_B" -H "Content-Type: application/json" \
+ -d "{\"data\": $(cat export.json), \"format\": \"engram\"}"
+```
+
+导入按 id 幂等:已存在的条目跳过、绝不覆盖,重复导入不会产生重复记忆。两端 embedder 可以不同——
+这也是更换 embedder 的官方迁移路径。CLI 等价:`python -m engram.connectors -f export.json --api-url $B --key $KEY_B`。
+
## 5. 控制台(可视化)
浏览器开 **`/ui/`** → 输入你的 key → 看「画像 / 事实管理 / 时间线 / 关系图谱 / 记忆问答 / 冲突待确认」。
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 456e49d..3af9e21 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,30 @@
All notable changes to Engram are documented here. The project uses semantic versioning for distributed
artifacts; storage schema compatibility is documented separately when it changes.
+## [Unreleased]
+
+### Added
+
+- Native export→import roundtrip (`format="engram"`, auto-sniffed): a `/v1/export` payload now restores
+ directly into another instance via `POST /v1/import`, `Memory.import_export()`, the `engram_import`
+ MCP tool, or `python -m engram.connectors` — preserving fact ids, bi-temporal stamps, supersession
+ chains, and provenance. Idempotent by id; the target re-embeds with its own embedder, which is also
+ the supported embedder-migration path.
+- `ENGRAM_STORAGE` environment variable selects the vector backend (`memory` default, `lancedb`
+ opt-in) for the server/MCP surfaces; unknown values fail closed at startup.
+- Optional Bearer authentication for the MCP streamable-HTTP transport (`--http-token` /
+ `ENGRAM_MCP_HTTP_TOKEN`). Non-loopback `--http` binds without a token are refused at startup unless
+ `ENGRAM_MCP_HTTP_OPEN=1` explicitly delegates access control to an external layer.
+
+### Fixed
+
+- `POST /v1/import` returns 400 with the parser's reason on malformed payloads instead of an
+ unhandled 500.
+- The import CLI's local mode writes through `MemoryService`, so it uses the same digest-backed
+ namespace directories and locks as the HTTP/MCP surfaces (previously a namespace like `a/b` landed
+ in a different directory than the one the server reads).
+- `/v1/stats` now filters by the canonical linked identity, matching every other read path.
+
## [0.1.0] - 2026-07-14
### Added
diff --git a/README.md b/README.md
index 6542471..f60030e 100644
--- a/README.md
+++ b/README.md
@@ -236,7 +236,32 @@ For a user-facing "my memory" page, the SDK also exposes paged inspection:
The standalone graph endpoint is share-safe by default; pass `/v1/graph?include_sensitive=true` only for
an explicit private graph inspection.
-**3. Batch import** — bring your whole history (ChatGPT export, OpenAI messages, JSONL, transcript;
+**3. Python SDK** — the same API from Python, with no runtime dependencies (it speaks over stdlib
+`urllib`, so installing Engram still pulls in nothing):
+
+```python
+from engram.client import EngramClient
+
+engram = EngramClient(base_url="http://localhost:8000", api_key="sk-engram-...")
+engram.remember("I live in Shenzhen and work on retrieval.")
+print(engram.recall("where do I live?")["context"])
+
+# retry-safe writes: the same key replays the first response instead of storing twice
+engram.remember("...", idempotency_key="2026-08-16-abc")
+```
+
+`EngramError` carries `status` so callers can branch without parsing prose — `401` wrong key, `429` back
+off (`err.retry_after` is the server's `Retry-After`), `503` server misconfigured rather than a bad
+request. The `transport` hook swaps the HTTP layer for `httpx`/`requests` or an in-process test client.
+
+**4. Runtime keys, rate limits and metrics** — for self-hosting: mint and revoke tenant keys without a
+restart (`POST /v1/admin/keys`, gated by its own `ENGRAM_ADMIN_TOKEN` and absent unless you set it),
+per-tenant rate limiting (`ENGRAM_RATE_LIMIT_PER_MIN`), retry-safe writes (`Idempotency-Key`), and
+`GET /metrics` for live latency percentiles, token totals and defence counters. `/metrics` is
+aggregate-only by construction — no namespace names, no queries, no content — so it can stay as open as
+`/health`. See [`API.md`](API.md).
+
+**5. Batch import** — bring your whole history (ChatGPT export, OpenAI messages, JSONL, transcript;
auto-detected):
```bash
diff --git a/README.zh-CN.md b/README.zh-CN.md
index c05ab60..9721b2a 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -237,6 +237,37 @@ TypeScript SDK 的 `engram.export()` 默认走这个安全导出;只有用户
`engram.memories({ factsLimit, factsOffset, episodesLimit, status, query, includeSensitive })`。
独立关系图接口默认也是安全视图;只有明确要完整私有图谱时才传 `/v1/graph?include_sensitive=true`。
+### Python SDK(零运行时依赖)
+
+Agent 生态是 Python 优先的,所以 SDK 直接装在核心包里,走 stdlib `urllib`,装 Engram 依然不会额外
+拉进任何依赖:
+
+```python
+from engram.client import EngramClient
+
+engram = EngramClient(base_url="http://localhost:8000", api_key="sk-engram-...")
+engram.remember("我住在深圳,做检索相关的工作。")
+print(engram.recall("我住在哪儿?")["context"])
+
+# 重试安全:同一个 key 会重放首次响应,而不是写第二遍
+engram.remember("...", idempotency_key="2026-08-16-abc")
+```
+
+`EngramError` 带 `status`,调用方不用解析文案就能分支——`401` 密钥错、`429` 该退避
+(`err.retry_after` 就是服务端的 `Retry-After`)、`503` 是服务端配置有问题而不是请求有问题。
+`transport` 钩子可以把 HTTP 层换成 `httpx`/`requests` 或进程内测试客户端。
+
+### 运行时密钥、限流与运行指标(自托管用)
+
+- **运行时签发密钥**:`POST /v1/admin/keys` 不重启就能签发/吊销租户密钥;只落盘 SHA-256 摘要,
+ 明文只在签发响应里出现一次。管理面由独立的 `ENGRAM_ADMIN_TOKEN` 把守,**不设置就完全不存在**。
+- **按租户限流**:`ENGRAM_RATE_LIMIT_PER_MIN`(默认 0 = 关闭),超限返回 `429` + `Retry-After`。
+- **重试安全**:`/v1/remember` 与 `/v1/import` 支持 `Idempotency-Key` 头。
+- **运行指标**:`GET /metrics` 给出实时延迟分位数、token 总量和防护计数。载荷按构造只含聚合量——
+ 无命名空间名、无查询、无正文——所以可以和 `/health` 一样开放。
+
+细节见 [`API.md`](API.md)。
+
### 自部署(数据完全在你自己机器上)
```bash
diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts
index e8e287f..cf03dc9 100644
--- a/clients/typescript/src/client.ts
+++ b/clients/typescript/src/client.ts
@@ -15,7 +15,6 @@ import type {
ChatCompletionCreateParams,
CloseSessionResult,
AgentStatus,
- Fact,
FactInput,
FactPatch,
ForgetOptions,
@@ -46,12 +45,35 @@ export class EngramError extends Error {
public readonly status: number,
message: string,
public readonly detail?: unknown,
+ /**
+ * Seconds to wait before retrying, from a 429's `Retry-After`. Undefined when the response did
+ * not carry the header — which is every status except a rate-limit rejection.
+ */
+ public readonly retryAfter?: number,
) {
super(message)
this.name = 'EngramError'
}
}
+/**
+ * Seconds to wait, read off the response headers.
+ *
+ * It has to come from the headers because that is the only place the server puts it — the 429 body
+ * says "rate limit exceeded" and nothing more. A transport that hands back only status + body makes
+ * this permanently undefined, which is an error object nobody can act on; the optional calls below
+ * are for that shape of injected fetch, not for a real `Response`.
+ *
+ * Only delta-seconds are parsed: the server always sends an integer (`str(int(retry_after) + 1)`),
+ * so an HTTP-date branch here would be a claim about the wire that nothing can reach.
+ */
+function retryAfterOf(res: Response): number | undefined {
+ const raw = res.headers?.get?.('Retry-After')
+ if (raw == null) return undefined
+ const seconds = Number.parseInt(raw.trim(), 10)
+ return Number.isFinite(seconds) ? seconds : undefined
+}
+
export interface EngramClientOptions {
/** Server base URL. Default 'http://localhost:8000'. A trailing '/v1' is allowed and normalized away. */
baseUrl?: string
@@ -115,7 +137,7 @@ export class EngramClient {
} catch {
/* non-JSON error body */
}
- throw new EngramError(res.status, message, detail)
+ throw new EngramError(res.status, message, detail, retryAfterOf(res))
}
private async request(path: string, init: RequestInit = {}): Promise {
diff --git a/clients/typescript/src/types.ts b/clients/typescript/src/types.ts
index db8569d..1fbf51b 100644
--- a/clients/typescript/src/types.ts
+++ b/clients/typescript/src/types.ts
@@ -7,7 +7,9 @@ export interface Health {
ok: boolean
ready: boolean
service: string
- auth_mode: 'api_keys' | 'open' | 'disabled'
+ version: string
+ /** 'invalid' when the server's own auth/limit configuration failed to load — not a client error. */
+ auth_mode: 'api_keys' | 'open' | 'disabled' | 'invalid'
anonymous_allowed: boolean
embedder: string
llm_configured: boolean
@@ -18,6 +20,34 @@ export interface Health {
max_hot_facts: number
}
+/** Latency percentiles for one instrumented service operation (remember, recall, ...). */
+export interface MetricOp {
+ n: number
+ p50_ms: number
+ p95_ms: number
+ avg_ms: number
+ max_ms: number
+ /** Size of the rolling sample the percentiles were computed over. */
+ window: number
+}
+
+/** GET /metrics — aggregate-only by construction: no namespaces, queries or content. */
+export interface Metrics {
+ uptime_s: number
+ /** Timed operations, keyed by operation name. */
+ ops: Record
+ /** Plain counters (auth_rejected, rate_limited, idempotent_replays, ...). */
+ counts: Record
+ tokens: {
+ context_total: number
+ baseline_context_total: number
+ baseline_full_total: number
+ calls_with_baseline: number
+ /** full-history tokens / served-context tokens. null until one call measured both. */
+ savings_ratio: number | null
+ }
+}
+
export interface RememberResult {
ok: boolean
scope?: string
@@ -276,6 +306,141 @@ export interface ProfileResult {
facts: string[]
}
+/**
+ * How well-evidenced a structured-profile entry is. Deliberately a kind + a count rather than a
+ * fabricated 0..1 score, so a UI can say *why* it believes something.
+ */
+export interface ProfileEvidence {
+ kind: 'user' | 'reinforced' | 'mentions'
+ count: number
+}
+
+export interface StructuredProfileBasic {
+ field: string
+ label: string
+ value: string
+ evidence: ProfileEvidence
+ source: FactSource
+ /** '' when the value came from identity resolution rather than a stored fact. */
+ fact_id: string
+}
+
+export interface StructuredProfilePreference {
+ item: string
+ polarity: 'like' | 'dislike' | 'diet'
+ category: string
+ evidence: ProfileEvidence
+ source: FactSource
+ fact_id: string
+ subject: string
+ predicate: string
+ object: string
+}
+
+export interface StructuredProfileHabit {
+ text: string
+ evidence: ProfileEvidence
+ fact_id: string
+}
+
+/**
+ * GET /v1/profile/structured — live facts grouped for display (basic info / preferences / habits),
+ * split into confirmed vs `tentative`. A derived read-only view: it never filters retrieval.
+ */
+export interface StructuredProfile {
+ basic: StructuredProfileBasic[]
+ /** Confirmed preferences, keyed by coarse category. */
+ preferences: Record
+ habits: StructuredProfileHabit[]
+ /** Preferences shown as 待确认 — weak signals, displayed apart from the confirmed set. */
+ tentative: StructuredProfilePreference[]
+ counts: {
+ basic: number
+ preferences: number
+ tentative: number
+ habits: number
+ }
+}
+
+// --- working memory (ephemeral, session/TTL-scoped) -------------------------
+
+export interface WorkingItem {
+ id: string
+ content: string
+ kind: string
+ session_id: string
+ /** YYYY-MM-DD */
+ created: string
+ /** YYYY-MM-DD, or null when the item lives until the session is cleared. */
+ expires_at: string | null
+}
+
+/** GET /v1/working — live items only; expired and consumed ones are already excluded. */
+export interface WorkingMemory {
+ items: WorkingItem[]
+}
+
+export interface AddWorkingResult {
+ ok: boolean
+ id: string
+ kind: string
+ /** Epoch seconds, or null when there is no hard expiry. */
+ expires_at: number | null
+}
+
+export interface ClearWorkingResult {
+ ok: boolean
+ cleared: number
+}
+
+// --- suspected conflicts (detected in System-2, resolved by the user) -------
+
+export interface Conflict {
+ id: string
+ /** Fact id of the older claim. */
+ older: string
+ /** Fact id of the newer claim. */
+ newer: string
+ older_text: string
+ newer_text: string
+ reason: string
+}
+
+export interface ConflictList {
+ conflicts: Conflict[]
+}
+
+/** 'both' dismisses the conflict — the two claims are allowed to coexist. */
+export type ConflictResolution = 'newer' | 'older' | 'both'
+
+// --- admin: tenant API keys (ENGRAM_ADMIN_TOKEN, not a tenant key) ----------
+
+/** A key record as the server publishes it — never the secret, never its digest. */
+export interface ApiKeyRecord {
+ id: string
+ user: string
+ label: string
+ /** Epoch seconds. */
+ created_at: number
+ revoked: boolean
+ last_used_at: number | null
+}
+
+/** POST /v1/admin/keys — `key` is the plaintext, returned once here and recoverable nowhere else. */
+export interface IssuedApiKey extends ApiKeyRecord {
+ key: string
+}
+
+export interface ApiKeyList {
+ keys: ApiKeyRecord[]
+}
+
+export interface RevokeKeyResult {
+ ok: boolean
+ id: string
+ revoked: boolean
+}
+
export interface Focus {
track: string[]
mute: string[]
@@ -366,6 +531,15 @@ export interface ImportResult {
summaries: number
}
+/** Shared by the mutating calls the server can replay instead of re-running (remember, import). */
+export interface IdempotentOptions {
+ /**
+ * Sent as the `Idempotency-Key` header. The server replays the first successful response for a
+ * repeated (tenant, key) pair, so retrying after a timeout does not store the same history twice.
+ */
+ idempotencyKey?: string
+}
+
export interface MemoryExportFact {
id: string
subject: string
diff --git a/clients/typescript/test/client.test.mjs b/clients/typescript/test/client.test.mjs
index 1d01106..e8d6759 100644
--- a/clients/typescript/test/client.test.mjs
+++ b/clients/typescript/test/client.test.mjs
@@ -328,3 +328,48 @@ test('forget requires explicit confirmation before sending destructive request',
assert.deepEqual(JSON.parse(calls[0].init.body), { confirm: true })
assert.equal(done.ok, true)
})
+
+test('a 429 surfaces Retry-After so callers can back off', async () => {
+ // The regression this guards: retryAfterOf() existed but assertOk() never called it, so retryAfter
+ // was undefined on every response including the one status it exists for. A caller writing a backoff
+ // around it would have had that backoff silently never fire.
+ const client = new EngramClient({
+ baseUrl: 'http://x',
+ apiKey: 'k',
+ fetch: async () =>
+ new Response(JSON.stringify({ detail: 'rate limit exceeded' }), {
+ status: 429,
+ headers: { 'Content-Type': 'application/json', 'Retry-After': '7' },
+ }),
+ })
+
+ await assert.rejects(
+ () => client.remember('hi'),
+ (err) => {
+ assert.equal(err.status, 429)
+ assert.equal(err.retryAfter, 7)
+ return true
+ },
+ )
+})
+
+test('an error without Retry-After leaves retryAfter undefined rather than guessing', async () => {
+ const client = new EngramClient({
+ baseUrl: 'http://x',
+ apiKey: 'k',
+ fetch: async () =>
+ new Response(JSON.stringify({ detail: 'invalid or missing API key' }), {
+ status: 401,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ })
+
+ await assert.rejects(
+ () => client.remember('hi'),
+ (err) => {
+ assert.equal(err.status, 401)
+ assert.equal(err.retryAfter, undefined)
+ return true
+ },
+ )
+})
diff --git a/deploy/.env.example b/deploy/.env.example
index 1a3c5d8..1db54ac 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -7,6 +7,18 @@ ENGRAM_MAX_HOT_USERS=64
ENGRAM_MAX_HOT_FACTS=10000
ENGRAM_EMBEDDER=hashing
+# 按租户滑动窗口限流;0 = 关闭(默认)。超限返回 429 + Retry-After。
+# 进程内实现:多副本下有效限额是 每分钟配额 × 副本数。
+ENGRAM_RATE_LIMIT_PER_MIN=0
+
+# Idempotency-Key 重放缓存的保留时长(秒)。同样是进程内的:多副本下重试若路由到
+# 另一个副本仍会重跑。
+ENGRAM_IDEMPOTENCY_TTL_S=86400
+
+# 运行时签发/吊销租户密钥的管理面凭据。**故意留空**:不设置时 /v1/admin/keys 一律 403,
+# 管理面根本不存在。只有确实需要不重启就增删租户时才设一个强随机串(与租户 key 不同)。
+# ENGRAM_ADMIN_TOKEN=
+
# 可选模型后端;为空时保持离线规则抽取。
ENGRAM_LLM=
ENGRAM_ANSWERER=
diff --git a/docs/agent-adapters.md b/docs/agent-adapters.md
index 1695b51..a646b96 100644
--- a/docs/agent-adapters.md
+++ b/docs/agent-adapters.md
@@ -334,6 +334,19 @@ Then connect the client to:
http://127.0.0.1:8765/mcp
```
+The HTTP transport is loopback-only by default. To expose it beyond localhost (e.g. for a remote MCP
+client), you MUST set a Bearer token — a non-loopback bind without one refuses to start:
+
+```bash
+python -m engram.mcp --http --host 0.0.0.0 --port 8765 \
+ --http-token "$(openssl rand -hex 24)" \
+ --api-url http://localhost:8000 \
+ --api-key me
+```
+
+Clients then send `Authorization: Bearer ` (most MCP clients accept a headers map in their
+remote-server config). Terminate TLS at a reverse proxy in front of it, same as the REST server.
+
## OpenAI-Compatible Apps
If your app already uses the OpenAI SDK, point it at Engram and pass the memory extension.
diff --git a/docs/architecture-optimization-map.zh-CN.md b/docs/architecture-optimization-map.zh-CN.md
index 90c338e..f20df5a 100644
--- a/docs/architecture-optimization-map.zh-CN.md
+++ b/docs/architecture-optimization-map.zh-CN.md
@@ -1,6 +1,6 @@
# Engram 架构优化地图
-最后更新:2026-07-14
+最后更新:2026-08-12
用途:这是给项目负责人和后续 AI/人类贡献者看的本地中文驾驶舱。它回答四个问题:
@@ -100,6 +100,21 @@ flowchart TD
| 2026-06-30 | `aggregation_constraint_filter` | Aggregation evidence / Query constraints | 当题面有月份约束时,排除局部上下文绑定到其他月份的数值候选 | `results/aggregation_constraint_filter_experiments.md`, `results/aggregation_constraint_filter_lme_s_context27.jsonl` |
| 2026-07-09 | `chain_provenance_promotion` | Chain-aware retrieval / Raw evidence fusion | previous-value 问题中,`supersedes` 链上的旧事实也能作为 provenance raw chunk promotion 的种子,优先提升旧值源会话 | `results/chain_provenance_promotion_experiments.md`, `results/chain_provenance_promotion_ablation.jsonl`, `results/chain_provenance_promotion_context_sample.jsonl` |
| 2026-07-14 | `commercial_release_0_1_0` | Service boundary / Namespace storage / Deployment / Release gate | 修复命名空间路径穿越与字符过滤碰撞;默认鉴权失败关闭;增加 request limits、liveness/readiness、非 root 容器和统一发布门禁 | `results/commercial_release_0_1_0_validation.jsonl`, `specs/003-commercial-release/` |
+| 2026-08-16 | `bounded_candidates`(默认关) | Read path / Hybrid retrieval / Store 索引层 | 读路径此前对每次查询全量扫描存活事实(`hybrid.py` 的 `fact_store.values()`),实测为 O(n):每千条事实耗时恒定 ~17–20ms,10000 条时单查询 177ms,已超宪章 <100ms 目标。新增倒排/槽位索引(`engram/store/indexed.py`)+ 存储装饰器,让融合阶段只对有界候选集打分 | `results/bounded_candidates_scaling.md`,`eval/scaling.py`,`tests/test_bounded_candidates.py`(8 测试,含逐位等价性) |
+| 2026-08-16 | `layered_proxy_wiring`(opt-in) | 接入层 / OpenAI 兼容代理 | 代理此前把整个检索切片放进 system prompt,导致 system 块每轮都变、provider 的 prompt-cache 一个前缀都匹配不上(这也推翻了 `layered.py` 原docstring「调用方放进 user turn」的前提)。改为 stable 半置于 system 最前、本轮证据移入 user turn,system 块跨轮字节相同(去重数 5→1),稳定前缀 61 tokens。响应新增 `engram.cacheable_tokens_est` 让调用方不必猜。**同内容对比下 token −1.1%(中性)**,收益是「system 块不再每轮失效」这个此前不存在的性质,是否值得取决于 provider 的 cache-read 定价,本测量不计价格 | `results/layered_context_tokens.md`,`tests/test_layered_context.py`(+4 测试,含前缀稳定性与「拆分不丢证据」) |
+| 2026-08-16 | `defence_counters` | 服务可观测层 | 限流、幂等、密钥解析三层防护此前是黑盒:运维无法判断它们是否在生效。`/metrics` 新增 `rate_limited` / `idempotent_replays` / `auth_rejected` / `auth_misconfigured` 四个聚合计数。**刻意不按租户分桶**——该端点不鉴权,分桶会暴露有哪些租户存在。埋点统一走 `_count()`,其中吞掉自身异常:埋点不得改变请求行为,而在异常处理器里构造服务失败会把一个精确的 401 换成笼统的 500,恰好丢掉计数本来要支撑的诊断 | `tests/test_metrics.py`(+4 测试,含首次调用不算重放、以及计数载荷不含租户名的隐私断言) |
+| 2026-08-16 | `python_sdk` | 接入层 / 客户端 | 主干只有 TS 客户端,而 Agent 生态是 Python 优先的。新增 `engram/client.py`:零运行时依赖(stdlib urllib)、方法名对齐 TS SDK 避免两者漂移、覆盖今日全部端点(含新增的 `/metrics`、`/v1/admin/keys`、`Idempotency-Key`)。**transport 契约改为返回 `(status, headers, body)`**——最初只返回 `(status, body)`,导致 `EngramError.retry_after` 永远为 `None`,因为 `Retry-After` 在响应头里;发一个永远为空的字段比不发更糟 | `tests/test_client.py`(17 测试,全部通过可注入 transport 打到**真实 app**而非 mock——mock 只能证明 SDK 自洽,而客户端库的典型失效恰恰是与服务端漂移) |
+| 2026-08-16 | `self_serve_api_keys` | 服务边界 / 鉴权(多租户) | 此前只能靠静态 `ENGRAM_API_KEYS`(改环境变量+重启)或开放模式。新增 `engram/server/keys.py`:运行时签发 `sk-engram-*`、**只落盘 SHA-256 摘要**(泄露的密钥文件不可重放)、立即吊销、可列出;`/v1/admin/keys` 由独立的 `ENGRAM_ADMIN_TOKEN` 把守,未设置即 403(开放模式部署不会被路人签发租户)。解析顺序为「运行时密钥 → 静态映射 → 开放模式」,密钥库不可读时返回 503 而非放行。**修正了保全版的一处数据丢失风险**:原实现在密钥文件损坏时静默以空状态启动,随后任一次签发都会 `_save()` 覆盖该文件、永久销毁全部已签发记录;改为拒绝启动并保持文件原样。另修掉锁外改 `last_used_at` 的数据竞争,并把密钥文件权限收紧为仅属主可读写 | `tests/test_api_keys.py`(20 测试,含失败关闭、吊销后拒绝、损坏文件不被覆盖、跨租户隔离用 bob 自己的密钥走 API 验证) |
+| 2026-08-16 | `rate_limit` + `idempotency_key` | 服务边界 / 多租户防护 | 公开记忆 API 无限流等于开放滥用(LLM 路径接上后还是开放账单);客户端超时重试会把同一 episode 存两遍并付两遍固化成本——首次请求其实成功了,丢的只是响应。新增 `engram/server/limits.py`:按租户滑动窗口限流(`ENGRAM_RATE_LIMIT_PER_MIN`,默认 0=关闭)+ `Idempotency-Key` 重放缓存(按「租户+key」隔离)。限流放在 `auth()` 里——那是每个受保护路由识别租户的必经点,新增端点不会漏配。**修正了保全版的一处泄漏**:租户命中表是 defaultdict 且从不回收,租户多了会无界增长,改为窗口清空即回收 | `tests/test_rate_limit_idempotency.py`(19 测试,含「被拒请求不得延长自身窗口」「缓存响应不得跨租户」「限流期间 /health 与 /metrics 仍可达」) |
+| 2026-08-16 | `layered_context`(默认不接主路径) | Read path / 上下文装配(Bet A 的 tokens+latency 维度) | `lean_context` 拍平成单串放进 user turn,多轮会话每轮重发不变的画像与指引。新增 `engram/retrieve/layered.py` 把上下文拆成查询无关的 stable 半(进 system prompt,可被 provider prompt-cache 复用)与每轮变化的 dynamic 半。证据完全相同,准确率按构造不变。**实测收益温和且有下限**:长会话 +5%~+9%,5 轮以下净亏;导航图默认关闭(约 20 轮才回本) | `results/layered_context_tokens.md`,`tests/test_layered_context.py`(14 测试,含"stable 半跨查询字节相同"这一缓存前提,以及"拆分不丢证据"的守卫) |
+| 2026-08-16 | `live_service_metrics` | 服务可观测层(Bet D 应用于线上) | 架构写明写路径 <50ms、读路径 <100ms,但服务侧没有任何实时观测,这两个目标一直是断言而非测量;token 节省比也只有离线基准,从未反映真实服务过的量。新增 `engram/metrics.py`(纯 stdlib、滑动窗口 p50/p95、单调计数器)+ `GET /metrics`,在 `remember`/`recall`/`import`/`close_session` 四个操作上埋点,并计数 `remember_degraded`(降级写入仍返回成功,不计数就完全不可见)。**修正了保全版的一个真实缺陷**:`savings_ratio` 原本用全部 context 总量做分母、只有部分调用的 full 总量做分子,两者样本集不同会系统性低估节省比;改为只在两侧都测量的配对样本上计算 | `tests/test_metrics.py`(11 测试,含配对样本回归、`/metrics` 无鉴权可访问、以及断言载荷不含租户名与内容的隐私边界测试) |
+| 2026-08-16 | `entity_anchor_index` | Read path / 图锚定 + Graph store 索引层(Bet E) | `query_entity_ids()` 每次检索遍历全部实体做名称/别名匹配。`InMemoryGraphStore` 改为维护 `(user_id, 词干) -> 实体 id` 倒排索引,只查询查询自身的词。实体名有区分度时 10000 实体下 **4055x**,且耗时恒定 0.004ms 与库大小无关。**边界**:实体名共享高频词时倒排表长度等于全库、索引退化为扫描(仅 1.4x),真实人名/地名/机构名不属于该情形 | `results/entity_anchor_index.md`,`tests/test_entity_index.py`(7 测试;等价性用真实 retriever 对比"带索引"与"去掉索引查找"两条路径,要求结果完全一致) |
+| 2026-08-16 | `segment_level_rerank` | Read path / 会话重排(Bet A) | cross-encoder 只读 ~512 token,而 LongMemEval 的 session 约 2000 token。`retrieve_episodes()` 把整篇 `ep.content` 交给 reranker,模型不会报错,只会**静默地只对开头四分之一打分**——答案落在后半段的 session 因此被判为无关(该回归此前已在 `lean_context` 的注释里被记录为已知问题,但会话路径一直没修,且**完全没有测试覆盖**)。改为按段落/句子边界切成 ≤`rerank_segment_words`(默认 300 词)的片段分别打分,每篇取其**最佳片段**得分(不是平均——长会话之所以该被检出,正是因为其中某一段命中) | `tests/test_rerank_segments.py`(13 测试,含先断言"整篇打分必然选错"再断言"分段打分选对"的回归用例,以及此前无人覆盖的 `Memory.retrieve_episodes` 接线) |
+| 2026-08-16 | `exclusion_early_out` + `lance_key_pushdown` | Read path / 图排除区 + Store 按键读取 | 每次检索都经 `query_entity_ids()` → `graph_excluded_entity_ids()`,后者对每个实体名跑两遍正则,而多数查询无否定词。改为先用提示词必要条件判定,5000 实体时无否定词查询从 509ms 降到 0.0009ms。同时 `LanceDBVectorStore.get()` 改用纯过滤查询,不再全表物化后线性找 key | `results/bounded_candidates_scaling.md`,`tests/test_exclusion_shortcut.py`(含逐前缀验证的正则不变量,已抓到一个中文实体名下的静默漏排除缺陷),`tests/test_lancedb_tenant_filter.py` |
+| 2026-08-16 | `tenant_filter_pushdown` | Store / 向量检索索引层(Bet E) | 多租户检索每次都必须按 user 过滤,而唯一的表达方式是 Python 谓词——后端看不进去,只能先物化全表再排序。于是"接了 LanceDB"从未换来任何 ANN 收益。现把 `user_id` 从 JSON payload 提升为真实列,`VectorStore.search()` 增加声明式 `user_id=` 参数,LanceDB 走 `prefilter=True` 在索引内收窄。40000 行时 **165x**,且延迟基本随行数不变 | `results/bounded_candidates_scaling.md`,`tests/test_lancedb_tenant_filter.py`(5 测试,含专门证伪"后置过滤"的用例 + 旧 schema 兼容) |
+| 2026-08-12 | `cross_instance_portability` | Connectors / Memory facade / Service import 路由 / HTTP `/v1/import` | 导出无法导回(POST 导出 JSON 得到 500)——补上原生 `engram` 导入格式:事实保留原 id/双时间戳/supersedes 链/provenance,目标端用本地 embedder 重嵌入(即官方换 embedder 迁移路径),按 id 幂等;`/v1/import` 对坏 payload 返回 400 | `tests/test_cross_account_portability.py`, `tests/test_server_import_export.py`(工程验收,非算法实验) |
+| 2026-08-12 | `mcp_http_bearer_gate` | MCP streamable-HTTP 传输边界 | MCP HTTP 模式此前无任何鉴权,仅靠默认 127.0.0.1;新增 `--http-token`/`ENGRAM_MCP_HTTP_TOKEN` Bearer 门,非回环绑定无 token 时启动即拒绝(失败关闭,与 REST 的 `ENGRAM_API_KEYS` 同哲学) | `tests/test_mcp_http_auth.py` |
+| 2026-08-12 | `engram_storage_env` + 一致性修复 | Service 配置边界 / stats / import CLI | 服务器此前永远 `storage="memory"`(无环境变量可选 LanceDB);新增 `ENGRAM_STORAGE`(非法值失败关闭)。`/v1/stats` 改按 canonical 身份过滤(与其它读路径一致);import CLI 本地模式改走 `MemoryService`,目录命名与服务端统一 | `tests/test_cross_account_portability.py` |
## 最近 PR 对架构的影响
@@ -112,15 +127,60 @@ flowchart TD
| 本次 `chain_provenance_promotion` | `supersedes` 链接入 provenance chunk promotion | 影响 previous/current-vs-past 问题的 raw source evidence | 复用 `chain_evidence` 开关 + 24/24 离线 ablation + LongMemEval sample context 0 errors |
| 本次 `commercial_release_0_1_0` | 服务安全、租户落盘、部署和发布门禁收束 | 不改变 extraction/retrieval/fusion;影响所有 HTTP 自托管入口和新命名空间目录 | 危险路径/跨租户/鉴权/请求测试 + 全量 pytest + zero-setup + SDK/frontend/package/container 验收 |
+## 算法迭代的前置条件(先读这条再决定跑什么)
+
+`eval/significance.py` 量出了这套评测的分辨率:**500 题、22% 分歧率下,最小可检测增益 2.94 点**。
+
+- 公开 headline(`engram_lean` 83.6% vs `full_context` 73.2%)**显著**:p<0.0001,
+ 95% 区间 [+6.4, +14.4],81:29 的分歧比。声称成立。
+- 但**到榜首的 +1.6 差距低于分辨率**——不是「还没追上」,是这套测量判定不了真假。
+- 因此:**只做期望增益 > 3 点的改动**,并优先攻占比最大的弱类别 multi-session(121 题 70.2%)与
+ temporal-reasoning(127 题 70.9%,合计 248/500);单类别 +10 点才换来整体约 +2.5 点。
+- **两个大类的失效形态不同,需要两种机制**(`results/error_modes_headline.md`):
+ multi-session 的错 **76% 是数值**(计数/聚合),temporal 的错 **63% 是弃答**(该答却说没有)。
+ 且数值误差**双向**(低估 16 / 高估 12),排除了"证据召回不足"这个解释——是计数本身失败。
+- 单点机制打单个类别恰好卡在可测边缘(multi-session 数值 19 题 = +3.8;temporal 弃答 15 题 = +3.0)。
+ **必须两条线一起做**(合计 34 题 = +6.8 点)才是舒服高于地板的实验。
+- **瓶颈在上下文装配,不在检索**(`results/retrieval_diagnosis.md`):检索层召回率 86%,
+ 但 58 道多会话错题里只有 14 道(24%)的答案会话**全部**进了全文窗口,平均覆盖率仅 48%。
+ 证据取回来了,装配时按相关度只渲染前 2 个全文、其余压成摘要——而计数需要的是**覆盖**不是**排序**。
+ 这解释了双向误差:压成摘要→漏数(低估 16),摘要表述模糊→重复计入(高估 12)。
+- **唯一有证据支撑的机制方向**:对聚合类查询让全文窗口覆盖证据集合。触发条件已存在且正常工作
+ (`plan_evidence().aggregation` 在计数错题上触发率 90%,对照答对题 79%),缺的是让它影响渲染
+ 多少全文块。与"无差别扩 `--chunks` 到 15"的区别是只在聚合类查询(约占 20%)上扩,成本不外溢。
+ 规模:44 道题缺完整覆盖,转化率一半即 +4.4 点,高于地板。**前提待离线证伪**:完整覆盖是否真能
+ 让这些题答对——已知 60 道题在全文条件下仍失败,转化率不是 1。
+- 想分辨更小的增益,只能加分辨率(更多题目 / 更确定性的 answerer),那是测量投资,
+ 但它是所有算法投资的前置条件。
+
+证据与复现命令:`results/significance_headline.md`。
+
## 当前重点区域
| 优先级 | 模块 | 为什么重要 | 下一步形态 |
| --- | --- | --- | --- |
| P0 | Raw evidence fusion hardening | Engram 已验证 facts-only 会丢细节,hybrid 是 load-bearing 发现 | 已把 chain facts 接入 provenance promotion;下一步继续把 raw chunks、facts、graph paths、summary/provenance 证据类型化,减少重复和噪声 |
| P0 | Chain-aware retrieval | knowledge-update 强项还可以转化成更稳定的 temporal/current-vs-past 能力 | 已落地 previous-value 源会话提升;下一步扩展到多段属性演化和 profile-level chain |
-| P1 | Graph proximity / multi-hop | multi-session、multi-hop 是长期记忆系统最难类别,也是差异化战场 | 轻量 n-hop/PPR-style expansion,先用真实错例切片验证 |
+| ~~P1~~ **降级** | ~~Graph proximity / multi-hop(提升召回)~~ | **已被错例证据降级**:82 道错题里 79 道(96%)答案会话本来就被检索到了,其中 73% 还在前 2 名以全文展示。任何"多检索一点"的机制上限是 3 题 = +0.6 点,低于 2.94 分辨率地板——做了也测不出来 | 证据:`results/retrieval_diagnosis.md`。图能力本身仍可用于证据**组织**(如跨会话可数项的结构化),但不要以提升召回为目标 |
| P1 | Temporal interval reasoning | temporal-reasoning 仍低于 full-context,需要更强的区间和 duration 证据 | 显式 start/end pair、invalid_at span、date arithmetic block |
| P2 | Runtime profiles | 让用户选择 lite/standard/graph/consolidated,并用同一 harness 报三联表 | 在 `Config`/bench 层定义可测 profile,而不是手动组合开关 |
+| ~~P0~~ 已落地 | ~~向量存储的过滤 ANN~~ | 已修复,见下方台账 `tenant_filter_pushdown` | 剩余:`InMemoryVectorStore` 仍是暴力扫描(参考实现,设计如此);`query_entity_ids()` 仍扫 `graph.entities.values()`,需实体名索引;`LanceDBVectorStore.get()` 仍全表物化后线性找 key |
+
+## 未采用/回滚原因
+
+| 方向 | 结论 | 证据 |
+| --- | --- | --- |
+| 只把词汇/融合环节收敛为有界候选池(保留语义通道) | **净收益 ≈ 0**(10000 事实:177.03ms vs 全扫 177.88ms)。语义通道自身就是一次全量扫描,省下的又赔回去,还多付索引维护成本。不是候选池思路错,是缺 ANN 索引 | `results/bounded_candidates_scaling.md` |
+| `candidate_vector_channel=False` 作为默认 | **不采用**。它确实快 14.5x 且次线性,但会丢"语义相关却无共享查询词"的事实,正是 M1 已验证 hybrid 论点依赖的召回。需真 ANN 后端或 keyed harness 证明召回损失可接受 | 同上 |
+| 上下文拆分接进 `lean_context` 默认路径 | **不采用**。实测 5 轮以下是净亏(stable 半虽只计费一次,但扁平上下文本身小,首轮结构开销收不回),且已发布数字均由扁平路径产出。保留为独立方法 `Memory.layered_context()`,由调用方按会话长度选择 | `results/layered_context_tokens.md` |
+| 聚合类扩大全文预算(`aggregation_chunk_cap`) | **不采用**。覆盖率 38%→56%(cap=5)→59%(cap=12)即饱和,预算翻 2.4 倍只换 3 个百分点;名义上限 +1.2 点,低于 2.94 地板,且渲染量翻倍朝全上下文回退。**真正的约束是选块策略而非预算**:89% 的答案会话在主查询 top-15 里,子查询轮询只选出 59% | `results/aggregation_coverage.md` |
+| 「44 题 × 50% 转化率 = +4.4 点」的收益估算 | **作废**。该估算默认机制能达成完整覆盖,实测未达成。教训:先测机制的直接效果,再谈转化率 | 同上 |
+| 上下文导航图(MEMORY MAP)默认开启 | **不采用**。它是扁平上下文里本来没有的新增内容,约 20 轮才回本;最初的对比实为"扁平 vs 扁平+导航图",缓存收益被吃光还倒欠。改为 `map_limit=0` 默认关闭 | 同上 |
+| 代理侧叠加 `RECALL_GUIDE` | **不采用**。代理已用 `_MEMORY_PREAMBLE` 框定记忆,再叠一层是同一条指令的第二份拷贝,实测 +14% prompt tokens 且不改变行为。代理显式传 `guide=False` | `results/layered_context_tokens.md` |
+| 上下文拆分作为代理默认 | **不采用**,保持 opt-in(`{"memory":{"layered":true}}`)。同内容对比下 token 收益仅 −1.1%(噪声级),真实价值是 61 tokens 的可缓存稳定前缀,是否划算取决于 provider 的 cache-read 定价,尚未用真实计费验证 | 同上 |
+
+> ⚠️ **同一个测量陷阱已踩两次**:把"扁平 vs 扁平+新增块"当作公平对比(第一次是 MEMORY MAP,第二次是
+> RECALL_GUIDE),两次都先得出"拆分更贵"的错误结论。任何对比先确认两边内容集合相同,只有放置方式不同。
## 验收分层规则
@@ -155,7 +215,12 @@ flowchart TD
- 算法说明:`docs/algorithm-architecture.md`
- 结果日志规则:`results/README.md`
- 公开结果:`RESULTS.md`
-- 当前 headline 原始日志:`results/headline_500.jsonl`
+- 当前 headline 原始日志(**两个数字来自两次运行**,同 answerer + judge、同 500 题):
+ - `engram_lean` 83.6% → `results/longmemeval_s_engram_lean_v2_final.jsonl`
+ - `full_context` 73.2% → `results/longmemeval_s_volcano_doubao_deepseekjudge.jsonl`
+ (同一份日志内还有严格同轮对照:`engram_full` 83.4% vs `full_context` 73.2%)
+ - ⚠️ `results/headline_500.jsonl` **不是** headline 日志,它是另一次配置的运行(79.0% / 76.0%)。
+ 此处此前指向该文件,任何人照着复现都会对不上。
## 当前结论
diff --git a/docs/branch-debt-triage.zh-CN.md b/docs/branch-debt-triage.zh-CN.md
new file mode 100644
index 0000000..fae1af9
--- /dev/null
+++ b/docs/branch-debt-triage.zh-CN.md
@@ -0,0 +1,98 @@
+# 分支债务甄别台账(2026-08-16)
+
+## 为什么有这份文档
+
+仓库长期存在"能力写完并测过、但从未合入主干"的债务。记忆账本和交接记录里这些能力被标记为
+`shipped`,但主干上并不存在——**"写完并测过" ≠ "主干有"**。本文件记录一次逐项甄别的结论,
+避免后续 AI 或人类重新推导,也避免把已被主干超越的旧实现硬合回来。
+
+甄别基线:`8decc8c`(= `ed0e53a` release 0.1.0 + cross-account),439 passed / 1 skipped。
+
+## 甄别方法(重要)
+
+**按能力比对,不是按文件名比对。** main 在 2026-06 之后独立演进了 100+ 个 commit,同一能力
+经常以不同文件名和不同设计存在。硬 `git merge` 会把主干已经更好的实现覆盖掉。
+
+每一项的判定必须给出主干侧的 `文件:行号` 证据。
+
+## 分支状态
+
+| 分支 | 相对 main | 时间 | 处置 |
+| --- | --- | --- | --- |
+| `claude/cross-account-memory-module-95f184` | +1 / −0 | 2026-08-12 | ✅ 已 fast-forward 合入(+22 测试) |
+| `claude/sweet-haibt-7231c5` | +1 / −102 | 2026-06-10 | 逐项移植,见下表 |
+| `claude/goofy-shockley-f70706` | +11 / −103 | 2026-06-08 | 逐项移植,见下表 |
+
+## A3 — `sweet-haibt-7231c5`
+
+| 能力 | 主干现状 | 建议 | 风险 |
+| --- | --- | --- | --- |
+| ANN 候选生成(`Config.ann_candidates`/`ann_pool`) | ❌ 无。`engram/retrieve/hybrid.py` 每次查询全量扫描 `fact_store.values()` 与 `graph.entities.values()` | **重写实现同等能力**(宪章 Bet E 承重墙) | 中 |
+| async System-2(`ENGRAM_ASYNC_CONSOLIDATION`) | ❌ 无 | 移植 | 中(`service.py` 已大改) |
+| multi-Space(`engram/spaces.py`) | ❌ 无 | 重写(`MemoryService` 已是 namespace-keyed,主要是读融合 + ACL 层) | 中 |
+| 文档摄入 PDF/DOCX + 图片 caption | ❌ 无 | 移植(可选 extra,独立于检索路径) | 低 |
+| embedding 空间版本守卫 | ✅ **主干更强**:`engram/store/persist.py:36,238` 的 manifest 同时校验 `embedder_id` 与 `embedding_dim` | **跳过守卫本身** | — |
+| └ 但缺 `reembed()` 迁移 | ❌ 主干只能抛 `EmbedderMismatchError`,无法从源文本重建向量 | 补迁移路径 | 中 |
+| import 幂等 | ✅ cross-account 合入时已带来(按 id 幂等) | 跳过 | — |
+
+## A4 — `goofy-shockley-f70706`
+
+| 能力 | 主干现状 | 建议 | 风险 |
+| --- | --- | --- | --- |
+| `store/snapshot.py`(SQLite 快照) | ✅ **主干更强**:`engram/store/persist.py` 用 JSONL + manifest + 原子 tmp→final + 文件锁 + committed-prefix 校验 + embedder/dim 守卫;另有 `store/migrate.py` 迁移旧 pickle | **丢弃分支版** | — |
+| `server/keys.py`(自助签发 API key) | ⚠️ 部分:主干 `engram/server/app.py:48,183` 有 `ENGRAM_API_KEYS` 静态映射 + `hmac.compare_digest` 常量时间比较 + 严格 Bearer 解析。分支版额外提供自助签发、只存 hash、吊销、列表(101 行) | 有增量价值,中优先级 | 低 |
+| `server/ratelimit.py`(49 行) | ❌ 无 | 移植 | 低 |
+| `server/idempotency.py` | ❌ 无 | 移植 | 低 |
+| `server/metrics.py` | ❌ 无 | 移植 | 低 |
+| `engram/client.py`(Python SDK) | ❌ 无(只有 TS 客户端) | 移植 | 低 |
+| `store/crypto.py`(Fernet 落盘加密) | ❌ 无 | 移植(需可选 `cryptography` 依赖) | 中 |
+| `store/pg_store.py`(pgvector) | ❌ 无(`store/base.py:2`、`server/app.py:19` 只在文档里提到) | **暂缓** — 该代码从未用真实数据库验证过,属未验证代码,不应盲目并入 | 高 |
+| `integrations/`(LangChain / LlamaIndex retriever) | ❌ 无(主干有 `docs/agent-adapters.md`,但那是 MCP/跨 Agent 适配,不是框架 retriever) | 移植(需可选依赖) | 低 |
+
+## 未提交工作(不在任何分支上)
+
+13 个 worktree 中有 5 个含**从未提交**的改动,合计 2000+ 行。这些不在任何分支上,删除 worktree
+即永久销毁:
+
+| worktree | 未提交内容 |
+| --- | --- |
+| `wonderful-tereshkova-b63377` | `retrieve/router.py`(140) + `retrieve/segment.py`(131) + 两个测试(189) + 167 行 diff |
+| `vigilant-mahavira-73886f` | `retrieve/recall_pipeline.py`(253),无测试 |
+| `laughing-tesla-360ff1` | `TECHNIQUES.md` + `docs/{README,console,evaluation,governance,memory-policy,moat}.md` 共 730 行 |
+| `cool-chebyshev-aa0347` | consolidate 三模块改动 72 行 + `tests/test_profile.py`(40) |
+| `nostalgic-clarke-dda32e` | 480 行 diff |
+
+### 处置结果(2026-08-16)
+
+隐私前置条件已由仓库所有者确认:`zhangyuwei` 为**化名**;另对该语料扫描了邮箱、手机号、证件号与
+账号类标识,**未发现**其它可识别信息,满足 `CONTRIBUTING.md` 的提交门槛。
+
+全部未提交工作已**按原样提交到各自分支保全**(每个 worktree 现为 main+1、工作区清空)。保全 ≠ 合并:
+这些都是 2026-06 的代码,主干此后走了 100+ 个 commit,需逐个对照今日主干重新验证后才能移植。
+
+| worktree | 保全提交 | 判定 |
+| --- | --- | --- |
+| `wonderful-tereshkova-b63377` | `c6912df` | **部分仍有效**。`segment.py` 记录的缺陷**今天仍在**:`retrieve/rerank.py` 的 cross-encoder 仍是 `max_length=512` 且无分段,重排 ~2000 token 的 session 会静默只对前 512 token 打分(实测 70.0%→57.5%)。主干 rerank 默认关闭,限制了影响面但没有修复。`router.py` 的按查询路由与优化地图上仍未关闭的 P2「runtime profiles」高度重合 |
+| `vigilant-mahavira-73886f` | `21e7baa` | **未被覆盖**。把上下文拆成可缓存的 STABLE 块(进 system prompt)与每轮变化的 DYNAMIC 块,主干 `lean_context` 至今仍拍平成单个字符串。同样证据、更少重复计费——属 Bet A 三联表里 tokens/latency 那两维。无测试 |
+| `sweet-haibt-7231c5` | `d3f4f70` | **真实缺口**。`engram/metrics.py`(99 行)+ 测试(119 行)+ `/metrics` 端点,主干至今没有可观测层。纯 stdlib、只暴露聚合量(不含命名空间名与查询文本) |
+| `laughing-tesla-360ff1` | `103359c` | 730 行文档。主干此后自建了文档体系,重合度未知,且其中数字未按「每个公开数字可追溯到已提交日志」的规则复核过 |
+| `cool-chebyshev-aa0347` | `7ee1123` | consolidate 三模块改动 + `tests/test_profile.py`。主干此后重写了大部分 consolidation,需重新对照 |
+| `nostalgic-clarke-dda32e` | `2ccfe15` | 车载记忆演示语料 + 控制台接线。`memory.py`/`server/app.py`/前端在主干均已大改 |
+
+**未提交但未保全的**(判定为可再生或无价值,故意留在原处):
+`distracted-tereshkova` 的 `paper/main_anon.{pdf,bbl}`(匿名构建产物,可由已提交的开关重新生成)、
+`hardcore-nobel` 的 `frontend/package-lock.json`(前端与 CI 用 pnpm,该 npm 锁文件是残留)、
+`sharp-chandrasekhar` 与 `peaceful-almeida` 的 `TECHNIQUES.md`(与 `103359c` 中已保全的同名文档重复)。
+
+**main 工作区的未跟踪文件未处理**:`IDENTITY.md` / `SOUL.md` / `USER.md` 和 11 个
+`results/*.jsonl`。后者是实验日志,按 Bet D「每个公开数字可追溯到已提交日志」的规则**可能应当提交**,
+但提交到 main 属于仓库所有者的决定,未擅自执行。
+
+## 结论
+
+1. 分支上的东西**不都是财富**。持久化层是反例:主干的 `persist.py` 明确优于分支的 `snapshot.py`。
+ 任何"把旧分支合回来"的动作都必须逐能力甄别。
+2. 主干真实缺口按价值排序:**ANN 候选生成**(宪章 Bet E)→ 服务层加固三件套
+ (ratelimit / metrics / idempotency)→ Python SDK → async System-2 / multi-Space →
+ 文档摄入 → 加密 → 框架适配器。
+3. `pg_store.py` 属未验证代码,在拿到真实数据库前不并入。
diff --git a/docs/cross-account-memory-roadmap.zh-CN.md b/docs/cross-account-memory-roadmap.zh-CN.md
new file mode 100644
index 0000000..c3a0183
--- /dev/null
+++ b/docs/cross-account-memory-roadmap.zh-CN.md
@@ -0,0 +1,125 @@
+# 跨账号个人记忆总线 Roadmap
+
+最后更新:2026-08-12
+状态:阶段 1 已落地(本仓当前分支),阶段 0 等待运维执行,阶段 2 起按序推进。
+
+## 目标与定位
+
+让 Engram 成为「记忆跟人走」的个人记忆总线:**换厂商账号、换 AI 客户端、换设备、换服务器,
+记忆都还在**。身份锚点是「服务器 + API key + namespace」,与任何厂商账号零耦合——所以"跨账号"
+不是要新增的能力,而是架构已经成立的性质;本 roadmap 要补的是让这个性质在真实世界可用的工程链条。
+
+判断每一步是否值得做的标准(对齐 CLAUDE.md 战略):是否消除一个"记忆会丢/会断/会泄漏"的场景。
+
+## 现状基线(2026-08-12 审计结论)
+
+已经扎实的(不要重做):
+
+- 0.1.0 商业版全部落地:严格 key 鉴权(一租户多 key 轮换)、SHA-256 摘要命名空间、JSONL+manifest
+ 崩溃安全持久化、非 root 容器 + systemd、`/health` `/ready` 分离、发布门禁 CI。
+- 跨 agent 接入层:`engram-agent-setup / -doctor / -bootstrap` 三个 CLI 覆盖 Codex / Claude Code /
+ Cursor;MCP stdio + streamable HTTP;OpenAI 兼容代理;TS SDK;跨进程 manifest 指纹重载。
+- **本次新增(阶段 1)**:export→import 原生回路(`format="engram"`,幂等、保 id/双时间戳/
+ supersedes 链、目标端重嵌入);MCP HTTP Bearer 门(`--http-token`,非回环无 token 拒绝启动);
+ `ENGRAM_STORAGE` 后端选择;`/v1/import` 坏 payload 返回 400;import CLI 与服务端目录统一;
+ `/v1/stats` 身份规范化。验收:`tests/test_cross_account_portability.py`、
+ `tests/test_server_import_export.py`、`tests/test_mcp_http_auth.py`(22 个新测试,全量回归绿)。
+
+主要欠账(按对目标场景的阻塞度排序):
+
+1. 用户自己的线上实例是 6 月的 demo 档(内存后端、Hashing embedder、公网明文 HTTP、弱 key)。
+2. key 生命周期:无自助创建/轮换/吊销 API,无 per-key 审计;改 key 要改 env 重启。
+3. 无限流、无 CORS(全部外推反代);`/health` 无鉴权暴露运营信息。
+4. LanceDB 后端 ~40%(where 过滤全表扫、无 DocStore/GraphStore、与 JSONL 双写);
+ 每次写全量重写 JSONL(写放大 O(全部记忆))。
+5. 单进程单 worker;热租户 LRU 64。
+
+---
+
+## 阶段 0 —— 把自己的实例升级到 0.1.0(运维,本周可完成)
+
+**为什么第一**:个人记忆正在公网明文 HTTP 上传输,key 是可猜的 `my-app`,后端是内存档——
+这不是功能缺口,是正在发生的风险。所有后续阶段都以一个可信实例为前提。
+
+行动清单:
+
+1. 用 `deploy/docker-compose.yml` 部署 0.1.0:`ENGRAM_API_KEYS=me:<32+随机字符>`、持久卷、
+ `ENGRAM_EMBEDDER=bge-small`(或 bge-m3)、可选 `ENGRAM_LLM` 开启 LLM 抽取。
+2. TLS 反代(Caddy/Nginx,自动续期),只转发受信 Host;按 `deploy/README.md` 网关清单配限流。
+3. 旧数据迁移:旧实例 `GET /v1/export?include_sensitive=true` → 新实例
+ `POST /v1/import format=engram`(阶段 1 的成果使这一步成为可能;两端 embedder 不同也可以)。
+4. 本机 MCP 配置切到新地址+新 key(`engram-agent-setup --install-mcp-json --doctor ...`)。
+5. 旧实例下线或封端口。
+
+**验收**:`/ready` 200;写入→容器重启→召回保留;`engram-agent-doctor --api-url https://... --api-key ...`
+全绿;旧实例数据在新实例可召回;`engram_agent_status` 显示 storage/embedder 为新配置。
+
+**决策点(唯一需要 owner 拍板)**:服务器驻留——继续用现有云主机(跨设备,但要信任那台机器),
+还是退回本机/家庭服务器 + 内网穿透(隐私最优,跨设备体验差一档)。默认建议:现有云主机 + TLS +
+强 key,敏感 facts 用 `sensitive` 标记走默认脱敏导出。
+
+## 阶段 1 —— 可迁移性(已完成,本分支)
+
+见「现状基线-本次新增」。遗留小项(不阻塞):
+
+- TS SDK 补 `importExport` 便捷方法(服务端已支持,SDK 里 `import` 端点已在,只差文档示例)。
+- 控制台 Privacy 页加"导入"入口(目前只有导出下载)。
+
+## 阶段 2 —— 跨客户端一致体验(1–2 周)
+
+存储层是通用的,行为层不是:MCP 只保证工具存在,不保证 agent 会调。这一阶段把"每个客户端都会
+正确地 recall/remember"变成被验证的事实。
+
+1. **Codex 接入解冻**:`~/.ai-shared/collab.md` 第 5 条的"暂缓"解除——阶段 0 完成后执行
+ `engram-agent-setup --install-codex --doctor --api-url ... --api-key ...`,并用
+ `--install-policy` 写入 AGENTS.md 记忆策略块。
+2. **claude.ai 网页/移动端**(真正的"换账号也能用"):`python -m engram.mcp --http --http-token ...`
+ 挂在 TLS 反代后,作为 remote MCP connector 添加到 claude.ai 账号。换账号 = 在新账号加一次
+ connector(分钟级,一次性)。
+3. **Cursor / 其它 MCP 客户端**:同一 `.mcp.json` 配方。
+4. **召回策略统一**:各客户端的 policy 文案统一维护在 `docs/agent-adapters.md`,变更走 bootstrap
+ 的 managed block,避免各处漂移。
+
+**验收**:`engram-agent-doctor` 对每个客户端跑通远程生命周期(status→remember→close→report→recall);
+一条在 Codex 写入的决策,能在 Claude Code 与 claude.ai 网页端被 recall 命中(跨 agent handoff 冒烟,
+`examples/cross_agent_handoff.py --base https://...`)。
+
+## 阶段 3 —— key 生命周期与安全深化(2–4 周)
+
+把"key 即身份"从裸配置升级为可管理的凭据体系(仍是单节点自托管边界,不做企业 SSO/RBAC):
+
+1. key 管理 API + 控制台页:同租户多 key 的创建/吊销(写回 env 或独立 keys 文件均可,落盘方案
+ 先出一页设计再动手);不再要求重启。
+2. per-key 审计:写路径在 episode/fact provenance 侧记录 key 指纹(不是 key 明文),回答
+ "哪台设备/哪个 agent 写了这条"。
+3. `/health` 信息收敛:无鉴权时只报 `ok/ready/version`,运营细节移入鉴权后的 `/v1/stats`。
+4. 应用层限流(简单令牌桶,按租户),不再完全依赖网关;CORS 白名单可选项。
+
+**验收**:吊销一个 key 后旧连接立即 401 且其它 key 不受影响;审计字段在 session_report 可见;
+未鉴权 `/health` 响应不含 embedder/租户数。
+
+## 阶段 4 —— 规模与后端(与算法路线并行,按 harness 证据推进)
+
+1. **增量持久化**:JSONL 追加 + 定期 compaction,替代每写全量重写(先用 harness/压测量化写放大,
+ 再动手——Measure Before Optimizing)。
+2. **LanceDB 补全**:where 过滤下推(先按 user_id 分表或 Lance filter 表达式)、大表 ANN 索引;
+ DocStore/GraphStore 是否迁移按测量决定。
+3. multi-worker 前置条件梳理(文件锁已可跨进程,线程锁失效面要清点)。
+
+**验收**:10 万 facts 规模下写延迟与 p95 recall 延迟对比基线成表(accuracy + tokens + latency 三联,
+遵守 Bet D),否则不合并。
+
+## 阶段 5 —— 产品面(Path-A 开发者基础设施定位的延伸,按需启动)
+
+- 控制台:导入 UI、key 管理 UI、迁移向导(导出→导入一条龙)。
+- 托管多用户注册/开通流(若决定做 hosted 服务,需要真正的用户体系——独立规格,不混入本 roadmap)。
+
+---
+
+## 与既有机制的关系
+
+- **Claude Code auto-memory(MEMORY.md)与 Engram 并行**:auto-memory 绑定机器+项目路径,作为
+ 本机缓存继续用;跨机器/跨客户端的持久事实沉到 Engram。阶段 2 完成后可评估把 auto-memory 的
+ 高价值条目定期 `engram_remember` 化(脚本化,不手抄)。
+- **公开数字纪律**:本 roadmap 全部为工程交付,不产生任何算法性能主张;LongMemEval 相关数字
+ 仍以 `RESULTS.md` + 已提交 JSONL 为准。
diff --git a/docs/engram-full-architecture-report.zh-CN.md b/docs/engram-full-architecture-report.zh-CN.md
index 6db1969..41ccefb 100644
--- a/docs/engram-full-architecture-report.zh-CN.md
+++ b/docs/engram-full-architecture-report.zh-CN.md
@@ -667,6 +667,33 @@ flowchart LR
这条链路没有改变 `Memory` 内部的抽取、冲突、图构建或检索算法。工程发布结果记录到
`results/commercial_release_0_1_0_validation.jsonl`,不能被当作算法效果提升证据。
+### 10.2 跨实例迁移数据流(2026-08-12 新增)
+
+记忆的所有权锚点是「服务器 + key + namespace」,与任何厂商账号无关。换服务器/换部署时,
+`/v1/export` 的产物现在可以直接导回 `/v1/import`(原生 `engram` 格式,按 `engram_export_version` 自动嗅探):
+
+```mermaid
+flowchart LR
+ A["实例 A\nGET /v1/export?include_sensitive=true"] --> P["export payload v1\nfacts(id/双时间戳/supersedes/provenance)\n+ episodes + summaries + focus + graph"]
+ P --> B["实例 B\nPOST /v1/import format=engram"]
+ B --> R["Memory.import_export()\n按 id 幂等跳过已存在项\n本地 embedder 重嵌入\nGraphBuilder 重建图(保留失效边)"]
+ R --> S["episodes 标记 consolidated\n(事实已随导出携带,不重复抽取)"]
+```
+
+关键规则:
+
+1. **幂等**:已存在的 fact/episode id 直接跳过、绝不覆盖(现有记忆优先),重复导入零副作用。
+2. **重嵌入即迁移**:导出不携带向量;目标端用自己的 embedder 重算——这也是更换 embedder 的官方路径
+ (此前 manifest 的 `embedder_id/embedding_dim` 硬校验导致换 embedder 等于存储报废)。
+3. **历史以历史身份迁移**:GraphBuilder 把 `invalid_at` 一并写到关系边上,superseded 事实不会复活。
+4. **share-safe 导出同样可导入**(只有非敏感 facts + graph),敏感内容不会经由默认导出泄漏到新实例。
+5. 坏 payload 由 `/v1/import` 返回 400 + 解析原因(此前是裸 500)。
+
+同时补齐的服务边界:`ENGRAM_STORAGE` 环境变量显式选择向量后端(非法值启动即失败);MCP
+streamable-HTTP 传输新增 `--http-token`/`ENGRAM_MCP_HTTP_TOKEN` Bearer 门,非回环绑定无 token 拒绝启动
+(`ENGRAM_MCP_HTTP_OPEN=1` 才可显式豁免);import CLI 本地模式改走 `MemoryService`,与服务端共用同一套
+摘要目录和锁;`/v1/stats` 与其它读路径一致按 canonical 身份过滤。
+
## 11. Eval harness 数据流
评测不是附属品,是架构的一部分。Engram 的所有算法主张都应能走 `eval/bench.py`。
diff --git a/engram/client.py b/engram/client.py
new file mode 100644
index 0000000..2c62462
--- /dev/null
+++ b/engram/client.py
@@ -0,0 +1,315 @@
+"""EngramClient — the Python SDK for the Engram memory server.
+
+The agent ecosystem is Python-first, so this ships inside the core package rather than as a separate
+install: `pip install engram-memory`, then `from engram.client import EngramClient`. It is the peer of
+the TypeScript SDK in clients/typescript and deliberately mirrors its method names, so the two read the
+same way and neither drifts into being the "real" one.
+
+Zero runtime dependencies — it speaks to the server over stdlib urllib, keeping the charter's promise
+that installing Engram pulls in nothing. Like the TS client's injectable `fetch`, `transport` swaps the
+HTTP layer for httpx, requests, or an in-process test client without touching call sites.
+
+ from engram.client import EngramClient
+
+ engram = EngramClient(base_url="http://localhost:8000", api_key="sk-engram-...")
+ engram.remember("I live in Shenzhen and work on retrieval.")
+ print(engram.recall("where do I live?")["context"])
+
+One Bearer key is one isolated namespace on the server, so a client instance is a tenant.
+"""
+from __future__ import annotations
+
+import json
+import urllib.error
+import urllib.parse
+import urllib.request
+from typing import Any, Callable, Optional
+
+__all__ = ["EngramClient", "EngramError", "Transport"]
+
+# One request in, (status, response headers, body) out. Never raises for HTTP status -- the client maps
+# status to errors, so a custom transport only has to move bytes. Headers are part of the contract
+# because some of the server's answer lives there: a 429 carries Retry-After, and an error object that
+# cannot report how long to wait is an error object nobody can act on.
+Transport = Callable[[str, str, dict, Optional[bytes], float], "tuple[int, dict, bytes]"]
+
+
+class EngramError(Exception):
+ """Any non-2xx response, or a connection failure (status 0).
+
+ `status` lets a caller branch without parsing prose: 401 means the key is wrong, 429 means back off
+ (`retry_after` carries the server's Retry-After), 503 means the server is misconfigured rather than
+ the request being bad.
+ """
+
+ def __init__(self, status: int, message: str, detail: Any = None, retry_after: Optional[int] = None) -> None:
+ super().__init__(message)
+ self.status = status
+ self.detail = detail
+ self.retry_after = retry_after
+
+
+def _safe_json(raw: bytes) -> Any:
+ try:
+ return json.loads(raw.decode("utf-8"))
+ except (ValueError, UnicodeDecodeError):
+ return None
+
+
+def _urllib_transport(
+ method: str, url: str, headers: dict, body: Optional[bytes], timeout: float
+) -> "tuple[int, dict, bytes]":
+ request = urllib.request.Request(url, data=body, method=method)
+ for name, value in headers.items():
+ request.add_header(name, value)
+ try:
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - caller's URL
+ return response.status, dict(response.headers), response.read()
+ except urllib.error.HTTPError as exc: # a non-2xx response still carries a readable body
+ return exc.code, dict(exc.headers or {}), exc.read()
+ except urllib.error.URLError as exc: # never reached the server: DNS, refused, TLS
+ raise EngramError(0, f"cannot reach Engram at {url}: {exc.reason}") from exc
+
+
+class EngramClient:
+ def __init__(
+ self,
+ base_url: str = "http://localhost:8000",
+ api_key: Optional[str] = None,
+ timeout: float = 30.0,
+ transport: Optional[Transport] = None,
+ ) -> None:
+ self.base_url = base_url.rstrip("/")
+ self.api_key = api_key
+ self.timeout = timeout
+ self._transport: Transport = transport or _urllib_transport
+
+ # --- plumbing ---
+
+ def _request(
+ self,
+ method: str,
+ path: str,
+ body: Any = None,
+ params: Optional[dict] = None,
+ extra_headers: Optional[dict] = None,
+ ) -> Any:
+ url = f"{self.base_url}{path}"
+ if params:
+ clean = {k: v for k, v in params.items() if v is not None}
+ if clean:
+ url = f"{url}?{urllib.parse.urlencode(clean)}"
+
+ headers = {"Accept": "application/json"}
+ if self.api_key:
+ headers["Authorization"] = f"Bearer {self.api_key}"
+ data = None
+ if body is not None:
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
+ headers["Content-Type"] = "application/json"
+ if extra_headers:
+ headers.update(extra_headers)
+
+ status, response_headers, raw = self._transport(method, url, headers, data, self.timeout)
+ if status < 200 or status >= 300:
+ raise self._error(status, response_headers, raw)
+ return _safe_json(raw)
+
+ @staticmethod
+ def _error(status: int, headers: dict, raw: bytes) -> EngramError:
+ detail = _safe_json(raw)
+ message = f"Engram request failed ({status})"
+ if isinstance(detail, dict) and detail.get("detail"):
+ reported = detail["detail"]
+ message = reported if isinstance(reported, str) else json.dumps(reported, ensure_ascii=False)
+ # Header names are case-insensitive on the wire; normalise rather than trust one spelling.
+ lowered = {str(k).lower(): v for k, v in (headers or {}).items()}
+ try:
+ retry_after = int(lowered["retry-after"])
+ except (KeyError, TypeError, ValueError):
+ retry_after = None
+ return EngramError(status, message, detail, retry_after)
+
+ @staticmethod
+ def _idempotency(key: Optional[str]) -> Optional[dict]:
+ return {"Idempotency-Key": key} if key else None
+
+ # --- service ---
+
+ def health(self) -> dict:
+ return self._request("GET", "/health")
+
+ def ready(self) -> dict:
+ return self._request("GET", "/ready")
+
+ def metrics(self) -> dict:
+ """Aggregate latency, volume and token counters. Unauthenticated on the server."""
+ return self._request("GET", "/metrics")
+
+ # --- writing ---
+
+ def remember(
+ self,
+ content: str,
+ session_id: str = "default",
+ scope: str = "auto",
+ idempotency_key: Optional[str] = None,
+ ) -> dict:
+ """Store a message. Pass `idempotency_key` so a retry after a timeout does not store it twice."""
+ return self._request(
+ "POST",
+ "/v1/remember",
+ {"content": content, "session_id": session_id, "scope": scope},
+ extra_headers=self._idempotency(idempotency_key),
+ )
+
+ def import_(
+ self,
+ sessions: Optional[list] = None,
+ data: Any = None,
+ format: str = "auto",
+ consolidate: bool = True,
+ summarize: bool = True,
+ idempotency_key: Optional[str] = None,
+ ) -> dict:
+ """Bulk-ingest a history. The most expensive call there is, so it is the one most worth an
+ idempotency key."""
+ return self._request(
+ "POST",
+ "/v1/import",
+ {
+ "sessions": sessions,
+ "data": data,
+ "format": format,
+ "consolidate": consolidate,
+ "summarize": summarize,
+ },
+ extra_headers=self._idempotency(idempotency_key),
+ )
+
+ def export(self, include_sensitive: bool = False) -> dict:
+ return self._request("GET", "/v1/export", params={"include_sensitive": include_sensitive})
+
+ def forget(self, confirm: bool = False) -> dict:
+ """Erase this namespace. Requires `confirm=True`; the server refuses otherwise."""
+ return self._request("POST", "/v1/forget", {}, params={"confirm": confirm})
+
+ # --- reading ---
+
+ def recall(
+ self,
+ query: str,
+ lean: bool = True,
+ n_chunks: int = 6,
+ session_id: Optional[str] = None,
+ as_of: Optional[float] = None,
+ redact_sensitive: bool = False,
+ answer: bool = False,
+ ) -> dict:
+ return self._request(
+ "POST",
+ "/v1/recall",
+ {
+ "query": query,
+ "lean": lean,
+ "n_chunks": n_chunks,
+ "session_id": session_id,
+ "as_of": as_of,
+ "redact_sensitive": redact_sensitive,
+ "answer": answer,
+ },
+ )
+
+ def search(self, query: str, **kwargs: Any) -> dict:
+ """A direct factual answer rather than a context to answer from."""
+ return self.recall(query, lean=False, **kwargs)
+
+ def memories(self, limit: Optional[int] = None, offset: int = 0, **params: Any) -> dict:
+ return self._request("GET", "/v1/memories", params={"limit": limit, "offset": offset, **params})
+
+ def profile(self, structured: bool = False) -> dict:
+ return self._request("GET", "/v1/profile/structured" if structured else "/v1/profile")
+
+ def stats(self) -> dict:
+ return self._request("GET", "/v1/stats")
+
+ def graph(self, **params: Any) -> dict:
+ return self._request("GET", "/v1/graph", params=params)
+
+ def agent_status(self, session_id: Optional[str] = None) -> dict:
+ return self._request("GET", "/v1/agent/status", params={"session_id": session_id})
+
+ # --- sessions ---
+
+ def sessions(self, **params: Any) -> dict:
+ return self._request("GET", "/v1/sessions", params=params)
+
+ def session_report(self, session_id: str, **params: Any) -> dict:
+ return self._request("GET", "/v1/sessions/report", params={"session_id": session_id, **params})
+
+ def close_session(self, session_id: str = "default", summarize: bool = True,
+ clear_working: bool = True) -> dict:
+ return self._request(
+ "POST",
+ "/v1/sessions/close",
+ {"session_id": session_id, "summarize": summarize, "clear_working": clear_working},
+ )
+
+ # --- facts ---
+
+ def add_fact(self, subject: str, predicate: str, object: str, **fields: Any) -> dict:
+ return self._request(
+ "POST", "/v1/facts", {"subject": subject, "predicate": predicate, "object": object, **fields}
+ )
+
+ def delete_fact(self, fact_id: str) -> dict:
+ return self._request("DELETE", f"/v1/facts/{urllib.parse.quote(fact_id, safe='')}")
+
+ def conflicts(self) -> dict:
+ return self._request("GET", "/v1/conflicts")
+
+ def resolve_conflict(self, conflict_id: str, keep: str = "newer") -> dict:
+ path = f"/v1/conflicts/{urllib.parse.quote(conflict_id, safe='')}/resolve"
+ return self._request("POST", path, {"keep": keep})
+
+ # --- working memory, focus, policy ---
+
+ def add_working(self, content: str, session_id: str = "default", kind: str = "state",
+ **fields: Any) -> dict:
+ return self._request(
+ "POST", "/v1/working",
+ {"content": content, "session_id": session_id, "kind": kind, **fields},
+ )
+
+ def working_memory(self, session_id: Optional[str] = None) -> dict:
+ return self._request("GET", "/v1/working", params={"session_id": session_id})
+
+ def clear_working(self, session_id: str) -> dict:
+ return self._request("DELETE", "/v1/working", params={"session_id": session_id})
+
+ def get_focus(self) -> dict:
+ return self._request("GET", "/v1/focus")
+
+ def set_focus(self, track: Optional[list] = None, mute: Optional[list] = None) -> dict:
+ return self._request("PUT", "/v1/focus", {"track": track, "mute": mute})
+
+ def get_policy(self) -> dict:
+ return self._request("GET", "/v1/policy")
+
+ def set_policy(self, **fields: Any) -> dict:
+ return self._request("PUT", "/v1/policy", fields)
+
+ # --- admin ---
+ #
+ # These need ENGRAM_ADMIN_TOKEN as the api_key, not a tenant key -- a separate client instance,
+ # because mixing an admin token into a tenant client would send it on every ordinary call.
+
+ def issue_key(self, user: str, label: str = "") -> dict:
+ """Mint a tenant key. The plaintext is in the response and nowhere else."""
+ return self._request("POST", "/v1/admin/keys", {"user": user, "label": label})
+
+ def list_keys(self, user: Optional[str] = None) -> dict:
+ return self._request("GET", "/v1/admin/keys", params={"user": user})
+
+ def revoke_key(self, key_id: str) -> dict:
+ return self._request("DELETE", f"/v1/admin/keys/{urllib.parse.quote(key_id, safe='')}")
diff --git a/engram/config.py b/engram/config.py
index aebfbe7..90f35fa 100644
--- a/engram/config.py
+++ b/engram/config.py
@@ -33,6 +33,25 @@ class Config:
recency_tau_days: float = 45.0
top_k: int = 5
candidate_k: int = 24 # per-retriever candidate pool before fusion
+ # Bounded candidate retrieval (Bet E). OFF by default: it changes which facts get *scored*, and the
+ # published numbers were produced by the full scan, so turning it on silently would break the
+ # "every number traces to a committed log" rule. With `candidate_pool` >= the live fact count the two
+ # paths are provably identical (tests/test_bounded_candidates.py) — the flag buys speed at scale, and
+ # its ranking behaviour at scale still needs a keyed harness run before it becomes the default.
+ bounded_candidates: bool = False
+ candidate_pool: int = 400 # per-channel candidate budget before fusion when bounded_candidates is on
+ # Whether bounded retrieval asks the vector store for semantic candidates. Measured cost/benefit:
+ # neither shipped backend has a real ANN index (the in-memory store brute-forces cosine and sorts;
+ # LanceDB materialises the whole table whenever a Python predicate is supplied), so this channel
+ # re-introduces the very O(n) pass the candidate pool exists to avoid. Leaving it ON is the
+ # recall-safe default; turning it OFF is what makes the read path genuinely bounded, at the cost of
+ # missing facts that are semantically relevant but share no query term. Flip it off only with a real
+ # ANN backend, or after a keyed harness run shows the recall loss is acceptable.
+ candidate_vector_channel: bool = True
+ # Segment size for reranking long documents. Cross-encoders read a bounded window (512 tokens for the
+ # BGE rerankers) and truncate silently past it, so raw sessions are scored segment-by-segment and take
+ # their best segment's score. ~300 words sits inside that window once the query is added.
+ rerank_segment_words: int = 300
rrf_k: int = 60 # Reciprocal Rank Fusion constant
max_hops: int = 2 # multi-hop planner depth
max_hot_facts: int = 10_000 # heat-tier cap; cold facts remain durable and can page back on hot miss
@@ -45,6 +64,27 @@ class Config:
preference_reversal_extraction: bool = True # extract high-confidence "no longer like X" preference updates
numeric_aggregation_candidates: bool = True # extract money/hour/page rows for count/sum questions
aggregation_recall_expansion: bool = True # expand count/sum queries into high-recall evidence lookups
+ # How many sessions a counting question may render in FULL. 0 keeps today's behaviour.
+ #
+ # A count is answered by every relevant session at once, not by the best-ranked one, so a detail
+ # window narrower than the evidence set yields a confident wrong number. Measured on the committed
+ # headline log, the counting failures needed a median of 3 answer sessions while the planner asked
+ # for 1 chunk; coverage inside the detail window was 48% and the numeric errors ran in both
+ # directions — undercounting what was summarised away, overcounting what a summary said twice
+ # (results/retrieval_diagnosis.md).
+ #
+ # MEASURED AND INSUFFICIENT — off by default, and the measurement says leave it there.
+ # On the 28 multi-session counting failures (results/aggregation_coverage.md): coverage of the answer
+ # sessions rose 38% -> 56% at cap=5 and only 59% at cap=12, while rendered sessions went 2.0 -> 4.4.
+ # Raising the budget 2.4x bought three points of coverage, because the subqueries run out of distinct
+ # sessions at about four. The remaining 41% is never selected even though 89% of it sits in the main
+ # query's top-15 — so the binding constraint is which sessions the round-robin picks, not how many it
+ # is allowed to render. Best case +1.2 points, under a conversion of 1 that the same log refutes;
+ # below the 2.94-point resolution floor either way.
+ #
+ # Kept rather than deleted because the knob is inert at 0 and a coverage-driven selector would want
+ # the budget it provides. It is not a fix on its own, and turning it on buys tokens, not accuracy.
+ aggregation_chunk_cap: int = 0
aggregation_constraint_filter: bool = True # mark numeric candidates that violate query constraints as EXCLUDE
procedural_extraction: bool = True # extract durable runbooks/how-to steps into typed procedure facts
summary_fallback: bool = True # answer from derived session summaries when facts cannot answer
diff --git a/engram/connectors/__init__.py b/engram/connectors/__init__.py
index e2792f5..d50ee63 100644
--- a/engram/connectors/__init__.py
+++ b/engram/connectors/__init__.py
@@ -13,6 +13,9 @@
records flat list of {content, [session_id], [speaker], [timestamp]}
jsonl JSON-Lines, one record per line
transcript plain text / markdown ("Speaker: text" lines, or freeform)
+ engram a native `/v1/export` payload (engram_export_version) — NOT parsed into sessions;
+ it restores directly via `Memory.import_export()` (facts keep their ids, bi-temporal
+ stamps, and supersession chains instead of being re-extracted).
"""
from __future__ import annotations
@@ -28,7 +31,7 @@
"extract_text", "to_epoch",
]
-FORMATS = ("chatgpt", "messages", "records", "jsonl", "transcript")
+FORMATS = ("chatgpt", "messages", "records", "jsonl", "transcript", "engram")
def sniff(data: Any) -> str:
@@ -54,6 +57,8 @@ def sniff(data: Any) -> str:
def _sniff_obj(obj: Any) -> str:
if isinstance(obj, dict):
+ if "engram_export_version" in obj:
+ return "engram" # a native export restores directly; it is not a message-shaped history
if "mapping" in obj or "conversations" in obj:
return "chatgpt"
if "messages" in obj:
@@ -89,6 +94,13 @@ def parse(data: Any, format: str = "auto", session_id: str = "imported") -> list
if fmt == "auto":
fmt = sniff(data)
+ if fmt == "engram":
+ raise ValueError(
+ "this is a native Engram export (engram_export_version): it restores facts/episodes "
+ "directly instead of parsing into sessions. Import it via Memory.import_export(payload), "
+ "MemoryService.import_(format='engram'), POST /v1/import {'data': ..., 'format': 'engram'}, "
+ "or `python -m engram.connectors --format engram`."
+ )
if fmt == "transcript" or fmt in ("text", "markdown", "md"):
text = data.decode("utf-8") if isinstance(data, (bytes, bytearray)) else str(data)
return parse_transcript(text, session_id=session_id)
diff --git a/engram/connectors/__main__.py b/engram/connectors/__main__.py
index d377c36..1d7e040 100644
--- a/engram/connectors/__main__.py
+++ b/engram/connectors/__main__.py
@@ -9,11 +9,15 @@
# push into a running Engram server instead of a local store:
python -m engram.connectors -f conversations.json --api-url http://localhost:8000 --key sk-alice-123
+ # migrate a namespace between instances (a native /v1/export payload restores directly):
+ python -m engram.connectors -f export.json -n me # format auto-sniffed as 'engram'
+
# from stdin with an explicit format:
cat log.jsonl | python -m engram.connectors --format jsonl -n me
-This module is pure-stdlib; the remote POST uses urllib (no httpx needed) so import works in the
-zero-setup install.
+Local mode writes through MemoryService, so the CLI uses exactly the same digest-backed namespace
+directories (and locks) as the HTTP/MCP surfaces — an import is immediately visible to a running
+local agent. The remote POST uses urllib (no httpx needed) so import works in the zero-setup install.
"""
from __future__ import annotations
@@ -25,6 +29,7 @@
import urllib.request
from . import parse, sniff
+from .base import load_json
def _read_input(path: str | None) -> str:
@@ -34,45 +39,32 @@ def _read_input(path: str | None) -> str:
return sys.stdin.read()
-def _post_remote(api_url: str, key: str, sessions: list, consolidate: bool, summarize: bool) -> dict:
- body = json.dumps({
- "sessions": [dataclasses.asdict(s) for s in sessions],
- "consolidate": consolidate,
- "summarize": summarize,
- }).encode("utf-8")
+def _post_remote(api_url: str, key: str, body: dict) -> dict:
+ payload = json.dumps(body).encode("utf-8")
headers = {"Content-Type": "application/json"}
if key:
headers["Authorization"] = f"Bearer {key}"
- req = urllib.request.Request(api_url.rstrip("/") + "/v1/import", data=body,
+ req = urllib.request.Request(api_url.rstrip("/") + "/v1/import", data=payload,
headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=600) as resp: # noqa: S310 (user-supplied URL is intentional)
return json.load(resp)
-def _import_local(args, sessions) -> dict:
- from .. import Memory # lazy: keep the connectors package importable without the full facade graph
-
- embedder = llm = None
- if args.embedder or args.llm:
- from ..llm.providers import load_dotenv, make_embedder, make_llm
- load_dotenv()
- if args.embedder:
- embedder = make_embedder(args.embedder)
- if args.llm:
- llm = make_llm(args.llm)
-
- os.makedirs(os.path.expanduser(args.data_dir), exist_ok=True)
- safe = "".join(c for c in args.namespace if c.isalnum() or c in "-_.") or "me"
- base = os.path.expanduser(args.data_dir)
- path = os.path.join(base, safe)
- legacy = os.path.join(base, f"{safe}.pkl")
- if os.path.exists(legacy) and not os.path.exists(path):
- path = legacy
- mem = Memory.open(path, embedder=embedder, llm=llm)
- stats = mem.import_messages(sessions, user_id=args.namespace,
- consolidate=not args.no_consolidate, summarize=not args.no_summarize)
- mem.save()
- stats["store"] = path
+def _service(args):
+ from ..service import MemoryService # lazy: keep the parser-only paths import-light
+
+ return MemoryService(data_dir=os.path.expanduser(args.data_dir),
+ embedder_name=args.embedder, llm_name=args.llm)
+
+
+def _import_local(args, sessions=None, engram_payload=None) -> dict:
+ svc = _service(args)
+ if engram_payload is not None:
+ stats = svc.import_(args.namespace, data=engram_payload, format="engram")
+ else:
+ stats = svc.import_(args.namespace, sessions=sessions,
+ consolidate=not args.no_consolidate, summarize=not args.no_summarize)
+ stats["store"] = svc._path(args.namespace)
return stats
@@ -81,7 +73,7 @@ def main() -> None:
description="Import a chat/history export into Engram memory.")
ap.add_argument("--file", "-f", help="export file (default: stdin)")
ap.add_argument("--format", default="auto",
- help="chatgpt | messages | records | jsonl | transcript | auto (default)")
+ help="chatgpt | messages | records | jsonl | transcript | engram | auto (default)")
ap.add_argument("--namespace", "-n", default="me", help="memory namespace / user id (default: me)")
ap.add_argument("--data-dir", default=os.environ.get("ENGRAM_DATA_DIR", "~/.engram/data"),
help="local store dir (local mode; default ~/.engram/data)")
@@ -95,7 +87,23 @@ def main() -> None:
args = ap.parse_args()
raw = _read_input(args.file)
- fmt = sniff(raw) if args.format == "auto" else args.format
+ fmt = sniff(raw) if args.format == "auto" else args.format.lower().strip()
+
+ if fmt == "engram":
+ payload = load_json(raw)
+ n_facts = len(payload.get("facts") or []) if isinstance(payload, dict) else 0
+ n_eps = len(payload.get("episodes") or []) if isinstance(payload, dict) else 0
+ print(f"parsed native Engram export: {n_facts} fact(s), {n_eps} episode(s) [format: engram]",
+ file=sys.stderr)
+ if args.dry_run:
+ return
+ if args.api_url:
+ stats = _post_remote(args.api_url, args.key, {"data": payload, "format": "engram"})
+ else:
+ stats = _import_local(args, engram_payload=payload)
+ print(json.dumps(stats, ensure_ascii=False))
+ return
+
sessions = parse(raw, format=args.format)
n_msgs = sum(len(s.messages) for s in sessions)
print(f"parsed {len(sessions)} session(s), {n_msgs} message(s) [format: {fmt}]", file=sys.stderr)
@@ -110,10 +118,13 @@ def main() -> None:
return
if args.api_url:
- stats = _post_remote(args.api_url, args.key, sessions,
- not args.no_consolidate, not args.no_summarize)
+ stats = _post_remote(args.api_url, args.key, {
+ "sessions": [dataclasses.asdict(s) for s in sessions],
+ "consolidate": not args.no_consolidate,
+ "summarize": not args.no_summarize,
+ })
else:
- stats = _import_local(args, sessions)
+ stats = _import_local(args, sessions=sessions)
print(json.dumps(stats, ensure_ascii=False))
diff --git a/engram/localize.py b/engram/localize.py
index cf45ef7..64e3dc5 100644
--- a/engram/localize.py
+++ b/engram/localize.py
@@ -31,7 +31,7 @@
"owns": "拥有 {o}", "has": "有 {o}", "has_pet": "养了 {o}",
"visited": "去过 {o}", "traveled_to": "去过 {o}", "been_to": "去过 {o}", "went_to": "去过 {o}",
"bought": "买了 {o}", "ordered": "点了 {o}", "plans": "计划 {o}", "plans_to": "计划 {o}",
- "wants": "想要 {o}", "needs": "需要 {o}", "goal": "目标:{o}", "uses": "用 {o}",
+ "wants": "想要 {o}", "needs": "需要 {o}", "goal": "目标:{o}",
"has_disease": "患有 {o}", "medication": "在用药 {o}", "salary": "薪资 {o}", "income": "收入 {o}",
"plays": "会 {o}", "watches": "看 {o}", "reads": "读 {o}", "listens_to": "听 {o}",
"studies": "学 {o}", "learns": "学 {o}", "learning": "在学 {o}", "drives": "开 {o}",
diff --git a/engram/mcp/__main__.py b/engram/mcp/__main__.py
index 60fc097..aa10822 100644
--- a/engram/mcp/__main__.py
+++ b/engram/mcp/__main__.py
@@ -7,8 +7,9 @@
# proxy a running Engram HTTP server (hosted / multi-tenant):
python -m engram.mcp --api-url http://localhost:8000 --api-key sk-alice-123
- # serve over streamable HTTP instead of stdio:
+ # serve over streamable HTTP instead of stdio (loopback only unless a token is set):
python -m engram.mcp --http --port 8765
+ python -m engram.mcp --http --host 0.0.0.0 --http-token # remote MCP clients
Claude Desktop config (claude_desktop_config.json):
{"mcpServers": {"engram": {"command": "python", "args": ["-m", "engram.mcp"]}}}
@@ -16,9 +17,56 @@
from __future__ import annotations
import argparse
+import hmac
import os
import sys
+_LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}
+
+
+class _BearerGate:
+ """Minimal ASGI gate: require `Authorization: Bearer ` on every HTTP request.
+
+ The MCP streamable-HTTP transport has no authentication of its own, so anyone who can reach the
+ port can read and write the memory namespace behind it. Loopback binding is the default guard;
+ this gate is what makes a non-loopback bind safe. Same failure-closed philosophy as the REST
+ server's ENGRAM_API_KEYS.
+ """
+
+ def __init__(self, app, token: str) -> None:
+ self.app = app
+ self.token = token
+
+ async def __call__(self, scope, receive, send) -> None:
+ if scope["type"] != "http":
+ await self.app(scope, receive, send)
+ return
+ auth = ""
+ for key, value in scope.get("headers") or []:
+ if key.decode("latin-1").lower() == "authorization":
+ auth = value.decode("latin-1")
+ break
+ supplied = auth[7:].strip() if auth.startswith("Bearer ") else ""
+ if not (supplied and hmac.compare_digest(supplied, self.token)):
+ await send({"type": "http.response.start", "status": 401,
+ "headers": [(b"content-type", b"application/json"),
+ (b"www-authenticate", b"Bearer")]})
+ await send({"type": "http.response.body", "body": b'{"error":"unauthorized"}'})
+ return
+ await self.app(scope, receive, send)
+
+
+def _require_http_token(host: str, token: str, allow_open: bool) -> None:
+ """Fail closed: refuse a non-loopback --http bind without a token unless explicitly opened."""
+ if host in _LOOPBACK_HOSTS or token or allow_open:
+ return
+ raise SystemExit(
+ f"refusing to serve unauthenticated MCP over HTTP on non-loopback host {host!r}: anyone who can "
+ "reach this port could read and write this memory namespace. Set --http-token (or "
+ "ENGRAM_MCP_HTTP_TOKEN), keep the default 127.0.0.1 bind behind a reverse proxy, or set "
+ "ENGRAM_MCP_HTTP_OPEN=1 only if an external layer already enforces access control."
+ )
+
def main() -> None:
ap = argparse.ArgumentParser(
@@ -27,6 +75,9 @@ def main() -> None:
ap.add_argument("--http", action="store_true", help="serve over streamable HTTP instead of stdio")
ap.add_argument("--host", default="127.0.0.1", help="bind host for --http (default 127.0.0.1)")
ap.add_argument("--port", type=int, default=8765, help="bind port for --http (default 8765)")
+ ap.add_argument("--http-token", default=os.environ.get("ENGRAM_MCP_HTTP_TOKEN", ""),
+ help="Bearer token required on every --http request "
+ "(default $ENGRAM_MCP_HTTP_TOKEN; mandatory for non-loopback binds)")
ap.add_argument("--namespace", "-n", help="local memory namespace (default $ENGRAM_NAMESPACE or 'me')")
ap.add_argument("--api-url", help="proxy a running Engram server (default $ENGRAM_API_URL); else local")
ap.add_argument("--api-key", help="Bearer key for --api-url (default $ENGRAM_API_KEY)")
@@ -46,10 +97,20 @@ def main() -> None:
b = backend()
print(f"engram_mcp ready · {b.describe()}", file=sys.stderr)
if args.http:
+ allow_open = os.environ.get("ENGRAM_MCP_HTTP_OPEN", "").strip().lower() in {"1", "true", "yes", "on"}
+ _require_http_token(args.host, args.http_token, allow_open)
mcp.settings.host = args.host
mcp.settings.port = args.port
- print(f" transport: streamable-http on http://{args.host}:{args.port}/mcp", file=sys.stderr)
- mcp.run(transport="streamable-http")
+ guard = "Bearer token required" if args.http_token else "no token (loopback/explicit-open)"
+ print(f" transport: streamable-http on http://{args.host}:{args.port}/mcp · {guard}",
+ file=sys.stderr)
+ if args.http_token:
+ import uvicorn
+
+ uvicorn.run(_BearerGate(mcp.streamable_http_app(), args.http_token),
+ host=args.host, port=args.port)
+ else:
+ mcp.run(transport="streamable-http")
else:
print(" transport: stdio", file=sys.stderr)
mcp.run()
diff --git a/engram/mcp/server.py b/engram/mcp/server.py
index c8d7c38..c77c453 100644
--- a/engram/mcp/server.py
+++ b/engram/mcp/server.py
@@ -656,20 +656,28 @@ async def engram_delete_fact(
async def engram_import(
content: Annotated[str | list | dict,
Field(description="The raw export to import: a ChatGPT conversations.json, an "
- "OpenAI messages array, JSON-Lines, or a plain 'Speaker: text' transcript. "
+ "OpenAI messages array, JSON-Lines, a plain 'Speaker: text' transcript, or "
+ "a native Engram export from engram_export (restores facts/episodes with "
+ "their ids and history intact — the migration path between instances). "
"Paste the file contents (JSON text, an array, or transcript text).")],
- format: Annotated[str, Field(description="chatgpt | messages | records | jsonl | transcript | auto "
- "(default, sniffs the shape).")] = "auto",
+ format: Annotated[str, Field(description="chatgpt | messages | records | jsonl | transcript | "
+ "engram | auto (default, sniffs the shape).")] = "auto",
) -> str:
"""Bulk-import an exported chat history into memory in one batched pass (extract facts + summaries).
Use this to seed memory from an existing history (e.g. the user's ChatGPT export) rather than
- replaying it message by message. `format='auto'` detects the shape.
+ replaying it message by message. A native Engram export restores directly instead of re-extracting.
+ `format='auto'` detects the shape.
"""
try:
data = await backend().import_(content, format=format)
except Exception as e: # noqa: BLE001
return _err(e)
+ if data.get("format") == "engram":
+ return (f"Restored Engram export: {data.get('facts', 0)} fact(s) "
+ f"({data.get('facts_skipped', 0)} already present), {data.get('episodes', 0)} episode(s) "
+ f"({data.get('episodes_skipped', 0)} already present), "
+ f"{data.get('summaries', 0)} summary(ies).")
return (f"Imported {data.get('sessions', 0)} session(s) / {data.get('episodes', 0)} episode(s); "
f"extracted {data.get('facts_added', 0)} fact(s), {data.get('summaries', 0)} summary(ies).")
diff --git a/engram/memory.py b/engram/memory.py
index 5cb8758..095733b 100644
--- a/engram/memory.py
+++ b/engram/memory.py
@@ -32,8 +32,10 @@
render_aggregation_candidates,
)
from .retrieve.lexical import bm25_scores, overlap_terms, stems
+from .retrieve.rerank import rerank_long
from .store import (
GraphStore,
+ IndexedVectorStore,
InMemoryDocStore,
InMemoryGraphStore,
InMemoryVectorStore,
@@ -157,7 +159,13 @@ def make_vector_store(name: str) -> VectorStore:
self.episodes_doc = InMemoryDocStore()
self.episodes_vec = make_vector_store("episodes_vec")
- self.fact_store = make_vector_store("fact_store") # HOT tier: the fast, frequently-retrieved working set
+ # HOT tier: the fast, frequently-retrieved working set. Decorated with a lexical/slot index when
+ # bounded candidate retrieval is on, so the retriever can pick candidates without scanning every
+ # fact. A decorator (not a new store) keeps every existing .upsert()/.delete() call site — here,
+ # in the consolidation engine, and in the persistence loader — indexing for free.
+ self.fact_store = make_vector_store("fact_store")
+ if self.config.bounded_candidates:
+ self.fact_store = IndexedVectorStore(self.fact_store)
self.cold_store = make_vector_store("cold_store") # COLD tier: aged-out facts, preserved (never deleted)
self.summary_vec = make_vector_store("summary_vec") # L2 session summaries, retrievable for a lean read slice
self.graph = graph_store_factory()
@@ -447,11 +455,142 @@ def _coerce(s) -> Optional[ImportSession]:
def import_data(self, data, format: str = "auto", user_id: str = "default",
session_id: str = "imported", **kwargs) -> dict[str, int]:
"""Convenience: parse a raw export (ChatGPT/OpenAI/JSONL/transcript — auto-sniffed) and import it
- in one call. See `engram.connectors.parse` for formats."""
- from .connectors import parse
+ in one call. A native Engram export (engram_export_version) routes to `import_export()` instead —
+ it restores facts/episodes directly rather than re-extracting them from parsed sessions."""
+ from .connectors import parse, sniff
+ from .connectors.base import load_json
+ fmt = (format or "auto").lower().strip()
+ if fmt == "engram" or (fmt == "auto" and sniff(data) == "engram"):
+ return self.import_export(load_json(data), user_id=user_id)
return self.import_messages(parse(data, format=format, session_id=session_id),
user_id=user_id, **kwargs)
+ def import_export(self, payload: dict, user_id: str = "default") -> dict:
+ """Restore a native `export()` payload — the migration path between Engram instances.
+
+ This is what makes memory belong to the user rather than to one deployment: facts arrive with
+ their original ids, bi-temporal stamps, supersession chains, and provenance intact (no
+ re-extraction), and the graph is rebuilt from them through the canonical GraphBuilder path.
+ Three invariants:
+ * Idempotent by id — an item whose id already exists locally is SKIPPED, never overwritten
+ (existing memory wins; re-importing the same export cannot duplicate or corrupt anything).
+ * Re-embedded locally — embeddings are never part of an export, so the target's embedder
+ regenerates them. This is also the supported way to migrate a store between embedders.
+ * No System-2 replay — imported episodes are marked consolidated because their extracted
+ facts are already in the payload; re-running extraction would mint duplicate facts.
+ Works on both export flavors: share-safe (facts + graph only) and include_sensitive=true
+ (full fidelity). Raises ValueError on a payload this build cannot read.
+ """
+ if not isinstance(payload, dict) or "engram_export_version" not in payload:
+ raise ValueError("not an Engram export: missing 'engram_export_version'")
+ version = payload.get("engram_export_version")
+ if version != 1:
+ raise ValueError(f"unsupported engram_export_version {version!r}; this build reads version 1")
+ user = self.resolver.resolve(user_id)
+
+ new_eps: list[Episode] = []
+ episodes_skipped = 0
+ for e in payload.get("episodes") or []:
+ if not isinstance(e, dict) or not str(e.get("content") or ""):
+ continue
+ eid = str(e.get("id") or "")
+ if eid and self.episodes_doc.get(eid) is not None:
+ episodes_skipped += 1
+ continue
+ ep = Episode(
+ content=str(e.get("content", "")),
+ user_id=user,
+ session_id=str(e.get("session_id") or "imported"),
+ speaker=str(e.get("speaker") or "session"),
+ event_time=float(e.get("event_time") or now()),
+ consolidated=True,
+ summary=str(e.get("summary") or ""),
+ )
+ if eid:
+ ep.id = eid
+ ep.metadata["date"] = str(e.get("date") or fmt_date(ep.event_time))
+ ep.metadata["source"] = "engram-export"
+ new_eps.append(ep)
+ if new_eps:
+ vecs = self.embedder.embed_batch([ep.content for ep in new_eps])
+ for ep, vec in zip(new_eps, vecs):
+ ep.embedding = vec
+ self.episodes_doc.put(ep.id, ep)
+ self.episodes_vec.upsert(ep.id, vec, ep)
+ with_summary = [ep for ep in new_eps if ep.summary]
+ if with_summary:
+ svecs = self.embedder.embed_batch([ep.summary for ep in with_summary])
+ for ep, vec in zip(with_summary, svecs):
+ ep.summary_embedding = vec
+ self.summary_vec.upsert(ep.id, vec, ep)
+
+ new_facts: list[Fact] = []
+ facts_skipped = 0
+ for fd in payload.get("facts") or []:
+ if not isinstance(fd, dict):
+ continue
+ fid = str(fd.get("id") or "")
+ if fid and (self.fact_store.get(fid) is not None or self.cold_store.get(fid) is not None):
+ facts_skipped += 1
+ continue
+ f = Fact(
+ subject=str(fd.get("subject") or ""),
+ predicate=str(fd.get("predicate") or ""),
+ object=str(fd.get("object") or ""),
+ text=str(fd.get("text") or ""),
+ display=str(fd.get("display") or ""),
+ user_id=user,
+ salience=float(fd.get("salience") or 1.0),
+ confidence=float(fd.get("confidence") or 1.0),
+ source=str(fd.get("source") or "extracted"),
+ category=str(fd.get("category") or ""),
+ sensitive=bool(fd.get("sensitive", False)),
+ valid_at=float(fd.get("valid_at") or now()),
+ invalid_at=float(fd["invalid_at"]) if fd.get("invalid_at") is not None else None,
+ created_at=float(fd.get("created_at") or now()),
+ expired_at=float(fd["expired_at"]) if fd.get("expired_at") is not None else None,
+ supersedes=str(fd["supersedes"]) if fd.get("supersedes") else None,
+ provenance=[str(p) for p in (fd.get("provenance") or [])],
+ )
+ if fid:
+ f.id = fid
+ new_facts.append(f)
+ if new_facts:
+ vecs = self.embedder.embed_batch([f.text for f in new_facts])
+ for f, vec in zip(new_facts, vecs):
+ f.embedding = vec
+ self.fact_store.upsert(f.id, vec, f)
+ # GraphBuilder copies valid_at/invalid_at onto the relation, so a superseded fact's
+ # edge arrives already invalidated — history moves as history, never resurrected.
+ self.engine.graph_builder.add_fact(f)
+
+ focus = payload.get("focus") or {}
+ focus_terms_added = 0
+ for key in ("track", "mute"):
+ incoming = [str(t).strip() for t in (focus.get(key) or []) if str(t).strip()]
+ if not incoming:
+ continue
+ merged = list(dict.fromkeys([*self.focus.get(key, []), *incoming]))
+ focus_terms_added += len(merged) - len(self.focus.get(key, []))
+ self.focus[key] = merged
+ if focus_terms_added:
+ self.apply_focus()
+
+ self._enforce_hot_limit()
+ self._persona_cache.clear()
+ return {
+ "format": "engram",
+ "engram_export_version": 1,
+ "sessions": len({ep.session_id for ep in new_eps}),
+ "episodes": len(new_eps),
+ "episodes_skipped": episodes_skipped,
+ "facts": len(new_facts),
+ "facts_skipped": facts_skipped,
+ "facts_added": len(new_facts),
+ "summaries": sum(1 for ep in new_eps if ep.summary),
+ "focus_terms_added": focus_terms_added,
+ }
+
def link_identity(self, a: str, b: str) -> str:
return self.resolver.link(a, b)
@@ -1882,6 +2021,7 @@ def lean_context(
plan_evidence(
query,
aggregation_recall_expansion=self.config.aggregation_recall_expansion,
+ aggregation_chunk_cap=self.config.aggregation_chunk_cap,
)
if self.config.evidence_planner
else None
@@ -2167,6 +2307,15 @@ def lean_context(
return self._fit_blocks_by_evidence_budget(blocks, char_budget, need)
return assembled[:char_budget]
+ def layered_context(self, query: str, user_id: str = "default", **kwargs):
+ """`lean_context` split into a cacheable half and a per-query half (see retrieve/layered.py).
+
+ Same evidence, same retrieval — it only changes where a caller can put each part, so a multi-turn
+ session stops re-sending the profile and the memory map on every turn."""
+ from .retrieve.layered import layered_context
+
+ return layered_context(self, query, user_id=user_id, **kwargs)
+
# --- read path ---
def search(
self,
@@ -2298,7 +2447,16 @@ def retrieve_episodes(
eps = [eps[i] for i in fused_order]
if self.reranker is not None and len(eps) > k:
- ranked = self.reranker.rerank(query, [(i, ep.content) for i, ep in enumerate(eps)], k)
+ # Segment-level, because a session is ~2000 tokens and the cross-encoder reads ~512: scoring
+ # whole sessions silently ranks each one on its opening quarter (the known _S regression
+ # noted in lean_context). Each session scores as its best segment.
+ ranked = rerank_long(
+ self.reranker,
+ query,
+ [(i, ep.content) for i, ep in enumerate(eps)],
+ k,
+ max_words=self.config.rerank_segment_words,
+ )
return [eps[i] for i, _ in ranked]
return eps[:k]
diff --git a/engram/metrics.py b/engram/metrics.py
new file mode 100644
index 0000000..3ee82a7
--- /dev/null
+++ b/engram/metrics.py
@@ -0,0 +1,131 @@
+"""Live service metrics — the charter's own discipline (Bet D) turned on the running service.
+
+The architecture states a <50ms target for the write path and <100ms for the read path. Without live
+percentiles those are assertions, not measurements, and the same goes for the token-saving claim: it is
+reported from offline benchmark logs but never from what the service actually served.
+
+Pure stdlib, fixed memory, and **aggregate-only by construction** — operation latencies, counters, and
+token totals. No namespace names, no queries, no content, so the endpoint can stay as open as /health
+without leaking one tenant's existence to another.
+"""
+from __future__ import annotations
+
+import functools
+import threading
+import time
+from collections import Counter, defaultdict, deque
+from typing import Optional
+
+__all__ = ["Metrics", "timed"]
+
+
+def _pct(sorted_vals: list[float], q: float) -> float:
+ """Nearest-rank percentile over a sorted sample. No interpolation — for an SLO readout the extra
+ precision would be false: the sample is a bounded window, not the full population."""
+ if not sorted_vals:
+ return 0.0
+ index = min(int(q * (len(sorted_vals) - 1) + 0.5), len(sorted_vals) - 1)
+ return sorted_vals[index]
+
+
+class Metrics:
+ """Thread-safe metrics with a bounded footprint.
+
+ Latencies live in a sliding window per operation, so percentiles describe how the service is behaving
+ *now* rather than averaging away a regression under months of history. Counters are monotonic.
+ """
+
+ def __init__(self, window: int = 512) -> None:
+ self._lock = threading.Lock()
+ self._lat: dict[str, deque] = defaultdict(lambda: deque(maxlen=window))
+ self._counts: Counter = Counter()
+ # Token accounting is kept in two buckets on purpose. `_ctx_total` is every served context, which
+ # is the honest total volume. The savings ratio may only be computed from calls where BOTH sides
+ # were measured -- dividing a total full-history figure by a total context figure would compare
+ # different sets of calls and understate the saving whenever a caller skipped the baseline.
+ self._ctx_total = 0
+ self._paired_ctx = 0
+ self._paired_full = 0
+ self._paired_n = 0
+ self._started = time.time()
+
+ # --- recording ---
+
+ def observe(self, op: str, seconds: float) -> None:
+ with self._lock:
+ self._lat[op].append(seconds)
+ self._counts[op] += 1
+
+ def count(self, name: str, n: int = 1) -> None:
+ with self._lock:
+ self._counts[name] += n
+
+ def tokens(self, context: int, full: Optional[int] = None) -> None:
+ """Record one served context's size, and the full-history baseline when the caller computed it."""
+ with self._lock:
+ self._ctx_total += int(context)
+ if full is not None:
+ self._paired_ctx += int(context)
+ self._paired_full += int(full)
+ self._paired_n += 1
+
+ # --- reading ---
+
+ def snapshot(self) -> dict:
+ """The /metrics payload: numbers only, no user data by construction."""
+ with self._lock:
+ ops = {}
+ for op, window in self._lat.items():
+ if not window:
+ continue
+ sample = sorted(window)
+ ops[op] = {
+ "n": self._counts[op],
+ "p50_ms": round(_pct(sample, 0.50) * 1000, 2),
+ "p95_ms": round(_pct(sample, 0.95) * 1000, 2),
+ "avg_ms": round(sum(sample) / len(sample) * 1000, 2),
+ "max_ms": round(sample[-1] * 1000, 2),
+ "window": len(sample),
+ }
+ counts = {k: v for k, v in self._counts.items() if k not in ops}
+ tokens = {
+ "context_total": self._ctx_total,
+ "baseline_context_total": self._paired_ctx,
+ "baseline_full_total": self._paired_full,
+ "calls_with_baseline": self._paired_n,
+ # The live version of the headline "~8x fewer tokens": full history over served context,
+ # across the calls that measured both. None until at least one such pair exists -- a
+ # made-up ratio would be worse than no ratio.
+ "savings_ratio": (
+ round(self._paired_full / self._paired_ctx, 2) if self._paired_ctx else None
+ ),
+ }
+ return {
+ "uptime_s": round(time.time() - self._started, 1),
+ "ops": ops,
+ "counts": counts,
+ "tokens": tokens,
+ }
+
+
+def timed(op: str):
+ """Record a MemoryService method's wall-clock under `op`.
+
+ Reads `self.metrics` at call time rather than binding it at decoration, so a service constructed
+ without metrics (or re-initialised) still works and the decorator costs nothing at import.
+ """
+
+ def deco(fn):
+ @functools.wraps(fn)
+ def wrapper(self, *args, **kwargs):
+ started = time.perf_counter()
+ try:
+ return fn(self, *args, **kwargs)
+ finally:
+ meter = getattr(self, "metrics", None)
+ if meter is not None:
+ meter.observe(op, time.perf_counter() - started)
+
+ return wrapper
+
+ return deco
diff --git a/engram/retrieve/evidence.py b/engram/retrieve/evidence.py
index 99dbeff..8a7d24f 100644
--- a/engram/retrieve/evidence.py
+++ b/engram/retrieve/evidence.py
@@ -254,7 +254,11 @@ def _multi_hop_subqueries(query: str) -> tuple[str, ...]:
return _dedupe(candidates, query)
-def plan_evidence(query: str, aggregation_recall_expansion: bool = True) -> EvidenceNeed:
+def plan_evidence(
+ query: str,
+ aggregation_recall_expansion: bool = True,
+ aggregation_chunk_cap: int = 0,
+) -> EvidenceNeed:
"""Return the evidence structure a question needs, using only question text.
The output is deliberately coarse and explainable; it never inspects benchmark labels or gold answers.
@@ -354,6 +358,25 @@ def plan_evidence(query: str, aggregation_recall_expansion: bool = True) -> Evid
subquery_items.extend(_multi_hop_subqueries(query))
subqueries = _dedupe(subquery_items, query)
+ # A count needs COVERAGE, not ranking. Everything else asks "which session answers this?" and the
+ # best-ranked one does; "how many X have I done" is answered by all of them at once, and a detail
+ # window narrower than the evidence set produces a confident wrong number rather than a miss.
+ #
+ # Measured on the committed headline log: the counting failures needed a median of 3 answer sessions
+ # and this planner already generated a median of 3 subqueries to find them — then asked for 1 chunk.
+ # Coverage of the answer sessions inside the detail window came out at 48%, and the numeric errors ran
+ # in both directions, which is the signature of counting from a subset rather than of bad retrieval
+ # (results/retrieval_diagnosis.md).
+ #
+ # The subquery count is the right budget because it is derived from the question alone — the same
+ # signal at eval time and in production. Sizing off the benchmark's answer_session_ids would score
+ # well and ship nothing.
+ if aggregation and subqueries and aggregation_chunk_cap:
+ # +1 so the original question keeps a slot of its own alongside the decomposed angles. Capped
+ # because the budget has to stay bounded: without a ceiling a six-way decomposition would render
+ # seven full sessions and walk the lean context back toward the full-history baseline it beats.
+ n_chunks = max(n_chunks, min(len(subqueries) + 1, aggregation_chunk_cap))
+
return EvidenceNeed(
kinds=kinds,
timeline=timeline,
diff --git a/engram/retrieve/hybrid.py b/engram/retrieve/hybrid.py
index 00bb0c1..18fbf12 100644
--- a/engram/retrieve/hybrid.py
+++ b/engram/retrieve/hybrid.py
@@ -9,27 +9,17 @@
from ..consolidate.conflict import is_single_valued
from ..embed import Embedder
from ..store import GraphStore, VectorStore
-from ..types import Fact
-from ..util import cosine, fmt_date, now, recency, tokenize
+from ..types import Entity, Fact
+from ..util import cosine, indexed_text, now, recency, tokenize
+from ..util import date_terms as date_terms # noqa: F401 re-export: callers import it from here
from .fusion import order_by_score, weighted_rrf
from .lexical import bm25_scores, stem, stems
-_MONTHS = ("january", "february", "march", "april", "may", "june", "july", "august",
- "september", "october", "november", "december")
-_GRAPH_HOP_DECAY = 0.65
-
+# `date_terms` now lives in engram.util so the lexical index can tokenize facts identically — the index's
+# corpus statistics must describe the same documents the scorer scores. Imported (not redefined) here
+# because callers already do `from .hybrid import date_terms`.
-def date_terms(epoch: float) -> str:
- """Render a fact's date as searchable tokens (year, numeric month, month name) so a query that names
- a time ('May 2023', 'in 2024') matches the right-dated facts via BM25 — dates otherwise live only in
- valid_at and are invisible to retrieval. This is query-time temporal matching done as a lexical signal
- (MemoryScope time_ratio in spirit), with no score multiplier that could override relevance."""
- try:
- d = fmt_date(epoch) # YYYY-MM-DD
- y, m, _ = d.split("-")
- return f"{d} {y} {m} {_MONTHS[int(m) - 1]}"
- except Exception: # noqa: BLE001
- return ""
+_GRAPH_HOP_DECAY = 0.65
# Predicates that mark a durable identity or preference fact (vs. an incidental event mention). Used for
# type-weighted fusion — these get a retrieval boost (CLAUDE.md §3.3; MemoryScope/OMEGA convergent finding).
@@ -73,10 +63,38 @@ def date_terms(epoch: float) -> str:
"the", "and", "for", "with", "from", "inc", "ltd", "llc", "corp", "co", "company", "project",
"user", "assistant", "team", "group", "system", "ai",
})
+# (cue, how many words may sit between the cue and the entity name). Both exclusion regexes are built
+# from this one list so the cheap pre-test below can never drift out of sync with the real matcher.
+_EXCLUSION_CUES: tuple[tuple[str, int], ...] = (
+ (r"\bnot\b", 5),
+ (r"\bexcept\b", 4),
+ (r"\bexcluding\b", 4),
+ (r"\bexclude\b", 4),
+ (r"\bother\s+than\b", 4),
+ (r"\brather\s+than\b", 4),
+ (r"\bbesides\b", 4),
+ ("不是", 0),
+ ("不在", 0),
+ ("排除", 0),
+ ("除了", 0),
+)
_EXCLUSION_BEFORE_RE = re.compile(
- r"(?:\bnot\b(?:\s+\w+){0,5}|\bexcept\b(?:\s+\w+){0,4}|\bexcluding\b(?:\s+\w+){0,4}|"
- r"\bexclude\b(?:\s+\w+){0,4}|\bother\s+than\b(?:\s+\w+){0,4}|"
- r"\brather\s+than\b(?:\s+\w+){0,4}|\bbesides\b(?:\s+\w+){0,4}|不是|不在|排除|除了)\s*$",
+ "(?:"
+ + "|".join(cue + (rf"(?:\s+\w+){{0,{gap}}}" if gap else "") for cue, gap in _EXCLUSION_CUES)
+ + r")\s*$",
+ re.IGNORECASE,
+)
+# A necessary condition for _EXCLUSION_BEFORE_RE to match any substring of the query: the query must
+# contain at least one cue somewhere. _EXCLUSION_BEFORE_RE is anchored to the end of the text preceding
+# an entity mention, so it cannot be run against the whole query directly — but if no cue appears at all,
+# no slice of the query can contain one either, and the entity scan can be skipped outright.
+#
+# The trailing \b is dropped deliberately, making this test weaker than the real matcher. It has to be:
+# the slice ends where an entity name begins, and a non-ASCII name carries no boundary guard, so in
+# "not上海" the slice "not" ends on a word boundary that does not exist in the full string. Over-matching
+# only costs a scan that finds nothing; under-matching would silently drop an exclusion.
+_EXCLUSION_CUE_RE = re.compile(
+ "|".join(cue[:-2] if cue.endswith(r"\b") else cue for cue, _ in _EXCLUSION_CUES),
re.IGNORECASE,
)
_EXCLUSION_VALUE_PREDS = frozenset({
@@ -131,12 +149,21 @@ def graph_relation_relevance(query: str, fact: Fact) -> float:
return 0.0
+def _is_anchor_term(term: str) -> bool:
+ """Whether a stemmed term is distinctive enough to anchor a query to a single entity.
+
+ A property of the term alone, never of the entity holding it — which is what lets the same decision be
+ made from an index keyed on terms as from a walk over every entity.
+ """
+ return len(term) >= 3 and not term.isdigit() and term not in _ENTITY_ANCHOR_STOP
+
+
def _entity_anchor_terms(name: str, aliases: list[str]) -> set[str]:
terms: set[str] = set()
for text in (name, *aliases):
for tok in tokenize(text):
term = stem(tok)
- if len(term) >= 3 and not term.isdigit() and term not in _ENTITY_ANCHOR_STOP:
+ if _is_anchor_term(term):
terms.add(term)
return terms
@@ -171,20 +198,47 @@ def __init__(self, fact_store: VectorStore, graph: GraphStore, embedder: Embedde
from ..embed import HashingEmbedder
self._semantic = not isinstance(embedder, HashingEmbedder)
+ def _anchor_scope(self, q: set[str], user_id: str) -> tuple[list[Entity], Optional[dict[str, set[str]]]]:
+ """Entities worth testing against the query, and per-term owner sets when the backend indexes them.
+
+ An entity can only anchor a query it shares a term with, so walking the whole store to find that
+ out is wasted work. When the graph can look terms up, both the candidate list and the alias-anchor
+ uniqueness counts come straight from the query's own terms. Backends without the lookup fall back
+ to the full scan, which is what the GraphStore interface actually guarantees.
+ """
+ lookup = getattr(self.graph, "entities_by_terms", None)
+ if lookup is None:
+ return [ent for ent in self.graph.entities.values() if ent.user_id == user_id], None
+ hits = lookup(user_id, q)
+ by_term = {term: {ent.id for ent in ents} for term, ents in hits.items()}
+ seen: dict[str, Entity] = {}
+ for ents in hits.values():
+ for ent in ents:
+ seen[ent.id] = ent
+ return list(seen.values()), by_term
+
def query_entity_ids(self, query: str, user_id: str) -> set[str]:
"""Entity nodes whose full name appears in the query (the query's anchor entities)."""
q = set(stems(query)) | set(tokenize(query))
ids: set[str] = set()
- entities = [ent for ent in self.graph.entities.values() if ent.user_id == user_id]
+ entities, indexed_terms = self._anchor_scope(q, user_id)
for ent in entities:
names = (ent.name, *ent.aliases)
if any((toks := [stem(t) for t in tokenize(name)]) and all(t in q for t in toks) for name in names):
ids.add(ent.id)
if self.config.graph_entity_alias_anchor:
- term_to_ids: dict[str, set[str]] = {}
- for ent in entities:
- for term in _entity_anchor_terms(ent.name, ent.aliases):
- term_to_ids.setdefault(term, set()).add(ent.id)
+ if indexed_terms is None:
+ term_to_ids: dict[str, set[str]] = {}
+ for ent in entities:
+ for term in _entity_anchor_terms(ent.name, ent.aliases):
+ term_to_ids.setdefault(term, set()).add(ent.id)
+ else:
+ # The index keys on every name/alias term; the anchor filter tests the term, not the
+ # entity (see _is_anchor_term), so applying it to the query's terms selects exactly the
+ # owner sets the full build would have produced.
+ term_to_ids = {
+ term: owners for term, owners in indexed_terms.items() if _is_anchor_term(term)
+ }
for term in q:
hits = term_to_ids.get(term)
if hits is not None and len(hits) == 1:
@@ -207,6 +261,11 @@ def graph_excluded_entity_ids(self, query: str, user_id: str) -> set[str]:
if not (self.config.graph_proximity and self.config.graph_negative_constraints):
return set()
query_l = query.lower()
+ # Most queries carry no negation at all. Checking the query once is exactly equivalent to
+ # checking every entity name against it (see _EXCLUSION_CUE_RE) and skips a scan of the whole
+ # entity set — which every retrieval paid, since query_entity_ids() ends by calling this.
+ if not _EXCLUSION_CUE_RE.search(query_l):
+ return set()
direct: set[str] = set()
entities = [ent for ent in self.graph.entities.values() if ent.user_id == user_id]
for ent in entities:
@@ -339,11 +398,76 @@ def _current_slot_heads(self, facts: list[Fact]) -> list[Fact]:
if not is_single_valued(fact.predicate) or heads.get(fact.slot) is fact
]
+ def _bounded_candidates(
+ self, query: str, user_id: str, as_of: Optional[float], qvec: list[float]
+ ) -> Optional[list[Fact]]:
+ """A bounded candidate set for fusion, or None when the store has no index to select from.
+
+ Three channels are unioned, because dropping any one loses recall the hybrid thesis depends on
+ (CLAUDE.md M1 — facts-only lost recall; so does vectors-only):
+
+ * **lexical** — exact names, numbers, dates. Vector-weak, retrieval-decisive.
+ * **semantic** — paraphrases no shared token would catch.
+ * **graph** — facts hanging off the entities the query names, which is how cross-session links
+ surface at all. Read from the relations incident to those entities, so it stays bounded.
+
+ Then each candidate's conflict slot is completed. Without that, `_current_slot_heads` could see a
+ superseded fact whose head happened to fall outside the pool and let the stale value through —
+ a correctness bug, not a ranking one.
+ """
+ index = getattr(self.fact_store, "index", None)
+ if index is None:
+ return None
+ pool = max(1, self.config.candidate_pool)
+
+ ids = index.lexical_candidates(query, pool, user_id=user_id)
+
+ if self.config.candidate_vector_channel:
+ # Declarative tenant filter, not a Python predicate: a backend can push the former into its
+ # own index, while the latter forces it to scan every row before ranking.
+ for _score, payload in self.fact_store.search(qvec, pool, user_id=user_id):
+ fid = getattr(payload, "id", None)
+ if isinstance(fid, str):
+ ids.add(fid)
+
+ for eid in self.query_entity_ids(query, user_id):
+ for direction in ("out", "in"):
+ for rel in self.graph.neighbors(eid, as_of, direction):
+ fid = getattr(rel, "fact_id", None)
+ if fid:
+ ids.add(fid)
+
+ matched = [f for f in index.resolve(ids) if f.user_id == user_id]
+ slots = {f.slot for f in matched if is_single_valued(f.predicate)}
+ if not slots:
+ return matched
+ # Resolve once over the completed id set rather than appending: `resolve` returns store order,
+ # and appending slot-mates at the end would put the candidates in a different order than the
+ # full scan sees, which rank-based fusion would turn into a different result.
+ return [f for f in index.resolve(ids | index.slot_members(slots)) if f.user_id == user_id]
+
def retrieve(
self, query: str, user_id: str, as_of: Optional[float] = None, top_k: Optional[int] = None
) -> tuple[list[tuple[Fact, float]], dict]:
top_k = top_k or self.config.top_k
- live = [f for f in self.fact_store.values() if f.user_id == user_id and f.is_live(as_of)]
+ qvec = self.embedder.embed(query)
+
+ # Bounded retrieval scores a candidate pool instead of the whole store (Bet E). It is off by
+ # default: with `candidate_pool` >= the live fact count the two paths return the same ranking,
+ # but below that they can differ, and the published numbers came from the full scan.
+ corpus = None
+ candidates = (
+ self._bounded_candidates(query, user_id, as_of, qvec)
+ if self.config.bounded_candidates
+ else None
+ )
+ if candidates is None:
+ live = [f for f in self.fact_store.values() if f.user_id == user_id and f.is_live(as_of)]
+ else:
+ live = [f for f in candidates if f.is_live(as_of)]
+ # Corpus statistics must describe the tenant's collection, not the pool — see corpus_for().
+ corpus = self.fact_store.index.corpus_for(user_id, stems(query))
+
live = self._current_slot_heads(live)
excluded = self.graph_exclusion_zone(query, user_id, as_of)
if excluded:
@@ -351,7 +475,6 @@ def retrieve(
if not live:
return [], {"sem": {}, "lex": {}, "qids": set()}
- qvec = self.embedder.embed(query)
sem = {f.id: cosine(qvec, f.embedding or []) for f in live}
# Type-weighted retrieval: scale the SEMANTIC score by fact type. Because an off-topic fact has
# sem≈0, the multiplier only reorders among genuinely-relevant candidates (a preference fact beats
@@ -363,7 +486,9 @@ def retrieve(
if tw != 1.0:
sem[f.id] *= tw
# include each fact's date as searchable tokens so time-named queries ('May 2023') match by date
- lex = bm25_scores(query, [(f.id, f"{f.text} {date_terms(f.valid_at)}") for f in live])
+ lex = bm25_scores(
+ query, [(f.id, indexed_text(f.text, f.valid_at)) for f in live], corpus=corpus
+ )
gph, qids = self._graph_scores(query, user_id, live, as_of)
t = now() if as_of is None else as_of
rec = {f.id: recency(max(0.0, t - f.valid_at), self.config.recency_tau_days) for f in live}
diff --git a/engram/retrieve/layered.py b/engram/retrieve/layered.py
new file mode 100644
index 0000000..eee4ad5
--- /dev/null
+++ b/engram/retrieve/layered.py
@@ -0,0 +1,143 @@
+"""Split the read context into a cacheable half and a per-query half.
+
+`Memory.lean_context` returns one flat string, and callers drop it into the user turn. Across a
+multi-turn session that re-sends and re-processes the whole thing every turn, including the parts that
+did not change — the user's profile, the map of what exists in memory, the instructions on how to use it.
+
+Splitting it lets the unchanging half sit in the system prompt, where provider prompt-caching can reuse
+it for the rest of the session, while only this query's evidence varies. The retrieved evidence is
+identical either way, so accuracy is unchanged by construction: this is a tokens-and-latency change, two
+thirds of the triple the charter insists on reporting together, and it should not be expected to move a
+benchmark score.
+
+The property that makes it work is that the stable half is **query-independent**. If it varied with the
+question it would invalidate the cache every turn and cost more than it saved, so the map below is ranked
+by recency rather than relevance and `test_stable_block_is_identical_across_queries` pins that down.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Optional
+
+from ..util import fmt_date
+
+__all__ = ["LayeredContext", "layered_context", "memory_map", "RECALL_GUIDE"]
+
+# How to use a retrieved slice, including when to refuse. Query-independent, so it belongs in the cached
+# half rather than being re-sent every turn. It states the abstention rule the read path already applies.
+RECALL_GUIDE = (
+ "The memories below are a retrieved slice of this user's history, not the complete record. Answer "
+ "only from what is shown plus the current question. If the needed fact is not present, say it is not "
+ "in memory rather than guessing — a confident wrong answer is worse than an honest 'I don't have "
+ "that.'"
+)
+
+# Appended only when a map is actually included. Pointing the model at a section that is not there —
+# which is what a redacted context would do — invites it to ask for something it cannot be given.
+_MAP_HINT = (
+ " MEMORY MAP lists sessions that exist but were not opened; if the answer plausibly lives in one, "
+ "you may ask for it by its date."
+)
+
+
+@dataclass(frozen=True)
+class LayeredContext:
+ """The same evidence as `lean_context`, in two halves.
+
+ `stable` is identical across a user's turns until their memory itself changes; `dynamic` is this
+ query's evidence.
+ """
+
+ stable: str
+ dynamic: str
+
+ @property
+ def text(self) -> str:
+ """Both halves as one string — what `lean_context` would have returned."""
+ return "\n\n".join(part for part in (self.stable, self.dynamic) if part)
+
+ def as_messages(self, query: str, system: str = "") -> list[dict[str, str]]:
+ """Chat messages placing the cacheable half in the system turn and the rest in the user turn."""
+ system_parts = [part for part in (system, self.stable) if part]
+ messages = []
+ if system_parts:
+ messages.append({"role": "system", "content": "\n\n".join(system_parts)})
+ user_parts = [part for part in (self.dynamic, query) if part]
+ messages.append({"role": "user", "content": "\n\n".join(user_parts)})
+ return messages
+
+
+def memory_map(mem: Any, user_id: str, limit: int = 20, as_of: Optional[float] = None) -> str:
+ """A query-independent index of what is in memory but was not retrieved.
+
+ Deliberately ranked by recency, not by relevance to the question: relevance ranking would reorder the
+ map on every turn and defeat the caching this exists for. Each row carries a date the model can ask
+ for, which is what makes the unretrieved remainder reachable instead of merely invisible.
+ """
+ if limit <= 0:
+ return ""
+ sessions: dict[str, Any] = {}
+ for ep in mem.episodes_doc.values():
+ if ep.user_id != user_id:
+ continue
+ if as_of is not None and ep.event_time > as_of:
+ continue
+ current = sessions.get(ep.session_id)
+ if current is None or ep.event_time > current.event_time:
+ sessions[ep.session_id] = ep
+ if not sessions:
+ return ""
+
+ rows = []
+ for ep in sorted(sessions.values(), key=lambda e: e.event_time, reverse=True)[:limit]:
+ gist = (ep.summary or ep.content or "").strip().replace("\n", " ")
+ if len(gist) > 96:
+ gist = gist[:93].rstrip() + "..."
+ rows.append(f"- {fmt_date(ep.event_time)} [{ep.session_id}] {gist}")
+ return "MEMORY MAP (sessions in memory, most recent first):\n" + "\n".join(rows)
+
+
+def layered_context(
+ mem: Any,
+ query: str,
+ user_id: str = "default",
+ as_of: Optional[float] = None,
+ # The map is off by default because it was measured, not assumed: it is content the flat context does
+ # not carry, and at typical session lengths it costs more than the caching saves — a net loss until
+ # roughly 20 turns (results/layered_context_tokens.md). Turn it on for long sessions, or when the
+ # progressive-disclosure capability is worth paying for on its own.
+ map_limit: int = 0,
+ guide: bool = True,
+ **lean_kwargs: Any,
+) -> LayeredContext:
+ """Assemble the read context in two halves.
+
+ `mem` is duck-typed as `engram.memory.Memory` to avoid a circular import. Extra keyword arguments go
+ straight to `lean_context`, so the dynamic half is produced by the same retrieval as the flat path —
+ there is no second, drifting implementation of the read path here.
+ """
+ user = mem.resolver.resolve(user_id)
+ redact = bool(lean_kwargs.get("redact_sensitive", False))
+
+ # The persona and the map are free-text layers that can fold in sensitive content, so a redacted
+ # context omits both for the same reason lean_context drops the persona.
+ persona = map_block = ""
+ if not redact:
+ persona = mem._persona_at(user, as_of)
+ map_block = memory_map(mem, user, limit=map_limit, as_of=as_of)
+
+ stable_parts = []
+ if guide:
+ stable_parts.append(RECALL_GUIDE + (_MAP_HINT if map_block else ""))
+ if persona:
+ label = "USER PROFILE" if as_of is None else f"USER PROFILE (as of {fmt_date(as_of)})"
+ stable_parts.append(f"{label}:\n{persona}")
+ if map_block:
+ stable_parts.append(map_block)
+
+ # persona=False: it is already in the stable half, and sending it twice would cost exactly the tokens
+ # this split exists to save.
+ lean_kwargs["persona"] = False
+ dynamic = mem.lean_context(query, user_id=user_id, as_of=as_of, **lean_kwargs)
+
+ return LayeredContext(stable="\n\n".join(stable_parts), dynamic=dynamic)
diff --git a/engram/retrieve/lexical.py b/engram/retrieve/lexical.py
index d502e6a..0e6dd53 100644
--- a/engram/retrieve/lexical.py
+++ b/engram/retrieve/lexical.py
@@ -2,10 +2,24 @@
from __future__ import annotations
import math
+from typing import Optional, Protocol
from ..util import stem, stems
-__all__ = ["stem", "stems", "token_overlap", "bm25_scores"]
+__all__ = ["stem", "stems", "token_overlap", "bm25_scores", "CorpusStats"]
+
+
+class CorpusStats(Protocol):
+ """Collection-wide BM25 statistics, so a candidate subset can be scored as if the whole store were."""
+
+ @property
+ def n_docs(self) -> int: ...
+
+ @property
+ def avgdl(self) -> float: ...
+
+ @property
+ def df(self) -> dict[str, int]: ...
def overlap_terms(query: str, text: str) -> set[str]:
@@ -16,17 +30,36 @@ def token_overlap(query: str, text: str) -> int:
return len(overlap_terms(query, text))
-def bm25_scores(query: str, docs: list[tuple[str, str]], k1: float = 1.5, b: float = 0.75) -> dict[str, float]:
- """docs: list of (id, text). Returns {id: bm25_score}."""
+def bm25_scores(
+ query: str,
+ docs: list[tuple[str, str]],
+ k1: float = 1.5,
+ b: float = 0.75,
+ *,
+ corpus: Optional["CorpusStats"] = None,
+) -> dict[str, float]:
+ """docs: list of (id, text). Returns {id: bm25_score}.
+
+ `corpus` supplies collection-wide statistics (N, avgdl, document frequency). Pass it when `docs` is a
+ *candidate subset* of a larger store: BM25's IDF and length normalisation are properties of the whole
+ collection, so deriving them from a subset would give the same fact a different score depending on
+ which other facts happened to be retrieved alongside it. With `corpus`, scoring a subset yields
+ exactly the scores a full scan would have produced — which is what makes bounded candidate retrieval
+ a pure speed optimisation rather than a silent ranking change."""
if not docs:
return {}
doc_tokens = {doc_id: stems(text) for doc_id, text in docs}
- n = len(docs)
- avgdl = max(1.0, sum(len(t) for t in doc_tokens.values()) / n)
- df: dict[str, int] = {}
- for toks in doc_tokens.values():
- for w in set(toks):
- df[w] = df.get(w, 0) + 1
+ if corpus is not None:
+ n = corpus.n_docs
+ avgdl = corpus.avgdl
+ df = corpus.df
+ else:
+ n = len(docs)
+ avgdl = max(1.0, sum(len(t) for t in doc_tokens.values()) / n)
+ df = {}
+ for toks in doc_tokens.values():
+ for w in set(toks):
+ df[w] = df.get(w, 0) + 1
q_terms = set(stems(query))
scores: dict[str, float] = {}
for doc_id, toks in doc_tokens.items():
@@ -41,7 +74,11 @@ def bm25_scores(query: str, docs: list[tuple[str, str]], k1: float = 1.5, b: flo
for w in q_terms:
if w not in tf:
continue
- idf = math.log(1 + (n - df[w] + 0.5) / (df[w] + 0.5))
- s += idf * (tf[w] * (k1 + 1)) / (tf[w] + k1 * (1 - b + b * dl / avgdl))
+ # df.get(): with a supplied corpus a term can be absent if the index has not caught up; an
+ # unseen term is maximally rare, which is what df=0 yields here. In the self-derived path
+ # every term of every doc is present by construction, so this changes nothing.
+ dfw = df.get(w, 0)
+ idf = math.log(1 + (n - dfw + 0.5) / (dfw + 0.5))
+ s += idf * (tf[w] * (k1 + 1)) / (tf[w] + k1 * (1 - b + b * dl / max(1.0, avgdl)))
scores[doc_id] = s
return scores
diff --git a/engram/retrieve/rerank.py b/engram/retrieve/rerank.py
index dd298f8..a43afad 100644
--- a/engram/retrieve/rerank.py
+++ b/engram/retrieve/rerank.py
@@ -1,9 +1,118 @@
"""Cross-encoder reranker (CLAUDE.md L1 strong retrieval). A bi-encoder (BGE) gives a cheap candidate
pool; a cross-encoder rescores (query, passage) jointly for far better precision on which sessions/chunks
-actually answer the question. This is the highest-leverage retrieval upgrade for LongMemEval _S."""
+actually answer the question. This is the highest-leverage retrieval upgrade for LongMemEval _S.
+
+A cross-encoder reads a bounded window — 512 tokens for the BGE rerankers. Hand it a whole LongMemEval
+session (~2000 tokens) and it does not fail; it silently scores the first quarter and discards the rest,
+so a session whose answer sits in its second half ranks as if it were irrelevant. `rerank_long` scores at
+a granularity the model can actually read and keeps each document's best segment, which is what makes
+reranking safe to apply to raw sessions rather than only to short facts.
+"""
from __future__ import annotations
-from typing import Optional
+import re
+from typing import Any, Optional
+
+__all__ = ["CrossEncoderReranker", "segment_text", "rerank_long"]
+
+# Paragraph first, then sentence: splitting mid-sentence would hand the model a fragment whose meaning
+# depends on text it cannot see, which is the same failure as truncation, just smaller.
+_PARA_RE = re.compile(r"\n\s*\n")
+_SENT_RE = re.compile(r"(?<=[.!?。!?])\s+")
+
+
+def segment_text(text: str, max_words: int = 300) -> list[str]:
+ """Split `text` into segments of at most `max_words` words, preferring natural boundaries.
+
+ Word count is a proxy for the model's token budget: ~300 words is comfortably inside a 512-token
+ window once the query and special tokens are added. Text already short enough comes back as a single
+ segment, so short candidates behave exactly as they did before.
+ """
+ if not text or not text.strip():
+ return []
+ words = text.split()
+ if len(words) <= max_words:
+ return [text.strip()]
+
+ segments: list[str] = []
+ for block in _split_units(text):
+ block_words = block.split()
+ if not block_words:
+ continue
+ if len(block_words) <= max_words:
+ _append_or_merge(segments, block, max_words)
+ continue
+ # A single unit longer than the budget (an unpunctuated wall of text): cut on word count. Better
+ # a hard cut than handing the model something it will truncate invisibly.
+ for start in range(0, len(block_words), max_words):
+ segments.append(" ".join(block_words[start:start + max_words]))
+ return segments or [" ".join(words[:max_words])]
+
+
+def _split_units(text: str) -> list[str]:
+ units: list[str] = []
+ for para in _PARA_RE.split(text):
+ para = para.strip()
+ if not para:
+ continue
+ units.extend(s for s in (part.strip() for part in _SENT_RE.split(para)) if s)
+ return units
+
+
+def _append_or_merge(segments: list[str], unit: str, max_words: int) -> None:
+ """Pack consecutive units together while they fit, so segments are as informative as the budget
+ allows instead of one-sentence slivers."""
+ if segments and len(segments[-1].split()) + len(unit.split()) <= max_words:
+ segments[-1] = f"{segments[-1]} {unit}"
+ else:
+ segments.append(unit)
+
+
+def rerank_long(
+ reranker: Any,
+ query: str,
+ candidates: list[tuple[Any, str]],
+ top_k: int,
+ max_words: int = 300,
+) -> list[tuple[Any, float]]:
+ """Rerank documents that may exceed the model's window, by scoring their segments.
+
+ A document scores as its BEST segment, not its average: a long session earns its place because one
+ passage answers the question, and averaging would dilute exactly the signal being looked for.
+
+ Takes any object with a `.rerank(query, [(id, text)], top_k)` method rather than a concrete type, so
+ the segmentation is testable without loading a cross-encoder — the zero-setup invariant means the
+ default test path cannot import sentence-transformers.
+ """
+ if not candidates:
+ return []
+
+ pieces: list[tuple[str, str]] = []
+ owner: dict[str, Any] = {}
+ order: dict[Any, int] = {}
+ for position, (cid, text) in enumerate(candidates):
+ order.setdefault(cid, position)
+ for seg_index, segment in enumerate(segment_text(text, max_words)):
+ key = f"{position}:{seg_index}"
+ owner[key] = cid
+ pieces.append((key, segment))
+ if not pieces:
+ return []
+
+ # Score every segment: the reranker truncates its own return value to top_k, and a document's best
+ # segment can sit anywhere in that list.
+ scored = reranker.rerank(query, pieces, len(pieces))
+
+ best: dict[Any, float] = {}
+ for key, score in scored:
+ cid = owner.get(key)
+ if cid is None:
+ continue
+ if cid not in best or score > best[cid]:
+ best[cid] = score
+ # Ties fall back to the incoming order, so the caller's upstream ranking still decides.
+ ranked = sorted(best.items(), key=lambda item: (-item[1], order.get(item[0], 0)))
+ return ranked[:top_k]
class CrossEncoderReranker:
diff --git a/engram/server/app.py b/engram/server/app.py
index b7f1aa9..fcebb8f 100644
--- a/engram/server/app.py
+++ b/engram/server/app.py
@@ -35,6 +35,8 @@
from .. import __version__
from ..service import MemoryService
+from .keys import KeyStore, KeyStoreError
+from .limits import IdempotencyCache, RateLimiter, idempotency_ttl, rate_limit_per_min
DEFAULT_MAX_REQUEST_BYTES = 2 * 1024 * 1024
@@ -180,15 +182,152 @@ async def request_limits_and_security_headers(request: Request, call_next):
return _apply_security_headers(await call_next(request), path)
+_keystore: Optional[KeyStore] = None
+_keystore_path: Optional[str] = None
+_limiter: Optional[RateLimiter] = None
+_limiter_per_min = -1
+_idempotency: Optional[IdempotencyCache] = None
+
+# Beyond this many tracked tenants, sweep the ones whose windows have emptied. Opportunistic rather than
+# on a timer so the server needs no background thread.
+_PRUNE_ABOVE = 1_000
+
+
+def _rate_limiter() -> RateLimiter:
+ """The shared limiter, rebuilt when the configured limit changes so a reconfigure takes effect."""
+ global _limiter, _limiter_per_min
+ per_min = rate_limit_per_min()
+ if _limiter is None or per_min != _limiter_per_min:
+ _limiter = RateLimiter(per_min)
+ _limiter_per_min = per_min
+ return _limiter
+
+
+def _idempotency_cache() -> IdempotencyCache:
+ global _idempotency
+ if _idempotency is None:
+ _idempotency = IdempotencyCache(ttl_seconds=idempotency_ttl())
+ return _idempotency
+
+
+def _count(name: str) -> None:
+ """Bump an aggregate counter, never at the cost of the request.
+
+ Instrumentation must not change behaviour. Building the service can itself fail (a misconfigured
+ backend), and that failure surfacing from inside an exception handler would replace a precise 401
+ with a generic 500 — losing the very diagnosis the counter exists to support.
+ """
+ try:
+ svc().metrics.count(name)
+ except Exception: # noqa: BLE001 - a missing metric is never worth failing a request over
+ pass
+
+
+def _enforce_rate_limit(user: str) -> None:
+ limiter = _rate_limiter()
+ if not limiter.enabled:
+ return
+ if limiter.tracked_tenants > _PRUNE_ABOVE:
+ limiter.prune()
+ allowed, retry_after = limiter.check(user)
+ if not allowed:
+ # Counted, not logged per tenant: /metrics is unauthenticated, so a per-tenant breakdown would
+ # tell any caller which tenants exist. An operator needs to know throttling is happening at all.
+ _count("rate_limited")
+ # Retry-After is what makes a 429 actionable rather than a client guessing and hammering.
+ raise HTTPException(
+ 429, "rate limit exceeded", headers={"Retry-After": str(int(retry_after) + 1)}
+ )
+
+
+def idempotent(user: str, request: Request, compute):
+ """Return the cached response for this Idempotency-Key, or compute it and cache it.
+
+ Applied to the endpoints that both mutate state and do expensive work, because that is where a
+ client's timeout-and-retry is most costly: the first request succeeded, only its response was lost.
+ Only successful results are cached -- replaying an exception would turn a transient failure into a
+ permanent one for the lifetime of the entry.
+ """
+ key = (request.headers.get("Idempotency-Key") or "").strip()[:256]
+ if not key:
+ return compute()
+ cache = _idempotency_cache()
+ cached = cache.get(user, key)
+ if cached is not None:
+ # A replay is work the server did not have to redo. Counting it is how an operator learns the
+ # header is actually being used, rather than assuming clients set it.
+ _count("idempotent_replays")
+ return cached
+ result = compute()
+ cache.put(user, key, result)
+ return result
+
+
def auth(authorization: str = Header(default="")) -> str:
- """Resolve the caller's user_id from the Bearer key. Dev open mode uses the key text itself as the
- namespace, so anyone can try it without pre-provisioned keys while still avoiding shared anonymous
- memory unless it is explicitly enabled."""
+ """Resolve the caller's user_id from the Bearer key, then apply their rate limit.
+
+ The limit lives here because this is the one place every protected route already passes through to
+ learn who is calling — a new endpoint cannot forget to be limited. Dev open mode uses the key text
+ itself as the namespace, so anyone can try it without pre-provisioned keys while still avoiding
+ shared anonymous memory unless it is explicitly enabled.
+ """
+ try:
+ user = _resolve_user(authorization)
+ except HTTPException as exc:
+ # A rising rejection count is how a misconfigured deployment or a credential-stuffing attempt
+ # becomes visible. Bucketed by status only -- the presented token is never recorded anywhere.
+ if exc.status_code in {401, 403}:
+ _count("auth_rejected")
+ elif exc.status_code == 503:
+ _count("auth_misconfigured")
+ raise
+ _enforce_rate_limit(user)
+ return user
+
+
+def keystore() -> KeyStore:
+ """Runtime-issued keys, persisted beside the data dir. Built lazily so importing the app touches
+ no disk, and rebuilt when the service is (tests point it at a temp dir)."""
+ global _keystore, _keystore_path
+ path = os.path.join(svc().data_dir, "api_keys.json")
+ if _keystore is None or _keystore_path != path:
+ _keystore = KeyStore(path)
+ _keystore_path = path
+ return _keystore
+
+
+def admin_auth(authorization: str = Header(default="")) -> bool:
+ """Gate the key-management surface behind its own token.
+
+ Fails closed: with ENGRAM_ADMIN_TOKEN unset there is no admin surface at all, so an open-mode
+ deployment cannot have keys minted against it by anyone who finds the endpoint.
+ """
+ expected = os.environ.get("ENGRAM_ADMIN_TOKEN", "").strip()
+ if not expected:
+ raise HTTPException(403, "admin surface disabled — set ENGRAM_ADMIN_TOKEN to manage API keys")
+ token = _bearer_token(authorization)
+ if not token or not hmac.compare_digest(token, expected):
+ raise HTTPException(401, "invalid admin token")
+ return True
+
+
+def _resolve_user(authorization: str) -> str:
try:
keys = _load_keys()
except AuthConfigError as exc:
raise HTTPException(503, "invalid API key configuration") from exc
token = _bearer_token(authorization)
+
+ # Runtime-issued keys are consulted first so a revoked key cannot be resurrected by a stale env
+ # entry, and so a hosted deployment can mint tenants without a restart. An unreadable key store is
+ # a 503, never an open door.
+ try:
+ issued_user = keystore().resolve(token)
+ except KeyStoreError as exc:
+ raise HTTPException(503, "invalid API key store") from exc
+ if issued_user:
+ return issued_user
+
if keys:
matched_user = None
for key, user in keys.items():
@@ -207,7 +346,48 @@ def auth(authorization: str = Header(default="")) -> str:
"missing bearer namespace: in ENGRAM_OPEN mode send Authorization: Bearer , "
"or set ENGRAM_ALLOW_ANONYMOUS=1 to allow shared anonymous memory",
)
- raise HTTPException(401, "set ENGRAM_API_KEYS (user:key,...) or ENGRAM_OPEN=1")
+ raise HTTPException(
+ 401,
+ "no API key recognized — issue one via POST /v1/admin/keys (needs ENGRAM_ADMIN_TOKEN), "
+ "or set ENGRAM_API_KEYS (user:key,...) or ENGRAM_OPEN=1",
+ )
+
+
+class IssueKeyReq(BaseModel):
+ user: str = Field(min_length=1, max_length=512)
+ label: str = Field(default="", max_length=256)
+
+
+@app.post("/v1/admin/keys")
+def issue_key(req: IssueKeyReq, _: bool = Depends(admin_auth)):
+ """Mint a key for a tenant. The plaintext is in this response and nowhere else — it is hashed at
+ rest, so a lost key is reissued, never recovered."""
+ try:
+ return keystore().issue(req.user, label=req.label)
+ except KeyStoreError as exc:
+ raise HTTPException(503, "invalid API key store") from exc
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+
+
+@app.get("/v1/admin/keys")
+def list_keys(user: Optional[str] = None, _: bool = Depends(admin_auth)):
+ """Key records, newest first. Never includes a secret or its digest."""
+ try:
+ return {"keys": keystore().list(user)}
+ except KeyStoreError as exc:
+ raise HTTPException(503, "invalid API key store") from exc
+
+
+@app.delete("/v1/admin/keys/{key_id}")
+def revoke_key(key_id: str, _: bool = Depends(admin_auth)):
+ try:
+ revoked = keystore().revoke(key_id)
+ except KeyStoreError as exc:
+ raise HTTPException(503, "invalid API key store") from exc
+ if not revoked:
+ raise HTTPException(404, "no such live key")
+ return {"ok": True, "id": key_id, "revoked": True}
class RememberReq(BaseModel):
@@ -271,6 +451,17 @@ def ready():
return JSONResponse(payload, status_code=200 if payload["ready"] else 503)
+@app.get("/metrics")
+def metrics():
+ """Live latency, volume and token aggregates for the running service.
+
+ Unauthenticated, like /health, and safe to be: the payload is aggregate-only by construction — it
+ carries no namespace names, queries or content, so it cannot reveal that a given tenant exists, let
+ alone what they stored. Operators who still want it private should not expose it at the proxy.
+ """
+ return svc().metrics.snapshot()
+
+
# The production console (the React app in frontend/) is served at /ui once built; "/" redirects
# there. When it ISN'T built (fresh clone, tests, the zero-setup demo) we fall back to the tiny inline
# dashboard below — so the server is always usable with no build step (CLAUDE.md zero-setup invariant).
@@ -377,10 +568,16 @@ def robots_txt():
@app.post("/v1/remember")
-def remember(req: RememberReq, user: str = Depends(auth)):
+def remember(req: RememberReq, request: Request, user: str = Depends(auth)):
# Route by ephemerality inside the service: either way the dated episode is stored (history stays
# answerable); transient state also goes to working memory and is NOT promoted into a durable fact.
- return svc().remember(user, req.content, session_id=req.session_id, scope=req.scope)
+ # Idempotency-Key guards the retry-after-timeout case, which would otherwise store the episode twice
+ # and pay to consolidate it twice.
+ return idempotent(
+ user,
+ request,
+ lambda: svc().remember(user, req.content, session_id=req.session_id, scope=req.scope),
+ )
@app.post("/v1/recall")
@@ -519,13 +716,24 @@ class ImportReq(BaseModel):
@app.post("/v1/import")
-def import_history(req: ImportReq, user: str = Depends(auth)):
+def import_history(req: ImportReq, request: Request, user: str = Depends(auth)):
"""Bulk-ingest an external history in one batched pass. See `python -m engram.connectors` for a CLI
- that parses common exports and posts here."""
+ that parses common exports and posts here. A native `/v1/export` payload (format='engram' or
+ auto-sniffed) restores directly — the cross-instance migration path."""
if req.sessions is None and req.data is None:
raise HTTPException(400, "provide either 'sessions' (pre-parsed) or 'data' (+ 'format') to import")
- return svc().import_(user, sessions=req.sessions, data=req.data, format=req.format,
- consolidate=req.consolidate, summarize=req.summarize)
+ try:
+ # The most expensive mutating endpoint there is, so a lost response is the most expensive thing
+ # to replay: an Idempotency-Key makes the retry free.
+ return idempotent(
+ user,
+ request,
+ lambda: svc().import_(user, sessions=req.sessions, data=req.data, format=req.format,
+ consolidate=req.consolidate, summarize=req.summarize),
+ )
+ except ValueError as exc:
+ # a malformed payload is the CLIENT's error — 400 with the parser's reason, never a raw 500
+ raise HTTPException(400, f"import failed: {exc}") from exc
# --- OpenAI-compatible chat with transparent memory (drop-in: point your OpenAI client's base_url here) --
@@ -562,6 +770,9 @@ def chat_completions(req: ChatCompletionReq, background: BackgroundTasks, user:
as_of=opts.get("as_of"),
redact_sensitive=bool(opts.get("redact_sensitive", False)),
session_id=session_id,
+ # Opt-in: it pays off across a multi-turn session and costs a little on a one-shot call
+ # (results/layered_context_tokens.md), so the caller who knows which they are decides.
+ layered=bool(opts.get("layered", False)),
)
except oc.NoLLMConfigured as exc:
raise HTTPException(503, str(exc))
diff --git a/engram/server/keys.py b/engram/server/keys.py
new file mode 100644
index 0000000..1813778
--- /dev/null
+++ b/engram/server/keys.py
@@ -0,0 +1,148 @@
+"""Runtime-issued API keys, stored hashed.
+
+The server authenticated only through a static `ENGRAM_API_KEYS` env map — edit and restart to add a
+tenant — or open mode, where anyone is a tenant. A hosted deployment needs to mint keys while running,
+revoke them immediately, and never hold the secret in a form that a leaked file would expose.
+
+ * a key is minted as `sk-engram-` and returned exactly once;
+ * only its SHA-256 digest is persisted, so the file cannot be replayed as credentials;
+ * revocation drops the digest from the lookup index and takes effect on the next request.
+
+State is one JSON file next to the data dir — inspectable, and not a pickle. It is written atomically and
+with owner-only permissions, because it lists which tenants exist even though it holds no secrets.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import secrets
+import stat
+import threading
+import time
+from typing import Optional
+
+__all__ = ["KeyStore", "KeyStoreError"]
+
+KEY_PREFIX = "sk-engram-"
+
+
+class KeyStoreError(RuntimeError):
+ """The key file exists but could not be read. Deliberately fatal — see KeyStore._load."""
+
+
+def _digest(token: str) -> str:
+ """Only this is persisted. A leaked key file must not be replayable as credentials."""
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+
+class KeyStore:
+ def __init__(self, path: str) -> None:
+ self.path = path
+ self._lock = threading.Lock()
+ self._records: dict[str, dict] = {} # key_id -> record, including the digest, never the secret
+ self._by_digest: dict[str, str] = {} # digest -> key_id, live keys only
+ self._load()
+
+ def _load(self) -> None:
+ """Read the store, or fail loudly.
+
+ A corrupt file must not be treated as an empty one. Starting empty would silently reject every
+ previously issued key, and then the first `issue()` would rewrite the file and destroy the
+ records that were merely unreadable. Refusing to start is recoverable; overwriting is not.
+ """
+ if not os.path.exists(self.path):
+ return
+ try:
+ with open(self.path, encoding="utf-8") as fh:
+ data = json.load(fh)
+ except OSError as exc:
+ raise KeyStoreError(f"cannot read API key store at {self.path}: {exc}") from exc
+ except ValueError as exc:
+ raise KeyStoreError(
+ f"API key store at {self.path} is not valid JSON; refusing to start rather than "
+ "overwrite it — restore it from backup or move it aside"
+ ) from exc
+ if not isinstance(data, dict) or not isinstance(data.get("keys", []), list):
+ raise KeyStoreError(f"API key store at {self.path} has an unexpected shape")
+
+ for rec in data["keys"]:
+ if not isinstance(rec, dict) or "id" not in rec or "hash" not in rec:
+ raise KeyStoreError(f"API key store at {self.path} has a malformed record")
+ self._records[rec["id"]] = rec
+ if not rec.get("revoked"):
+ self._by_digest[rec["hash"]] = rec["id"]
+
+ def _save(self) -> None:
+ """Atomic replace, owner-only. Caller holds the lock."""
+ directory = os.path.dirname(self.path) or "."
+ os.makedirs(directory, exist_ok=True)
+ tmp = f"{self.path}.tmp"
+ with open(tmp, "w", encoding="utf-8") as fh:
+ json.dump({"keys": list(self._records.values())}, fh, ensure_ascii=False, indent=2)
+ # Set the mode before the swap so the file is never briefly world-readable. It holds no secrets,
+ # but it does enumerate the tenants on this deployment.
+ os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
+ os.replace(tmp, self.path)
+
+ def issue(self, user: str, label: str = "") -> dict:
+ """Mint a key for `user`. The plaintext is in the return value and is never stored."""
+ if not user or not user.strip():
+ raise ValueError("a key must belong to a tenant")
+ token = KEY_PREFIX + secrets.token_hex(24)
+ record = {
+ "id": "key_" + secrets.token_hex(8),
+ "user": user.strip(),
+ "label": label,
+ "hash": _digest(token),
+ "created_at": time.time(),
+ "revoked": False,
+ "last_used_at": None,
+ }
+ with self._lock:
+ self._records[record["id"]] = record
+ self._by_digest[record["hash"]] = record["id"]
+ self._save()
+ issued = self._public(record)
+ issued["key"] = token # shown once; there is no way to recover it later
+ return issued
+
+ def resolve(self, token: str) -> Optional[str]:
+ """The tenant a presented token belongs to, or None if unknown or revoked."""
+ if not token:
+ return None
+ with self._lock:
+ key_id = self._by_digest.get(_digest(token))
+ if key_id is None:
+ return None
+ record = self._records.get(key_id)
+ if record is None or record.get("revoked"):
+ return None
+ # In memory only: flushing on every request would turn each read into a file write.
+ record["last_used_at"] = time.time()
+ return record["user"]
+
+ def revoke(self, key_id: str) -> bool:
+ with self._lock:
+ record = self._records.get(key_id)
+ if record is None or record.get("revoked"):
+ return False
+ record["revoked"] = True
+ self._by_digest.pop(record["hash"], None)
+ self._save()
+ return True
+
+ def list(self, user: Optional[str] = None) -> list[dict]:
+ """Key records, newest first. Never includes the secret or its digest."""
+ with self._lock:
+ records = [
+ self._public(rec)
+ for rec in self._records.values()
+ if user is None or rec["user"] == user
+ ]
+ return sorted(records, key=lambda rec: rec["created_at"], reverse=True)
+
+ @staticmethod
+ def _public(record: dict) -> dict:
+ """Strip the digest. Publishing it would let anyone verify a guessed key offline."""
+ return {k: v for k, v in record.items() if k != "hash"}
diff --git a/engram/server/limits.py b/engram/server/limits.py
new file mode 100644
index 0000000..7d93a45
--- /dev/null
+++ b/engram/server/limits.py
@@ -0,0 +1,146 @@
+"""Per-tenant rate limiting and Idempotency-Key replay, both dependency-free.
+
+Two different failure modes on the same multi-tenant surface. Without a rate limit, one caller can spend
+the whole process — and once the LLM-backed paths are wired, the whole budget. Without idempotency, a
+client that retries after a network timeout stores the same episode twice and pays for consolidating it
+twice, because the first request did succeed; only the response was lost.
+
+**Both are in-process.** Behind several replicas each process keeps its own window and its own cache, so
+the effective rate limit is `per_min x replicas` and a retry routed to a different replica will re-run.
+That is an honest first step, not a distributed limiter; the shape of `RateLimiter.check` and
+`IdempotencyCache.get/put` is what a Redis-backed version would replace. Saying so is better than
+shipping something that looks distributed and is not.
+"""
+from __future__ import annotations
+
+import os
+import threading
+import time
+from collections import OrderedDict
+from typing import Any, Optional
+
+__all__ = ["RateLimiter", "IdempotencyCache", "rate_limit_per_min", "idempotency_ttl"]
+
+DEFAULT_IDEMPOTENCY_TTL = 86_400.0 # a day: long enough to cover a client's retry budget
+DEFAULT_IDEMPOTENCY_ENTRIES = 10_000
+
+
+def rate_limit_per_min() -> int:
+ """Requests per tenant per minute. 0 (the default) disables limiting entirely, so the zero-setup
+ demo and existing deployments behave exactly as before."""
+ raw = os.environ.get("ENGRAM_RATE_LIMIT_PER_MIN", "").strip()
+ if not raw:
+ return 0
+ try:
+ value = int(raw)
+ except ValueError:
+ return 0
+ return max(0, value)
+
+
+def idempotency_ttl() -> float:
+ raw = os.environ.get("ENGRAM_IDEMPOTENCY_TTL_S", "").strip()
+ if not raw:
+ return DEFAULT_IDEMPOTENCY_TTL
+ try:
+ return max(0.0, float(raw))
+ except ValueError:
+ return DEFAULT_IDEMPOTENCY_TTL
+
+
+class RateLimiter:
+ """Sliding-window limiter: at most `per_min` requests per tenant in any trailing 60 seconds."""
+
+ def __init__(self, per_min: int, window_seconds: float = 60.0) -> None:
+ self.per_min = per_min
+ self.window = window_seconds
+ self._lock = threading.Lock()
+ self._hits: dict[str, list[float]] = {}
+
+ @property
+ def enabled(self) -> bool:
+ return self.per_min > 0
+
+ def check(self, user: str, now: Optional[float] = None) -> tuple[bool, float]:
+ """Record a request and decide whether it is allowed.
+
+ Returns `(allowed, retry_after_seconds)`. A rejected request is deliberately NOT recorded --
+ otherwise a client that keeps retrying would hold its own window permanently full and never
+ recover. `now` is injectable so the tests do not sleep.
+ """
+ if not self.enabled:
+ return True, 0.0
+ t = time.time() if now is None else now
+ cutoff = t - self.window
+ with self._lock:
+ kept = [hit for hit in self._hits.get(user, ()) if hit > cutoff]
+ if len(kept) >= self.per_min:
+ self._hits[user] = kept
+ return False, max(0.0, self.window - (t - kept[0]))
+ kept.append(t)
+ self._hits[user] = kept
+ return True, 0.0
+
+ def prune(self, now: Optional[float] = None) -> int:
+ """Drop tenants with no hits left in the window.
+
+ Without this the map grows by one entry per tenant that ever called and never shrinks -- a slow
+ leak that only shows up on the deployment with the most tenants, which is the one that can least
+ afford it. Called opportunistically rather than on a timer, so there is no background thread.
+ """
+ t = time.time() if now is None else now
+ cutoff = t - self.window
+ with self._lock:
+ stale = [user for user, hits in self._hits.items() if not any(h > cutoff for h in hits)]
+ for user in stale:
+ del self._hits[user]
+ return len(stale)
+
+ @property
+ def tracked_tenants(self) -> int:
+ with self._lock:
+ return len(self._hits)
+
+
+class IdempotencyCache:
+ """Replay the first response for an (tenant, Idempotency-Key) pair instead of re-running the work."""
+
+ def __init__(
+ self, ttl_seconds: float = DEFAULT_IDEMPOTENCY_TTL, max_entries: int = DEFAULT_IDEMPOTENCY_ENTRIES
+ ) -> None:
+ self.ttl = ttl_seconds
+ self.max_entries = max_entries
+ self._lock = threading.Lock()
+ # Ordered so eviction can drop the oldest entry; keyed by tenant AND key so two namespaces
+ # choosing the same key can never read each other's response.
+ self._store: "OrderedDict[tuple[str, str], tuple[float, Any]]" = OrderedDict()
+
+ def get(self, user: str, key: str, now: Optional[float] = None) -> Optional[Any]:
+ if not key:
+ return None
+ t = time.time() if now is None else now
+ with self._lock:
+ hit = self._store.get((user, key))
+ if hit is None:
+ return None
+ stored_at, response = hit
+ if t - stored_at > self.ttl:
+ self._store.pop((user, key), None)
+ return None
+ return response
+
+ def put(self, user: str, key: str, response: Any, now: Optional[float] = None) -> None:
+ """Cache a response. Only ever called with a successful one -- replaying a failure would turn a
+ transient error into a permanent one for the lifetime of the entry."""
+ if not key:
+ return
+ t = time.time() if now is None else now
+ with self._lock:
+ self._store[(user, key)] = (t, response)
+ self._store.move_to_end((user, key))
+ while len(self._store) > self.max_entries:
+ self._store.popitem(last=False)
+
+ def __len__(self) -> int:
+ with self._lock:
+ return len(self._store)
diff --git a/engram/server/openai_compat.py b/engram/server/openai_compat.py
index 4e6a4d2..afdc40a 100644
--- a/engram/server/openai_compat.py
+++ b/engram/server/openai_compat.py
@@ -47,14 +47,25 @@ def latest_user_text(messages: list) -> str:
return ""
-def build_prompt(messages: list, memory_context: str) -> tuple[Optional[str], str]:
+def build_prompt(
+ messages: list, memory_context: str, stable_context: str = ""
+) -> tuple[Optional[str], str]:
"""Render the request's messages into a (system, prompt) pair for the LLM.complete interface:
* system = the injected memory block + any of the request's own system messages
* prompt = the single user turn, or the full transcript for a multi-turn conversation
Backend-agnostic on purpose: it works with any LLM (incl. the offline FakeLLM in tests), not only
- a litellm chat model."""
+ a litellm chat model.
+
+ `stable_context` is the query-independent half of memory (profile and usage guide). It goes at the
+ very front of the system prompt and the per-query evidence moves into the user turn, so the system
+ block is byte-identical across a session's turns. Provider prompt-caching matches on a prefix, and
+ with the whole retrieved slice in the system block — which is what happens when `stable_context` is
+ empty — that prefix changes every turn and nothing can ever be reused.
+ """
system_parts: list[str] = []
- if memory_context.strip():
+ if stable_context.strip():
+ system_parts.append(_MEMORY_PREAMBLE + stable_context.strip())
+ elif memory_context.strip():
system_parts.append(_MEMORY_PREAMBLE + memory_context.strip())
system_parts += [c for c in (_content(m) for m in messages
if isinstance(m, dict) and m.get("role") == "system") if c.strip()]
@@ -68,6 +79,12 @@ def build_prompt(messages: list, memory_context: str) -> tuple[Optional[str], st
else:
rendered = "\n".join(f"{m['role'].capitalize()}: {_content(m)}" for m in convo)
prompt = rendered + "\nAssistant:"
+
+ # Only when the split is active: this turn's evidence rides with the turn, leaving the system block
+ # unchanged. Without a stable half there is nothing to protect, and moving the memory here would
+ # only make the prompt longer for no gain.
+ if stable_context.strip() and memory_context.strip():
+ prompt = f"{memory_context.strip()}\n\n{prompt}" if prompt else memory_context.strip()
return system, prompt
@@ -84,6 +101,7 @@ def chat_completion(
as_of: Optional[float] = None,
redact_sensitive: bool = False,
session_id: Optional[str] = None,
+ layered: bool = False,
) -> dict:
"""Recall → inject → generate → return an OpenAI ChatCompletion object (with an `engram` extension
describing what memory was used). Does NOT write memory — the route schedules that off the critical
@@ -93,25 +111,43 @@ def chat_completion(
query = latest_user_text(messages) or (_content(messages[-1]) if messages else "")
memory_context = ""
+ stable_context = ""
if do_recall and query:
- memory_context = (
- svc.recall(
- user,
+ if layered:
+ # Split so the system prompt stops changing every turn. Same retrieval either way, so the
+ # evidence the model sees is unchanged -- only where each half is placed.
+ parts = svc.get(user).layered_context(
query,
- lean=True,
+ user_id=user,
n_chunks=n_chunks,
session_id=session_id,
as_of=as_of,
redact_sensitive=redact_sensitive,
- ).get("context") or ""
- )
+ # This surface already frames the memory with _MEMORY_PREAMBLE, so the library's own
+ # usage guide would be a second copy of the same instruction. Measured at +14% prompt
+ # tokens for no behaviour change (results/layered_context_tokens.md).
+ guide=False,
+ )
+ stable_context, memory_context = parts.stable, parts.dynamic
+ else:
+ memory_context = (
+ svc.recall(
+ user,
+ query,
+ lean=True,
+ n_chunks=n_chunks,
+ session_id=session_id,
+ as_of=as_of,
+ redact_sensitive=redact_sensitive,
+ ).get("context") or ""
+ )
if svc.llm is None:
raise NoLLMConfigured(
"no LLM configured for generation — set ENGRAM_LLM (e.g. 'deepseek'), or use /v1/recall "
"for retrieval-only.")
- system, prompt = build_prompt(messages, memory_context)
+ system, prompt = build_prompt(messages, memory_context, stable_context)
content = svc.llm.complete(prompt, system=system)
p_tokens = _est_tokens((system or "") + " " + prompt)
@@ -130,8 +166,11 @@ def chat_completion(
"total_tokens": p_tokens + c_tokens},
# Engram extension — transparency about the memory layer (ignored by standard OpenAI clients).
"engram": {
- "recalled": bool(memory_context.strip()),
- "memory_tokens_est": _est_tokens(memory_context),
+ "recalled": bool((memory_context + stable_context).strip()),
+ "memory_tokens_est": _est_tokens(memory_context) + _est_tokens(stable_context),
+ # When layered, this is the byte-identical prefix a provider's prompt cache can reuse across
+ # the session; 0 means the whole slice still rides in the system block and nothing is stable.
+ "cacheable_tokens_est": _est_tokens(stable_context),
"session_id": session_id,
"as_of": as_of,
"redacted_sensitive": redact_sensitive,
diff --git a/engram/service.py b/engram/service.py
index b978f48..1785821 100644
--- a/engram/service.py
+++ b/engram/service.py
@@ -20,6 +20,7 @@
from typing import Any, Optional
from .memory import Memory
+from .metrics import Metrics, timed
from .util import fmt_date, fmt_datetime
DEFAULT_DATA_DIR = os.path.expanduser("~/.engram/data")
@@ -119,6 +120,15 @@ def __init__(
from .config import Config
self.config = Config()
+ # Live latency/volume/token counters (aggregate-only; see engram/metrics.py).
+ self.metrics = Metrics()
+ # ENGRAM_STORAGE selects the vector backend ('memory' default, 'lancedb' opt-in). Fail closed on
+ # anything else: silently falling back to 'memory' would misreport /health's storage field.
+ storage = os.environ.get("ENGRAM_STORAGE", "").strip().lower()
+ if storage:
+ if storage not in {"memory", "lancedb"}:
+ raise ValueError(f"ENGRAM_STORAGE must be 'memory' or 'lancedb', got {storage!r}")
+ self.config.storage = storage
if os.environ.get("ENGRAM_MAX_HOT_FACTS"):
self.config.max_hot_facts = int(os.environ["ENGRAM_MAX_HOT_FACTS"])
# opt-in System-2 LLM conflict detection -> the detect->confirm loop (needs an LLM). Off by
@@ -279,6 +289,7 @@ def hot_count(self) -> int:
return len(self._hot)
# --- write path ---------------------------------------------------------
+ @timed("remember")
def remember(self, user: str, content: str, session_id: str = "default",
scope: str = "auto") -> dict:
"""Store a message + run System-2 consolidation/summarization (best-effort: a transient model
@@ -298,20 +309,34 @@ def remember(self, user: str, content: str, session_id: str = "default",
mem.summarize_episodes(list(mem.episodes_doc.values()))
except Exception as exc: # noqa: BLE001 — keep the raw episode no matter what
self._save(user, mem)
+ # Degraded writes still succeed, so they are invisible in error rates and latency alike.
+ # Counting them is the only way an operator learns consolidation is silently failing.
+ self.metrics.count("remember_degraded")
return {"ok": True, "extracted": 0, "degraded": type(exc).__name__, "stored_raw": True}
self._save(user, mem)
return {"ok": True, "scope": "long", "extracted": added,
"total_facts": len([f for f in _all_facts(mem) if f.is_live()])}
+ @timed("import")
def import_(self, user: str, sessions: Optional[list] = None, format: str = "auto",
data: Any = None, consolidate: bool = True, summarize: bool = True,
session_id: str = "imported") -> dict:
"""Bulk import: either pre-parsed `sessions` (list of ImportSession/dicts) OR raw `data` to parse
- with `format` (chatgpt/messages/records/jsonl/transcript/auto). One batched ingest + consolidation."""
+ with `format` (chatgpt/messages/records/jsonl/transcript/auto). One batched ingest + consolidation.
+
+ A native Engram export (`format='engram'`, or auto-sniffed by its engram_export_version) takes
+ the direct restore path instead: facts/episodes keep their ids, bi-temporal stamps, and
+ supersession chains — this is how a namespace moves between instances."""
with self.write_lock(user):
mem = self.get(user)
if sessions is None:
- from .connectors import parse
+ from .connectors import parse, sniff
+ from .connectors.base import load_json
+ fmt = (format or "auto").lower().strip()
+ if fmt == "engram" or (fmt == "auto" and sniff(data) == "engram"):
+ stats = mem.import_export(load_json(data), user_id=user)
+ self._save(user, mem)
+ return {"ok": True, **stats}
sessions = parse(data, format=format, session_id=session_id)
stats = mem.import_messages(sessions, user_id=user, consolidate=consolidate,
summarize=summarize)
@@ -376,6 +401,7 @@ def set_policy(self, user: str, **fields: Optional[str]) -> dict:
return {"ok": True, **result}
# --- read path ----------------------------------------------------------
+ @timed("recall")
def recall(self, user: str, query: str, lean: bool = True, n_chunks: int = 6,
session_id: Optional[str] = None, as_of: Optional[float] = None,
redact_sensitive: bool = False,
@@ -406,6 +432,9 @@ def recall(self, user: str, query: str, lean: bool = True, n_chunks: int = 6,
)
out["full_tokens"] = _est_tokens(full)
out["answer"] = _answer_from_memory(self.answerer, query, ctx)
+ # The baseline is only computed on the answer path, so the savings ratio is derived from
+ # those calls alone (see Metrics.tokens) rather than mixing two different call sets.
+ self.metrics.tokens(out["tokens_est"], out.get("full_tokens"))
return out
res = mem.search(query, user_id=user, as_of=as_of)
visible_facts = [
@@ -481,6 +510,7 @@ def clear_working(self, user: str, session_id: str) -> dict:
self._save(user, mem)
return {"ok": True, "cleared": n}
+ @timed("close_session")
def close_session(
self,
user: str,
@@ -879,25 +909,28 @@ def stats(self, user: str) -> dict:
"""Content-free namespace stats for dashboards/readiness probes. This intentionally avoids
profile text, fact text, episode snippets, and data paths so it is safe to poll in production."""
mem = self.get(user)
- episodes = [ep for ep in mem.episodes_doc.values() if ep.user_id == user]
- hot_facts = [f for f in mem.fact_store.values() if f.user_id == user]
- cold_facts = [f for f in mem.cold_store.values() if f.user_id == user]
+ # Filter by the CANONICAL identity, like every other read path: after link_identity, data is
+ # written under the canonical id, so filtering by the raw handle would undercount.
+ canonical = mem.resolver.resolve(user)
+ episodes = [ep for ep in mem.episodes_doc.values() if ep.user_id == canonical]
+ hot_facts = [f for f in mem.fact_store.values() if f.user_id == canonical]
+ cold_facts = [f for f in mem.cold_store.values() if f.user_id == canonical]
facts = hot_facts + cold_facts
facts_by_id = {f.id: f for f in facts}
- working = [w for w in mem.working_mem.values() if w.user_id == user]
+ working = [w for w in mem.working_mem.values() if w.user_id == canonical]
live_facts = [f for f in facts if f.is_live()]
superseded = [f for f in facts if not f.is_live()]
sensitive = [f for f in facts if getattr(f, "sensitive", False)]
pending_conflicts = [
c for c in mem.conflicts.values()
- if c.user_id == user and c.status == "pending"
+ if c.user_id == canonical and c.status == "pending"
]
consolidated_episodes = [ep for ep in episodes if ep.consolidated]
pending_episodes = [ep for ep in episodes if not ep.consolidated]
ephemeral_episodes = [ep for ep in episodes if ep.metadata.get("ephemeral")]
event_times = [ep.event_time for ep in episodes]
fact_times = [f.valid_at for f in facts]
- user_entities = [e for e in mem.graph.entities.values() if e.user_id == user]
+ user_entities = [e for e in mem.graph.entities.values() if e.user_id == canonical]
all_relations = mem.graph.relations()
user_entity_ids = {e.id for e in user_entities}
user_relations = [
@@ -917,13 +950,13 @@ def stats(self, user: str) -> dict:
"episodes_ephemeral": len(ephemeral_episodes),
"facts_hot": len(hot_facts),
"facts_cold": len(cold_facts),
- "cold_pages_out": int(mem.cold_pages_out.get(user, 0)),
- "cold_pages_in": int(mem.cold_pages_in.get(user, 0)),
+ "cold_pages_out": int(mem.cold_pages_out.get(canonical, 0)),
+ "cold_pages_in": int(mem.cold_pages_in.get(canonical, 0)),
"facts_live": len(live_facts),
"facts_superseded": len(superseded),
"facts_sensitive": len(sensitive),
"working_live": sum(1 for w in working if w.is_live()),
- "summaries": len([s for s in mem.summary_vec.values() if s.user_id == user]),
+ "summaries": len([s for s in mem.summary_vec.values() if s.user_id == canonical]),
"entities": len(user_entities),
"relations": sum(1 for r in user_relations if r.fact_id in facts_by_id),
"graph_orphan_entities": sum(1 for e in user_entities if e.id not in referenced_entity_ids),
@@ -970,7 +1003,8 @@ def export(self, user: str, include_sensitive: bool = False) -> dict:
"focus": mem.get_focus(),
"facts": [{
"id": f.id, "subject": f.subject, "predicate": f.predicate, "object": f.object,
- "text": f.text, "source": f.source, "status": "live" if f.is_live() else "superseded",
+ "text": f.text, "display": getattr(f, "display", ""),
+ "source": f.source, "status": "live" if f.is_live() else "superseded",
"category": getattr(f, "category", ""), "sensitive": getattr(f, "sensitive", False),
"salience": round(f.salience, 3), "confidence": f.confidence,
"valid_at": f.valid_at, "valid_at_h": fmt_date(f.valid_at),
diff --git a/engram/store/__init__.py b/engram/store/__init__.py
index 391a011..a54946c 100644
--- a/engram/store/__init__.py
+++ b/engram/store/__init__.py
@@ -1,4 +1,5 @@
from .base import DocStore, GraphStore, VectorStore
+from .indexed import FactIndex, IndexedVectorStore
from .memory_store import InMemoryDocStore, InMemoryGraphStore, InMemoryVectorStore
from .persist import (
DimensionMismatchError,
@@ -17,6 +18,8 @@
"InMemoryVectorStore",
"InMemoryDocStore",
"InMemoryGraphStore",
+ "FactIndex",
+ "IndexedVectorStore",
"PersistenceError",
"IncompatibleStoreError",
"DimensionMismatchError",
diff --git a/engram/store/base.py b/engram/store/base.py
index 98b789e..ee2030f 100644
--- a/engram/store/base.py
+++ b/engram/store/base.py
@@ -18,8 +18,24 @@ def upsert(self, key: str, vector: list[float], payload: Any) -> None: ...
@abstractmethod
def search(
- self, vector: list[float], top_k: int, where: Optional[Predicate] = None
- ) -> list[tuple[float, Any]]: ...
+ self,
+ vector: list[float],
+ top_k: int,
+ where: Optional[Predicate] = None,
+ *,
+ user_id: Optional[str] = None,
+ ) -> list[tuple[float, Any]]:
+ """Nearest neighbours, optionally restricted.
+
+ Two filters, because they cost different things. `where` is an arbitrary Python predicate: a
+ backend cannot see inside it, so it must consider every row before ranking — correct, but a full
+ scan. `user_id` is the one filter this system applies on literally every retrieval (multi-tenant
+ isolation), and stating it declaratively lets a backend push it into its own index instead.
+
+ That distinction is load-bearing: with only the predicate form, the tenant filter silently turned
+ every "ANN" search into a table scan, so no vector backend could ever deliver sub-linear reads.
+ Backends without native filtering may implement `user_id` as an equality check.
+ """
@abstractmethod
def get(self, key: str) -> Any | None: ...
diff --git a/engram/store/indexed.py b/engram/store/indexed.py
new file mode 100644
index 0000000..79155aa
--- /dev/null
+++ b/engram/store/indexed.py
@@ -0,0 +1,266 @@
+"""A lexical/slot index over the fact store, and a VectorStore decorator that keeps it current.
+
+Why this exists (CLAUDE.md Bet E — linear scaling). The read path scored *every* live fact on every
+query: cosine over all embeddings, and — more expensively — re-tokenising and re-stemming every fact's
+text to compute BM25 from scratch. Measured with `eval/scaling.py`, per-query cost tracked store size
+exactly (constant ms per 1k facts), so a 100x larger store cost 81x more per query.
+
+The fix is to score a *bounded candidate set* instead of the whole store. Two invariants make that a
+speed optimisation rather than a silent ranking change:
+
+ 1. **Corpus statistics stay global.** BM25's IDF and average document length describe the collection,
+ not the candidate subset. The index keeps them, so a candidate scores exactly what it would have
+ scored in a full scan (see `lexical.bm25_scores(corpus=...)`).
+ 2. **Slots stay whole.** `_current_slot_heads` suppresses superseded facts by comparing facts that
+ share a (user, subject, predicate) slot. If a candidate's slot-mates were missing, a stale fact
+ could survive that a full scan would have filtered. The index can return a slot's full membership.
+
+The decorator keeps every existing `.upsert()/.delete()` call site untouched — the index is maintained
+where writes already land, not by threading a new dependency through eight callers.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Iterable, Optional
+
+from ..util import indexed_text, stems
+from .base import Predicate, VectorStore
+
+__all__ = ["FactIndex", "IndexedVectorStore"]
+
+
+@dataclass(frozen=True)
+class _UserCorpus:
+ """Satisfies retrieve.lexical.CorpusStats for one tenant's slice of the index."""
+
+ n_docs: int
+ avgdl: float
+ df: dict[str, int] = field(default_factory=dict)
+
+
+def _fact_key(fact: Any) -> Optional[tuple[str, str, str]]:
+ """A fact's conflict slot, or None for payloads that are not facts (episodes, summaries)."""
+ slot = getattr(fact, "slot", None)
+ return slot if isinstance(slot, tuple) else None
+
+
+class FactIndex:
+ """Inverted index + corpus statistics + slot membership for the facts in one store.
+
+ Deliberately pure-stdlib: the charter's zero-setup invariant means the default path cannot depend on
+ tantivy/bm25s. Those remain valid drop-in replacements behind the same three lookups this exposes
+ (`lexical_candidates`, `slot_members`, `user_members`)."""
+
+ def __init__(self) -> None:
+ self.postings: dict[str, set[str]] = {}
+ self.doc_len: dict[str, int] = {}
+ self._doc_terms: dict[str, set[str]] = {}
+ self._total_len: int = 0
+ self._by_user: dict[str, set[str]] = {}
+ self._by_slot: dict[tuple[str, str, str], set[str]] = {}
+ # key -> (user_id, slot): lets remove() unhook a fact in O(1) instead of sweeping every bucket.
+ self._doc_meta: dict[str, tuple[Optional[str], Optional[tuple[str, str, str]]]] = {}
+ # key -> payload. Candidate selection produces ids; resolving them through the backend's get()
+ # would be O(store) per id on LanceDB (it materialises the table and scans for the key), turning
+ # a bounded pool back into a quadratic read. These are references to objects the store already
+ # holds, so the cost is one dict entry per fact, not a second copy of the data.
+ self.payloads: dict[str, Any] = {}
+ # key -> insertion rank. Candidate ids arrive as an unordered set, but the scorers downstream are
+ # rank-based (RRF), so ties are broken by list position: an unordered candidate list would make
+ # the same query return different orderings run to run, and would diverge from the full scan even
+ # when the pool covers the whole store. Ranking by insertion order reproduces the store's own
+ # iteration order in O(k log k) over the candidates instead of O(store).
+ self._seq: dict[str, int] = {}
+ self._next_seq: int = 0
+
+ # --- corpus statistics (the CorpusStats protocol in retrieve.lexical) ---
+
+ @property
+ def n_docs(self) -> int:
+ return len(self.doc_len)
+
+ @property
+ def avgdl(self) -> float:
+ return max(1.0, self._total_len / self.n_docs) if self.n_docs else 1.0
+
+ @property
+ def df(self) -> dict[str, int]:
+ # Document frequency is exactly the posting-list length; materialising a parallel counter would
+ # be one more thing to keep in sync for no gain at these sizes.
+ return {term: len(ids) for term, ids in self.postings.items()}
+
+ # --- maintenance ---
+
+ def add(self, key: str, fact: Any) -> None:
+ """Index (or re-index) one fact. Re-upserting the same id is an update, not a duplicate."""
+ text = getattr(fact, "text", None)
+ if not isinstance(text, str):
+ return # not a fact payload; nothing lexical to index
+ # An update keeps its original position, matching dict semantics in the backing stores.
+ seq = self._seq.get(key)
+ self.remove(key)
+ if seq is None:
+ seq = self._next_seq
+ self._next_seq += 1
+ self._seq[key] = seq
+
+ terms = stems(indexed_text(text, getattr(fact, "valid_at", 0.0) or 0.0))
+ unique = set(terms)
+ for term in unique:
+ self.postings.setdefault(term, set()).add(key)
+ self._doc_terms[key] = unique
+ self.doc_len[key] = len(terms)
+ self._total_len += len(terms)
+
+ user = getattr(fact, "user_id", None)
+ user = user if isinstance(user, str) else None
+ if user is not None:
+ self._by_user.setdefault(user, set()).add(key)
+ slot = _fact_key(fact)
+ if slot is not None:
+ self._by_slot.setdefault(slot, set()).add(key)
+ self._doc_meta[key] = (user, slot)
+ self.payloads[key] = fact
+
+ def remove(self, key: str) -> None:
+ if key not in self.doc_len:
+ return
+ for term in self._doc_terms.pop(key, ()): # only this doc's terms, not the whole vocabulary
+ ids = self.postings.get(term)
+ if ids is not None:
+ ids.discard(key)
+ if not ids:
+ del self.postings[term]
+ self._total_len -= self.doc_len.pop(key, 0)
+ self.payloads.pop(key, None)
+ self._seq.pop(key, None)
+ user, slot = self._doc_meta.pop(key, (None, None))
+ if user is not None and (ids := self._by_user.get(user)) is not None:
+ ids.discard(key)
+ if not ids:
+ del self._by_user[user]
+ if slot is not None and (ids := self._by_slot.get(slot)) is not None:
+ ids.discard(key)
+ if not ids:
+ del self._by_slot[slot]
+
+ def clear(self) -> None:
+ self.__init__() # noqa: PLC2801 — re-initialising is the whole operation
+
+ # --- lookups ---
+
+ def user_members(self, user_id: str) -> set[str]:
+ return set(self._by_user.get(user_id, ()))
+
+ def corpus_for(self, user_id: str, terms: Iterable[str]) -> "_UserCorpus":
+ """BM25 statistics for one user's facts, restricted to `terms` (all a scorer ever looks up).
+
+ Scoping matters twice over. Per *user*, because the full-scan path scores one tenant's facts and
+ an IDF polluted by other tenants would both leak signal and misrank. Per *term*, because building
+ the whole vocabulary's document frequencies per query would be its own linear scan.
+
+ One deliberate difference from the full scan: document frequency here counts a user's indexed
+ facts, not only those live at `as_of`. When nothing has been invalidated the two are identical;
+ once facts have been superseded, IDF is computed over the slightly larger historical corpus. That
+ is the more stable statistic (a term's rarity should not jitter as facts age out) and it never
+ reorders a single query's results, since every candidate is scored against the same corpus."""
+ members = self._by_user.get(user_id) or set()
+ n = len(members)
+ total = sum(self.doc_len.get(key, 0) for key in members) if n else 0
+ df = {}
+ for term in set(terms):
+ ids = self.postings.get(term)
+ if ids:
+ df[term] = len(ids & members)
+ return _UserCorpus(n_docs=n, avgdl=max(1.0, total / n) if n else 1.0, df=df)
+
+ def resolve(self, keys: Iterable[str]) -> list[Any]:
+ """Candidate ids -> payloads in store order, skipping anything the index no longer holds.
+
+ Store order (not set order) is what makes bounded retrieval reproducible and what lets it match
+ the full scan exactly — see `_seq`."""
+ found = [(self._seq.get(key, 0), key) for key in keys if key in self.payloads]
+ found.sort()
+ return [self.payloads[key] for _, key in found]
+
+ def slot_members(self, slots: Iterable[tuple[str, str, str]]) -> set[str]:
+ out: set[str] = set()
+ for slot in slots:
+ out |= self._by_slot.get(slot, set())
+ return out
+
+ def lexical_candidates(self, query: str, limit: int, user_id: Optional[str] = None) -> set[str]:
+ """Facts sharing at least one query term, ranked by summed inverse document frequency.
+
+ This is the recall half of the hybrid thesis (CLAUDE.md M1): a fact can be lexically strong and
+ semantically weak — an exact name, a number, a date — and a vector-only candidate pool would drop
+ it. Ranking by rarity rather than raw overlap keeps a single distinctive term (a proper noun)
+ ahead of several common ones."""
+ if limit <= 0:
+ return set()
+ allowed = self._by_user.get(user_id) if user_id is not None else None
+ n = max(1, self.n_docs)
+ score: dict[str, float] = {}
+ for term in set(stems(query)):
+ ids = self.postings.get(term)
+ if not ids:
+ continue
+ # Rarer term -> larger weight. len(ids) is the term's document frequency.
+ weight = n / len(ids)
+ for key in ids:
+ if allowed is not None and key not in allowed:
+ continue
+ score[key] = score.get(key, 0.0) + weight
+ if len(score) <= limit:
+ return set(score)
+ ranked = sorted(score.items(), key=lambda kv: (-kv[1], kv[0]))
+ return {key for key, _ in ranked[:limit]}
+
+
+class IndexedVectorStore(VectorStore):
+ """Wraps any VectorStore and maintains a `FactIndex` alongside it.
+
+ A decorator rather than a subclass so it composes with whichever backend is configured (in-memory,
+ LanceDB, a future pgvector), and so persistence keeps working: `store.persist` replays facts through
+ `.upsert()`, which rebuilds the index for free on load."""
+
+ def __init__(self, inner: VectorStore) -> None:
+ self.inner = inner
+ self.index = FactIndex()
+ for payload in inner.values(): # adopt a pre-populated store
+ key = getattr(payload, "id", None)
+ if isinstance(key, str):
+ self.index.add(key, payload)
+
+ def upsert(self, key: str, vector: list[float], payload: Any) -> None:
+ self.inner.upsert(key, vector, payload)
+ self.index.add(key, payload)
+
+ def search(
+ self,
+ vector: list[float],
+ top_k: int,
+ where: Optional[Predicate] = None,
+ *,
+ user_id: Optional[str] = None,
+ ) -> list[tuple[float, Any]]:
+ return self.inner.search(vector, top_k, where, user_id=user_id)
+
+ def get(self, key: str) -> Any | None:
+ return self.inner.get(key)
+
+ def delete(self, key: str) -> None:
+ self.inner.delete(key)
+ self.index.remove(key)
+
+ def values(self) -> list[Any]:
+ return self.inner.values()
+
+ # Some backends expose extra surface (LanceDB's table handle, pickling hooks). Forward what we don't
+ # wrap so decorating a store never removes capability. Guarded against being consulted before
+ # __init__ has bound `inner` (unpickling calls __getattr__ before __dict__ is restored).
+ def __getattr__(self, name: str) -> Any:
+ inner = self.__dict__.get("inner")
+ if inner is None:
+ raise AttributeError(name)
+ return getattr(inner, name)
diff --git a/engram/store/lancedb_store.py b/engram/store/lancedb_store.py
index 799849c..4e2626e 100644
--- a/engram/store/lancedb_store.py
+++ b/engram/store/lancedb_store.py
@@ -17,6 +17,11 @@
_TYPES = {"Episode": Episode, "Fact": Fact}
+# Tenant id lifted out of the JSON payload into its own column. Without it every multi-tenant search
+# degrades to a table scan, because a Python predicate is the only way to express "this user's facts"
+# and LanceDB cannot see inside one.
+_TENANT_COL = "user_id"
+
def _quote(value: str) -> str:
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
@@ -52,6 +57,7 @@ def __init__(self, path: str, table: str = "vectors") -> None:
os.makedirs(self.path, exist_ok=True)
self._db = lancedb.connect(self.path)
self._table = None
+ self._tenant_col: Optional[bool] = None # resolved lazily against the real schema
def _open(self):
if self._table is not None:
@@ -62,14 +68,33 @@ def _open(self):
self._table = self._db.open_table(self.table_name)
return self._table
+ def _row(self, key: str, vector: list[float], payload: Any) -> dict[str, Any]:
+ """One table row. `user_id` is promoted out of the opaque payload into a real column so the
+ tenant filter can be pushed into LanceDB's index rather than applied in Python after a scan."""
+ return {
+ "key": key,
+ "vector": vector,
+ "payload": _encode_payload(payload),
+ _TENANT_COL: getattr(payload, "user_id", None) or "",
+ }
+
+ def _has_tenant_column(self, table) -> bool:
+ """Tables written before the tenant column existed still load and still work — they just cannot
+ prefilter. Detect rather than assume, so an upgrade never corrupts or rejects existing data."""
+ if self._tenant_col is None:
+ try:
+ self._tenant_col = _TENANT_COL in set(table.schema.names)
+ except AttributeError: # pragma: no cover - older/newer client without .schema.names
+ self._tenant_col = False
+ return self._tenant_col
+
def _ensure(self, vector: list[float], key: str, payload: Any):
table = self._open()
if table is None:
self._table = self._db.create_table(
- self.table_name,
- data=[{"key": key, "vector": vector, "payload": _encode_payload(payload)}],
- mode="overwrite",
+ self.table_name, data=[self._row(key, vector, payload)], mode="overwrite"
)
+ self._tenant_col = True
return None
return table
@@ -78,23 +103,43 @@ def upsert(self, key: str, vector: list[float], payload: Any) -> None:
if table is None:
return
table.delete(f"key = {_quote(key)}")
- table.add([{"key": key, "vector": vector, "payload": _encode_payload(payload)}])
+ row = self._row(key, vector, payload)
+ if not self._has_tenant_column(table):
+ row.pop(_TENANT_COL) # legacy table: adding an unknown column would be a schema violation
+ table.add([row])
def search(
- self, vector: list[float], top_k: int, where: Optional[Predicate] = None
+ self,
+ vector: list[float],
+ top_k: int,
+ where: Optional[Predicate] = None,
+ *,
+ user_id: Optional[str] = None,
) -> list[tuple[float, Any]]:
table = self._open()
if table is None or top_k <= 0:
return []
- # Python predicates are part of the VectorStore contract, so filtered searches must consider every
- # row before ranking. Otherwise a tenant/user filter can miss valid hits hidden beyond LanceDB's
- # nearest unfiltered rows.
- rows = table.to_arrow().to_pylist() if where is not None else table.search(vector).limit(top_k).to_list()
+
+ if where is None and user_id is not None and self._has_tenant_column(table):
+ # The whole point of the tenant column: a prefiltered ANN query, so LanceDB narrows to this
+ # tenant inside its own index and returns top_k without materialising the table.
+ query = table.search(vector).where(f"{_TENANT_COL} = {_quote(user_id)}", prefilter=True)
+ rows = query.limit(top_k).to_list()
+ elif where is None and user_id is None:
+ rows = table.search(vector).limit(top_k).to_list()
+ else:
+ # An arbitrary Python predicate is opaque to the backend, so every row must be considered
+ # before ranking — otherwise a filter can miss valid hits hidden beyond the nearest
+ # unfiltered rows. Same for a tenant filter on a legacy table with no column to filter on.
+ rows = table.to_arrow().to_pylist()
+
scored: list[tuple[float, Any]] = []
for row in rows:
payload = _decode_payload(row["payload"])
if where is not None and not where(payload):
continue
+ if user_id is not None and getattr(payload, "user_id", None) != user_id:
+ continue
scored.append((cosine(vector, row["vector"]), payload))
scored.sort(key=lambda x: x[0], reverse=True)
return scored[:top_k]
@@ -103,11 +148,13 @@ def get(self, key: str) -> Any | None:
table = self._open()
if table is None:
return None
- rows = table.to_arrow().to_pylist()
- for row in rows:
- if row.get("key") == key:
- return _decode_payload(row["payload"])
- return None
+ # A filter-only query (no vector) so the key predicate runs inside LanceDB. Materialising the
+ # table and scanning it in Python made a single-key read cost the whole store, which turns any
+ # id-at-a-time access pattern quadratic.
+ rows = table.search().where(f"key = {_quote(key)}").limit(1).to_list()
+ if not rows:
+ return None
+ return _decode_payload(rows[0]["payload"])
def delete(self, key: str) -> None:
table = self._open()
diff --git a/engram/store/memory_store.py b/engram/store/memory_store.py
index 374867d..f5bd9bf 100644
--- a/engram/store/memory_store.py
+++ b/engram/store/memory_store.py
@@ -3,10 +3,10 @@
from __future__ import annotations
from collections import defaultdict
-from typing import Any, Optional
+from typing import Any, Iterable, Optional
from ..types import Entity, Relation
-from ..util import cosine
+from ..util import cosine, stem, tokenize
from .base import DocStore, GraphStore, Predicate, VectorStore
@@ -18,12 +18,20 @@ def upsert(self, key: str, vector: list[float], payload: Any) -> None:
self._d[key] = (vector, payload)
def search(
- self, vector: list[float], top_k: int, where: Optional[Predicate] = None
+ self,
+ vector: list[float],
+ top_k: int,
+ where: Optional[Predicate] = None,
+ *,
+ user_id: Optional[str] = None,
) -> list[tuple[float, Any]]:
+ # No index to push a tenant filter into, so it is just another equality check here. The reference
+ # store is brute-force by design (see the module docstring); scale comes from a real backend.
scored = [
(cosine(vector, vec), payload)
for vec, payload in self._d.values()
- if where is None or where(payload)
+ if (user_id is None or getattr(payload, "user_id", None) == user_id)
+ and (where is None or where(payload))
]
scored.sort(key=lambda x: x[0], reverse=True)
return scored[:top_k]
@@ -56,10 +64,25 @@ def delete(self, key: str) -> None:
self._d.pop(key, None)
+def _name_terms(entity: Entity) -> set[str]:
+ """Stemmed tokens of an entity's name and aliases — the keys it can be looked up by."""
+ terms: set[str] = set()
+ for text in (entity.name, *entity.aliases):
+ for token in tokenize(text):
+ terms.add(stem(token))
+ return terms
+
+
class InMemoryGraphStore(GraphStore):
def __init__(self) -> None:
self.entities: dict[str, Entity] = {}
self._by_name: dict[tuple[str, str], str] = {}
+ # (user_id, stemmed term) -> entity ids. Anchoring a query to its entities otherwise means
+ # walking every entity in the store on every retrieval; this turns it into a lookup of the
+ # query's own terms. Built at upsert: an entity's name and aliases are fixed once inserted
+ # (upsert_entity returns the existing node rather than updating it), so there is nothing to
+ # invalidate. A backend that lets names change would need to re-index on that change.
+ self._by_term: dict[tuple[str, str], set[str]] = {}
self.rels: dict[str, Relation] = {}
self._out: dict[str, list[str]] = defaultdict(list)
self._in: dict[str, list[str]] = defaultdict(list)
@@ -71,8 +94,24 @@ def upsert_entity(self, entity: Entity) -> Entity:
return self.entities[existing_id]
self.entities[entity.id] = entity
self._by_name[key] = entity.id
+ for term in _name_terms(entity):
+ self._by_term.setdefault((entity.user_id, term), set()).add(entity.id)
return entity
+ def entities_by_terms(self, user_id: str, terms: Iterable[str]) -> dict[str, list[Entity]]:
+ """For each requested term, this user's entities whose name or aliases contain it.
+
+ Serves both halves of query anchoring: the union across terms is the candidate set to test
+ properly, and a single term's list size is the uniqueness signal an alias anchor needs. Only the
+ query's terms are looked up, so the cost follows the query rather than the store.
+ """
+ found: dict[str, list[Entity]] = {}
+ for term in terms:
+ ids = self._by_term.get((user_id, term))
+ if ids:
+ found[term] = [self.entities[eid] for eid in ids if eid in self.entities]
+ return found
+
def get_entity(self, user_id: str, name: str) -> Entity | None:
eid = self._by_name.get((user_id, name.lower()))
return self.entities.get(eid) if eid else None
@@ -119,6 +158,13 @@ def prune_orphan_entities(self) -> int:
continue
self.entities.pop(eid, None)
self._by_name.pop((ent.user_id, ent.name.lower()), None)
+ for term in _name_terms(ent):
+ key = (ent.user_id, term)
+ ids = self._by_term.get(key)
+ if ids is not None:
+ ids.discard(eid)
+ if not ids:
+ del self._by_term[key]
self._out.pop(eid, None)
self._in.pop(eid, None)
removed += 1
diff --git a/engram/util.py b/engram/util.py
index e6c5054..d5c8268 100644
--- a/engram/util.py
+++ b/engram/util.py
@@ -34,6 +34,33 @@ def fmt_datetime(epoch: float) -> str:
return "?"
+_MONTHS = ("january", "february", "march", "april", "may", "june", "july", "august",
+ "september", "october", "november", "december")
+
+
+def date_terms(epoch: float) -> str:
+ """Render a fact's date as searchable tokens (year, numeric month, month name) so a query that names
+ a time ('May 2023', 'in 2024') matches the right-dated facts via BM25 — dates otherwise live only in
+ valid_at and are invisible to retrieval. This is query-time temporal matching done as a lexical signal
+ (MemoryScope time_ratio in spirit), with no score multiplier that could override relevance.
+
+ Lives here, in the dependency-free base layer, because BOTH the retriever (which scores) and the
+ lexical index (which precomputes corpus statistics) must tokenize a fact identically — otherwise the
+ index's global IDF would describe a different corpus than the one being scored."""
+ try:
+ d = fmt_date(epoch) # YYYY-MM-DD
+ y, m, _ = d.split("-")
+ return f"{d} {y} {m} {_MONTHS[int(m) - 1]}"
+ except (ValueError, IndexError):
+ return ""
+
+
+def indexed_text(text: str, epoch: float) -> str:
+ """The exact string the lexical channel treats as a fact's document. Single source of truth so the
+ index and the scorer never drift apart."""
+ return f"{text} {date_terms(epoch)}"
+
+
def now() -> float:
"""Current wall-clock time in epoch seconds."""
return time.time()
diff --git a/eval/NOISE_FLOOR_RUNBOOK.md b/eval/NOISE_FLOOR_RUNBOOK.md
new file mode 100644
index 0000000..65be16f
--- /dev/null
+++ b/eval/NOISE_FLOOR_RUNBOOK.md
@@ -0,0 +1,71 @@
+# 噪声底测量 · 运行手册
+
+## 为什么先跑这个
+
+`results/significance_headline.md` 已经量出:500 题、22% 分歧率下,最小可检测增益约 **2.94 点**。
+但那是从**两个不同配置**的运行反推的分歧率。真正的噪声底——**同一份配置重跑会翻转多少题**——
+项目里至今没有测量,只有"大约 6–10 题"这个观察。
+
+这个数字是所有精度声称的地板:低于它的增益,无论重跑多少次都无法证明。**先钉死地板,再爬。**
+
+已提交的三份 lean 日志(`..._v1` 78.8 / `..._v2_final` 83.6 / `headline_500` 79.0)都是**不同配置**,
+所以不能用来算噪声底。必须新跑两次同配置。
+
+## 需要你提供
+
+| 项 | 说明 |
+| --- | --- |
+| `ARK_API_KEY`(+ `ARK_BASE_URL`) | 答题模型 doubao-seed-2.0-pro 与抽取模型 doubao-seed-1.6-flash |
+| `DEEPSEEK_API_KEY` | 判分模型 deepseek-v3.2 |
+| LongMemEval_S 数据集 | HuggingFace `xiaowu0162/longmemeval`,本仓库当前只有 2.7KB 的 sample |
+
+嵌入用本地 `bge-small`,不需要 key。
+
+## 成本(来自已提交日志的真实数据,非估算)
+
+单次 `engram_lean` 500 题运行:送入答题模型的上下文合计 **4.78M tokens**,单题 p50 延迟 61s,
+串行约 **8.4 小时**。跑两次即约 9.6M tokens / 17 小时(可并行以缩短墙钟时间)。
+
+抽取与判分的调用量不在日志的 `tok` 字段里,属额外开销,按你的 provider 计费自行核算。
+
+## 执行
+
+两条命令**除 `--out` 外必须逐字符相同**——任何一个 flag 不同,测到的就不是噪声而是配置差异。
+
+```bash
+HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 python3 eval/bench.py \
+ --data s --limit 500 --systems engram_lean \
+ --answerer volcano:doubao-seed-2-0-pro-260215 \
+ --judge volcano:deepseek-v3-2-251201 \
+ --extractor volcano:doubao-seed-1-6-flash-250615 \
+ --embedder bge-small --reasoning --persona \
+ --chunks 2 --topk 15 --extract-k 8 --summ-k 28 --n-summaries 28 \
+ --out results/noise_repeat_1.jsonl
+
+# 同一条命令,只改 --out
+# --out results/noise_repeat_2.jsonl
+```
+
+## 读出结果
+
+```bash
+python3 eval/noise_floor.py results/noise_repeat_1.jsonl results/noise_repeat_2.jsonl \
+ --system engram_lean
+```
+
+输出会给出:翻转题数与方向、两次运行的准确率差、以及据此推出的**最小可信增益**。
+
+### 怎么解读
+
+- **翻转本身不是 bug,也不是回归**,是测量仪器的误差棒。同配置下"翻成错"和"翻成对"是同一个现象,
+ 不要读成某一次更好。
+- 如果工具报出 `WARNING: some pairs differ by more than chance`,说明两次运行**并非真的同配置**——
+ 检查 flag、数据集切片、模型版本是否一致,而不是把它当成噪声记下来。
+- 拿到地板数字后,更新 `results/significance_headline.md` 与架构地图的「算法迭代的前置条件」,
+ 之后每个机制提案都要先回答「预期增益是否高于地板」。
+
+## 已完成的免费前置检查
+
+跑之前已确认本分支相对 main **检索行为无漂移**:离线评测两侧完全一致
+(accuracy 100.0%/70.0%,context tokens 5.4/14.2),延迟因否定约束提前返回而更低。
+所以测出的翻转可以归因于答题模型的不确定性,而不是本轮代码改动。
diff --git a/eval/coverage_check.py b/eval/coverage_check.py
new file mode 100644
index 0000000..f6ba32e
--- /dev/null
+++ b/eval/coverage_check.py
@@ -0,0 +1,172 @@
+"""Does widening the detail window for counting questions actually cover the evidence?
+
+`retrieval_diagnosis.md` established that counting failures are a coverage problem: the answer spans
+several sessions, retrieval finds them, and the detail window renders about half. `aggregation_chunk_cap`
+is the proposed fix. This measures whether it does what it claims — before anyone pays for a run.
+
+The measurement has to mirror the real selection loop, not a simplification of it. `lean_context` does
+not take the top-N sessions for the main query: it retrieves per subquery and interleaves by rank
+(`memory.py`, the `detail_eps` loop). Testing top-N instead would measure a mechanism the code does not
+have, and could report a gain that evaporates in production.
+
+LLM-free — retrieval runs on the local embedder, and the benchmark labels which sessions hold the answer.
+
+ python3 eval/coverage_check.py --failures-from results/longmemeval_s_engram_lean_v2_final.jsonl
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import statistics
+import sys
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from engram.retrieve.evidence import plan_evidence # noqa: E402
+from eval.error_modes import classify # noqa: E402
+from eval.retrieval_check import DEFAULT_DATASET, failure_modes # noqa: E402
+
+__all__ = ["select_detail_sessions", "coverage_for"]
+
+
+def select_detail_sessions(mem, session_of_episode: dict, query: str, need, n_chunks: int) -> list[str]:
+ """The session ids `lean_context` would render in full, for this query and budget.
+
+ Mirrors the round-robin in memory.py: each subquery contributes its rank-1 session first, then every
+ subquery's rank-2, and so on until the budget is spent. That ordering is the whole point — it spreads
+ the window across the decomposed angles instead of spending it all on the main query's top hits.
+ """
+ if n_chunks <= 0:
+ return []
+ detail_queries = list(need.subqueries) + [query] if need.subqueries else [query]
+ per_query = [mem.retrieve_episodes(q, "u", max(n_chunks, 1)) for q in detail_queries]
+
+ seen: set[str] = set()
+ chosen: list[str] = []
+ for rank in range(max((len(eps) for eps in per_query), default=0)):
+ for eps in per_query:
+ if rank >= len(eps):
+ continue
+ episode = eps[rank]
+ if episode.id in seen:
+ continue
+ seen.add(episode.id)
+ chosen.append(session_of_episode.get(episode.id, episode.session_id))
+ if len(chosen) >= n_chunks:
+ return chosen
+ return chosen
+
+
+def coverage_for(item: dict, embedder, cap: int) -> dict:
+ from engram.memory import Memory
+ from engram.util import DAY, now
+
+ mem = Memory(embedder=embedder)
+ base = now() - len(item["haystack_sessions"]) * DAY
+ session_of_episode = {}
+ for index, (session_id, session) in enumerate(
+ zip(item["haystack_session_ids"], item["haystack_sessions"])
+ ):
+ text = "\n".join(f"{t.get('role', 'user')}: {t.get('content', '')}" for t in session)
+ episode = mem.add(text, user_id="u", session_id=session_id, event_time=base + index * DAY)
+ session_of_episode[episode.id] = session_id
+
+ wanted = set(item.get("answer_session_ids") or [])
+ question = item["question"]
+
+ # The run under analysis passed --chunks 2 on the CLI, and lean_context takes max(cli, planner), so
+ # the baseline here is 2 rather than the planner's 1. Comparing against the planner alone would
+ # flatter the change by pretending the baseline was worse than it was.
+ before_need = plan_evidence(question, aggregation_chunk_cap=0)
+ after_need = plan_evidence(question, aggregation_chunk_cap=cap)
+ before_chunks = max(2, before_need.n_chunks)
+ after_chunks = max(2, after_need.n_chunks)
+
+ before = select_detail_sessions(mem, session_of_episode, question, before_need, before_chunks)
+ after = select_detail_sessions(mem, session_of_episode, question, after_need, after_chunks)
+
+ return {
+ "qid": item["question_id"],
+ "answer_sessions": len(wanted),
+ "chunks_before": before_chunks,
+ "chunks_after": after_chunks,
+ "covered_before": len(wanted & set(before)),
+ "covered_after": len(wanted & set(after)),
+ "complete_before": wanted.issubset(set(before)),
+ "complete_after": wanted.issubset(set(after)),
+ }
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="Does the wider detail window cover the answer sessions?")
+ ap.add_argument("--failures-from", required=True)
+ ap.add_argument("--system", default="engram_lean")
+ ap.add_argument("--dataset", default=DEFAULT_DATASET)
+ ap.add_argument("--cap", type=int, default=5, help="aggregation_chunk_cap to test")
+ ap.add_argument("--mode", default="numeric", help="failure mode to check")
+ ap.add_argument("--out", default=None)
+ args = ap.parse_args()
+
+ modes = failure_modes(args.failures_from, args.system)
+ with open(args.dataset, encoding="utf-8") as fh:
+ dataset = {item["question_id"]: item for item in json.load(fh)}
+
+ targets = [
+ qid for qid, mode in modes.items()
+ if mode == args.mode and qid in dataset and len(dataset[qid].get("answer_session_ids") or []) > 1
+ ]
+ if not targets:
+ print("no multi-session failures of that mode")
+ return 1
+ print(f"checking {len(targets)} multi-session {args.mode} failures at cap={args.cap} (LLM-free)\n")
+
+ from engram.llm.providers import make_embedder
+
+ embedder = make_embedder("bge-small")
+
+ rows = []
+ started = time.time()
+ for index, qid in enumerate(targets, start=1):
+ rows.append(coverage_for(dataset[qid], embedder, args.cap))
+ if index % 10 == 0 or index == len(targets):
+ print(f" {index}/{len(targets)} ({time.time() - started:.0f}s)", flush=True)
+
+ if args.out:
+ with open(args.out, "w", encoding="utf-8") as fh:
+ for row in rows:
+ fh.write(json.dumps(row, ensure_ascii=False) + "\n")
+
+ def ratio(row, key):
+ return row[key] / row["answer_sessions"] if row["answer_sessions"] else 0.0
+
+ before_cov = statistics.fmean(ratio(r, "covered_before") for r in rows)
+ after_cov = statistics.fmean(ratio(r, "covered_after") for r in rows)
+ before_full = sum(1 for r in rows if r["complete_before"])
+ after_full = sum(1 for r in rows if r["complete_after"])
+ chunks_before = statistics.fmean(r["chunks_before"] for r in rows)
+ chunks_after = statistics.fmean(r["chunks_after"] for r in rows)
+
+ print(f"\n{'':<26}{'before':>10}{'after':>10}")
+ print("-" * 46)
+ print(f"{'mean answer-session coverage':<26}{before_cov:>9.0%}{after_cov:>10.0%}")
+ print(f"{'fully covered':<26}{f'{before_full}/{len(rows)}':>10}{f'{after_full}/{len(rows)}':>10}")
+ print(f"{'mean full sessions rendered':<26}{chunks_before:>10.1f}{chunks_after:>10.1f}")
+
+ gained = after_full - before_full
+ print(
+ f"\n{gained} more questions now have every answer session in view "
+ f"(+{100.0 * gained / 500:.1f} points at best, if each converts to a correct answer)."
+ )
+ print(
+ "That ceiling assumes a conversion of 1, which the same log refutes: questions already failed\n"
+ "with their evidence in full detail. The measured gain needs a keyed run; this only establishes\n"
+ "that the mechanism does what it is supposed to do, and at what cost in rendered sessions."
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/eval/error_modes.py b/eval/error_modes.py
new file mode 100644
index 0000000..c8a3174
--- /dev/null
+++ b/eval/error_modes.py
@@ -0,0 +1,200 @@
+"""What kind of wrong is each wrong answer?
+
+An accuracy number says how many questions were missed; it does not say whether the system had nothing
+to say, said a number that was off by one, or confidently said the wrong thing. Those need different
+mechanisms, and without separating them a proposal is aimed at an average rather than a failure.
+
+Run against a committed log — free, no API keys — before designing anything:
+
+ python3 eval/error_modes.py results/longmemeval_s_engram_lean_v2_final.jsonl --system engram_lean
+
+The modes are deliberately few and mechanical, so the classification is reproducible rather than a
+judgement call:
+
+ * **abstained** — the system declined ("I don't know"). Evidence was retrieved badly or not at all.
+ * **numeric** — the gold answer contains a number and so did the prediction. Counting, dates,
+ durations, quantities: retrieval may have been fine and the arithmetic or the aggregation was not.
+ * **wrong value** — a confident answer that was simply not the right one.
+
+It also reports how much each mode is *worth*: eliminating a mode entirely moves the overall score by a
+known number of points, which is what decides whether attacking it can be measured at all.
+"""
+from __future__ import annotations
+
+import argparse
+import collections
+import re
+import statistics
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from eval.compare import load # noqa: E402
+from eval.significance import minimum_detectable_effect # noqa: E402
+
+__all__ = ["classify", "attribute", "ABSTAIN_RE"]
+
+# Matched against the prediction. Anchored forms first so a mere mention of "unknown" inside a real
+# answer is not counted as a refusal.
+ABSTAIN_RE = re.compile(
+ r"^\s*(i\s+don'?t\s+know|i\s+do\s+not\s+know|unknown|n/?a|none)\b"
+ r"|don'?t\s+have\s+(that|this|it|any)"
+ r"|(not|isn'?t)\s+(in|mentioned\s+in)\s+(my\s+)?(memory|the\s+memory|our\s+conversations?)"
+ r"|no\s+information\s+(about|on|regarding)"
+ r"|cannot\s+(determine|tell|find)"
+ r"|记忆里(暂时)?没有|不知道|无法确定|没有(相关|提到)",
+ re.IGNORECASE,
+)
+_HAS_DIGIT = re.compile(r"\d")
+
+
+def classify(pred: str, gold: str) -> str:
+ """One of: abstained | numeric | wrong_value."""
+ pred = (pred or "").strip()
+ gold = (gold or "").strip()
+ if ABSTAIN_RE.search(pred):
+ return "abstained"
+ if _HAS_DIGIT.search(gold) and _HAS_DIGIT.search(pred):
+ return "numeric"
+ return "wrong_value"
+
+
+def _leading_number(text: str) -> float | None:
+ match = re.search(r"-?\d+(?:\.\d+)?", text or "")
+ return float(match.group()) if match else None
+
+
+def attribute(log: dict, system: str) -> dict:
+ """Per-category failure modes, plus the direction of numeric errors.
+
+ The direction matters more than it looks: a systematic undercount points at missing evidence, which
+ recall expansion can fix. Errors in both directions point at the counting itself, which is a
+ different and harder target — so getting this wrong sends a mechanism after the wrong problem.
+ """
+ by_category: dict[str, collections.Counter] = collections.defaultdict(collections.Counter)
+ totals: dict[str, int] = collections.Counter()
+ scored: dict[str, int] = collections.Counter()
+ direction = collections.Counter()
+ examples: dict[str, list] = collections.defaultdict(list)
+ # Context size per outcome. A refusal on a context as large as the ones that answered correctly is
+ # not retrieval starvation — the evidence budget was spent, and something after retrieval failed.
+ tokens: dict[str, list[int]] = collections.defaultdict(list)
+
+ for qid, entry in log.items():
+ # Same split report.py makes: `_abs` items are the benchmark's *unanswerable* variants, graded
+ # by a different judge, and on those a refusal is the correct answer. Folding them into their
+ # base category counts correct behaviour as a failure mode and aims mechanisms at the wrong
+ # target — which is exactly what the first version of this tool did.
+ category = "abstention" if str(qid).endswith("_abs") else entry.get("_cat", "?")
+ result = entry.get(system)
+ if not result or result.get("err"):
+ continue
+ scored[category] += 1
+ context_tokens = int(result.get("tok") or 0)
+ if result.get("ok"):
+ tokens["correct"].append(context_tokens)
+ continue
+ pred, gold = result.get("pred") or "", result.get("gold") or ""
+ mode = classify(pred, gold)
+ tokens[mode].append(context_tokens)
+ by_category[category][mode] += 1
+ totals[mode] += 1
+ if len(examples[f"{category}/{mode}"]) < 3:
+ examples[f"{category}/{mode}"].append((pred[:120], gold[:120]))
+ if mode == "numeric":
+ a, b = _leading_number(pred), _leading_number(gold)
+ if a is not None and b is not None:
+ direction["under" if a < b else "over" if a > b else "equal_but_judged_wrong"] += 1
+
+ return {
+ "by_category": {k: dict(v) for k, v in by_category.items()},
+ "totals": dict(totals),
+ "scored": dict(scored),
+ "numeric_direction": dict(direction),
+ "median_context_tokens": {
+ mode: statistics.median(values) for mode, values in tokens.items() if values
+ },
+ "examples": dict(examples),
+ "n": sum(scored.values()),
+ }
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="Classify a run's wrong answers by failure mode.")
+ ap.add_argument("log")
+ ap.add_argument("--system", required=True)
+ ap.add_argument("--examples", action="store_true", help="print sample predictions per mode")
+ args = ap.parse_args()
+
+ report = attribute(load(args.log), args.system)
+ if not report["n"]:
+ print(f"no scored items for system {args.system!r}")
+ return 1
+
+ n = report["n"]
+ modes = ("abstained", "numeric", "wrong_value")
+ print(f"{'category':<28}{'scored':>7}{'wrong':>7}" + "".join(f"{m:>13}" for m in modes))
+ print("-" * (42 + 13 * len(modes)))
+ for category in sorted(report["by_category"], key=lambda c: -sum(report["by_category"][c].values())):
+ counts = report["by_category"][category]
+ wrong = sum(counts.values())
+ print(
+ f"{category:<28}{report['scored'].get(category, 0):>7}{wrong:>7}"
+ + "".join(f"{counts.get(m, 0):>13}" for m in modes)
+ )
+ total_wrong = sum(report["totals"].values())
+ print(
+ f"{'TOTAL':<28}{n:>7}{total_wrong:>7}"
+ + "".join(f"{report['totals'].get(m, 0):>13}" for m in modes)
+ )
+
+ direction = report["numeric_direction"]
+ if direction:
+ print(
+ f"\nnumeric errors: {direction.get('under', 0)} under, {direction.get('over', 0)} over, "
+ f"{direction.get('equal_but_judged_wrong', 0)} numerically equal but judged wrong"
+ )
+ if direction.get("under", 0) and direction.get("over", 0):
+ ratio = direction["under"] / max(1, direction["over"])
+ if 0.5 <= ratio <= 2.0:
+ print(
+ " errors go both ways, so this is not missing evidence that recall expansion would\n"
+ " recover — the counting itself is what fails."
+ )
+
+ # What each mode is worth, against what the benchmark can actually resolve.
+ floor = minimum_detectable_effect(n, 0.22)["mde_points"]
+ print(f"\nfixing a mode completely would move the overall score by (floor at this size: {floor:.2f}):")
+ for mode in modes:
+ count = report["totals"].get(mode, 0)
+ if not count:
+ continue
+ points = 100.0 * count / n
+ verdict = "measurable" if points > floor else "BELOW THE FLOOR — unmeasurable alone"
+ print(f" {mode:<14} {count:>4} questions = {points:+.1f} points {verdict}")
+
+ medians = report.get("median_context_tokens") or {}
+ if len(medians) > 1:
+ print("\nmedian retrieved context, by outcome:")
+ for mode, value in sorted(medians.items(), key=lambda kv: -kv[1]):
+ print(f" {mode:<14} {value:>8.0f} tokens")
+ spread = (max(medians.values()) - min(medians.values())) / max(1.0, max(medians.values()))
+ if spread < 0.15:
+ print(
+ " Within a few percent of each other: the refusals and the misses were given as much\n"
+ " evidence as the correct answers. Whatever failed, it was not retrieval running dry."
+ )
+
+ if args.examples:
+ print("\nexamples:")
+ for key, samples in sorted(report["examples"].items()):
+ print(f"\n {key}")
+ for pred, gold in samples:
+ print(f" pred: {pred}")
+ print(f" gold: {gold}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/eval/noise_floor.py b/eval/noise_floor.py
new file mode 100644
index 0000000..ea374d4
--- /dev/null
+++ b/eval/noise_floor.py
@@ -0,0 +1,140 @@
+"""Measure how much the benchmark moves when nothing changes.
+
+The answerer is not deterministic at temperature 0: run an identical configuration twice and some
+answers flip. Everyone working on this repo knows that as "roughly 6-10 of 500", which is an
+observation, not a measurement — and the difference matters, because that number is the floor under
+every accuracy claim the project can make. A mechanism that gains less than the floor cannot be shown
+to work, however many times it is re-run.
+
+This turns the observation into a committed number. Feed it two or more runs of the *same* config and
+it reports how many answers flipped, in which direction, and what the smallest trustworthy gain is as a
+consequence.
+
+ # produce the inputs (identical flags, different --out) — this is the part that costs money
+ python3 eval/bench.py --data s --limit 500 --systems engram_lean \
+ --answerer volcano:doubao-seed-2-0-pro-260215 --judge volcano:deepseek-v3-2-251201 \
+ --extractor volcano:doubao-seed-1-6-flash-250615 --embedder bge-small --reasoning --persona \
+ --chunks 2 --topk 15 --extract-k 8 --summ-k 28 --n-summaries 28 \
+ --out results/noise_repeat_1.jsonl
+ # ... same command again with --out results/noise_repeat_2.jsonl
+
+ python3 eval/noise_floor.py results/noise_repeat_1.jsonl results/noise_repeat_2.jsonl \
+ --system engram_lean
+
+A flip is not a bug and not a regression. It is the measurement apparatus, and this is its error bar.
+"""
+from __future__ import annotations
+
+import argparse
+import itertools
+import statistics
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from eval.compare import load # noqa: E402
+from eval.significance import ( # noqa: E402
+ mcnemar_exact,
+ minimum_detectable_effect,
+ paired_outcomes,
+)
+
+__all__ = ["compare_repeats", "summarise"]
+
+
+def compare_repeats(logs: list[dict], system: str) -> list[dict]:
+ """Every pairing of identical-config runs, with what changed between them."""
+ out = []
+ for (index_a, log_a), (index_b, log_b) in itertools.combinations(enumerate(logs), 2):
+ pairs = paired_outcomes(log_a, log_b, system, system)
+ result = mcnemar_exact(pairs)
+ out.append({
+ "a": index_a,
+ "b": index_b,
+ "n": result["n"],
+ # Direction is reported but should NOT be read as one run being better: with the same
+ # config, both directions are the same phenomenon.
+ "flipped_to_wrong": result["only_a"],
+ "flipped_to_right": result["only_b"],
+ "flips": result["discordant"],
+ "flip_rate": result["discordant"] / result["n"] if result["n"] else 0.0,
+ "acc_a": result["acc_a"],
+ "acc_b": result["acc_b"],
+ "spread": abs(result["acc_a"] - result["acc_b"]),
+ "p_value": result["p_value"],
+ })
+ return out
+
+
+def summarise(comparisons: list[dict]) -> dict:
+ """The floor: the flip rate across repeats, and the gain it makes unmeasurable."""
+ if not comparisons:
+ return {}
+ flip_rates = [c["flip_rate"] for c in comparisons]
+ spreads = [c["spread"] for c in comparisons]
+ n = max(c["n"] for c in comparisons)
+ worst_rate = max(flip_rates)
+ plan = minimum_detectable_effect(n, worst_rate)
+ return {
+ "runs_compared": len(comparisons),
+ "items": n,
+ "mean_flip_rate": statistics.fmean(flip_rates),
+ "worst_flip_rate": worst_rate,
+ "mean_flips": statistics.fmean([c["flips"] for c in comparisons]),
+ "max_accuracy_spread": max(spreads),
+ "mde_points": plan["mde_points"],
+ # A same-config difference that reads as "significant" means the runs differ by more than
+ # chance -- i.e. the configs were not actually identical, or something else changed.
+ "suspicious_pairs": [c for c in comparisons if c["p_value"] < 0.05],
+ }
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="How much does the benchmark move when nothing changes?")
+ ap.add_argument("logs", nargs="+", help="two or more runs of the SAME configuration")
+ ap.add_argument("--system", required=True, help="system name present in every log")
+ args = ap.parse_args()
+
+ if len(args.logs) < 2:
+ ap.error("need at least two runs of the same configuration")
+
+ logs = [load(path) for path in args.logs]
+ comparisons = compare_repeats(logs, args.system)
+ if not any(c["n"] for c in comparisons):
+ print("no questions scored by both runs — are these the same benchmark?")
+ return 1
+
+ names = [Path(p).name for p in args.logs]
+ print(f"system: {args.system}")
+ for i, name in enumerate(names):
+ print(f" run {i}: {name}")
+
+ print(f"\n{'pair':>8} {'items':>6} {'flips':>6} {'rate':>6} {'acc A':>7} {'acc B':>7} {'spread':>7}")
+ print("-" * 60)
+ for c in comparisons:
+ print(
+ f"{c['a']}↔{c['b']:<6} {c['n']:>6} {c['flips']:>6} {c['flip_rate']:>5.1%} "
+ f"{c['acc_a']:>6.1%} {c['acc_b']:>6.1%} {c['spread']:>6.1%}"
+ )
+
+ summary = summarise(comparisons)
+ print(
+ f"\nre-running the same configuration flips {summary['mean_flips']:.0f} of "
+ f"{summary['items']} answers on average ({summary['mean_flip_rate']:.1%}); the widest gap "
+ f"between two identical runs is {summary['max_accuracy_spread']*100:.1f} points."
+ )
+ print(
+ f"\nTHE FLOOR: a claimed gain below {summary['mde_points']:.2f} points cannot be told apart "
+ f"from re-running the same configuration."
+ )
+ if summary["suspicious_pairs"]:
+ print(
+ "\nWARNING: some pairs differ by more than chance (p < 0.05). Runs of an identical config "
+ "should not. Check that the flags, dataset slice and model versions really were the same."
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/eval/report.py b/eval/report.py
index 121e383..2eab68c 100644
--- a/eval/report.py
+++ b/eval/report.py
@@ -12,6 +12,11 @@
import json
import sys
from collections import defaultdict
+from pathlib import Path
+
+# Run as a script (`python3 eval/report.py …`), so the repo root is not on the path and `eval` is not
+# importable as a package. The sibling analysis modules need it to be.
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
def load(path: str) -> list[dict]:
with open(path, encoding="utf-8") as fh:
@@ -162,12 +167,53 @@ def format_bench_report(path: str, rows: list[dict]) -> str:
return "\n".join(lines)
+def format_significance(rows: list[dict]) -> str:
+ """Whether the gaps in the table above are real, for every pair of systems in the run.
+
+ Two accuracy percentages side by side invite the reader to subtract them and believe the result. On
+ this benchmark an unchanged configuration moves several answers by itself, so a gap can be smaller
+ than the apparatus. Printing the test next to the table means nobody has to remember to ask.
+ """
+ from eval.significance import bootstrap_difference, mcnemar_exact, verdict
+
+ systems: list[str] = []
+ for row in rows:
+ for name in (row.get("sys") or {}):
+ if name not in systems:
+ systems.append(name)
+ if len(systems) < 2:
+ return ""
+
+ lines = [" significance (paired McNemar over questions both systems scored)", ""]
+ for index, system_a in enumerate(systems):
+ for system_b in systems[index + 1:]:
+ pairs = []
+ for row in rows:
+ results = row.get("sys") or {}
+ a, b = results.get(system_a), results.get(system_b)
+ if not a or not b or a.get("err") or b.get("err"):
+ continue
+ pairs.append((row.get("qid"), bool(a.get("ok")), bool(b.get("ok"))))
+ if not pairs:
+ continue
+ result = mcnemar_exact(pairs)
+ interval = bootstrap_difference(pairs, iterations=2000, seed=0)
+ lines.append(f" {system_a} vs {system_b}:")
+ lines.append(f" {verdict(result, interval)}")
+ lines.append("")
+ return "\n".join(lines)
+
+
def main() -> None:
if len(sys.argv) < 2:
print("usage: python eval/report.py [more.jsonl ...]")
return
for path in sys.argv[1:]:
- print(format_bench_report(path, load(path)))
+ rows = load(path)
+ print(format_bench_report(path, rows))
+ report = format_significance(rows)
+ if report:
+ print(report)
if __name__ == "__main__":
diff --git a/eval/retrieval_check.py b/eval/retrieval_check.py
new file mode 100644
index 0000000..fa6cd68
--- /dev/null
+++ b/eval/retrieval_check.py
@@ -0,0 +1,182 @@
+"""Did retrieval surface the evidence, or did reasoning fail on evidence it had?
+
+`error_modes.py` shows that refusals and correct answers were given the same amount of context, which
+rules out retrieval running dry but not retrieval returning the *wrong* sessions. Those two call for
+opposite fixes — better recall versus better use of what was recalled — so the distinction has to be
+settled before designing anything.
+
+LongMemEval labels which sessions contain the answer (`answer_session_ids`), so this can be checked
+directly. And checked **without spending anything**: session retrieval is driven by the embedder and
+BM25 over raw text, not by the extractor LLM, so the whole thing runs on the local bge-small model.
+
+ python3 eval/retrieval_check.py --failures-from results/longmemeval_s_engram_lean_v2_final.jsonl \
+ --system engram_lean --out results/retrieval_check_failures.jsonl
+
+A hit means the answer-bearing session was inside the retrieved slice. If refusals mostly hit, the
+evidence was there and something after retrieval failed; if they mostly miss, retrieval is the target.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from eval.compare import load # noqa: E402
+from eval.error_modes import classify # noqa: E402
+
+DEFAULT_DATASET = os.path.expanduser(
+ "~/.cache/huggingface/hub/datasets--xiaowu0162--longmemeval/snapshots/"
+ "2ec2a557f339b6c0369619b1ed5793734cc87533/longmemeval_s"
+)
+
+
+def failure_modes(log_path: str, system: str) -> dict[str, str]:
+ """qid -> failure mode, for the questions this run got wrong."""
+ out = {}
+ for qid, entry in load(log_path).items():
+ result = entry.get(system)
+ if not result or result.get("err") or result.get("ok"):
+ continue
+ out[qid] = classify(result.get("pred") or "", result.get("gold") or "")
+ return out
+
+
+def check_item(item: dict, embedder, k_sessions: int) -> dict:
+ """Retrieve for one question and report whether the answer's session came back.
+
+ Ingests only this item's haystack, exactly as the benchmark's per-question setup does, so the
+ retrieval being measured is the retrieval the run actually performed.
+ """
+ from engram.memory import Memory
+ from engram.util import DAY, now
+
+ mem = Memory(embedder=embedder)
+ base = now() - len(item["haystack_sessions"]) * DAY
+ session_of_episode = {}
+ for index, (session_id, session) in enumerate(
+ zip(item["haystack_session_ids"], item["haystack_sessions"])
+ ):
+ text = "\n".join(
+ f"{turn.get('role', 'user')}: {turn.get('content', '')}" for turn in session
+ )
+ episode = mem.add(text, user_id="u", session_id=session_id, event_time=base + index * DAY)
+ session_of_episode[episode.id] = session_id
+
+ retrieved = mem.retrieve_episodes(item["question"], "u", k=k_sessions)
+ ordered = [session_of_episode.get(ep.id, ep.session_id) for ep in retrieved]
+ wanted = set(item.get("answer_session_ids") or [])
+ # Rank, not just membership. The read path shows only the top few sessions in FULL detail and the
+ # rest as summaries, so "somewhere in the top 15" and "shown as evidence the answerer can read" are
+ # different claims — and they point at different layers to fix.
+ rank = next((i + 1 for i, sid in enumerate(ordered) if sid in wanted), None)
+ # Coverage, not just the first hit. A counting question whose answer spans four sessions cannot be
+ # answered from one of them, however highly that one ranked — so "the answer session was retrieved"
+ # is the wrong measure for exactly the questions that fail most.
+ covered_top2 = sum(1 for sid in ordered[:2] if sid in wanted)
+ covered_all = sum(1 for sid in ordered if sid in wanted)
+ return {
+ "covered_top2": covered_top2,
+ "covered_all": covered_all,
+ "qid": item["question_id"],
+ "cat": item.get("question_type"),
+ "answer_sessions": len(wanted),
+ "haystack_sessions": len(item["haystack_session_ids"]),
+ "hit": rank is not None,
+ "rank": rank,
+ "retrieved": len(ordered),
+ }
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="Was the answer's session retrieved at all?")
+ ap.add_argument("--failures-from", required=True, help="a bench log; its wrong answers are checked")
+ ap.add_argument("--system", default="engram_lean")
+ ap.add_argument("--dataset", default=DEFAULT_DATASET)
+ ap.add_argument("--k-sessions", type=int, default=15, help="retrieved slice width to test")
+ ap.add_argument("--limit", type=int, default=0)
+ ap.add_argument("--out", default=None)
+ args = ap.parse_args()
+
+ modes = failure_modes(args.failures_from, args.system)
+ if not modes:
+ print("no failures in that log")
+ return 1
+
+ with open(args.dataset, encoding="utf-8") as fh:
+ dataset = {item["question_id"]: item for item in json.load(fh)}
+
+ targets = [qid for qid in modes if qid in dataset]
+ if args.limit:
+ targets = targets[: args.limit]
+ print(f"checking {len(targets)} failed questions (LLM-free; local embedder only)\n")
+
+ from engram.llm.providers import make_embedder
+
+ embedder = make_embedder("bge-small")
+
+ rows = []
+ started = time.time()
+ for index, qid in enumerate(targets, start=1):
+ row = check_item(dataset[qid], embedder, args.k_sessions)
+ row["mode"] = modes[qid]
+ rows.append(row)
+ if index % 10 == 0 or index == len(targets):
+ elapsed = time.time() - started
+ print(f" {index}/{len(targets)} ({elapsed:.0f}s)", flush=True)
+
+ if args.out:
+ with open(args.out, "w", encoding="utf-8") as fh:
+ for row in rows:
+ fh.write(json.dumps(row, ensure_ascii=False) + "\n")
+
+ print(f"\n{'failure mode':<16}{'checked':>9}{'answer session retrieved':>26}")
+ print("-" * 51)
+ for mode in ("abstained", "numeric", "wrong_value"):
+ subset = [r for r in rows if r["mode"] == mode]
+ if not subset:
+ continue
+ hits = sum(1 for r in subset if r["hit"])
+ print(f"{mode:<16}{len(subset):>9}{f'{hits}/{len(subset)} ({hits/len(subset):.0%})':>26}")
+ total_hits = sum(1 for r in rows if r["hit"])
+ print(f"{'ALL':<16}{len(rows):>9}{f'{total_hits}/{len(rows)} ({total_hits/len(rows):.0%})':>26}")
+
+ # Where in the ranking it landed decides which layer is at fault: inside the full-detail window means
+ # the answerer read it and still failed; outside means context assembly showed only a summary.
+ multi = [r for r in rows if r["answer_sessions"] > 1]
+ if multi:
+ full = sum(1 for r in multi if r["covered_top2"] == r["answer_sessions"])
+ any_all = sum(1 for r in multi if r["covered_all"] == r["answer_sessions"])
+ print(
+ f"\nquestions whose answer spans several sessions: {len(multi)}\n"
+ f" every answer session inside the full-detail window: {full}/{len(multi)}"
+ f" ({full/len(multi):.0%})\n"
+ f" every answer session retrieved at all: {any_all}/{len(multi)}"
+ f" ({any_all/len(multi):.0%})\n"
+ " A count cannot come out right from a subset of the sessions it has to cover, so for these\n"
+ " the question is coverage, not whether the top-ranked one was found."
+ )
+
+ ranks = [r["rank"] for r in rows if r["rank"]]
+ if ranks:
+ print("\nrank of the answer session within the retrieved slice:")
+ for cut in (1, 2, 3, 5, 10, 15):
+ within = sum(1 for r in ranks if r <= cut)
+ print(f" top-{cut:<3} {within:>4}/{len(rows)} ({within/len(rows):.0%})")
+ print(
+ "\n The run under analysis rendered its top 2 sessions in full and the rest as summaries,\n"
+ " so top-2 is the share where the answerer had the raw evidence in front of it."
+ )
+ print(
+ "\nA high hit rate means the evidence was in the retrieved slice and the failure happened after\n"
+ "retrieval — so recall expansion would buy nothing. A low one makes retrieval the target."
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/eval/scaling.py b/eval/scaling.py
new file mode 100644
index 0000000..72b6c45
--- /dev/null
+++ b/eval/scaling.py
@@ -0,0 +1,223 @@
+"""Read-path scaling measurement (charter Bet E).
+
+The charter claims linear scaling to 10M+ tokens, and the coding conventions say performance claims
+come from the harness, not intuition. This script is that harness for the *read* path: it grows the
+fact store and measures what `HybridRetriever.retrieve()` actually costs per query.
+
+It is deliberately offline and deterministic (hashing embedder, synthetic facts), so it measures the
+*shape* of the cost curve, not absolute production latency. The shape is the point: a retriever that
+scores every live fact per query is O(n), and no vector backend fixes that on its own.
+
+ python3 eval/scaling.py # default sizes
+ python3 eval/scaling.py --sizes 100,1000,5000 --trials 20
+"""
+from __future__ import annotations
+
+import argparse
+import statistics
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from engram.config import Config # noqa: E402
+from engram.embed.hashing import HashingEmbedder # noqa: E402
+from engram.retrieve.hybrid import HybridRetriever # noqa: E402
+from engram.store.base import VectorStore # noqa: E402
+from engram.store.indexed import IndexedVectorStore # noqa: E402
+from engram.store.memory_store import InMemoryGraphStore, InMemoryVectorStore # noqa: E402
+from engram.types import Fact # noqa: E402
+from engram.util import now # noqa: E402
+
+# A small vocabulary reused across facts so lexical scoring has real term statistics rather than
+# every document being disjoint (which would make BM25 trivially cheap and hide the scan cost).
+SUBJECTS = ["alice", "bob", "carol", "dave", "erin", "frank", "grace", "heidi"]
+PREDICATES = ["works_at", "lives_in", "prefers", "visited", "studied", "owns", "avoids", "plans"]
+OBJECTS = [
+ "acme corp", "berlin", "oat milk", "kyoto", "linear algebra", "a road bike",
+ "crowded cafes", "a trip to lisbon", "the night shift", "sourdough baking",
+]
+
+
+def build_facts(n: int, embedder: HashingEmbedder, user_id: str = "u1") -> list[Fact]:
+ """Deterministic synthetic facts spread over a year of valid-time."""
+ t = now()
+ facts: list[Fact] = []
+ for i in range(n):
+ subj = SUBJECTS[i % len(SUBJECTS)]
+ pred = PREDICATES[(i // len(SUBJECTS)) % len(PREDICATES)]
+ obj = OBJECTS[(i // (len(SUBJECTS) * len(PREDICATES))) % len(OBJECTS)]
+ text = f"{subj} {pred.replace('_', ' ')} {obj} (record {i})"
+ f = Fact(
+ user_id=user_id,
+ subject=subj,
+ predicate=pred,
+ object=f"{obj} {i}",
+ text=text,
+ valid_at=t - (i % 365) * 86400.0,
+ embedding=embedder.embed(text),
+ )
+ facts.append(f)
+ return facts
+
+
+def measure(
+ n: int,
+ trials: int,
+ queries: list[str],
+ *,
+ bounded: bool = False,
+ pool: int = 400,
+ vector_channel: bool = True,
+) -> dict:
+ embedder = HashingEmbedder()
+ store: VectorStore = InMemoryVectorStore()
+ if bounded:
+ store = IndexedVectorStore(store)
+ graph = InMemoryGraphStore()
+ for f in build_facts(n, embedder):
+ store.upsert(f.id, f.embedding or [], f)
+
+ config = Config(
+ bounded_candidates=bounded, candidate_pool=pool, candidate_vector_channel=vector_channel
+ )
+ retriever = HybridRetriever(store, graph, embedder, config)
+
+ # warm up so first-call import/alloc costs do not land in the sample
+ retriever.retrieve(queries[0], "u1")
+
+ samples: list[float] = []
+ for i in range(trials):
+ q = queries[i % len(queries)]
+ t0 = time.perf_counter()
+ retriever.retrieve(q, "u1")
+ samples.append((time.perf_counter() - t0) * 1000.0)
+
+ samples.sort()
+ return {
+ "n": n,
+ "p50_ms": statistics.median(samples),
+ "p95_ms": samples[min(len(samples) - 1, int(len(samples) * 0.95))],
+ "mean_ms": statistics.fmean(samples),
+ }
+
+
+def measure_backend_filter(sizes: list[int], trials: int) -> None:
+ """How much the tenant filter costs on the scale backend, pushed down vs. applied in Python.
+
+ Separate from the retriever benchmark above because it isolates one question: can the vector backend
+ narrow to a tenant inside its own index, or must it hand every row to Python first? Multi-tenant
+ retrieval filters by user on every single query, so this is the difference between having a vector
+ index and merely having a vector file.
+ """
+ try:
+ import lancedb # noqa: PLC0415 - optional backend
+ except ImportError:
+ print("\n(lancedb not installed - skipping the backend filter benchmark)")
+ return
+
+ from engram.store.lancedb_store import LanceDBVectorStore, _encode_payload # noqa: PLC0415
+
+ print(f"\n{'rows':>8} {'pushed down':>13} {'python predicate':>18} {'speedup':>9}")
+ print("-" * 56)
+ t = now()
+ for n in sizes:
+ with tempfile.TemporaryDirectory() as tmp:
+ rows = []
+ for i in range(n):
+ # A skewed tenant mix: the minority tenant's rows sit outside the query's unfiltered
+ # neighbourhood, which is exactly the case a post-filter handles badly.
+ user = "alice" if i % 10 else "bob"
+ f = Fact(
+ user_id=user, subject="s", predicate="p", object=f"o{i}",
+ text=f"fact number {i} about things", valid_at=t - i * 86400.0,
+ embedding=[1.0, i / max(1, n), (i % 7) / 7],
+ )
+ rows.append(
+ {"key": f.id, "vector": f.embedding, "payload": _encode_payload(f), "user_id": user}
+ )
+ lancedb.connect(tmp).create_table("facts", data=rows, mode="overwrite")
+ store = LanceDBVectorStore(tmp, "facts")
+ q = [1.0, 0.5, 0.3]
+
+ def timed(call) -> float:
+ call() # warm
+ samples = []
+ for _ in range(trials):
+ t0 = time.perf_counter()
+ call()
+ samples.append((time.perf_counter() - t0) * 1000.0)
+ return statistics.median(samples)
+
+ pushed = timed(lambda: store.search(q, 15, user_id="bob"))
+ scanned = timed(lambda: store.search(q, 15, where=lambda p: p.user_id == "bob"))
+ ratio = scanned / pushed if pushed else float("nan")
+ print(f"{n:>8} {pushed:>11.2f}ms {scanned:>16.2f}ms {ratio:>8.1f}x")
+
+ print(
+ "\nA flat 'pushed down' column is the index working. The predicate column is what every\n"
+ "multi-tenant query cost before the tenant id became a real column."
+ )
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="Measure read-path cost vs. fact-store size.")
+ ap.add_argument("--sizes", default="100,500,2000,10000", help="comma-separated fact counts")
+ ap.add_argument("--trials", type=int, default=15, help="queries timed per size")
+ ap.add_argument("--pool", type=int, default=400, help="candidate pool for the bounded path")
+ args = ap.parse_args()
+
+ sizes = [int(s) for s in args.sizes.split(",") if s.strip()]
+ queries = [
+ "where does alice work",
+ "what does bob prefer to drink",
+ "which city did carol visit last year",
+ "what is dave studying",
+ ]
+
+ variants = [
+ ("full scan", [measure(n, args.trials, queries) for n in sizes]),
+ (
+ "bounded +vec",
+ [measure(n, args.trials, queries, bounded=True, pool=args.pool) for n in sizes],
+ ),
+ (
+ "bounded -vec",
+ [
+ measure(n, args.trials, queries, bounded=True, pool=args.pool, vector_channel=False)
+ for n in sizes
+ ],
+ ),
+ ]
+
+ print(f"candidate pool = {args.pool}\n")
+ header = f"{'facts':>8}" + "".join(f"{name:>15}" for name, _ in variants)
+ print(header)
+ print("-" * len(header))
+ for i, n in enumerate(sizes):
+ row = f"{n:>8}" + "".join(f"{rows[i]['p50_ms']:>13.2f}ms" for _, rows in variants)
+ print(row)
+
+ print("\nms per 1k facts (constant = O(n), falling = sub-linear):")
+ for name, rows in variants:
+ cells = "".join(f"{r['p50_ms'] / (r['n'] / 1000.0):>10.2f}" for r in rows)
+ print(f" {name:>13}{cells}")
+
+ print(f"\nstore grew {sizes[-1] / sizes[0]:.0f}x ({sizes[0]} -> {sizes[-1]} facts)")
+ for name, rows in variants:
+ growth = rows[-1]["p50_ms"] / rows[0]["p50_ms"] if rows[0]["p50_ms"] else float("nan")
+ print(f" {name:>13}: per-query cost grew {growth:>6.1f}x")
+ print(
+ "\n'+vec' asks the vector store for semantic candidates. The in-memory reference store is\n"
+ "brute-force by design, so that call is itself a full scan — which is why bounding the lexical\n"
+ "and fusion work alone does not pay off here. '-vec' is what the read path costs once nothing\n"
+ "scans the store. The scale backend does have an index; the benchmark below measures it."
+ )
+ measure_backend_filter(sizes, args.trials)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/eval/significance.py b/eval/significance.py
new file mode 100644
index 0000000..837a31a
--- /dev/null
+++ b/eval/significance.py
@@ -0,0 +1,272 @@
+"""Is a benchmark difference real, or is it the answerer's noise?
+
+Re-running an identical configuration on LongMemEval_S moves 6-10 of 500 answers, because the answerer is
+not deterministic even at temperature 0. Any single-run comparison smaller than that is indistinguishable
+from chance — which means "83.6 to 84.4, we improved" is not a finding, and a long list of mechanisms was
+already retired on exactly that basis.
+
+This module is the instrument that tells the two apart. Three questions, in the order they matter:
+
+ 1. **Is the observed difference significant?** McNemar's exact test, which is the right test for two
+ systems scored on the *same* items: it ignores the questions both got right or both got wrong (they
+ carry no information about which is better) and asks whether the disagreements lean one way more
+ than a coin would.
+ 2. **How large could the true difference plausibly be?** A bootstrap interval over the paired
+ per-question outcomes. A p-value alone hides that a "win" may be consistent with anything from -1 to
+ +4 points.
+ 3. **Could this experiment have detected the gain I am hoping for?** The minimum detectable effect,
+ from the item count and how often the systems disagree. Answered *before* spending on a run, it is
+ the difference between an experiment and an expense — and the honest answer is sometimes that the
+ benchmark cannot resolve what is being attempted.
+
+Pure stdlib: an exact binomial tail, not a chi-square approximation, because the discordant counts here
+are small enough that the approximation is wrong exactly where the decision is closest.
+
+ python3 eval/significance.py results/a.jsonl results/b.jsonl --system engram_lean
+ python3 eval/significance.py --plan 500 --discordance 0.08 # what could a 500-item run detect?
+"""
+from __future__ import annotations
+
+import argparse
+import math
+import random
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from eval.compare import _ok, _scored, load # noqa: E402
+
+__all__ = ["mcnemar_exact", "bootstrap_difference", "minimum_detectable_effect", "paired_outcomes"]
+
+
+def paired_outcomes(
+ log_a: dict, log_b: dict, system_a: str, system_b: str
+) -> list[tuple[str, bool, bool]]:
+ """(qid, a_correct, b_correct) for questions BOTH runs actually scored.
+
+ Restricting to the intersection is not tidiness: comparing over different question sets compares two
+ different exams, and a system that errored on its hardest items would look better for it.
+ """
+ shared = []
+ for qid, entry_a in log_a.items():
+ entry_b = log_b.get(qid)
+ if entry_b is None:
+ continue
+ if not (_scored(entry_a, system_a) and _scored(entry_b, system_b)):
+ continue
+ shared.append((qid, _ok(entry_a, system_a), _ok(entry_b, system_b)))
+ return shared
+
+
+def _binom_tail(k: int, n: int) -> float:
+ """P(X <= k) for X ~ Binomial(n, 0.5), computed exactly."""
+ if n == 0:
+ return 1.0
+ return sum(math.comb(n, i) for i in range(k + 1)) / (2**n)
+
+
+def mcnemar_exact(pairs: list[tuple[str, bool, bool]]) -> dict:
+ """Exact McNemar test over paired outcomes.
+
+ Only the disagreements carry information. `b` is where A is right and B is wrong, `c` the reverse; if
+ the two systems were equally good, each disagreement is a coin flip, so the test asks how unlikely
+ the observed split is. Questions both systems answer the same way are excluded by construction —
+ which is why this is far more sensitive than comparing two accuracy percentages.
+ """
+ both = only_a = only_b = neither = 0
+ for _qid, a_ok, b_ok in pairs:
+ if a_ok and b_ok:
+ both += 1
+ elif a_ok:
+ only_a += 1
+ elif b_ok:
+ only_b += 1
+ else:
+ neither += 1
+
+ discordant = only_a + only_b
+ smaller = min(only_a, only_b)
+ # Two-sided exact p: both tails of a fair coin, capped at 1 for the symmetric case.
+ p_value = min(1.0, 2.0 * _binom_tail(smaller, discordant)) if discordant else 1.0
+ n = len(pairs)
+ return {
+ "n": n,
+ "both_correct": both,
+ "only_a": only_a,
+ "only_b": only_b,
+ "neither": neither,
+ "discordant": discordant,
+ "acc_a": (both + only_a) / n if n else 0.0,
+ "acc_b": (both + only_b) / n if n else 0.0,
+ "difference": (only_b - only_a) / n if n else 0.0, # positive = B better
+ "p_value": p_value,
+ }
+
+
+def bootstrap_difference(
+ pairs: list[tuple[str, bool, bool]], iterations: int = 10_000, seed: int = 0, alpha: float = 0.05
+) -> dict:
+ """Percentile bootstrap interval for B's accuracy minus A's, resampling questions in pairs.
+
+ Resampling the *pair* keeps each question's two outcomes together, which is what makes the interval
+ reflect the paired design rather than treating the runs as independent samples.
+ """
+ if not pairs:
+ return {"low": 0.0, "high": 0.0, "iterations": 0}
+ rng = random.Random(seed) # seeded so a reported interval can be reproduced exactly
+ n = len(pairs)
+ diffs = []
+ for _ in range(iterations):
+ total = 0
+ for _ in range(n):
+ _qid, a_ok, b_ok = pairs[rng.randrange(n)]
+ total += int(b_ok) - int(a_ok)
+ diffs.append(total / n)
+ diffs.sort()
+ lo_index = int((alpha / 2) * iterations)
+ hi_index = min(iterations - 1, int((1 - alpha / 2) * iterations))
+ return {"low": diffs[lo_index], "high": diffs[hi_index], "iterations": iterations}
+
+
+def minimum_detectable_effect(
+ n_items: int, discordance: float, alpha: float = 0.05, power: float = 0.80
+) -> dict:
+ """The smallest true accuracy gain a run of this size could detect.
+
+ Asked before a run, this is the difference between an experiment and an expense. With `n_items`
+ questions and the two systems disagreeing on a `discordance` fraction of them, only the disagreements
+ are informative, so the resolving power comes from `n_items * discordance` — usually far fewer items
+ than the benchmark's headline count suggests.
+
+ Normal approximation on the discordant-pair proportion. Deliberately not exact: the answer is used to
+ decide whether to spend on a run, and a figure good to a fraction of a point is enough for that.
+ """
+ discordant = max(0.0, n_items * discordance)
+ if discordant < 1:
+ return {"discordant_items": discordant, "mde_points": float("inf")}
+ # z for a two-sided alpha and the requested power.
+ z_alpha = _z_for(1 - alpha / 2)
+ z_power = _z_for(power)
+ # Detectable imbalance in the discordant split, converted back to overall accuracy points.
+ imbalance = (z_alpha + z_power) / (2 * math.sqrt(discordant))
+ return {
+ "discordant_items": discordant,
+ "mde_points": 100.0 * imbalance * discordance,
+ }
+
+
+def _z_for(p: float) -> float:
+ """Inverse standard normal CDF (Acklam's rational approximation; accurate to ~1e-9 here)."""
+ if not 0.0 < p < 1.0:
+ raise ValueError("probability must be in (0, 1)")
+ a = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
+ 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00]
+ b = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
+ 6.680131188771972e+01, -1.328068155288572e+01]
+ c = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
+ -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00]
+ d = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, 3.754408661907416e+00]
+ p_low, p_high = 0.02425, 1 - 0.02425
+ if p < p_low:
+ q = math.sqrt(-2 * math.log(p))
+ return (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1)
+ if p > p_high:
+ q = math.sqrt(-2 * math.log(1 - p))
+ return -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1)
+ q = p - 0.5
+ r = q * q
+ return (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q / (((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1)
+
+
+def verdict(result: dict, interval: dict, alpha: float = 0.05) -> str:
+ """One line a person can act on, rather than a p-value to be interpreted optimistically."""
+ if result["discordant"] == 0:
+ return "IDENTICAL — the two runs agree on every scored question."
+ if result["p_value"] > alpha:
+ return (
+ f"NOT DISTINGUISHABLE (p={result['p_value']:.3f}) — this difference is what chance produces. "
+ f"The true gap is somewhere in [{interval['low']*100:+.1f}, {interval['high']*100:+.1f}] points."
+ )
+ b_ahead = result["difference"] > 0
+ direction = "B beats A" if b_ahead else "A beats B"
+ # The interval is stated B-minus-A. When A is the winner the sentence names A first, so the interval
+ # has to be reoriented to match — otherwise it reads "A beats B by 10 points, plausibly [-13, -7]",
+ # and a reader cannot tell whether the effect is positive or negative.
+ low, high = (
+ (interval["low"] * 100, interval["high"] * 100)
+ if b_ahead
+ else (-interval["high"] * 100, -interval["low"] * 100)
+ )
+ return (
+ f"SIGNIFICANT (p={result['p_value']:.4f}) — {direction} by {abs(result['difference'])*100:.1f} "
+ f"points, plausibly [{low:+.1f}, {high:+.1f}]."
+ )
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="Is a benchmark difference real, or is it noise?")
+ ap.add_argument("logs", nargs="*", help="two result JSONL logs to compare")
+ ap.add_argument("--system", default=None, help="system name in both logs")
+ ap.add_argument("--system-a", default=None)
+ ap.add_argument("--system-b", default=None)
+ ap.add_argument("--iterations", type=int, default=10_000)
+ ap.add_argument("--seed", type=int, default=0)
+ ap.add_argument("--plan", type=int, default=0, help="item count to plan for, instead of comparing")
+ ap.add_argument("--discordance", type=float, default=0.08,
+ help="expected fraction of items the two systems answer differently")
+ args = ap.parse_args()
+
+ if args.plan:
+ for rate in sorted({args.discordance, 0.05, 0.10, 0.20}):
+ plan = minimum_detectable_effect(args.plan, rate)
+ print(
+ f"n={args.plan:<6} discordance={rate:<5.0%} "
+ f"informative items={plan['discordant_items']:>6.0f} "
+ f"smallest detectable gain={plan['mde_points']:.2f} points"
+ )
+ print(
+ "\nOnly the questions the two systems answer differently carry information, so the usable\n"
+ "sample is far smaller than the headline item count. A gain below the last column cannot be\n"
+ "told from chance by a run this size, however many times it is repeated within one run."
+ )
+ return 0
+
+ if len(args.logs) != 2:
+ ap.error("give exactly two logs, or use --plan N")
+
+ log_a, log_b = load(args.logs[0]), load(args.logs[1])
+ system_a = args.system_a or args.system
+ system_b = args.system_b or args.system
+ if not (system_a and system_b):
+ ap.error("--system (or --system-a/--system-b) is required")
+
+ pairs = paired_outcomes(log_a, log_b, system_a, system_b)
+ if not pairs:
+ print("no questions were scored by both runs — nothing comparable")
+ return 1
+
+ result = mcnemar_exact(pairs)
+ interval = bootstrap_difference(pairs, iterations=args.iterations, seed=args.seed)
+
+ print(f"A = {Path(args.logs[0]).name} [{system_a}]")
+ print(f"B = {Path(args.logs[1]).name} [{system_b}]")
+ print(f"\ncompared on {result['n']} questions scored by both runs")
+ print(f" both correct {result['both_correct']:>5}")
+ print(f" only A correct {result['only_a']:>5}")
+ print(f" only B correct {result['only_b']:>5} <- these two are the entire evidence")
+ print(f" both wrong {result['neither']:>5}")
+ print(f"\naccuracy A {result['acc_a']*100:.1f}% B {result['acc_b']*100:.1f}% "
+ f"difference {result['difference']*100:+.1f} points")
+ print(f"\n{verdict(result, interval)}")
+
+ plan = minimum_detectable_effect(result["n"], result["discordant"] / max(1, result["n"]))
+ print(
+ f"\nAt this sample and disagreement rate, the smallest gain a run like this could have detected "
+ f"is {plan['mde_points']:.2f} points."
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/eval/tune_weights.py b/eval/tune_weights.py
index ddd7b61..32e74c8 100644
--- a/eval/tune_weights.py
+++ b/eval/tune_weights.py
@@ -22,7 +22,7 @@
from engram import Memory # noqa: E402
from engram.config import Config # noqa: E402
from engram.llm.providers import load_dotenv, make_embedder, make_llm # noqa: E402
-from eval.longmemeval import ingest, load_data, sessions_of # noqa: E402
+from eval.longmemeval import ingest, load_data # noqa: E402
def build_dev(items, embedder, extractor, extract_k=20):
diff --git a/paper/compute_stats.py b/paper/compute_stats.py
index 87409fc..23bbb57 100644
--- a/paper/compute_stats.py
+++ b/paper/compute_stats.py
@@ -14,7 +14,6 @@
import glob
import json
import math
-import os
import random
from pathlib import Path
from typing import Iterable
diff --git a/results/aggregation_coverage.md b/results/aggregation_coverage.md
new file mode 100644
index 0000000..04f1df5
--- /dev/null
+++ b/results/aggregation_coverage.md
@@ -0,0 +1,68 @@
+# 聚合类查询的全文覆盖:机制实测不足(2026-08-17)
+
+## 裁决:no-go
+
+给聚合类查询扩大全文窗口,**实测最多 +1.2 点,低于 2.94 的分辨率地板**,也低于合并基准后的 1.32。
+不建议付费验证——跑出来必然落在噪声带里,无法判定。
+
+## 假设从哪来
+
+`results/retrieval_diagnosis.md` 确立:计数题的失败是覆盖问题。答案跨多个会话,检索找到了(89% 在
+主查询 top-15 内),但装配层按相关度只渲染前 2 个全文,其余压成摘要。误差双向——压成摘要的漏数,
+摘要表述模糊的重复计入。
+
+顺着查到根因(`engram/retrieve/evidence.py`):
+
+```python
+n_chunks = 2 if (preference or procedural or exact_lookup or multi_hop or duration) else (1 if aggregation or timeline else 0)
+```
+
+**聚合类拿到的是 1——所有非零档里最低的。** 而实测这 28 道多会话计数错题:需要的答案会话中位数 3,
+规划器生成的子查询中位数也是 3(召回扩展工作正常),却只渲染 1 个全文块。
+
+看起来是一个一行修复:让聚合类的全文预算跟随子查询数。
+
+## 实测结果
+
+```bash
+python3 eval/coverage_check.py --failures-from results/longmemeval_s_engram_lean_v2_final.jsonl --cap 5
+python3 eval/coverage_check.py --failures-from results/longmemeval_s_engram_lean_v2_final.jsonl --cap 12
+```
+
+| | 改动前 | cap=5 | cap=12 |
+| --- | ---: | ---: | ---: |
+| 答案会话平均覆盖率 | 38% | 56% | **59%** |
+| 完整覆盖题数 | 3/28 | 7/28 | 9/28 |
+| 平均渲染全文会话数 | 2.0 | 3.9 | 4.4 |
+| 名义上限 | — | +0.8 点 | +1.2 点 |
+
+**预算翻 2.4 倍,覆盖率只涨 3 个百分点。** 子查询能产出的不同会话在约 4 个时耗尽,剩下 41% 的
+答案会话**无论预算多大都选不到**。
+
+## 为什么错了(对自己的估算的复盘)
+
+我此前估 +4.4 点,算法是「44 道题缺完整覆盖 × 50% 转化率」。这个估算**默认机制能达成完整覆盖**,
+而实测只到 56%。用未经验证的前提去算收益,算出来的是愿望不是预测。
+
+正确的做法是先测机制的**直接效果**(覆盖率提升),再谈转化率——本文件就是补上这一步。
+
+## 真正的瓶颈(下一个该验证的假设)
+
+矛盾点:**89% 的答案会话在主查询 top-15 里,子查询轮询却只选出 59%。**
+
+所以约束不在「允许渲染几个」,而在「轮询选中哪几个」。当前策略是各子查询按名次交错取块
+(`engram/memory.py` 的 detail_eps 循环),它服务的是「多角度找到最相关的会话」,
+而计数需要的是「覆盖所有相关会话」——这两个目标在选块阶段是冲突的。
+
+下一个假设应当针对**选块策略**而非预算:对聚合类查询,从主查询的 top-k 候选里按**证据多样性**
+(例如按被计数对象的不同取值)选块,而不是按各子查询的相关度名次。这个假设同样可以离线验证,
+方法与本文件相同。
+
+## 未采用/回滚原因
+
+| 方向 | 结论 | 证据 |
+| --- | --- | --- |
+| 聚合类扩大全文预算(`aggregation_chunk_cap`) | **不采用**。覆盖率 38%→59% 即饱和,名义上限 +1.2 点,低于地板;且渲染量翻倍,token 成本朝全上下文回退 | 本文件 |
+| 「44 题 × 50% 转化率 = +4.4 点」这一估算 | **作废**。它假设机制达成完整覆盖,实测未达成 | 本文件 |
+
+开关保留但默认 0(惰性):它本身不是修复,但一个「按覆盖选块」的策略会需要它提供的预算。
diff --git a/results/bounded_candidates_scaling.md b/results/bounded_candidates_scaling.md
new file mode 100644
index 0000000..a4cad9c
--- /dev/null
+++ b/results/bounded_candidates_scaling.md
@@ -0,0 +1,116 @@
+# bounded_candidates — 读路径扩展性测量(2026-08-16)
+
+## 复现命令
+
+```bash
+python3 eval/scaling.py --sizes 100,500,2000,10000 --trials 15 --pool 400
+```
+
+离线、确定性(HashingEmbedder + 合成事实),无 API key。测的是**成本曲线的形状**,不是生产绝对延迟。
+
+## 结果
+
+| 事实数 | 全量扫描 p50 | bounded 含语义通道 | bounded 去语义通道 |
+| ---: | ---: | ---: | ---: |
+| 100 | 2.07ms | 3.82ms | 0.62ms |
+| 500 | 8.71ms | 15.64ms | 2.43ms |
+| 2000 | 33.13ms | 43.41ms | 8.54ms |
+| 10000 | 177.88ms | 177.03ms | **12.28ms** |
+
+每千条事实的毫秒数(恒定 = O(n),下降 = 次线性):
+
+| 变体 | 100 | 500 | 2000 | 10000 |
+| --- | ---: | ---: | ---: | ---: |
+| 全量扫描 | 20.67 | 17.42 | 16.57 | 17.79 |
+| bounded 含语义通道 | 38.18 | 31.29 | 21.70 | 17.70 |
+| bounded 去语义通道 | 6.17 | 4.86 | 4.27 | **1.23** |
+
+存储增长 100x 时,单查询成本增长:全量扫描 **86.0x** / 含语义通道 46.4x / 去语义通道 **19.9x**。
+
+## 结论
+
+1. **主干读路径是 O(n)。** 每千条事实的耗时在 100→10000 区间恒定在 ~17–20ms,这是全量扫描的
+ 特征。10000 条事实时单查询已达 177ms,超过宪章的 <100ms 读路径目标,而这个规模对
+ "10M+ token" 的目标而言还很小。
+2. **只把词汇与融合环节收敛成候选池,收益为零**(177.03ms vs 177.88ms)。因为语义通道调用
+ `fact_store.search()`,而两个后端都没有真正的 ANN 索引:`InMemoryVectorStore.search()` 是
+ 暴力 cosine 加一次全量排序;`LanceDBVectorStore.search()` 一旦传入 Python 谓词就走
+ `table.to_arrow().to_pylist()` 全表物化。多租户检索每次都必须带 user 过滤,所以线上路径
+ **永远拿不到 ANN 收益**。
+3. **候选池设计本身是有效的。** 去掉语义通道后 10000 条事实上快 14.5x,且每千条耗时从 6.17
+ 降到 1.23——真正的次线性。卡住 Bet E 的是缺失的 ANN 索引,不是候选池思路。
+
+## 追加:规模后端的租户过滤下推(同日)
+
+上面第 2 条指出的根因已修复。`user_id` 从不透明的 JSON payload 中提升为 LanceDB 的真实列,
+`VectorStore.search()` 增加声明式 `user_id=` 参数(与通用 Python 谓词并存),多租户检索因此
+可以走 `where(..., prefilter=True)` 在索引内部收窄。
+
+| 行数 | 下推 prefilter | Python 谓词 | 加速 |
+| ---: | ---: | ---: | ---: |
+| 500 | 1.13ms | 4.04ms | 3.6x |
+| 2000 | 1.16ms | 15.18ms | 13.1x |
+| 10000 | 1.35ms | 75.38ms | 56.0x |
+| 40000 | 1.83ms | 302.36ms | **165.4x** |
+
+行数增长 80x(500→40000)时,下推路径延迟只从 1.13ms 涨到 1.83ms——**基本是平的**,而谓词路径
+是严格线性的。这就是 Bet E 在规模后端上的兑现。
+
+正确性由 `tests/test_lancedb_tenant_filter.py` 保证,其中
+`test_prefilter_finds_hits_beyond_the_unfiltered_neighbourhood` 是专门设计来证伪"过滤发生在
+ANN 之后"的:让多数租户的行填满查询的整个最近邻域,少数租户的行全部远离查询。若过滤是后置的,
+top_k 里一条目标租户的行都没有,返回空。该测试通过,说明 prefilter 真实生效。
+
+向后兼容:旧版本写出的表没有 `user_id` 列。`_has_tenant_column()` 按真实 schema 探测而非假设,
+旧表继续可读可写(退回扫描),不会因 schema 不匹配而报错或损坏数据。
+
+## 追加:否定约束的提前返回 + 按键读取下推(同日)
+
+**否定约束提前返回。** 每次检索都会走 `query_entity_ids()`,它末尾调用
+`graph_excluded_entity_ids()`,后者对**每个实体名**跑两遍正则。而绝大多数查询根本没有否定词。
+
+`_EXCLUSION_BEFORE_RE` 用 `\s*$` 锚定在"实体名之前那段文本"的末尾,不能直接拿来搜整个查询;
+但"查询中至少出现一个提示词"是它匹配的必要条件,据此可以提前返回。
+
+5000 个实体时实测:
+
+| 查询 | 耗时 |
+| --- | ---: |
+| 无否定词(`where does alice work`) | **0.0009 ms** |
+| 含否定词(`anywhere except entity number 12`) | 508.94 ms |
+
+**一个被测试抓到的真实缺陷。** 提示词正则最初写成 `\bnot\b`(与真正的匹配器一致),这是错的:
+`before` 切片终止于实体名起始处,而非 ASCII 实体名在 `_entity_name_mentions` 里**不带词边界守卫**,
+所以 `"not上海"` 的切片 `"not"` 末尾构成词边界,而完整串里 `"not上"` 两侧都是 `\w`、提示词正则
+匹配不到——会静默丢掉一次排除。提示词正则因此去掉尾部 `\b`,故意比真正的匹配器更宽松:多扫一次
+只是白跑,漏扫则是错误。`tests/test_exclusion_shortcut.py` 对样本的**每一个前缀**验证
+"锚定匹配器命中 ⟹ 提示词匹配器命中"这一不变量,未来若只给其中一个正则加提示词会立即失败。
+
+**按键读取下推。** `LanceDBVectorStore.get()` 此前全表物化后在 Python 里线性找 key,使任何
+逐 id 访问变成二次复杂度。改用纯过滤查询 `table.search().where("key = ...")`,谓词在 LanceDB
+内部执行。(`pylance` 未安装、`table.query()` 在 0.33 不存在,实测确认 `search().where()` 可用。)
+
+## 仍然存在的热点
+
+- `graph_excluded_entity_ids()` 在**确实含否定词**时仍是 O(实体数),5000 实体需 509ms。提前返回
+ 只是让它不再影响绝大多数查询,没有解决这条路径本身。它按子串匹配(中文实体名必需),无法用
+ 词元倒排索引绕开。
+- `query_entity_ids()` 的名称匹配与别名锚定仍扫全部实体。这两处是词元化的,**可以**建倒排索引。
+- `InMemoryVectorStore.search()` 仍是暴力 cosine——参考实现设计如此,模块文档已写明适用到 ~10k。
+- `values()` 按接口语义必须返回全部,无法下推。
+
+## 未采用/暂缓原因
+
+- `candidate_vector_channel=False` **不作为默认**:它会丢掉"语义相关但与查询无共享词"的事实,
+ 这正是 M1 已验证的 hybrid 论点所依赖的召回。在拿到真正的 ANN 后端、或有 keyed harness 跑出
+ 召回损失可接受的证据之前,默认保持召回安全。
+- `bounded_candidates` **默认关闭**:已发布数字由全量扫描产出,静默改变打分集合会违反
+ "每个公开数字都可追溯到已提交日志"的规则。候选池 ≥ 存活事实数时两条路径逐位一致
+ (`tests/test_bounded_candidates.py::test_bounded_matches_full_scan_when_pool_covers_store`)。
+
+## 下一步(按依赖顺序)
+
+1. 给向量存储一个真正的过滤 ANN:LanceDB 侧把 user 过滤下推成 SQL 谓词(而不是 Python 回调),
+ 即可让 `search()` 走真实索引。这是解锁上面 14.5x 的唯一前提。
+2. `query_entity_ids()` 仍在扫 `graph.entities.values()`,需要实体名索引。
+3. `LanceDBVectorStore.get()` 全表物化后线性找 key,逐 id 取回会二次放大。
diff --git a/results/coverage_check_numeric.jsonl b/results/coverage_check_numeric.jsonl
new file mode 100644
index 0000000..ad2ae47
--- /dev/null
+++ b/results/coverage_check_numeric.jsonl
@@ -0,0 +1,28 @@
+{"qid": "gpt4_59c863d7", "answer_sessions": 4, "chunks_before": 2, "chunks_after": 5, "covered_before": 2, "covered_after": 4, "complete_before": false, "complete_after": true}
+{"qid": "gpt4_15e38248", "answer_sessions": 4, "chunks_before": 2, "chunks_after": 4, "covered_before": 2, "covered_after": 3, "complete_before": false, "complete_after": false}
+{"qid": "d682f1a2", "answer_sessions": 3, "chunks_before": 2, "chunks_after": 4, "covered_before": 2, "covered_after": 3, "complete_before": false, "complete_after": true}
+{"qid": "c18a7dc8", "answer_sessions": 2, "chunks_before": 2, "chunks_after": 3, "covered_before": 0, "covered_after": 0, "complete_before": false, "complete_after": false}
+{"qid": "28dc39ac", "answer_sessions": 5, "chunks_before": 2, "chunks_after": 5, "covered_before": 2, "covered_after": 3, "complete_before": false, "complete_after": false}
+{"qid": "370a8ff4", "answer_sessions": 2, "chunks_before": 2, "chunks_after": 5, "covered_before": 1, "covered_after": 1, "complete_before": false, "complete_after": false}
+{"qid": "6e984301", "answer_sessions": 2, "chunks_before": 2, "chunks_after": 3, "covered_before": 1, "covered_after": 1, "complete_before": false, "complete_after": false}
+{"qid": "45dc21b6", "answer_sessions": 2, "chunks_before": 2, "chunks_after": 4, "covered_before": 1, "covered_after": 1, "complete_before": false, "complete_after": false}
+{"qid": "3a704032", "answer_sessions": 3, "chunks_before": 2, "chunks_after": 4, "covered_before": 1, "covered_after": 3, "complete_before": false, "complete_after": true}
+{"qid": "69fee5aa", "answer_sessions": 2, "chunks_before": 2, "chunks_after": 4, "covered_before": 2, "covered_after": 2, "complete_before": true, "complete_after": true}
+{"qid": "9a707b81", "answer_sessions": 2, "chunks_before": 2, "chunks_after": 5, "covered_before": 1, "covered_after": 1, "complete_before": false, "complete_after": false}
+{"qid": "59524333", "answer_sessions": 2, "chunks_before": 2, "chunks_after": 2, "covered_before": 2, "covered_after": 2, "complete_before": true, "complete_after": true}
+{"qid": "gpt4_731e37d7", "answer_sessions": 4, "chunks_before": 2, "chunks_after": 5, "covered_before": 0, "covered_after": 2, "complete_before": false, "complete_after": false}
+{"qid": "gpt4_d6585ce8", "answer_sessions": 5, "chunks_before": 2, "chunks_after": 2, "covered_before": 2, "covered_after": 2, "complete_before": false, "complete_after": false}
+{"qid": "gpt4_31ff4165", "answer_sessions": 5, "chunks_before": 2, "chunks_after": 4, "covered_before": 1, "covered_after": 3, "complete_before": false, "complete_after": false}
+{"qid": "bf659f65", "answer_sessions": 3, "chunks_before": 2, "chunks_after": 5, "covered_before": 1, "covered_after": 2, "complete_before": false, "complete_after": false}
+{"qid": "d23cf73b", "answer_sessions": 4, "chunks_before": 2, "chunks_after": 4, "covered_before": 2, "covered_after": 3, "complete_before": false, "complete_after": false}
+{"qid": "8e91e7d9", "answer_sessions": 2, "chunks_before": 2, "chunks_after": 4, "covered_before": 0, "covered_after": 1, "complete_before": false, "complete_after": false}
+{"qid": "92a0aa75", "answer_sessions": 2, "chunks_before": 2, "chunks_after": 2, "covered_before": 0, "covered_after": 0, "complete_before": false, "complete_after": false}
+{"qid": "0a995998", "answer_sessions": 3, "chunks_before": 2, "chunks_after": 5, "covered_before": 2, "covered_after": 2, "complete_before": false, "complete_after": false}
+{"qid": "60472f9c", "answer_sessions": 3, "chunks_before": 2, "chunks_after": 4, "covered_before": 0, "covered_after": 1, "complete_before": false, "complete_after": false}
+{"qid": "10d9b85a", "answer_sessions": 2, "chunks_before": 2, "chunks_after": 5, "covered_before": 0, "covered_after": 0, "complete_before": false, "complete_after": false}
+{"qid": "4f54b7c9", "answer_sessions": 2, "chunks_before": 2, "chunks_after": 4, "covered_before": 2, "covered_after": 2, "complete_before": true, "complete_after": true}
+{"qid": "129d1232", "answer_sessions": 3, "chunks_before": 2, "chunks_after": 3, "covered_before": 0, "covered_after": 0, "complete_before": false, "complete_after": false}
+{"qid": "9ee3ecd6", "answer_sessions": 2, "chunks_before": 2, "chunks_after": 4, "covered_before": 1, "covered_after": 2, "complete_before": false, "complete_after": true}
+{"qid": "a3838d2b", "answer_sessions": 6, "chunks_before": 2, "chunks_after": 5, "covered_before": 1, "covered_after": 2, "complete_before": false, "complete_after": false}
+{"qid": "6a1eabeb", "answer_sessions": 2, "chunks_before": 2, "chunks_after": 2, "covered_before": 1, "covered_after": 1, "complete_before": false, "complete_after": false}
+{"qid": "6d550036", "answer_sessions": 4, "chunks_before": 2, "chunks_after": 4, "covered_before": 0, "covered_after": 0, "complete_before": false, "complete_after": false}
diff --git a/results/entity_anchor_index.md b/results/entity_anchor_index.md
new file mode 100644
index 0000000..fe3ffe2
--- /dev/null
+++ b/results/entity_anchor_index.md
@@ -0,0 +1,86 @@
+# entity_anchor_index — 实体锚定索引的扩展性测量(2026-08-16)
+
+## 背景
+
+`HybridRetriever.query_entity_ids()` 是每次检索都会走的路径,用于判断查询提到了哪些实体
+(图扩展的锚点)。它此前遍历 `graph.entities.values()` 全部实体,对每个实体做名称/别名匹配。
+
+`InMemoryGraphStore` 现在维护 `(user_id, 词干) -> 实体 id` 的倒排索引,检索时只查**查询自身的词**。
+
+## 复现
+
+```bash
+python3 - <<'PY'
+# 见本文件末尾脚本;或直接用 tests/test_entity_index.py 验证等价性
+PY
+```
+
+等价性由 `tests/test_entity_index.py::test_indexed_and_scanned_anchoring_agree` 保证:同一组实体
+分别装入带索引和去掉索引查找方法的图存储,用**真实的 HybridRetriever** 跑同一批查询,两条路径
+必须给出完全相同的锚定结果。索引只允许更快,不允许更不一样。
+
+## 结果
+
+**有区分度的实体名(真实场景:人名、地名、公司名)**
+
+| 实体数 | 全扫描 | 索引 | 加速 |
+| ---: | ---: | ---: | ---: |
+| 500 | 0.735ms | 0.004ms | 183.7x |
+| 2000 | 3.027ms | 0.004ms | 756.7x |
+| 10000 | 16.052ms | **0.004ms** | **4055.6x** |
+
+索引耗时**与实体数无关**——成本随查询词数走,不随库大小走。这是该路径第一次真正脱离 O(n)。
+
+**共享高频词的实体名(对抗场景:全部实体都叫 `entity number {i}`)**
+
+| 实体数 | 全扫描 | 索引 | 加速 |
+| ---: | ---: | ---: | ---: |
+| 500 | 0.810ms | 0.492ms | 1.6x |
+| 2000 | 3.351ms | 2.102ms | 1.6x |
+| 10000 | 17.029ms | 12.041ms | 1.4x |
+
+## 结论与边界(重要)
+
+**索引的收益取决于实体名的区分度,不是无条件的。** 当所有实体名共享同一批高频词时,这些词的
+倒排表长度等于全库,候选集就是全部实体,索引退化为"全扫描 + 一次索引查找",只剩常数因子收益。
+
+真实实体名(人名、地名、机构名、产品名)几乎总是有区分度的,所以真实场景是主导情形。但这个边界
+必须写明:这不是一个"任何情况下都 O(1)"的优化,而是一个"在词分布正常时 O(查询词数)"的优化。
+
+第一版基准曾用 `entity number {i}` 作为合成实体名,测出只有 1.5x 且仍然线性——那是基准数据落进了
+退化情形,不是实现有问题。记在这里,避免以后有人用同样的合成数据重新得出"这个索引没用"的错误结论。
+
+## 仍然线性的部分
+
+`graph_excluded_entity_ids()` 在查询**确实含否定词**时仍遍历全部实体。它按子串匹配(中文实体名
+必需,词元化看不见),无法用词元倒排索引替代。参见 `results/bounded_candidates_scaling.md`。
+
+## 测量脚本
+
+```python
+import time, statistics
+from engram.config import Config
+from engram.embed.hashing import HashingEmbedder
+from engram.retrieve.hybrid import HybridRetriever
+from engram.store.memory_store import InMemoryGraphStore, InMemoryVectorStore
+from engram.types import Entity
+
+class Unindexed(InMemoryGraphStore):
+ entities_by_terms = None # forces the retriever's full-scan fallback
+
+def bench(cls, n, namer, query):
+ g = cls()
+ for i in range(n):
+ g.upsert_entity(Entity(user_id="u1", name=namer(i)))
+ r = HybridRetriever(InMemoryVectorStore(), g, HashingEmbedder(), Config())
+ r.query_entity_ids(query, "u1") # warm
+ s = []
+ for _ in range(60):
+ a = time.perf_counter()
+ r.query_entity_ids(query, "u1")
+ s.append((time.perf_counter() - a) * 1000)
+ return statistics.median(s)
+
+# distinctive: lambda i: f"zeta{i} corp{i}" query "tell me about zeta42"
+# shared: lambda i: f"entity number {i}" query "tell me about entity number 42"
+```
diff --git a/results/error_modes_headline.md b/results/error_modes_headline.md
new file mode 100644
index 0000000..445a07d
--- /dev/null
+++ b/results/error_modes_headline.md
@@ -0,0 +1,94 @@
+# 错例归因:82 个错答分别错在哪(2026-08-17)
+
+## 为什么先做这个
+
+准确率只说"错了多少",不说"错成什么样"。**弃答、数值差一、自信答错**需要三种不同机制。
+不拆开就只能对着平均值设计,而平均值没有对应的失效模式。
+
+本分析**离线、免费**,用已提交日志 `results/longmemeval_s_engram_lean_v2_final.jsonl`(83.6%)。
+
+## 复现
+
+```bash
+python3 eval/error_modes.py results/longmemeval_s_engram_lean_v2_final.jsonl --system engram_lean
+python3 eval/error_modes.py results/longmemeval_s_engram_lean_v2_final.jsonl --system engram_lean --examples
+```
+
+## 结果
+
+| 类别 | 已评分 | 错 | 弃答 | 数值 | 值错 |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| multi-session | 121 | 25 | 5 | **19** | 1 |
+| temporal-reasoning | 127 | 24 | **15** | 5 | 4 |
+| knowledge-update | 72 | 9 | 2 | 4 | 3 |
+| single-session-preference | 30 | 8 | 5 | 0 | 3 |
+| single-session-user | 64 | 8 | 5 | 2 | 1 |
+| abstention | 30 | 4 | 1 | 0 | 3 |
+| single-session-assistant | 56 | 4 | 1 | 0 | 3 |
+| **合计** | **500** | **82** | **34** | **30** | **18** |
+
+总分解(34 弃答 / 30 数值 / 18 值错)与 noise-floor 记忆里的观察独立吻合。
+
+## 两个新发现
+
+### 1. 两个大类的失效形态完全不同,需要两种机制
+
+- **multi-session 的错 76% 是数值**(19/25)——计数与跨会话聚合。
+- **temporal-reasoning 的错 63% 是弃答**(15/24)——该答却说"记忆里没有"。
+
+此前只有总分解,看不出这个区别。把一种机制同时指望修两个类别是无效的。
+
+### 2. 数值误差是**双向**的,不是系统性低估(纠正既有认知)
+
+**低估 16 / 高估 12 / 数值相同但判错 2。**
+
+这条很要紧:若是系统性低估,说明证据没召回全,扩大召回即可修;**双向误差说明证据大体在,
+是计数与算术本身失败**。靶子完全不同,且更难。既有记忆里"以低估为主"的说法据此修正。
+
+## 可测量性(对照 2.94 点的分辨率地板)
+
+| 若某一模式被完全消除 | 题数 | 整体增益 | 是否可测 |
+| --- | ---: | ---: | --- |
+| 全部弃答 | 34 | +6.8 点 | 可测 |
+| 全部数值错 | 30 | +6.0 点 | 可测 |
+| 全部值错 | 18 | +3.6 点 | 可测 |
+| **仅** multi-session 的数值错 | 19 | +3.8 点 | 勉强可测 |
+| **仅** temporal 的弃答 | 15 | +3.0 点 | 贴着地板 |
+
+**结论:单点机制打单个类别,恰好卡在可测量性边缘。** 必须两条线一起做,合计 34 题 = +6.8 点,
+才是舒服地高于地板的实验。
+
+### 3. 弃答**不是**检索饿死——上下文规模在所有结果类型上一致
+
+| 结果 | 检索上下文 tokens 中位数 |
+| --- | ---: |
+| 弃答 | 9,666 |
+| 答对 | 9,600 |
+| 数值错 | 9,568 |
+| 值错 | 9,561 |
+
+差异约 1%。**弃答题拿到的证据和答对的题一样多**。如果是检索没命中,上下文会明显偏小。
+所以那 34 道弃答的修法不在"多检索一点",而在检索之后:要么召回的是错的证据(量对、内容不对),
+要么证据在但形态撑不住时间推理(有日期,但没有区间/时长/顺序)。
+
+区分这两者需要那些题的**检索上下文正文**,而已提交日志只有 token 计数。
+好消息是 harness 本来就会算 `answer_session_hit`(答案所在会话是否被检索到),
+只是现有 context dump 只覆盖到其中 3 道,样本太小不能下结论——方向上 3/3 的数值错题都命中了
+答案会话(与"证据在、计数失败"一致),但 n=3 不是证据。
+
+**一次定向重跑即可定论**:只跑那 34 道弃答题 + 30 道数值题(64 题,约全量的 1/8),
+导出 `answer_session_hit` 与上下文正文。这是本分析唯一需要付费的部分,成本约为全量运行的 13%。
+
+## 对机制设计的直接约束
+
+1. **multi-session** → 目标是计数/聚合的正确性,不是召回量。双向误差已排除"证据不够"这个解释。
+2. **temporal-reasoning** → 目标是消除"该答却弃答"。需要先查这 15 题是检索没命中,还是命中了但
+ 证据形态不足以支撑时间推理(区间/时长/顺序)。这一步同样可以离线做。
+3. 任何提案先回答:**预期能消除哪一模式的多少题**。低于约 15 题(+3 点)的提案,
+ 在 500 题上无法与噪声区分,不要花钱验证。
+
+## 工具自身的一个已修缺陷
+
+第一版把 `_abs` 后缀题并进了基础类别。那 30 题是基准的 **unanswerable 变体,由官方
+"unanswerable" 判分器评分——在那些题上弃答是正确答案**。并进去等于把正确行为记成失效模式,
+会把机制引向不存在的靶子。已按 `report.py` 的同一口径修正,类别数现与其完全一致。
diff --git a/results/layered_context_tokens.md b/results/layered_context_tokens.md
new file mode 100644
index 0000000..a749fd6
--- /dev/null
+++ b/results/layered_context_tokens.md
@@ -0,0 +1,104 @@
+# layered_context — 上下文拆分的 token 测量(2026-08-16)
+
+## 假设与做法
+
+`lean_context` 返回单个扁平字符串,调用方整段放进 user turn。多轮会话里,其中不随查询变化的部分
+(用户画像、使用指引)每轮都被重发和重算。把这部分拆进 system prompt,provider 的 prompt-cache
+就能跨轮复用,只有本轮证据是变化的。
+
+检索到的证据两种方式完全相同,所以**准确率按构造不变**——这是 tokens/latency 那两维的改动,
+不应期待它移动任何 benchmark 分数。
+
+计费模型:flat = 每轮全量;layered = stable 只在首轮计费 + 每轮 dynamic。
+
+## 复现
+
+```bash
+python3 - <<'PY'
+from engram.memory import Memory
+from engram.retrieve.layered import layered_context
+from engram.service import _est_tokens
+# 见 tests/test_layered_context.py 的 _memory() 构造;逐轮累加 flat 与 layered
+PY
+```
+
+## 结果
+
+**10 个会话**
+
+| 轮数 | flat | 拆分(无导航图) | 节省 | 拆分(含导航图) | 节省 |
+| ---: | ---: | ---: | ---: | ---: | ---: |
+| 1 | 239 | 295 | −23.4% | 463 | −93.7% |
+| 3 | 755 | 767 | −1.6% | 935 | −23.8% |
+| 5 | 1266 | 1234 | **+2.5%** | 1402 | −10.7% |
+| 10 | 2307 | 2165 | +6.2% | 2333 | −1.1% |
+| 20 | 4614 | 4252 | +7.8% | 4420 | +4.2% |
+| 50 | 11535 | 10513 | **+8.9%** | 10681 | +7.4% |
+
+**30 个会话**
+
+| 轮数 | flat | 拆分(无导航图) | 节省 | 拆分(含导航图) | 节省 |
+| ---: | ---: | ---: | ---: | ---: | ---: |
+| 1 | 361 | 417 | −15.5% | 720 | −99.4% |
+| 5 | 1896 | 1864 | +1.7% | 2167 | −14.3% |
+| 20 | 7190 | 6828 | +5.0% | 7131 | +0.8% |
+| 50 | 17975 | 16953 | **+5.7%** | 17256 | +4.0% |
+
+## 结论
+
+1. **纯拆分是真实但温和的收益**:长会话约 +5%~+9%,**5 轮以下是净亏**(stable 半虽只计费一次,
+ 但扁平上下文本身很小,首轮多出的结构开销收不回来)。收益上限就是画像块占上下文的比例。
+ 保全版模块的文档把它描述为"大幅减少重复计费",实测不支持这个力度。
+2. **导航图(MEMORY MAP)是净成本**,要到约 20 轮才回本。它是扁平上下文里本来没有的**新增内容**,
+ 所以最初那版对比其实是"扁平 vs 扁平+新增导航图",把缓存收益吃掉还倒欠。据此**默认关闭**
+ (`map_limit=0`),需要渐进式展开能力或确认是长会话时再显式开启。
+3. **本测量只算 token 数量,不算计费价格。** provider 的 cache read 通常显著便宜于全价 input token,
+ 所以经济收益应大于上表的 token 收益;但那取决于各家定价,本文件不做未经测量的推断。
+
+## 未采用/回滚原因
+
+- 导航图默认开启:**不采用**,见上表,典型会话长度下是净亏。
+- 把拆分接进 `lean_context` 默认路径:**不采用**。短会话是净亏,且已发布数字均由扁平路径产出。
+ 当前形态是独立方法 `Memory.layered_context()`,调用方按会话长度自行选择。
+
+## 追加:接进 OpenAI 兼容代理后的实测(同日)
+
+**一个前提被推翻。** 本文件开头写的「调用方把上下文整段放进 user turn」对 OpenAI 兼容代理**不成立**:
+它一直是把整个检索切片放进 **system prompt** 的(`_MEMORY_PREAMBLE + memory_context`)。所以拆分在
+这个接入面买到的不是「system 还是 user」的位置,而是**前缀稳定性**——provider 的 prompt-cache 按
+前缀匹配,而今天整个 system 块每轮都变,一个可复用前缀都没有。
+
+按前缀稳定性重新接线后(stable 半置于 system 最前,本轮证据移入 user turn),30 会话实测:
+
+| 轮数 | flat 总 prompt | 拆分(同内容) | 差 | 拆分 + 指引 | 差 |
+| ---: | ---: | ---: | ---: | ---: | ---: |
+| 1 | 371 | 367 | −1.1% | 422 | +13.7% |
+| 5 | 1925 | 1905 | −1.0% | 2180 | +13.2% |
+| 20 | 7102 | 7022 | −1.1% | 8122 | +14.4% |
+| 50 | 17755 | 17555 | −1.1% | 20305 | +14.4% |
+
+system 块跨 5 轮的去重数量:**flat = 5(每轮都变),拆分 = 1(完全稳定)**;稳定前缀 61 tokens。
+
+### 又一次不公平对比(同一个坑踩了第二次)
+
+第一次测量把 `RECALL_GUIDE` 也算进去,得出 +14%,于是看起来「拆分更贵」。但指引是 flat 路径里
+**本来没有的新增内容**——这和上一节导航图的错误完全相同:拿「扁平 vs 扁平+新增块」当公平对比。
+
+同内容对比下,拆分是 **−1.1%**,即 token 中性略优。
+
+并且这次暴露出一个真实的重复:**代理本身已经用 `_MEMORY_PREAMBLE` 框定了记忆**,再叠一层
+`RECALL_GUIDE` 是同一条指令的第二份拷贝,白付 14% 且不改变行为。代理侧因此显式传 `guide=False`。
+
+### 诚实结论
+
+- 拆分在这个接入面**不省 token**(−1.1%,噪声级别),它买到的是 **61 tokens 的可缓存稳定前缀**,
+ 以及「system 块不再每轮失效」这个此前完全不存在的性质。
+- 61 tokens 相对每轮约 350 tokens 的 prompt 是小头。**是否值得取决于 provider 的 cache-read 定价,
+ 而本测量不计价格**——那需要各家定价,不做未经测量的推断。
+- 因此保持 **opt-in**:`{"memory": {"layered": true}}`。多轮会话可以开,一次性调用没必要。
+- `engram.cacheable_tokens_est` 在响应里报告稳定前缀大小,调用方不用猜。
+
+## 下一步
+
+用真实 provider(而非本地估算器)跑一次多轮会话,回读 cache-read 命中率与实际计费,验证这 61 tokens
+在真实定价下是否值得。那需要真实 API key,属付费评测。
diff --git a/results/retrieval_check_failures.jsonl b/results/retrieval_check_failures.jsonl
new file mode 100644
index 0000000..d9c025b
--- /dev/null
+++ b/results/retrieval_check_failures.jsonl
@@ -0,0 +1,82 @@
+{"covered_top2": 0, "covered_all": 2, "qid": "bbf86515", "cat": "temporal-reasoning", "answer_sessions": 2, "haystack_sessions": 47, "hit": true, "rank": 7, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 2, "covered_all": 4, "qid": "gpt4_59c863d7", "cat": "multi-session", "answer_sessions": 4, "haystack_sessions": 48, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 2, "covered_all": 6, "qid": "gpt4_a1b77f9c", "cat": "temporal-reasoning", "answer_sessions": 6, "haystack_sessions": 49, "hit": true, "rank": 1, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 2, "qid": "07741c45", "cat": "knowledge-update", "answer_sessions": 2, "haystack_sessions": 52, "hit": true, "rank": 1, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 2, "covered_all": 3, "qid": "gpt4_15e38248", "cat": "multi-session", "answer_sessions": 4, "haystack_sessions": 48, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 1, "covered_all": 2, "qid": "gpt4_fa19884d", "cat": "temporal-reasoning", "answer_sessions": 2, "haystack_sessions": 56, "hit": true, "rank": 1, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 0, "covered_all": 1, "qid": "gpt4_8279ba03", "cat": "temporal-reasoning", "answer_sessions": 1, "haystack_sessions": 46, "hit": true, "rank": 15, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 2, "qid": "945e3d21", "cat": "knowledge-update", "answer_sessions": 2, "haystack_sessions": 51, "hit": true, "rank": 2, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 0, "covered_all": 1, "qid": "1c0ddc50", "cat": "single-session-preference", "answer_sessions": 1, "haystack_sessions": 52, "hit": true, "rank": 6, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 2, "covered_all": 3, "qid": "d682f1a2", "cat": "multi-session", "answer_sessions": 3, "haystack_sessions": 52, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 2, "covered_all": 3, "qid": "7024f17c", "cat": "multi-session", "answer_sessions": 3, "haystack_sessions": 50, "hit": true, "rank": 1, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 0, "covered_all": 2, "qid": "c18a7dc8", "cat": "multi-session", "answer_sessions": 2, "haystack_sessions": 50, "hit": true, "rank": 5, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 2, "covered_all": 2, "qid": "89941a94", "cat": "knowledge-update", "answer_sessions": 2, "haystack_sessions": 47, "hit": true, "rank": 1, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 2, "qid": "gpt4_e061b84f", "cat": "temporal-reasoning", "answer_sessions": 3, "haystack_sessions": 50, "hit": true, "rank": 2, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 2, "covered_all": 2, "qid": "73d42213", "cat": "multi-session", "answer_sessions": 2, "haystack_sessions": 51, "hit": true, "rank": 1, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 2, "covered_all": 2, "qid": "a96c20ee_abs", "cat": "multi-session", "answer_sessions": 2, "haystack_sessions": 57, "hit": true, "rank": 1, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 2, "covered_all": 5, "qid": "28dc39ac", "cat": "multi-session", "answer_sessions": 5, "haystack_sessions": 39, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 0, "covered_all": 1, "qid": "577d4d32", "cat": "single-session-user", "answer_sessions": 1, "haystack_sessions": 49, "hit": true, "rank": 6, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 1, "qid": "58ef2f1c", "cat": "single-session-user", "answer_sessions": 1, "haystack_sessions": 54, "hit": true, "rank": 1, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 2, "qid": "370a8ff4", "cat": "temporal-reasoning", "answer_sessions": 2, "haystack_sessions": 51, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 2, "covered_all": 2, "qid": "6e984301", "cat": "temporal-reasoning", "answer_sessions": 2, "haystack_sessions": 56, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 1, "covered_all": 1, "qid": "6ae235be", "cat": "single-session-assistant", "answer_sessions": 1, "haystack_sessions": 48, "hit": true, "rank": 1, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 0, "covered_all": 1, "qid": "09d032c9", "cat": "single-session-preference", "answer_sessions": 1, "haystack_sessions": 44, "hit": true, "rank": 3, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 1, "qid": "ad7109d1", "cat": "single-session-user", "answer_sessions": 1, "haystack_sessions": 49, "hit": true, "rank": 2, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 0, "covered_all": 1, "qid": "gpt4_468eb064", "cat": "temporal-reasoning", "answer_sessions": 1, "haystack_sessions": 53, "hit": true, "rank": 9, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 0, "covered_all": 1, "qid": "4dfccbf8", "cat": "temporal-reasoning", "answer_sessions": 2, "haystack_sessions": 49, "hit": true, "rank": 12, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 2, "qid": "45dc21b6", "cat": "knowledge-update", "answer_sessions": 2, "haystack_sessions": 50, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 0, "covered_all": 1, "qid": "726462e0", "cat": "single-session-user", "answer_sessions": 1, "haystack_sessions": 48, "hit": true, "rank": 9, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 2, "covered_all": 3, "qid": "3a704032", "cat": "multi-session", "answer_sessions": 3, "haystack_sessions": 51, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 1, "covered_all": 1, "qid": "ec81a493", "cat": "single-session-user", "answer_sessions": 1, "haystack_sessions": 53, "hit": true, "rank": 2, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 2, "qid": "157a136e", "cat": "multi-session", "answer_sessions": 2, "haystack_sessions": 50, "hit": true, "rank": 1, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 2, "covered_all": 2, "qid": "69fee5aa", "cat": "knowledge-update", "answer_sessions": 2, "haystack_sessions": 53, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 1, "covered_all": 1, "qid": "71017277", "cat": "temporal-reasoning", "answer_sessions": 1, "haystack_sessions": 53, "hit": true, "rank": 1, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 2, "covered_all": 2, "qid": "031748ae_abs", "cat": "knowledge-update", "answer_sessions": 2, "haystack_sessions": 54, "hit": true, "rank": 1, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 1, "covered_all": 2, "qid": "0977f2af", "cat": "knowledge-update", "answer_sessions": 2, "haystack_sessions": 47, "hit": true, "rank": 2, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 2, "covered_all": 2, "qid": "9a707b81", "cat": "temporal-reasoning", "answer_sessions": 2, "haystack_sessions": 58, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 1, "covered_all": 2, "qid": "gpt4_59149c78", "cat": "temporal-reasoning", "answer_sessions": 2, "haystack_sessions": 54, "hit": true, "rank": 2, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 2, "covered_all": 2, "qid": "37f165cf", "cat": "multi-session", "answer_sessions": 2, "haystack_sessions": 46, "hit": true, "rank": 1, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 1, "qid": "ceb54acb", "cat": "single-session-assistant", "answer_sessions": 1, "haystack_sessions": 52, "hit": true, "rank": 1, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 2, "covered_all": 2, "qid": "59524333", "cat": "knowledge-update", "answer_sessions": 2, "haystack_sessions": 45, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 1, "covered_all": 3, "qid": "gpt4_7f6b06db", "cat": "temporal-reasoning", "answer_sessions": 3, "haystack_sessions": 54, "hit": true, "rank": 2, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 2, "covered_all": 2, "qid": "gpt4_e414231f", "cat": "temporal-reasoning", "answer_sessions": 2, "haystack_sessions": 51, "hit": true, "rank": 1, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 1, "qid": "8cf51dda", "cat": "single-session-assistant", "answer_sessions": 1, "haystack_sessions": 47, "hit": true, "rank": 1, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 1, "covered_all": 1, "qid": "b86304ba", "cat": "single-session-user", "answer_sessions": 1, "haystack_sessions": 43, "hit": true, "rank": 2, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 4, "qid": "gpt4_731e37d7", "cat": "multi-session", "answer_sessions": 4, "haystack_sessions": 53, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 2, "covered_all": 5, "qid": "gpt4_d6585ce8", "cat": "temporal-reasoning", "answer_sessions": 5, "haystack_sessions": 55, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 2, "covered_all": 5, "qid": "gpt4_31ff4165", "cat": "multi-session", "answer_sessions": 5, "haystack_sessions": 42, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 2, "covered_all": 2, "qid": "9ea5eabc", "cat": "knowledge-update", "answer_sessions": 2, "haystack_sessions": 54, "hit": true, "rank": 1, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 1, "covered_all": 3, "qid": "bf659f65", "cat": "multi-session", "answer_sessions": 3, "haystack_sessions": 56, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 0, "covered_all": 0, "qid": "d6233ab6", "cat": "single-session-preference", "answer_sessions": 1, "haystack_sessions": 47, "hit": false, "rank": null, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 2, "covered_all": 4, "qid": "d23cf73b", "cat": "multi-session", "answer_sessions": 4, "haystack_sessions": 55, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 0, "covered_all": 2, "qid": "8e91e7d9", "cat": "multi-session", "answer_sessions": 2, "haystack_sessions": 50, "hit": true, "rank": 3, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 2, "covered_all": 2, "qid": "eeda8a6d_abs", "cat": "multi-session", "answer_sessions": 2, "haystack_sessions": 51, "hit": true, "rank": 1, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 0, "covered_all": 1, "qid": "af082822", "cat": "temporal-reasoning", "answer_sessions": 1, "haystack_sessions": 44, "hit": true, "rank": 15, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 0, "covered_all": 2, "qid": "92a0aa75", "cat": "multi-session", "answer_sessions": 2, "haystack_sessions": 46, "hit": true, "rank": 4, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 2, "covered_all": 3, "qid": "0a995998", "cat": "multi-session", "answer_sessions": 3, "haystack_sessions": 45, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 0, "covered_all": 1, "qid": "eac54add", "cat": "temporal-reasoning", "answer_sessions": 2, "haystack_sessions": 45, "hit": true, "rank": 10, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 0, "covered_all": 0, "qid": "75832dbd", "cat": "single-session-preference", "answer_sessions": 1, "haystack_sessions": 51, "hit": false, "rank": null, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 1, "covered_all": 5, "qid": "gpt4_d6585ce9", "cat": "temporal-reasoning", "answer_sessions": 5, "haystack_sessions": 43, "hit": true, "rank": 2, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 1, "qid": "32260d93", "cat": "single-session-preference", "answer_sessions": 1, "haystack_sessions": 47, "hit": true, "rank": 2, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 3, "qid": "60472f9c", "cat": "multi-session", "answer_sessions": 3, "haystack_sessions": 49, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 0, "covered_all": 1, "qid": "10d9b85a", "cat": "multi-session", "answer_sessions": 2, "haystack_sessions": 46, "hit": true, "rank": 4, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 2, "covered_all": 2, "qid": "4f54b7c9", "cat": "multi-session", "answer_sessions": 2, "haystack_sessions": 49, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 2, "covered_all": 6, "qid": "gpt4_7abb270c", "cat": "temporal-reasoning", "answer_sessions": 6, "haystack_sessions": 51, "hit": true, "rank": 1, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 0, "covered_all": 4, "qid": "b46e15ed", "cat": "temporal-reasoning", "answer_sessions": 4, "haystack_sessions": 48, "hit": true, "rank": 5, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 1, "qid": "eaca4986", "cat": "single-session-assistant", "answer_sessions": 1, "haystack_sessions": 50, "hit": true, "rank": 1, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 0, "covered_all": 1, "qid": "35a27287", "cat": "single-session-preference", "answer_sessions": 1, "haystack_sessions": 54, "hit": true, "rank": 4, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 0, "covered_all": 3, "qid": "129d1232", "cat": "multi-session", "answer_sessions": 3, "haystack_sessions": 50, "hit": true, "rank": 4, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 2, "covered_all": 2, "qid": "9ee3ecd6", "cat": "multi-session", "answer_sessions": 2, "haystack_sessions": 54, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 1, "covered_all": 2, "qid": "09ba9854_abs", "cat": "multi-session", "answer_sessions": 2, "haystack_sessions": 52, "hit": true, "rank": 1, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 0, "covered_all": 0, "qid": "gpt4_4929293b", "cat": "temporal-reasoning", "answer_sessions": 2, "haystack_sessions": 52, "hit": false, "rank": null, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 2, "qid": "a1cc6108", "cat": "multi-session", "answer_sessions": 2, "haystack_sessions": 44, "hit": true, "rank": 1, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 2, "covered_all": 6, "qid": "a3838d2b", "cat": "temporal-reasoning", "answer_sessions": 6, "haystack_sessions": 48, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 0, "covered_all": 1, "qid": "a82c026e", "cat": "single-session-user", "answer_sessions": 1, "haystack_sessions": 48, "hit": true, "rank": 4, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 1, "covered_all": 1, "qid": "0edc2aef", "cat": "single-session-preference", "answer_sessions": 1, "haystack_sessions": 49, "hit": true, "rank": 2, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 2, "covered_all": 3, "qid": "gpt4_a56e767c", "cat": "multi-session", "answer_sessions": 3, "haystack_sessions": 52, "hit": true, "rank": 1, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 1, "covered_all": 3, "qid": "0bc8ad93", "cat": "temporal-reasoning", "answer_sessions": 3, "haystack_sessions": 48, "hit": true, "rank": 1, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 2, "qid": "6a1eabeb", "cat": "knowledge-update", "answer_sessions": 2, "haystack_sessions": 41, "hit": true, "rank": 1, "retrieved": 15, "mode": "numeric"}
+{"covered_top2": 1, "covered_all": 1, "qid": "66f24dbb", "cat": "single-session-user", "answer_sessions": 1, "haystack_sessions": 55, "hit": true, "rank": 2, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 1, "covered_all": 1, "qid": "6e984302", "cat": "temporal-reasoning", "answer_sessions": 2, "haystack_sessions": 46, "hit": true, "rank": 1, "retrieved": 15, "mode": "abstained"}
+{"covered_top2": 1, "covered_all": 1, "qid": "caf03d32", "cat": "single-session-preference", "answer_sessions": 1, "haystack_sessions": 55, "hit": true, "rank": 1, "retrieved": 15, "mode": "wrong_value"}
+{"covered_top2": 0, "covered_all": 3, "qid": "6d550036", "cat": "multi-session", "answer_sessions": 4, "haystack_sessions": 53, "hit": true, "rank": 5, "retrieved": 15, "mode": "numeric"}
diff --git a/results/retrieval_diagnosis.md b/results/retrieval_diagnosis.md
new file mode 100644
index 0000000..1802e8e
--- /dev/null
+++ b/results/retrieval_diagnosis.md
@@ -0,0 +1,115 @@
+# 检索诊断:82 道错题里,证据到底有没有到手(2026-08-17)
+
+## 结论先行
+
+**检索找到了证据,但上下文装配只展示了其中一半。**
+
+- 82 道错题里 79 道(96%),答案会话被检索到了——检索不是问题。
+- 但 **58 道错题的答案跨多个会话**,其中只有 **14 道(24%)**的答案会话**全部**进了全文窗口。
+ 平均覆盖率约 **48%**——一半的该数的会话被压成了摘要。
+
+一道需要覆盖 4 个会话的计数题,看到 2 个全文 + 2 个摘要,结构上就数不对。
+
+> **本文件初版的结论是错的**,此处已修正。初版只记录了**第一个**匹配答案会话的排名,据此写下
+> "73% 的错题证据已在全文窗口内,失败发生在证据到手之后"。对单会话题这成立;对占多数的多会话
+> 计数题,"最相关的那个会话进了前 2"只说明 4 个里进来了 1 个,恰恰是数不对的原因。
+> 教训:**对聚合类问题,正确的度量是覆盖率,不是首命中排名。**
+
+这判死了一整类机制:**任何奔着"多检索一点"去的改动,上限是 3 题 = +0.6 点**,远低于
+2.94 的分辨率地板(`results/significance_headline.md`)。优化地图 P1 的
+"轻量 n-hop / PPR 图扩展"据此应当降级——不是它不好,是在这份数据上它修不到东西。
+
+## 复现(零 API 成本)
+
+```bash
+python3 eval/retrieval_check.py \
+ --failures-from results/longmemeval_s_engram_lean_v2_final.jsonl \
+ --system engram_lean --out results/retrieval_check_failures.jsonl
+```
+
+会话检索由嵌入 + BM25 驱动,不由抽取模型驱动,所以整个诊断只用本地 bge-small,不调任何付费 API。
+LongMemEval 自带 `answer_session_ids` 标注,"答案在哪个会话"是数据集给定的,不需要判断。
+
+## 结果
+
+| 失效模式 | 题数 | 前 2(全文可见) | 3–15(仅摘要) | 未检索到 |
+| --- | ---: | ---: | ---: | ---: |
+| 弃答 | 34 | 22(65%) | 10(29%) | 2 |
+| 数值错 | 30 | 23(77%) | 7(23%) | 0 |
+| 值错 | 18 | 15(83%) | 2(11%) | 1 |
+| **合计** | **82** | **60(73%)** | **19(23%)** | **3(4%)** |
+
+排名分布(累计):top-1 59% / top-2 73% / top-3 76% / top-5 85% / top-10 93% / top-15 96%。
+
+被分析的那次运行 `--chunks 2`,即**前 2 个会话以全文渲染,其余压成摘要**。所以 top-2 那一列
+就是"答题模型眼前有原始证据"的份额。
+
+## 三条独立证据指向同一处
+
+1. 弃答题的上下文规模(9,666 tokens)与答对题(9,600)几乎相同 → **不是检索饿死**
+ (`results/error_modes_headline.md`)
+2. 答案会话 79/82 被检索到 → **不是检索找错**
+3. 数值误差双向(低估 16 / 高估 12)→ **不是证据不全**
+
+**失败发生在证据到手之后。**
+
+## 扩大全文窗口值多少(以及为什么不建议)
+
+把 `--chunks` 从 2 扩到 15,最多能把 19 题的证据从摘要升为全文,**上限 +3.8 点**。但这是乐观上限,
+有两个理由不该照做:
+
+1. **转化率已被证伪。** 已经有 60 题在全文条件下失败了。"看到全文"到"答对"的转化率显然不是 100%,
+ 而 +3.8 的算法假设它是。真实收益会明显更低,很可能落回地板以下。
+2. **成本与核心论点冲突。** 更多全文块 = 更多 token。项目的 headline 主张正是
+ "精简检索胜过全上下文"(9.6k vs 79k)。把窗口开到 15 是朝全上下文回退,
+ 即使分数涨了,涨的也是论点本身在退让。
+
+## 多会话覆盖率(决定性数据)
+
+| 模式 | 多会话题数 | 答案会话全部进全文窗口 | 全部被检索到 | 全文窗口平均覆盖 |
+| --- | ---: | ---: | ---: | ---: |
+| 数值错 | 28 | 6(21%) | 25(89%) | 48% |
+| 弃答 | 19 | 5(26%) | 15(79%) | 49% |
+| 值错 | 11 | 3(27%) | 10(91%) | 58% |
+| **合计** | **58** | **14(24%)** | **50(86%)** | — |
+
+**检索层召回率 86%,装配层覆盖率 24%。** 差距全在上下文装配:证据取回来了,但按相关度只渲染前 2 个
+会话的全文,其余压成摘要——而计数需要的是**覆盖**,不是**排序**。
+
+这也解释了此前无法解释的双向误差:会话被压成摘要 → 漏数(低估 16 例);摘要表述模糊导致同一件事
+重复计入 → 多数(高估 12 例)。此前把双向误差读作"算术本身失败",错了一层。
+
+## 由此得出的机制(唯一有证据支撑的方向)
+
+**对聚合类查询,让全文窗口覆盖证据集合,而不是取相关度前 N。**
+
+- 触发条件已存在且工作正常:`plan_evidence().aggregation` 在这 30 道计数错上触发率 90%
+ (对照:答对的数值题 79%)。**不需要放宽触发**——它触发了,只是没有影响渲染多少个全文块。
+- 与"无差别把 `--chunks` 扩到 15"的关键区别:只在聚合类查询上扩,其余查询不变。
+ 聚合类约占查询的 20%,所以 token 成本落在需要它的那一小部分上,不会整体朝全上下文回退。
+- 规模:58 道多会话错题里 44 道缺完整覆盖。即便只有一半能因完整覆盖而答对,也是 **+4.4 点**,
+ 高于 2.94 的地板。
+
+**尚未验证的前提**(必须先离线证伪):完整覆盖是否真能让这些题答对。已知 60 道题在全文条件下
+仍然失败,说明"看到全文"到"答对"的转化率不是 1。下一步应先在离线装配层验证:
+把这 44 道题的全文窗口按证据集合扩展后,答案所需的事实是否真的都出现在装配出的上下文里。
+这一步不需要 LLM,因此免费。
+
+## 下一步该往哪打
+
+弃答(34 题)与数值(30 题)合计 64 题 = +12.8 点上限,仍是唯一舒服高于地板的靶子,
+但机制必须做在**证据到手之后**这一层:
+
+- **数值/计数**(30 题,77% 已有全文):证据在,是聚合与算术失败。方向是把跨会话的可数项
+ 结构化成显式候选表,而不是让答题模型从散文里数数。项目已有
+ `numeric_aggregation_candidates`,需要先查它在这 30 题上是否触发、触发后为何仍错。
+- **弃答**(34 题,65% 已有全文):拿着原文说"不知道"。需要区分是"证据形态撑不住时间推理"
+ (有日期但没有区间/时长/顺序)还是"弃答阈值过于保守"。前者对应显式区间证据块,
+ 后者是阈值调参——而阈值调参属于已知的噪声带内改动,不要单独做。
+
+## 未采用/回滚原因
+
+| 方向 | 结论 | 证据 |
+| --- | --- | --- |
+| 轻量 n-hop / PPR 图扩展(提升召回) | **降级**。上限 3 题 = +0.6 点,低于分辨率地板,做了也测不出来 | 本文件:79/82 已检索到 |
+| 扩大全文块窗口 `--chunks 2 → 15` | **不推荐**。名义上限 +3.8 点,但 60 题已证明全文条件下仍会失败,真实转化率远低于 1;且 token 成本朝全上下文回退,与 headline 论点冲突 | 本文件排名分布 |
diff --git a/results/significance_headline.md b/results/significance_headline.md
new file mode 100644
index 0000000..d3e80cb
--- /dev/null
+++ b/results/significance_headline.md
@@ -0,0 +1,78 @@
+# 显著性检验:headline 声称与基准的分辨率上限(2026-08-16)
+
+## 为什么需要这个
+
+同一份配置重跑 LongMemEval_S,500 题里有 6–10 题的结果会变。因此「83.6 → 84.4,提升了」不是发现,
+而是噪声。此前一长串机制(融合权重、cross-encoder rerank、agentic/HyDE、verify-retry、结构化偏好层、
+选择性一致性、K=5 自洽…)都是在这个噪声带里被判定为净收益 ≤0 的。
+
+在能分辨小增益之前,任何算法优化都无法验证。**先造尺子,再爬山。**
+
+`eval/significance.py` 提供三件事:McNemar 精确检验(配对,只用两系统答案不同的题)、差值的
+bootstrap 区间、以及**最小可检测增益**(MDE,跑之前就能回答「这次实验能不能分辨我期望的增益」)。
+
+## 复现
+
+```bash
+# headline 声称是否显著
+python3 eval/significance.py \
+ results/longmemeval_s_volcano_doubao_deepseekjudge.jsonl \
+ results/longmemeval_s_engram_lean_v2_final.jsonl \
+ --system-a full_context --system-b engram_lean
+
+# 规划:500 题能分辨多小的增益
+python3 eval/significance.py --plan 500
+```
+
+## 结果 1:公开 headline 经受住检验
+
+`engram_lean` 83.6% vs `full_context` 73.2%,500 题配对:
+
+| | 数量 |
+| --- | ---: |
+| 两者都对 | 337 |
+| 仅 full_context 对 | 29 |
+| 仅 engram_lean 对 | **81** |
+| 两者都错 | 53 |
+
+只有那 110 道分歧题携带信息。**p < 0.0001,差值 +10.4 点,95% bootstrap 区间 [+6.4, +14.4]。**
+81 : 29 的分歧比不是掷硬币能产生的结果。这个声称成立,而且现在有检验而不只是两个百分数。
+
+> 方法学说明:这两个数字来自**两次不同的运行**(同一 answerer + judge,同一批 500 题)。配对检验
+> 要求同一批题目,这点满足;但 CONTRIBUTING 要求基线来自 *same run*,严格来说 headline 表应注明
+> 基线取自 `..._volcano_doubao_deepseekjudge.jsonl`。同一份日志内的 `engram_full` 83.4% vs
+> `full_context` 73.2% 是严格同轮对照。
+
+## 结果 2:基准的分辨率上限——这才是登顶的真正障碍
+
+| 题数 | 分歧率 | 有效信息题数 | 最小可检测增益 |
+| ---: | ---: | ---: | ---: |
+| 500 | 5% | 25 | 1.40 点 |
+| 500 | 8% | 40 | 1.77 点 |
+| 500 | 10% | 50 | 1.98 点 |
+| 500 | 20% | 100 | 2.80 点 |
+
+上面那次真实运行的分歧率是 22%,**最小可检测增益 2.94 点**。
+
+含义直接而不舒服:
+
+- **到 Hunyuan 85.2 的 +1.6 差距,低于这个基准在 500 题规模上的分辨率。** 它不是「暂时没追上」,
+ 而是**在这套测量下无法判定真假**。反复调算法去追它,产出的是噪声不是名次。
+- 任何小于约 3 点的机制改进,在单次 500 题运行里都无法与偶然区分——无论重跑多少次**同一次运行内部**
+ 的自洽采样都不行(那减少的是答题方差,不是判定所需的样本量)。
+
+## 对「做到世界第一」的可执行推论
+
+1. **只做期望增益 > 3 点的改动。** 低于这个的,先不要花钱跑。目标锁定占比最大的弱类别:
+ multi-session(121 题,70.2%)与 temporal-reasoning(127 题,70.9%),合计 248/500。
+ 在单一类别上拿到 +10 点,整体才约 +2.5——这就是为什么必须挑大类别下手。
+2. **要想分辨更小的增益,只能加分辨率**:更多题目(LongMemEval_M、LOCOMO、PersonaMem 合并评测)、
+ 或更确定性的 answerer。这是测量投资,不是算法投资,但它是所有算法投资的前置条件。
+3. **公开声称必须附检验。** 「世界第一」在一个 ±3 点分辨率的基准上不是良定义的主张;
+ 可辩护的说法是「在 X 基准上以 p<0.0001 显著优于全上下文基线,区间 [+6.4,+14.4]」。
+ CONTRIBUTING 本来就禁止未经基准验证的 "#1"/"SOTA" 措辞。
+
+## 未采用/回滚原因
+
+- **继续在 500 题上做 <3 点的算法迭代**:不采用。测量无法验证,等于用真金白银换噪声。
+ 已有一长串证据(见 `docs/architecture-optimization-map.zh-CN.md` 与 noise floor 结论)。
diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py
new file mode 100644
index 0000000..81b687f
--- /dev/null
+++ b/tests/test_api_keys.py
@@ -0,0 +1,246 @@
+"""Runtime-issued API keys.
+
+This is an authentication surface, so the tests that matter are the ones about refusing, not the ones
+about admitting: the admin surface must be absent unless deliberately enabled, a revoked key must stop
+working, an unreadable key store must fail closed rather than open, and one tenant's key must never
+resolve to another's namespace.
+
+One behaviour is specifically a data-loss guard. A corrupt key file must make the store refuse to load,
+because starting empty would reject every issued key and then the first `issue()` would rewrite the file
+and destroy the records that were only unreadable.
+"""
+from __future__ import annotations
+
+import json
+import os
+import stat
+
+import pytest
+
+from engram.server.keys import KEY_PREFIX, KeyStore, KeyStoreError
+
+# --- store ---
+
+
+def test_issued_key_resolves_to_its_tenant(tmp_path):
+ store = KeyStore(str(tmp_path / "keys.json"))
+ issued = store.issue("alice", label="laptop")
+ assert issued["key"].startswith(KEY_PREFIX)
+ assert store.resolve(issued["key"]) == "alice"
+
+
+def test_secret_is_never_persisted(tmp_path):
+ """A leaked key file must not be replayable as credentials."""
+ path = tmp_path / "keys.json"
+ store = KeyStore(str(path))
+ issued = store.issue("alice")
+ on_disk = path.read_text(encoding="utf-8")
+ assert issued["key"] not in on_disk
+ assert "hash" in json.loads(on_disk)["keys"][0]
+
+
+def test_listing_exposes_neither_secret_nor_digest(tmp_path):
+ """Publishing the digest would let anyone verify a guessed key offline."""
+ store = KeyStore(str(tmp_path / "keys.json"))
+ issued = store.issue("alice")
+ listed = store.list()
+ assert len(listed) == 1
+ assert "hash" not in listed[0]
+ assert "key" not in listed[0]
+ assert listed[0]["id"] == issued["id"]
+
+
+def test_revoked_key_stops_working(tmp_path):
+ store = KeyStore(str(tmp_path / "keys.json"))
+ issued = store.issue("alice")
+ assert store.revoke(issued["id"]) is True
+ assert store.resolve(issued["key"]) is None
+ assert store.revoke(issued["id"]) is False, "revoking twice is not a second success"
+
+
+def test_revocation_survives_a_restart(tmp_path):
+ path = str(tmp_path / "keys.json")
+ store = KeyStore(path)
+ issued = store.issue("alice")
+ store.revoke(issued["id"])
+ assert KeyStore(path).resolve(issued["key"]) is None
+
+
+def test_keys_survive_a_restart(tmp_path):
+ path = str(tmp_path / "keys.json")
+ issued = KeyStore(path).issue("alice")
+ assert KeyStore(path).resolve(issued["key"]) == "alice"
+
+
+def test_one_tenants_key_never_resolves_to_another(tmp_path):
+ store = KeyStore(str(tmp_path / "keys.json"))
+ alice = store.issue("alice")
+ bob = store.issue("bob")
+ assert store.resolve(alice["key"]) == "alice"
+ assert store.resolve(bob["key"]) == "bob"
+ assert store.list("alice") == [rec for rec in store.list() if rec["user"] == "alice"]
+
+
+def test_unknown_and_empty_tokens_resolve_to_nothing(tmp_path):
+ store = KeyStore(str(tmp_path / "keys.json"))
+ store.issue("alice")
+ assert store.resolve("") is None
+ assert store.resolve("sk-engram-not-a-real-key") is None
+
+
+def test_corrupt_store_refuses_to_load_rather_than_overwrite(tmp_path):
+ """The data-loss guard. Starting empty would reject every issued key, and the next issue() would
+ rewrite the file over records that were merely unreadable."""
+ path = tmp_path / "keys.json"
+ path.write_text("{not json", encoding="utf-8")
+ with pytest.raises(KeyStoreError):
+ KeyStore(str(path))
+ assert path.read_text(encoding="utf-8") == "{not json", "the unreadable file must be left intact"
+
+
+def test_malformed_records_are_rejected(tmp_path):
+ path = tmp_path / "keys.json"
+ path.write_text(json.dumps({"keys": [{"id": "key_1"}]}), encoding="utf-8")
+ with pytest.raises(KeyStoreError):
+ KeyStore(str(path))
+
+
+def test_key_file_is_owner_only(tmp_path):
+ """It holds no secrets, but it does enumerate the tenants on this deployment."""
+ path = tmp_path / "keys.json"
+ KeyStore(str(path)).issue("alice")
+ mode = stat.S_IMODE(os.stat(path).st_mode)
+ assert mode & (stat.S_IRWXG | stat.S_IRWXO) == 0
+
+
+def test_a_key_must_belong_to_a_tenant(tmp_path):
+ store = KeyStore(str(tmp_path / "keys.json"))
+ with pytest.raises(ValueError):
+ store.issue(" ")
+
+
+# --- HTTP surface ---
+
+
+def _client(tmp_path, monkeypatch, **env):
+ pytest.importorskip("fastapi")
+ from fastapi.testclient import TestClient
+
+ monkeypatch.setenv("ENGRAM_DATA_DIR", str(tmp_path))
+ monkeypatch.delenv("ENGRAM_API_KEYS", raising=False)
+ monkeypatch.delenv("ENGRAM_OPEN", raising=False)
+ monkeypatch.delenv("ENGRAM_ADMIN_TOKEN", raising=False)
+ for name, value in env.items():
+ monkeypatch.setenv(name, value)
+
+ import engram.server.app as app_module
+
+ app_module._svc = None
+ app_module._keystore = None
+ app_module._keystore_path = None
+ app_module._limiter = None
+ app_module._idempotency = None
+ return TestClient(app_module.app), app_module
+
+
+def test_admin_surface_is_absent_unless_enabled(tmp_path, monkeypatch):
+ """Fail closed: an open deployment must not let a passer-by mint tenants."""
+ client, _ = _client(tmp_path, monkeypatch, ENGRAM_OPEN="1")
+ response = client.post("/v1/admin/keys", json={"user": "mallory"})
+ assert response.status_code == 403
+
+
+def test_wrong_admin_token_is_rejected(tmp_path, monkeypatch):
+ client, _ = _client(tmp_path, monkeypatch, ENGRAM_ADMIN_TOKEN="s3cret")
+ assert client.post(
+ "/v1/admin/keys", json={"user": "alice"}, headers={"Authorization": "Bearer wrong"}
+ ).status_code == 401
+ assert client.post("/v1/admin/keys", json={"user": "alice"}).status_code == 401
+
+
+def test_issued_key_authenticates_a_real_request(tmp_path, monkeypatch):
+ client, _ = _client(tmp_path, monkeypatch, ENGRAM_ADMIN_TOKEN="s3cret")
+ admin = {"Authorization": "Bearer s3cret"}
+
+ issued = client.post("/v1/admin/keys", json={"user": "alice"}, headers=admin).json()
+ assert client.post(
+ "/v1/remember", json={"content": "Alice works at Acme."},
+ headers={"Authorization": f"Bearer {issued['key']}"},
+ ).status_code == 200
+
+
+def test_revoked_key_is_rejected_by_the_api(tmp_path, monkeypatch):
+ client, _ = _client(tmp_path, monkeypatch, ENGRAM_ADMIN_TOKEN="s3cret")
+ admin = {"Authorization": "Bearer s3cret"}
+ issued = client.post("/v1/admin/keys", json={"user": "alice"}, headers=admin).json()
+ caller = {"Authorization": f"Bearer {issued['key']}"}
+
+ assert client.post("/v1/remember", json={"content": "one"}, headers=caller).status_code == 200
+ assert client.delete(f"/v1/admin/keys/{issued['id']}", headers=admin).status_code == 200
+ assert client.post("/v1/remember", json={"content": "two"}, headers=caller).status_code == 401
+ assert client.delete(f"/v1/admin/keys/{issued['id']}", headers=admin).status_code == 404
+
+
+def test_issued_keys_isolate_namespaces(tmp_path, monkeypatch):
+ """The isolation the whole multi-tenant model rests on — checked through the API with bob's own
+ key, not by inspecting the service, so it would fail if bob's key resolved to alice's namespace."""
+ client, _ = _client(tmp_path, monkeypatch, ENGRAM_ADMIN_TOKEN="s3cret")
+ admin = {"Authorization": "Bearer s3cret"}
+ alice = client.post("/v1/admin/keys", json={"user": "alice"}, headers=admin).json()
+ bob = client.post("/v1/admin/keys", json={"user": "bob"}, headers=admin).json()
+
+ client.post(
+ "/v1/remember", json={"content": "Alice's private note about the merger."},
+ headers={"Authorization": f"Bearer {alice['key']}"},
+ )
+
+ as_bob = {"Authorization": f"Bearer {bob['key']}"}
+ recalled = client.post("/v1/recall", json={"query": "merger"}, headers=as_bob)
+ assert recalled.status_code == 200
+ assert "merger" not in recalled.json().get("context", "")
+
+ listed = client.get("/v1/memories", headers=as_bob)
+ assert "Alice's private note" not in listed.text
+
+
+def test_static_env_keys_still_work_alongside_issued_ones(tmp_path, monkeypatch):
+ """Existing deployments must not break when the self-serve path appears."""
+ client, _ = _client(
+ tmp_path, monkeypatch, ENGRAM_ADMIN_TOKEN="s3cret", ENGRAM_API_KEYS="carol:sk-carol"
+ )
+ assert client.post(
+ "/v1/remember", json={"content": "hi"}, headers={"Authorization": "Bearer sk-carol"}
+ ).status_code == 200
+
+ issued = client.post(
+ "/v1/admin/keys", json={"user": "alice"}, headers={"Authorization": "Bearer s3cret"}
+ ).json()
+ assert client.post(
+ "/v1/remember", json={"content": "hi"},
+ headers={"Authorization": f"Bearer {issued['key']}"},
+ ).status_code == 200
+
+
+def test_unreadable_key_store_fails_closed(tmp_path, monkeypatch):
+ """A broken store must reject requests, never wave them through."""
+ client, app_module = _client(tmp_path, monkeypatch, ENGRAM_API_KEYS="carol:sk-carol")
+ app_module.svc() # ensure the data dir exists before corrupting the file inside it
+ with open(os.path.join(str(tmp_path), "api_keys.json"), "w", encoding="utf-8") as fh:
+ fh.write("{not json")
+ app_module._keystore = None
+ app_module._keystore_path = None
+
+ response = client.post(
+ "/v1/remember", json={"content": "hi"}, headers={"Authorization": "Bearer sk-carol"}
+ )
+ assert response.status_code == 503, "an unreadable key store must not fall through to other auth"
+
+
+def test_listing_over_http_hides_secrets(tmp_path, monkeypatch):
+ client, _ = _client(tmp_path, monkeypatch, ENGRAM_ADMIN_TOKEN="s3cret")
+ admin = {"Authorization": "Bearer s3cret"}
+ issued = client.post("/v1/admin/keys", json={"user": "alice"}, headers=admin).json()
+
+ body = client.get("/v1/admin/keys", headers=admin).text
+ assert issued["key"] not in body
+ assert "hash" not in json.loads(body)["keys"][0]
diff --git a/tests/test_bounded_candidates.py b/tests/test_bounded_candidates.py
new file mode 100644
index 0000000..f7a1c53
--- /dev/null
+++ b/tests/test_bounded_candidates.py
@@ -0,0 +1,212 @@
+"""Bounded candidate retrieval (CLAUDE.md Bet E).
+
+The load-bearing test is `test_bounded_matches_full_scan_when_pool_covers_store`: with a pool large
+enough to hold every fact, bounded retrieval must return exactly what the full scan returns. That is
+what makes the feature a speed optimisation rather than a silent ranking change — and it is the guard
+that would catch a future edit to one path but not the other.
+"""
+from __future__ import annotations
+
+from engram.config import Config
+from engram.embed.hashing import HashingEmbedder
+from engram.retrieve.hybrid import HybridRetriever
+from engram.store.indexed import FactIndex, IndexedVectorStore
+from engram.store.memory_store import InMemoryGraphStore, InMemoryVectorStore
+from engram.types import Fact
+from engram.util import now
+
+FACTS = [
+ ("alice", "works_at", "acme corp", "alice works at acme corp"),
+ ("alice", "lives_in", "berlin", "alice lives in berlin"),
+ ("alice", "prefers", "oat milk", "alice prefers oat milk in coffee"),
+ ("bob", "works_at", "globex", "bob works at globex"),
+ ("bob", "visited", "kyoto", "bob visited kyoto in spring"),
+ ("carol", "studied", "linear algebra", "carol studied linear algebra at university"),
+ ("carol", "owns", "a road bike", "carol owns a road bike"),
+ ("dave", "avoids", "crowded cafes", "dave avoids crowded cafes"),
+]
+
+
+def _facts(embedder: HashingEmbedder, user_id: str = "u1") -> list[Fact]:
+ t = now()
+ return [
+ Fact(
+ user_id=user_id,
+ subject=subj,
+ predicate=pred,
+ object=obj,
+ text=text,
+ valid_at=t - i * 86400.0,
+ embedding=embedder.embed(text),
+ )
+ for i, (subj, pred, obj, text) in enumerate(FACTS)
+ ]
+
+
+def _stores():
+ """One set of facts loaded into both an undecorated and a decorated store.
+
+ Building two independent fact sets would compare different data — different ids, and a different
+ `now()` so different dates, recency and lexical date terms.
+ """
+ embedder = HashingEmbedder()
+ facts = _facts(embedder)
+ plain = InMemoryVectorStore()
+ boxed = IndexedVectorStore(InMemoryVectorStore())
+ for f in facts:
+ plain.upsert(f.id, f.embedding or [], f)
+ boxed.upsert(f.id, f.embedding or [], f)
+ return plain, boxed, embedder
+
+
+def _retrieve(store, embedder, query, *, bounded, pool=400, user_id="u1"):
+ config = Config(bounded_candidates=bounded, candidate_pool=pool)
+ retriever = HybridRetriever(store, InMemoryGraphStore(), embedder, config)
+ ranked, _diag = retriever.retrieve(query, user_id, top_k=5)
+ return [(f.id, round(score, 9)) for f, score in ranked]
+
+
+QUERIES = [
+ "where does alice work",
+ "what does alice prefer",
+ "who visited kyoto",
+ "what did carol study",
+ "bike",
+]
+
+
+def test_bounded_matches_full_scan_when_pool_covers_store():
+ """The safety property: a pool wider than the store must not change a single result."""
+ plain, boxed, embedder = _stores()
+ for query in QUERIES:
+ full = _retrieve(plain, embedder, query, bounded=False)
+ bounded = _retrieve(boxed, embedder, query, bounded=True, pool=400)
+ assert bounded == full, f"bounded retrieval diverged from full scan on {query!r}"
+
+
+def test_bounded_is_a_noop_without_an_index():
+ """Turning the flag on against an undecorated store must fall back, not crash or silently empty."""
+ plain, _boxed, embedder = _stores()
+ for query in QUERIES:
+ assert _retrieve(plain, embedder, query, bounded=True) == _retrieve(
+ plain, embedder, query, bounded=False
+ )
+
+
+def test_bounded_still_returns_results_with_a_tiny_pool():
+ """A pool smaller than the store is allowed to rank differently, but must stay useful."""
+ _plain, boxed, embedder = _stores()
+ ranked = _retrieve(boxed, embedder, "where does alice work", bounded=True, pool=2)
+ assert ranked, "a small pool must still retrieve something"
+
+
+def test_slot_completion_keeps_superseded_facts_from_surviving():
+ """A single-valued slot's head must be pulled in even when only a stale slot-mate matched.
+
+ Without slot completion `_current_slot_heads` would see one lonely stale fact, treat it as its own
+ head, and let a superseded value through — the read path's non-destructive-invalidation guarantee.
+ """
+ embedder = HashingEmbedder()
+ store = IndexedVectorStore(InMemoryVectorStore())
+ t = now()
+ old = Fact(
+ user_id="u1", subject="alice", predicate="works_at", object="acme corp",
+ text="alice works at acme corp", valid_at=t - 400 * 86400.0,
+ embedding=embedder.embed("alice works at acme corp"),
+ )
+ new = Fact(
+ user_id="u1", subject="alice", predicate="works_at", object="initech",
+ text="alice works at initech", valid_at=t,
+ embedding=embedder.embed("alice works at initech"),
+ )
+ for f in (old, new):
+ store.upsert(f.id, f.embedding or [], f)
+
+ config = Config(bounded_candidates=True, candidate_pool=400)
+ retriever = HybridRetriever(store, InMemoryGraphStore(), embedder, config)
+ # Query only the OLD value's distinctive term, so the stale fact is what the lexical channel finds.
+ candidates = retriever._bounded_candidates("acme", "u1", None, embedder.embed("acme"))
+ ids = {f.id for f in candidates}
+ assert old.id in ids
+ assert new.id in ids, "slot head must be pulled in alongside a matched slot-mate"
+
+ ranked, _ = retriever.retrieve("acme", "u1", top_k=5)
+ assert old.id not in {f.id for f, _ in ranked}, "superseded slot value must not be retrieved"
+
+
+def test_index_tracks_updates_and_deletes():
+ index = FactIndex()
+ embedder = HashingEmbedder()
+ f = Fact(
+ user_id="u1", subject="alice", predicate="works_at", object="acme",
+ text="alice works at acme", valid_at=now(), embedding=embedder.embed("x"),
+ )
+ index.add(f.id, f)
+ assert index.n_docs == 1
+ assert index.lexical_candidates("acme", 10, user_id="u1") == {f.id}
+
+ # Re-adding the same id is an update: the old terms must not linger as a phantom document.
+ f.text = "alice works at initech"
+ index.add(f.id, f)
+ assert index.n_docs == 1, "re-upsert must update, not duplicate"
+ assert index.lexical_candidates("acme", 10, user_id="u1") == set()
+ assert index.lexical_candidates("initech", 10, user_id="u1") == {f.id}
+
+ index.remove(f.id)
+ assert index.n_docs == 0
+ assert index.lexical_candidates("initech", 10, user_id="u1") == set()
+ assert index.postings == {}, "posting lists must not retain empty entries"
+ assert index.payloads == {}
+
+
+def test_index_scopes_candidates_and_corpus_by_user():
+ """Tenants must not see each other's facts, nor pollute each other's IDF."""
+ index = FactIndex()
+ embedder = HashingEmbedder()
+ ids = {}
+ for user in ("u1", "u2"):
+ f = Fact(
+ user_id=user, subject="alice", predicate="works_at", object="acme",
+ text="alice works at acme", valid_at=now(), embedding=embedder.embed("x"),
+ )
+ index.add(f.id, f)
+ ids[user] = f.id
+
+ assert index.lexical_candidates("acme", 10, user_id="u1") == {ids["u1"]}
+ assert index.lexical_candidates("acme", 10, user_id="u2") == {ids["u2"]}
+
+ corpus = index.corpus_for("u1", ["acme"])
+ assert corpus.n_docs == 1, "corpus size must count only this tenant's facts"
+ assert corpus.df["acme"] == 1, "document frequency must not count the other tenant's copy"
+
+
+def test_decorator_preserves_store_behaviour():
+ """The decorator must be transparent: same reads, same values, delete still deletes."""
+ inner = InMemoryVectorStore()
+ boxed = IndexedVectorStore(inner)
+ embedder = HashingEmbedder()
+ f = Fact(
+ user_id="u1", subject="alice", predicate="works_at", object="acme",
+ text="alice works at acme", valid_at=now(), embedding=embedder.embed("x"),
+ )
+ boxed.upsert(f.id, f.embedding or [], f)
+ assert boxed.get(f.id) is f
+ assert boxed.values() == inner.values()
+ assert boxed.search(f.embedding or [], 5)[0][1] is f
+
+ boxed.delete(f.id)
+ assert boxed.get(f.id) is None
+ assert boxed.index.n_docs == 0
+
+
+def test_decorator_adopts_a_prepopulated_store():
+ """Wrapping a store that already holds facts must index them, not start blind."""
+ inner = InMemoryVectorStore()
+ embedder = HashingEmbedder()
+ f = Fact(
+ user_id="u1", subject="alice", predicate="works_at", object="acme",
+ text="alice works at acme", valid_at=now(), embedding=embedder.embed("x"),
+ )
+ inner.upsert(f.id, f.embedding or [], f)
+ boxed = IndexedVectorStore(inner)
+ assert boxed.index.lexical_candidates("acme", 10, user_id="u1") == {f.id}
diff --git a/tests/test_client.py b/tests/test_client.py
new file mode 100644
index 0000000..b2ed807
--- /dev/null
+++ b/tests/test_client.py
@@ -0,0 +1,195 @@
+"""The Python SDK.
+
+Tested against the real application rather than a mock: the injectable transport routes calls through
+FastAPI's TestClient, so every assertion here is about the actual request the server receives and the
+actual response it sends. A mocked SDK test only proves the SDK agrees with itself, which is exactly the
+failure mode a client library has — drifting from the server it claims to speak to.
+"""
+from __future__ import annotations
+
+import pytest
+
+from engram.client import EngramClient, EngramError
+
+pytest.importorskip("fastapi")
+
+
+def _sdk(tmp_path, monkeypatch, api_key="tenant-a", **env):
+ """An EngramClient wired to an in-process server, plus the app module for direct inspection."""
+ from fastapi.testclient import TestClient
+
+ monkeypatch.setenv("ENGRAM_DATA_DIR", str(tmp_path))
+ monkeypatch.delenv("ENGRAM_API_KEYS", raising=False)
+ monkeypatch.delenv("ENGRAM_ADMIN_TOKEN", raising=False)
+ monkeypatch.setenv("ENGRAM_OPEN", "1")
+ for name, value in env.items():
+ monkeypatch.setenv(name, value)
+
+ import engram.server.app as app_module
+
+ app_module._svc = None
+ app_module._keystore = None
+ app_module._keystore_path = None
+ app_module._limiter = None
+ app_module._idempotency = None
+ http = TestClient(app_module.app)
+
+ seen: list[dict] = []
+
+ def transport(method, url, headers, body, timeout):
+ path = url[len("http://testserver"):] if url.startswith("http://testserver") else url
+ seen.append({"method": method, "path": path, "headers": headers})
+ response = http.request(method, path, content=body, headers=headers)
+ return response.status_code, dict(response.headers), response.content
+
+ client = EngramClient(base_url="http://testserver", api_key=api_key, transport=transport)
+ return client, app_module, seen
+
+
+# --- round trips ---
+
+
+def test_remember_then_recall(tmp_path, monkeypatch):
+ client, _, _ = _sdk(tmp_path, monkeypatch)
+ assert client.remember("Alice works at Acme Corp.")["ok"] is True
+ assert "Acme" in client.recall("where does alice work")["context"]
+
+
+def test_health_and_metrics_need_no_key(tmp_path, monkeypatch):
+ client, _, _ = _sdk(tmp_path, monkeypatch, api_key=None)
+ assert client.health()["service"] == "engram"
+ assert set(client.metrics()) == {"uptime_s", "ops", "counts", "tokens"}
+
+
+def test_profile_stats_and_memories(tmp_path, monkeypatch):
+ client, _, _ = _sdk(tmp_path, monkeypatch)
+ client.remember("Alice prefers oat milk.")
+ assert isinstance(client.profile(), dict)
+ assert isinstance(client.profile(structured=True), dict)
+ assert isinstance(client.stats(), dict)
+ assert isinstance(client.memories(limit=5), dict)
+
+
+def test_sessions_and_working_memory(tmp_path, monkeypatch):
+ client, _, _ = _sdk(tmp_path, monkeypatch)
+ client.remember("A note.", session_id="s1")
+ client.add_working("today my throat hurts", session_id="s1")
+ assert isinstance(client.working_memory(session_id="s1"), dict)
+ assert client.close_session("s1")
+ assert isinstance(client.sessions(), dict)
+
+
+def test_focus_and_policy_round_trip(tmp_path, monkeypatch):
+ client, _, _ = _sdk(tmp_path, monkeypatch)
+ client.set_focus(track=["cycling"], mute=[])
+ assert "cycling" in str(client.get_focus())
+ assert isinstance(client.get_policy(), dict)
+
+
+def test_facts_and_export(tmp_path, monkeypatch):
+ client, _, _ = _sdk(tmp_path, monkeypatch)
+ client.add_fact("alice", "lives_in", "Shenzhen")
+ assert isinstance(client.export(), dict)
+ assert isinstance(client.conflicts(), dict)
+
+
+# --- request shape ---
+
+
+def test_bearer_key_is_sent(tmp_path, monkeypatch):
+ client, _, seen = _sdk(tmp_path, monkeypatch, api_key="tenant-x")
+ client.remember("hello")
+ assert seen[-1]["headers"]["Authorization"] == "Bearer tenant-x"
+
+
+def test_no_key_means_no_authorization_header(tmp_path, monkeypatch):
+ client, _, seen = _sdk(tmp_path, monkeypatch, api_key=None)
+ client.health()
+ assert "Authorization" not in seen[-1]["headers"]
+
+
+def test_query_params_skip_none_rather_than_sending_the_word_none(tmp_path, monkeypatch):
+ """A None that reaches the URL becomes the literal string 'None' and the server filters on it."""
+ client, _, seen = _sdk(tmp_path, monkeypatch)
+ client.agent_status(session_id=None)
+ assert "session_id" not in seen[-1]["path"]
+ client.agent_status(session_id="s1")
+ assert "session_id=s1" in seen[-1]["path"]
+
+
+def test_path_ids_are_escaped(tmp_path, monkeypatch):
+ """An id with a slash must not silently address a different route."""
+ client, _, seen = _sdk(tmp_path, monkeypatch)
+ with pytest.raises(EngramError):
+ client.delete_fact("a/b")
+ assert "a%2Fb" in seen[-1]["path"]
+
+
+def test_idempotency_key_is_sent_and_honoured(tmp_path, monkeypatch):
+ client, app_module, seen = _sdk(tmp_path, monkeypatch)
+ first = client.remember("Alice visited Kyoto.", idempotency_key="retry-1")
+ assert seen[-1]["headers"]["Idempotency-Key"] == "retry-1"
+ second = client.remember("Alice visited Kyoto.", idempotency_key="retry-1")
+ assert first == second
+
+ episodes = app_module.svc().get("tenant-a").episodes_doc.values()
+ assert len([ep for ep in episodes if "Kyoto" in ep.content]) == 1
+
+
+def test_no_idempotency_header_when_unset(tmp_path, monkeypatch):
+ client, _, seen = _sdk(tmp_path, monkeypatch)
+ client.remember("no key")
+ assert "Idempotency-Key" not in seen[-1]["headers"]
+
+
+# --- errors ---
+
+
+def test_non_2xx_raises_with_status_and_server_message(tmp_path, monkeypatch):
+ client, _, _ = _sdk(tmp_path, monkeypatch)
+ with pytest.raises(EngramError) as caught:
+ client.forget(confirm=False) # the server refuses without explicit confirmation
+ assert caught.value.status == 400
+ assert "confirm" in str(caught.value).lower()
+
+
+def test_rate_limited_error_carries_retry_after(tmp_path, monkeypatch):
+ """Retry-After lives in a header, so an SDK whose transport drops headers cannot report it — which
+ is what makes a 429 unactionable for the caller."""
+ client, _, _ = _sdk(tmp_path, monkeypatch, ENGRAM_RATE_LIMIT_PER_MIN="1")
+ client.remember("one")
+ with pytest.raises(EngramError) as caught:
+ client.remember("two")
+ assert caught.value.status == 429
+ assert caught.value.retry_after is not None and caught.value.retry_after >= 1
+
+
+def test_unreachable_server_raises_rather_than_hanging():
+ """A connection failure is status 0 -- distinguishable from any answer the server could give."""
+ client = EngramClient(base_url="http://127.0.0.1:1", api_key="k", timeout=1.0)
+ with pytest.raises(EngramError) as caught:
+ client.health()
+ assert caught.value.status == 0
+
+
+# --- admin ---
+
+
+def test_admin_key_lifecycle_through_the_sdk(tmp_path, monkeypatch):
+ client, app_module, _ = _sdk(tmp_path, monkeypatch, api_key="s3cret", ENGRAM_ADMIN_TOKEN="s3cret")
+
+ issued = client.issue_key("alice", label="laptop")
+ assert issued["key"].startswith("sk-engram-")
+ assert any(rec["id"] == issued["id"] for rec in client.list_keys()["keys"])
+ assert client.revoke_key(issued["id"])["revoked"] is True
+ with pytest.raises(EngramError) as caught:
+ client.revoke_key(issued["id"])
+ assert caught.value.status == 404
+
+
+def test_admin_surface_refuses_a_tenant_key(tmp_path, monkeypatch):
+ """A tenant key must not be able to mint tenants."""
+ client, _, _ = _sdk(tmp_path, monkeypatch, api_key="tenant-a", ENGRAM_ADMIN_TOKEN="s3cret")
+ with pytest.raises(EngramError) as caught:
+ client.issue_key("mallory")
+ assert caught.value.status == 401
diff --git a/tests/test_coverage_check.py b/tests/test_coverage_check.py
new file mode 100644
index 0000000..72a9e5e
--- /dev/null
+++ b/tests/test_coverage_check.py
@@ -0,0 +1,85 @@
+"""Measuring whether a wider detail window covers a counting question's evidence.
+
+The measurement decided a mechanism was not worth shipping, so it has to be right about the thing it
+measured. The load-bearing test is `test_selection_mirrors_the_round_robin_not_top_n`: lean_context does
+not take the top N sessions for the main query, it interleaves each subquery's ranks. Measuring top-N
+instead would have measured a mechanism the code does not have — and reported a gain that evaporates.
+"""
+from __future__ import annotations
+
+from engram.retrieve.evidence import plan_evidence
+from eval.coverage_check import select_detail_sessions
+
+
+class _Mem:
+ """Returns a scripted ranking per query, so selection order is the only thing under test."""
+
+ def __init__(self, by_query: dict):
+ self.by_query = by_query
+ self.asked: list[str] = []
+
+ def retrieve_episodes(self, query, _user, k):
+ self.asked.append(query)
+ return [_Ep(sid) for sid in self.by_query.get(query, [])][:k]
+
+
+class _Ep:
+ def __init__(self, sid: str):
+ self.id = sid
+ self.session_id = sid
+
+
+class _Need:
+ def __init__(self, subqueries):
+ self.subqueries = tuple(subqueries)
+
+
+def test_selection_mirrors_the_round_robin_not_top_n():
+ """Each subquery contributes its rank-1 before any contributes its rank-2."""
+ mem = _Mem({
+ "main": ["m1", "m2", "m3"],
+ "sub-a": ["a1", "a2"],
+ "sub-b": ["b1", "b2"],
+ })
+ chosen = select_detail_sessions(mem, {}, "main", _Need(["sub-a", "sub-b"]), n_chunks=3)
+ assert chosen == ["a1", "b1", "m1"], "rank 1 of every angle comes before rank 2 of any"
+
+
+def test_the_budget_is_respected():
+ mem = _Mem({"main": ["m1", "m2"], "sub-a": ["a1", "a2"]})
+ assert len(select_detail_sessions(mem, {}, "main", _Need(["sub-a"]), n_chunks=2)) == 2
+
+
+def test_a_zero_budget_renders_nothing():
+ mem = _Mem({"main": ["m1"]})
+ assert select_detail_sessions(mem, {}, "main", _Need([]), n_chunks=0) == []
+
+
+def test_duplicate_sessions_across_subqueries_are_not_double_counted():
+ """Two angles hitting the same session must not consume two slots of the budget."""
+ mem = _Mem({"main": ["s1"], "sub-a": ["s1", "s2"], "sub-b": ["s1", "s3"]})
+ chosen = select_detail_sessions(mem, {}, "main", _Need(["sub-a", "sub-b"]), n_chunks=3)
+ assert len(chosen) == len(set(chosen))
+ assert set(chosen) == {"s1", "s2", "s3"}
+
+
+def test_no_subqueries_falls_back_to_the_main_query():
+ mem = _Mem({"main": ["m1", "m2"]})
+ assert select_detail_sessions(mem, {}, "main", _Need([]), n_chunks=2) == ["m1", "m2"]
+
+
+def test_the_cap_raises_the_budget_only_for_aggregation():
+ """The knob must not widen the window for questions that are answered by one session."""
+ counting = "How many model kits have I worked on or bought?"
+ lookup = "Where do I live?"
+ assert plan_evidence(counting, aggregation_chunk_cap=5).n_chunks > plan_evidence(counting).n_chunks
+ assert plan_evidence(lookup, aggregation_chunk_cap=5).n_chunks == plan_evidence(lookup).n_chunks
+
+
+def test_the_cap_is_off_by_default():
+ """Measured insufficient, so the default must not quietly enable it."""
+ from engram.config import Config
+
+ assert Config().aggregation_chunk_cap == 0
+ counting = "How many trips did I take?"
+ assert plan_evidence(counting, aggregation_chunk_cap=0).n_chunks == plan_evidence(counting).n_chunks
diff --git a/tests/test_cross_account_portability.py b/tests/test_cross_account_portability.py
new file mode 100644
index 0000000..60a173a
--- /dev/null
+++ b/tests/test_cross_account_portability.py
@@ -0,0 +1,202 @@
+"""Cross-instance portability — the cross-account memory bus.
+
+The memory belongs to the user, not to any one instance/account: an `export()` payload must restore
+into a different Engram instance (different data dir, possibly a different embedder), preserving fact
+ids, bi-temporal stamps, supersession chains, and provenance. These tests drive the native "engram"
+import format, the identity-consistent stats fix, the import-CLI namespace unification, and the
+ENGRAM_STORAGE backend selector.
+"""
+from __future__ import annotations
+
+import json
+import sys
+
+import pytest
+
+from engram.connectors import parse, sniff
+from engram.service import MemoryService
+
+
+@pytest.fixture()
+def clean_env(monkeypatch):
+ for var in ("ENGRAM_EMBEDDER", "ENGRAM_LLM", "ENGRAM_ANSWERER", "ENGRAM_STORAGE",
+ "ENGRAM_MAX_HOT_FACTS", "ENGRAM_CONFLICT_DETECTION", "ENGRAM_DATA_DIR"):
+ monkeypatch.delenv(var, raising=False)
+
+
+def _seed(svc: MemoryService, user: str) -> None:
+ svc.remember(user, "I moved to Berlin in 2024 and work on the Engram project.", session_id="s1")
+ svc.add_fact(user, "user", "lives_in", "Beijing")
+ svc.add_fact(user, "user", "lives_in", "Shanghai") # supersedes Beijing -> a chain to preserve
+
+
+# --- format detection -------------------------------------------------------
+
+def test_sniff_recognizes_engram_export(tmp_path, clean_env):
+ svc = MemoryService(data_dir=str(tmp_path / "a"))
+ _seed(svc, "alice")
+ payload = svc.export("alice", include_sensitive=True)
+ assert sniff(payload) == "engram"
+ assert sniff(json.dumps(payload)) == "engram"
+
+
+def test_parse_gives_actionable_error_for_engram_format(tmp_path, clean_env):
+ svc = MemoryService(data_dir=str(tmp_path / "a"))
+ _seed(svc, "alice")
+ payload = svc.export("alice", include_sensitive=True)
+ # parse() produces sessions; a native export restores directly — the error must say where to go.
+ with pytest.raises(ValueError, match="engram"):
+ parse(payload)
+
+
+# --- the roundtrip ----------------------------------------------------------
+
+def test_export_import_roundtrip_preserves_memory(tmp_path, clean_env):
+ src = MemoryService(data_dir=str(tmp_path / "src"))
+ _seed(src, "alice")
+ payload = src.export("alice", include_sensitive=True)
+
+ dst = MemoryService(data_dir=str(tmp_path / "dst"))
+ stats = dst.import_("alice", data=payload, format="auto")
+ assert stats["ok"] is True
+ assert stats["format"] == "engram"
+ assert stats["facts"] == len(payload["facts"])
+ assert stats["episodes"] == len(payload["episodes"])
+
+ out = dst.export("alice", include_sensitive=True)
+ assert {f["id"] for f in out["facts"]} == {f["id"] for f in payload["facts"]}
+
+ # bi-temporal stamps, supersession chain, and provenance survive the move
+ src_by_id = {f["id"]: f for f in payload["facts"]}
+ for f in out["facts"]:
+ assert f["valid_at"] == pytest.approx(src_by_id[f["id"]]["valid_at"])
+ assert f["supersedes"] == src_by_id[f["id"]]["supersedes"]
+ assert f["provenance"] == src_by_id[f["id"]]["provenance"]
+ assert any(f["supersedes"] for f in out["facts"]), "Beijing->Shanghai chain must survive"
+ assert any(f["invalid_at"] for f in out["facts"]), "superseded facts must stay invalidated"
+
+ # the graph is rebuilt on the target, and episodes are not re-queued for System-2
+ assert out["graph"]["nodes"] and out["graph"]["edges"]
+ assert dst.stats("alice")["counts"]["episodes_pending"] == 0
+
+ # and the target instance actually answers from the migrated memory
+ res = dst.recall("alice", "Where does the user live now?", lean=False)
+ assert any("Shanghai" in t for t in res["facts"]) or "Shanghai" in res["answer"]
+
+
+def test_reimport_is_idempotent(tmp_path, clean_env):
+ src = MemoryService(data_dir=str(tmp_path / "src"))
+ _seed(src, "alice")
+ payload = src.export("alice", include_sensitive=True)
+
+ dst = MemoryService(data_dir=str(tmp_path / "dst"))
+ first = dst.import_("alice", data=payload, format="engram")
+ again = dst.import_("alice", data=payload, format="engram")
+ assert again["facts"] == 0 and again["episodes"] == 0
+ assert again["facts_skipped"] == first["facts"]
+ assert again["episodes_skipped"] == first["episodes"]
+ out = dst.export("alice", include_sensitive=True)
+ assert len(out["facts"]) == len(payload["facts"]) # no duplicates
+
+
+def test_share_safe_export_still_imports(tmp_path, clean_env):
+ src = MemoryService(data_dir=str(tmp_path / "src"))
+ _seed(src, "alice")
+ src.add_fact("alice", "user", "has_condition", "hay fever", sensitive=True)
+ payload = src.export("alice") # share-safe: no sensitive facts, no episodes
+
+ dst = MemoryService(data_dir=str(tmp_path / "dst"))
+ stats = dst.import_("alice", data=payload, format="auto")
+ assert stats["facts"] == len(payload["facts"]) > 0
+ assert stats["episodes"] == 0
+ out = dst.export("alice", include_sensitive=True)
+ assert not any(f["sensitive"] for f in out["facts"]) # redacted stayed redacted
+
+
+def test_import_rejects_unknown_version(tmp_path, clean_env):
+ dst = MemoryService(data_dir=str(tmp_path / "dst"))
+ with pytest.raises(ValueError, match="version"):
+ dst.import_("alice", data={"engram_export_version": 99}, format="engram")
+
+
+def test_import_cross_embedder_reembeds(tmp_path, clean_env, monkeypatch):
+ """The migration path IS the re-embedding path: the target re-embeds with its own embedder, so a
+ payload exported under one embedding space restores into another without touching the source."""
+ src = MemoryService(data_dir=str(tmp_path / "src"))
+ _seed(src, "alice")
+ payload = src.export("alice", include_sensitive=True)
+
+ monkeypatch.setenv("ENGRAM_MAX_HOT_FACTS", "10000")
+ dst = MemoryService(data_dir=str(tmp_path / "dst"), embedder_name="hashing")
+ dst.import_("alice", data=payload, format="engram")
+ mem = dst.get("alice")
+ dim = len(dst.embedder.embed("probe"))
+ for f in mem.fact_store.values():
+ assert f.embedding is not None and len(f.embedding) == dim
+
+
+# --- identity-consistent stats ---------------------------------------------
+
+def test_stats_follow_linked_identity(tmp_path, clean_env):
+ svc = MemoryService(data_dir=str(tmp_path / "id"))
+ mem = svc.get("zz-handle")
+ mem.link_identity("zz-handle", "aa-canonical") # canonical root: "aa-canonical"
+ svc.remember("zz-handle", "I work at Acme Corp.", session_id="s1")
+ counts = svc.stats("zz-handle")["counts"]
+ assert counts["episodes"] >= 1
+ assert counts["facts_live"] >= 1
+
+
+# --- import CLI writes the same namespace dirs as the service ---------------
+
+def test_import_cli_local_writes_service_namespace_dir(tmp_path, clean_env, monkeypatch, capsys):
+ from engram.connectors.__main__ import main
+
+ fp = tmp_path / "log.txt"
+ fp.write_text("User: I like green tea.\nAssistant: Noted.\n", encoding="utf-8")
+ data_dir = tmp_path / "data"
+ monkeypatch.setattr(sys, "argv", [
+ "prog", "--file", str(fp), "--format", "transcript",
+ "--namespace", "a/b", "--data-dir", str(data_dir),
+ ])
+ main()
+
+ svc = MemoryService(data_dir=str(data_dir))
+ assert (data_dir / svc._safe_user("a/b")).is_dir(), \
+ "CLI must write the same digest-backed namespace dir the service reads"
+ assert svc.stats("a/b")["counts"]["episodes"] >= 1
+
+
+def test_import_cli_accepts_engram_export(tmp_path, clean_env, monkeypatch, capsys):
+ from engram.connectors.__main__ import main
+
+ src = MemoryService(data_dir=str(tmp_path / "src"))
+ _seed(src, "alice")
+ fp = tmp_path / "export.json"
+ fp.write_text(json.dumps(src.export("alice", include_sensitive=True)), encoding="utf-8")
+
+ data_dir = tmp_path / "data"
+ monkeypatch.setattr(sys, "argv", [
+ "prog", "--file", str(fp), "--namespace", "alice", "--data-dir", str(data_dir),
+ ])
+ main()
+ out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
+ assert out["format"] == "engram" and out["facts"] > 0
+
+ dst = MemoryService(data_dir=str(data_dir))
+ assert dst.stats("alice")["counts"]["facts_live"] >= 1
+
+
+# --- ENGRAM_STORAGE backend selector ----------------------------------------
+
+def test_engram_storage_env_selects_backend(tmp_path, clean_env, monkeypatch):
+ monkeypatch.setenv("ENGRAM_STORAGE", "lancedb")
+ svc = MemoryService(data_dir=str(tmp_path / "s"))
+ assert svc.config.storage == "lancedb"
+ assert svc.stats.__self__ is svc # constructing the service must not import lancedb yet
+
+
+def test_engram_storage_env_rejects_unknown(tmp_path, clean_env, monkeypatch):
+ monkeypatch.setenv("ENGRAM_STORAGE", "postgres")
+ with pytest.raises(ValueError, match="ENGRAM_STORAGE"):
+ MemoryService(data_dir=str(tmp_path / "s"))
diff --git a/tests/test_entity_index.py b/tests/test_entity_index.py
new file mode 100644
index 0000000..4ecd550
--- /dev/null
+++ b/tests/test_entity_index.py
@@ -0,0 +1,112 @@
+"""Entity-term index behind query anchoring.
+
+`query_entity_ids` walked every entity in the store on every retrieval to find which ones the query
+names. The graph now indexes entity name and alias terms, so only the query's own terms are looked up.
+
+The load-bearing test is `test_indexed_and_scanned_anchoring_agree`: it runs the real retriever against a
+graph store with the index and against one with the lookup removed, and requires identical answers. The
+index is only allowed to be faster, never different.
+"""
+from __future__ import annotations
+
+from engram.config import Config
+from engram.embed.hashing import HashingEmbedder
+from engram.retrieve.hybrid import HybridRetriever
+from engram.store.memory_store import InMemoryGraphStore, InMemoryVectorStore
+from engram.types import Entity
+
+NAMES = [
+ "Lisbon", "Berlin", "Acme Corp", "Initech", "Kyoto University",
+ "oat milk", "road bike", "Zephyr", "Wei", "the Berlin office",
+]
+
+QUERIES = [
+ "where does wei work",
+ "tell me about lisbon",
+ "did wei visit kyoto university",
+ "what happened at acme corp",
+ "anything about zephyr",
+ "somewhere not lisbon",
+ "does she prefer oat milk",
+ "berlin",
+ "nothing relevant here at all",
+ "the berlin office and initech",
+]
+
+
+class UnindexedGraphStore(InMemoryGraphStore):
+ """The same store with the term lookup hidden, so the retriever takes its full-scan path."""
+
+ entities_by_terms = None # type: ignore[assignment]
+
+
+def _populate(graph: InMemoryGraphStore, user_id: str = "u1") -> InMemoryGraphStore:
+ for name in NAMES:
+ graph.upsert_entity(Entity(user_id=user_id, name=name))
+ graph.upsert_entity(Entity(user_id="other", name="Lisbon")) # a second tenant, must stay invisible
+ return graph
+
+
+def _retriever(graph: InMemoryGraphStore) -> HybridRetriever:
+ return HybridRetriever(InMemoryVectorStore(), graph, HashingEmbedder(), Config())
+
+
+def test_indexed_and_scanned_anchoring_agree():
+ """The property that makes the index safe: same answers, whichever path ran."""
+ indexed = _retriever(_populate(InMemoryGraphStore()))
+ scanned = _retriever(_populate(UnindexedGraphStore()))
+
+ # Ids differ between the two stores, so compare the entity NAMES each path anchored on.
+ def names(retriever, query):
+ return sorted(
+ retriever.graph.entities[eid].name for eid in retriever.query_entity_ids(query, "u1")
+ )
+
+ for query in QUERIES:
+ assert names(indexed, query) == names(scanned, query), f"paths disagree on {query!r}"
+
+
+def test_lookup_only_touches_requested_terms():
+ graph = _populate(InMemoryGraphStore())
+ hits = graph.entities_by_terms("u1", {"lisbon", "berlin", "absent"})
+ assert set(hits) == {"lisbon", "berlin"}
+ assert [e.name for e in hits["lisbon"]] == ["Lisbon"]
+ assert {e.name for e in hits["berlin"]} == {"Berlin", "the Berlin office"}
+
+
+def test_lookup_is_tenant_scoped():
+ """Another tenant's identically named entity must not surface."""
+ graph = _populate(InMemoryGraphStore())
+ hits = graph.entities_by_terms("u1", {"lisbon"})
+ assert all(e.user_id == "u1" for e in hits["lisbon"])
+ assert graph.entities_by_terms("nobody", {"lisbon"}) == {}
+
+
+def test_multi_word_names_are_indexed_by_every_term():
+ graph = _populate(InMemoryGraphStore())
+ for term in ("kyoto", "university"): # every token of a multi-word name is a lookup key
+ hits = graph.entities_by_terms("u1", {term})
+ assert any(e.name == "Kyoto University" for e in hits.get(term, [])), term
+
+
+def test_aliases_are_indexed():
+ graph = InMemoryGraphStore()
+ graph.upsert_entity(Entity(user_id="u1", name="Wei", aliases=["Xiaowei", "老王"]))
+ hits = graph.entities_by_terms("u1", {"xiaowei"})
+ assert [e.name for e in hits["xiaowei"]] == ["Wei"]
+
+
+def test_pruning_an_orphan_removes_it_from_the_index():
+ """A stale posting would resurrect a deleted entity as a query anchor."""
+ graph = _populate(InMemoryGraphStore())
+ assert graph.entities_by_terms("u1", {"lisbon"})
+ assert graph.prune_orphan_entities() > 0 # no relations exist, so every entity is an orphan
+ assert graph.entities_by_terms("u1", {"lisbon"}) == {}
+
+
+def test_reupserting_a_name_does_not_duplicate_postings():
+ graph = InMemoryGraphStore()
+ first = graph.upsert_entity(Entity(user_id="u1", name="Lisbon"))
+ again = graph.upsert_entity(Entity(user_id="u1", name="Lisbon"))
+ assert again.id == first.id
+ assert [e.id for e in graph.entities_by_terms("u1", {"lisbon"})["lisbon"]] == [first.id]
diff --git a/tests/test_error_modes.py b/tests/test_error_modes.py
new file mode 100644
index 0000000..2a9cadb
--- /dev/null
+++ b/tests/test_error_modes.py
@@ -0,0 +1,101 @@
+"""Classifying wrong answers by failure mode.
+
+An accuracy number says how many were missed, not what kind of wrong they were — and abstaining, being
+off by one, and confidently answering wrong need different mechanisms. The load-bearing test here is
+`test_unanswerable_items_are_not_counted_as_abstention_failures`: the benchmark's `_abs` items are
+graded by an unanswerable judge, so refusing them is correct, and folding them into their base category
+turns right behaviour into a failure mode and aims the next mechanism at nothing.
+"""
+from __future__ import annotations
+
+from eval.error_modes import attribute, classify
+
+
+def test_refusals_are_recognised_in_both_languages():
+ for text in ("I don't know", "i do not know.", "That's not in my memory", "记忆里暂时没有这条",
+ "No information about that", "Unknown"):
+ assert classify(text, "42") == "abstained", text
+
+
+def test_a_refusal_word_inside_a_real_answer_is_not_a_refusal():
+ """'unknown' appearing in an answer is not the system declining to answer."""
+ assert classify("The scale is unknown for two of the kits, the rest are 1:48", "1:48") != "abstained"
+
+
+def test_a_numeric_answer_against_a_numeric_gold_is_a_counting_failure():
+ assert classify("3", "4") == "numeric"
+ assert classify("11 weeks and 4 days", "15") == "numeric"
+
+
+def test_a_confident_non_numeric_miss_is_a_wrong_value():
+ assert classify("a jazz quartet", "a bluegrass band") == "wrong_value"
+
+
+def _log(rows):
+ """rows: (qid, cat, ok, pred, gold)"""
+ return {
+ qid: {"_cat": cat, "s": {"ok": ok, "pred": pred, "gold": gold}}
+ for qid, cat, ok, pred, gold in rows
+ }
+
+
+def test_unanswerable_items_are_not_counted_as_abstention_failures():
+ """`_abs` items are the benchmark's unanswerable variants; a refusal there is the right answer, so
+ they belong in their own bucket rather than inflating the category they came from."""
+ log = _log([
+ ("q1", "temporal-reasoning", False, "I don't know", "4 days"),
+ ("q2_abs", "temporal-reasoning", False, "I don't know", "unanswerable"),
+ ])
+ report = attribute(log, "s")
+ assert report["by_category"]["temporal-reasoning"] == {"abstained": 1}
+ assert report["by_category"]["abstention"] == {"abstained": 1}
+
+
+def test_correct_answers_are_not_classified():
+ log = _log([("q1", "multi-session", True, "4", "4"), ("q2", "multi-session", False, "3", "4")])
+ report = attribute(log, "s")
+ assert report["scored"]["multi-session"] == 2
+ assert sum(report["by_category"]["multi-session"].values()) == 1
+
+
+def test_errored_items_are_excluded_from_the_denominator():
+ """An error is missing data; counting it as scored would understate accuracy and the mode split."""
+ log = {"q1": {"_cat": "c", "s": {"err": "timeout"}}, "q2": {"_cat": "c", "s": {"ok": True}}}
+ report = attribute(log, "s")
+ assert report["n"] == 1
+
+
+def test_numeric_direction_is_reported():
+ """A one-sided miss means missing evidence; a two-sided one means the counting itself fails, and the
+ two call for different mechanisms."""
+ log = _log([
+ ("q1", "multi-session", False, "3", "4"),
+ ("q2", "multi-session", False, "5", "4"),
+ ("q3", "multi-session", False, "2", "4"),
+ ])
+ direction = attribute(log, "s")["numeric_direction"]
+ assert direction["under"] == 2
+ assert direction["over"] == 1
+
+
+def test_empty_log_reports_nothing_rather_than_dividing_by_zero():
+ assert attribute({}, "s")["n"] == 0
+
+
+def test_context_size_is_reported_per_outcome():
+ """A refusal on as much evidence as the correct answers got is not retrieval running dry — the
+ distinction decides whether the next mechanism belongs before or after retrieval."""
+ log = {
+ "q1": {"_cat": "c", "s": {"ok": True, "tok": 9600}},
+ "q2": {"_cat": "c", "s": {"ok": False, "pred": "I don't know", "gold": "4 days", "tok": 9650}},
+ "q3": {"_cat": "c", "s": {"ok": False, "pred": "3", "gold": "4", "tok": 9500}},
+ }
+ medians = attribute(log, "s")["median_context_tokens"]
+ assert medians["correct"] == 9600
+ assert medians["abstained"] == 9650
+ assert medians["numeric"] == 9500
+
+
+def test_missing_token_counts_do_not_break_the_report():
+ log = {"q1": {"_cat": "c", "s": {"ok": False, "pred": "x", "gold": "y"}}}
+ assert attribute(log, "s")["median_context_tokens"]["wrong_value"] == 0
diff --git a/tests/test_exclusion_shortcut.py b/tests/test_exclusion_shortcut.py
new file mode 100644
index 0000000..8bcfe7a
--- /dev/null
+++ b/tests/test_exclusion_shortcut.py
@@ -0,0 +1,96 @@
+"""The negation early-out in graph_excluded_entity_ids.
+
+Every retrieval calls query_entity_ids(), which ends by calling graph_excluded_entity_ids(), which used
+to scan every entity in the store. Most queries contain no negation at all, so that scan almost always
+found nothing. The early-out skips it — but only because of one invariant, which the first test here
+pins down: if the anchored matcher can fire on some slice of a text, the cheap cue matcher must fire on
+the whole text. If a future edit adds a cue to one regex and not the other, that test fails rather than
+silently dropping exclusions.
+"""
+from __future__ import annotations
+
+from engram.config import Config
+from engram.embed.hashing import HashingEmbedder
+from engram.retrieve.hybrid import (
+ _EXCLUSION_BEFORE_RE,
+ _EXCLUSION_CUE_RE,
+ HybridRetriever,
+)
+from engram.store.memory_store import InMemoryGraphStore, InMemoryVectorStore
+from engram.types import Entity, Fact
+from engram.util import now
+
+# Strings chosen to exercise each cue, the word-gap forms, the Chinese cues, and near-misses.
+SAMPLES = [
+ "not lisbon",
+ "somewhere other than lisbon",
+ "anywhere except berlin",
+ "excluding berlin",
+ "exclude the berlin office",
+ "rather than berlin",
+ "besides berlin",
+ "cities not counting the two big ones berlin",
+ "不是上海",
+ "不在北京",
+ "排除广州",
+ "除了深圳",
+ "where does alice work",
+ "notable places she visited",
+ "the exception was minor",
+ "",
+ "berlin",
+ # A non-ASCII entity name carries no word-boundary guard, so the slice preceding it can end
+ # mid-word. This is the case that forced the cue test to drop its trailing \b.
+ "not上海",
+ "except北京",
+]
+
+
+def test_cue_matcher_is_a_necessary_condition_for_the_anchored_matcher():
+ """The invariant the early-out depends on. Checked over every prefix, since the real matcher runs
+ against arbitrary slices of the query (the text preceding an entity mention)."""
+ for text in SAMPLES:
+ for end in range(len(text) + 1):
+ slice_ = text[:end]
+ if _EXCLUSION_BEFORE_RE.search(slice_):
+ assert _EXCLUSION_CUE_RE.search(text), (
+ f"anchored matcher fired on {slice_!r} but the cue matcher misses {text!r}; "
+ "the early-out would drop this exclusion"
+ )
+
+
+def _retriever_with_entity(name: str) -> tuple[HybridRetriever, str]:
+ graph = InMemoryGraphStore()
+ ent = graph.upsert_entity(Entity(user_id="u1", name=name))
+ embedder = HashingEmbedder()
+ store = InMemoryVectorStore()
+ f = Fact(
+ user_id="u1", subject="alice", predicate="lives_in", object=name,
+ text=f"alice lives in {name}", valid_at=now(), embedding=embedder.embed(name),
+ )
+ store.upsert(f.id, f.embedding or [], f)
+ return HybridRetriever(store, graph, embedder, Config()), ent.id
+
+
+def test_negated_entity_is_still_excluded():
+ """The early-out must not weaken the feature it guards."""
+ retriever, ent_id = _retriever_with_entity("lisbon")
+ assert ent_id in retriever.graph_excluded_entity_ids("somewhere not lisbon", "u1")
+
+
+def test_chinese_negation_still_excluded():
+ """Chinese cues have no word boundaries; the tokenizer cannot see them, so the cue test must not
+ depend on tokenization."""
+ retriever, ent_id = _retriever_with_entity("上海")
+ assert ent_id in retriever.graph_excluded_entity_ids("不是上海", "u1")
+
+
+def test_plain_query_excludes_nothing():
+ retriever, _ent_id = _retriever_with_entity("lisbon")
+ assert retriever.graph_excluded_entity_ids("where does alice live", "u1") == set()
+
+
+def test_entity_named_after_a_cue_word_is_not_self_excluded():
+ """'not' appearing only as part of the entity's own name is not a negation of it."""
+ retriever, ent_id = _retriever_with_entity("notion")
+ assert ent_id not in retriever.graph_excluded_entity_ids("does alice use notion", "u1")
diff --git a/tests/test_lancedb_tenant_filter.py b/tests/test_lancedb_tenant_filter.py
new file mode 100644
index 0000000..1844784
--- /dev/null
+++ b/tests/test_lancedb_tenant_filter.py
@@ -0,0 +1,121 @@
+"""LanceDB tenant prefiltering — the change that lets a vector backend actually be an index.
+
+Before this, the only way to say "this user's facts" was a Python predicate, which LanceDB cannot see
+inside, so every multi-tenant search materialised the whole table. Since multi-tenant retrieval always
+filters by user, no search was ever sub-linear.
+
+The load-bearing test is `test_prefilter_finds_hits_beyond_the_unfiltered_neighbourhood`: it is designed
+to FAIL if the filter is applied after the ANN query instead of inside it.
+"""
+from __future__ import annotations
+
+import pytest
+
+from engram.types import Fact
+from engram.util import now
+
+lancedb = pytest.importorskip("lancedb")
+
+from engram.store.lancedb_store import _TENANT_COL, LanceDBVectorStore # noqa: E402
+
+
+def _fact(user: str, text: str, vec: list[float]) -> Fact:
+ return Fact(
+ user_id=user, subject="s", predicate="p", object=text, text=text,
+ valid_at=now(), embedding=vec,
+ )
+
+
+def test_tenant_filter_returns_only_that_tenant(tmp_path):
+ store = LanceDBVectorStore(str(tmp_path / "db"), "facts")
+ for user in ("alice", "bob"):
+ for i in range(3):
+ f = _fact(user, f"{user} fact {i}", [1.0, float(i), 0.0])
+ store.upsert(f.id, f.embedding or [], f)
+
+ hits = store.search([1.0, 0.0, 0.0], 10, user_id="alice")
+ assert hits, "tenant search must return the tenant's own rows"
+ assert {p.user_id for _s, p in hits} == {"alice"}
+
+
+def test_prefilter_finds_hits_beyond_the_unfiltered_neighbourhood(tmp_path):
+ """The correctness property a post-filter cannot satisfy.
+
+ One tenant's rows sit far from the query; the other tenant fills the entire nearest neighbourhood.
+ Filtering *after* a top_k ANN query would return nothing, because none of the k nearest rows belong
+ to the tenant asked for. Only a filter applied inside the search can find them.
+ """
+ store = LanceDBVectorStore(str(tmp_path / "db"), "facts")
+ query = [1.0, 0.0, 0.0]
+
+ for i in range(50): # noisy majority tenant, all near the query
+ f = _fact("loud", f"loud {i}", [1.0, 0.001 * i, 0.0])
+ store.upsert(f.id, f.embedding or [], f)
+ wanted = []
+ for i in range(3): # quiet tenant, all far from the query
+ f = _fact("quiet", f"quiet {i}", [0.0, 1.0, 0.05 * i])
+ store.upsert(f.id, f.embedding or [], f)
+ wanted.append(f.id)
+
+ hits = store.search(query, 3, user_id="quiet")
+ assert {p.id for _s, p in hits} == set(wanted), (
+ "tenant filter must run inside the search; a post-filter would return nothing here"
+ )
+
+
+def test_tenant_column_is_written(tmp_path):
+ store = LanceDBVectorStore(str(tmp_path / "db"), "facts")
+ f = _fact("alice", "hello", [1.0, 0.0, 0.0])
+ store.upsert(f.id, f.embedding or [], f)
+ rows = store._open().to_arrow().to_pylist()
+ assert rows[0][_TENANT_COL] == "alice"
+
+
+def test_python_predicate_still_supported(tmp_path):
+ """The general escape hatch must keep working (it scans, by necessity)."""
+ store = LanceDBVectorStore(str(tmp_path / "db"), "facts")
+ for user in ("alice", "bob"):
+ f = _fact(user, f"{user} note", [1.0, 0.0, 0.0])
+ store.upsert(f.id, f.embedding or [], f)
+
+ hits = store.search([1.0, 0.0, 0.0], 10, where=lambda p: p.user_id == "bob")
+ assert {p.user_id for _s, p in hits} == {"bob"}
+
+
+def test_legacy_table_without_tenant_column_still_works(tmp_path):
+ """A store written by an earlier release has no tenant column. It must keep reading and writing —
+ falling back to a scan — rather than failing on a schema mismatch."""
+ path = str(tmp_path / "db")
+ db = lancedb.connect(path)
+ legacy = _fact("alice", "legacy row", [1.0, 0.0, 0.0])
+ from engram.store.lancedb_store import _encode_payload
+
+ db.create_table(
+ "facts",
+ data=[{"key": legacy.id, "vector": legacy.embedding, "payload": _encode_payload(legacy)}],
+ mode="overwrite",
+ )
+
+ store = LanceDBVectorStore(path, "facts")
+ assert store._has_tenant_column(store._open()) is False
+
+ fresh = _fact("alice", "new row", [1.0, 0.01, 0.0])
+ store.upsert(fresh.id, fresh.embedding or [], fresh) # must not raise on the old schema
+
+ hits = store.search([1.0, 0.0, 0.0], 10, user_id="alice")
+ assert {p.id for _s, p in hits} == {legacy.id, fresh.id}
+
+
+def test_get_by_key_is_pushed_down(tmp_path):
+ """Single-key reads must not materialise the table (that makes id-at-a-time access quadratic)."""
+ store = LanceDBVectorStore(str(tmp_path / "db"), "facts")
+ made = [_fact("alice", f"note {i}", [1.0, 0.01 * i, 0.0]) for i in range(5)]
+ for f in made:
+ store.upsert(f.id, f.embedding or [], f)
+
+ assert store.get(made[3].id).id == made[3].id
+ assert store.get("no-such-key") is None
+
+ store.delete(made[3].id)
+ assert store.get(made[3].id) is None
+ assert len(store.values()) == 4
diff --git a/tests/test_layered_context.py b/tests/test_layered_context.py
new file mode 100644
index 0000000..1632a52
--- /dev/null
+++ b/tests/test_layered_context.py
@@ -0,0 +1,236 @@
+"""Splitting the read context into a cacheable half and a per-query half.
+
+The whole feature rests on one property: the stable half must be byte-identical across a user's turns.
+If it drifts with the question, prompt-caching misses every turn and the split costs more than the flat
+context it replaced. `test_stable_block_is_identical_across_queries` is that property, and
+`test_no_evidence_is_lost_by_splitting` is the guard that the split does not quietly drop retrieval.
+"""
+from __future__ import annotations
+
+from engram.memory import Memory
+from engram.retrieve.layered import RECALL_GUIDE, layered_context, memory_map
+from engram.util import DAY, now
+
+QUERIES = [
+ "where does alice work",
+ "what does alice drink",
+ "when did alice go to kyoto",
+ "who is alice's manager",
+]
+
+
+def _memory() -> Memory:
+ mem = Memory()
+ base = now() - 30 * DAY
+ for i, text in enumerate(
+ [
+ "Alice works at Acme Corp as a staff engineer.",
+ "Alice prefers oat milk in her coffee.",
+ "Alice travelled to Kyoto in April for a conference.",
+ "Alice's manager is Bob.",
+ "Alice is learning to play the cello.",
+ ]
+ ):
+ mem.add(text, user_id="u1", session_id=f"s{i}", event_time=base + i * DAY)
+ mem.consolidate()
+ mem.summarize_episodes(list(mem.episodes_doc.values()))
+ return mem
+
+
+def test_stable_block_is_identical_across_queries():
+ """The property the feature exists for: a differing question must not change the cached half."""
+ mem = _memory()
+ blocks = {layered_context(mem, q, "u1").stable for q in QUERIES}
+ assert len(blocks) == 1, "the stable half must not vary with the query, or caching never hits"
+
+
+def test_dynamic_block_does_vary_with_the_query():
+ """And the other half must actually be doing per-query work."""
+ mem = _memory()
+ blocks = {layered_context(mem, q, "u1").dynamic for q in QUERIES}
+ assert len(blocks) > 1
+
+
+def test_no_evidence_is_lost_by_splitting():
+ """Splitting must not drop retrieval: everything the flat path shows must still be present."""
+ mem = _memory()
+ query = "where does alice work"
+ flat = mem.lean_context(query, user_id="u1")
+ layered = layered_context(mem, query, "u1")
+
+ for line in (ln.strip() for ln in flat.splitlines()):
+ if line.startswith("- ") and len(line) > 8:
+ assert line in layered.text, f"evidence dropped by the split: {line!r}"
+
+
+def test_profile_is_not_duplicated_across_the_halves():
+ """The profile lives in the cached half; repeating it in the dynamic half would spend exactly the
+ tokens this split saves."""
+ mem = _memory()
+ layered = layered_context(mem, "where does alice work", "u1")
+ assert "USER PROFILE" in layered.stable
+ assert "USER PROFILE" not in layered.dynamic
+
+
+def test_guide_is_in_the_cached_half_and_optional():
+ mem = _memory()
+ assert RECALL_GUIDE in layered_context(mem, "q", "u1").stable
+ assert RECALL_GUIDE not in layered_context(mem, "q", "u1", guide=False).stable
+
+
+def test_as_messages_places_each_half_in_its_own_turn():
+ mem = _memory()
+ layered = layered_context(mem, "where does alice work", "u1")
+ messages = layered.as_messages("where does alice work", system="You are a helpful assistant.")
+
+ assert [m["role"] for m in messages] == ["system", "user"]
+ assert "You are a helpful assistant." in messages[0]["content"]
+ assert layered.stable in messages[0]["content"]
+ assert layered.dynamic in messages[1]["content"]
+ assert "where does alice work" in messages[1]["content"]
+
+
+# --- memory map ---
+
+
+def test_memory_map_is_recency_ordered_and_query_independent():
+ """Ranking by relevance would reorder the map every turn and defeat the caching."""
+ mem = _memory()
+ rendered = memory_map(mem, "u1")
+ dates = [line.split()[1] for line in rendered.splitlines()[1:]]
+ assert dates == sorted(dates, reverse=True)
+
+
+def test_memory_map_is_tenant_scoped():
+ mem = _memory()
+ mem.add("Bob works at Globex.", user_id="u2", session_id="other")
+ assert "Globex" not in memory_map(mem, "u1")
+
+
+def test_memory_map_respects_as_of():
+ """An as-of read must not reveal sessions from after the time being asked about."""
+ mem = _memory()
+ cutoff = now() - 29 * DAY
+ rendered = memory_map(mem, "u1", as_of=cutoff)
+ assert rendered.count("\n- ") + (1 if "\n- " not in rendered and "- " in rendered else 0) <= 2
+ assert "cello" not in rendered
+
+
+def test_memory_map_is_empty_without_episodes():
+ assert memory_map(Memory(), "nobody") == ""
+
+
+def test_memory_map_is_off_by_default():
+ """Measured as a net token cost below long sessions, so callers opt in (see results/)."""
+ mem = _memory()
+ assert "MEMORY MAP" not in layered_context(mem, "q", "u1").stable
+ assert memory_map(mem, "u1", limit=0) == "", "a zero limit must render nothing, not a bare header"
+
+
+def test_redacted_context_omits_profile_and_map():
+ """A redacted context is structured-facts-only; free-text layers can fold in sensitive content."""
+ mem = _memory()
+ layered = layered_context(
+ mem, "where does alice work", "u1", map_limit=20, redact_sensitive=True
+ )
+ assert "USER PROFILE" not in layered.stable
+ assert "MEMORY MAP (" not in layered.stable
+ assert RECALL_GUIDE in layered.stable, "the abstention guide carries no user content"
+
+
+def test_guide_only_mentions_the_map_when_one_is_present():
+ """Pointing the model at a section that is not there invites it to ask for the unavailable."""
+ mem = _memory()
+ assert "MEMORY MAP" in layered_context(mem, "q", "u1", map_limit=20).stable
+ assert "MEMORY MAP" not in layered_context(mem, "q", "u1").stable, "map is off by default"
+ assert "MEMORY MAP" not in layered_context(
+ mem, "q", "u1", map_limit=20, redact_sensitive=True
+ ).stable
+
+
+def test_memory_method_matches_the_module():
+ mem = _memory()
+ assert mem.layered_context("where does alice work", "u1").stable == (
+ layered_context(mem, "where does alice work", "u1").stable
+ )
+
+
+# --- OpenAI-compatible proxy wiring ---
+#
+# The proxy already put the whole retrieved slice in the SYSTEM prompt, so the system block changed on
+# every turn and no provider prompt cache could ever match a prefix. The split's job on this surface is
+# to make that block byte-identical; the tests below pin both halves of that claim.
+
+
+def _proxy_setup():
+ from engram.server import openai_compat as oc
+
+ mem = _memory()
+
+ class _Svc:
+ """Only what chat_completion touches."""
+
+ def __init__(self, memory):
+ self._mem = memory
+ self.llm = self
+
+ def get(self, _user):
+ return self._mem
+
+ def recall(self, _user, query, **kwargs):
+ return {"context": self._mem.lean_context(query, user_id="u1")}
+
+ def complete(self, prompt, system=None):
+ return "ok"
+
+ return oc, _Svc(mem)
+
+
+def test_proxy_system_block_is_stable_across_turns_when_layered():
+ oc, svc = _proxy_setup()
+ flat, layered = set(), set()
+ for query in QUERIES:
+ body = {"model": "engram", "messages": [{"role": "user", "content": query}]}
+ ctx = svc.recall("u1", query)["context"]
+ flat.add(oc.build_prompt(body["messages"], ctx)[0])
+ parts = svc.get("u1").layered_context(query, user_id="u1", guide=False)
+ layered.add(oc.build_prompt(body["messages"], parts.dynamic, parts.stable)[0])
+
+ # Not "one block per query": two questions can retrieve the same slice. The property is that the
+ # unsplit block varies at all — that alone is enough to miss a prefix cache on those turns.
+ assert len(flat) > 1, "precondition: today's system block varies across turns"
+ assert len(layered) == 1, "the split must make the system block byte-identical, or caching never hits"
+
+
+def test_proxy_reports_the_cacheable_prefix_size():
+ """A caller cannot reason about caching without knowing how much of the prompt is stable."""
+ oc, svc = _proxy_setup()
+ body = {"model": "engram", "messages": [{"role": "user", "content": "where does alice work"}]}
+
+ plain = oc.chat_completion(svc, "u1", body)
+ assert plain["engram"]["cacheable_tokens_est"] == 0, "nothing is stable without the split"
+
+ split = oc.chat_completion(svc, "u1", body, layered=True)
+ assert split["engram"]["cacheable_tokens_est"] > 0
+
+
+def test_proxy_keeps_the_evidence_when_splitting():
+ """Moving evidence to the user turn must not drop it."""
+ oc, svc = _proxy_setup()
+ body = {"model": "engram", "messages": [{"role": "user", "content": "where does alice work"}]}
+ parts = svc.get("u1").layered_context("where does alice work", user_id="u1", guide=False)
+ system, prompt = oc.build_prompt(body["messages"], parts.dynamic, parts.stable)
+
+ assert parts.stable in (system or "")
+ assert parts.dynamic in prompt
+ assert "where does alice work" in prompt
+
+
+def test_proxy_is_unchanged_when_not_layered():
+ """The default path must behave exactly as before."""
+ oc, svc = _proxy_setup()
+ messages = [{"role": "user", "content": "where does alice work"}]
+ ctx = svc.recall("u1", "where does alice work")["context"]
+ system, prompt = oc.build_prompt(messages, ctx)
+ assert ctx.strip() in (system or ""), "unsplit memory still rides in the system block"
+ assert prompt == "where does alice work"
diff --git a/tests/test_mcp_http_auth.py b/tests/test_mcp_http_auth.py
new file mode 100644
index 0000000..0759a7d
--- /dev/null
+++ b/tests/test_mcp_http_auth.py
@@ -0,0 +1,86 @@
+"""The MCP streamable-HTTP transport must not be an unauthenticated door into memory.
+
+`python -m engram.mcp --http` is loopback-only by default; exposing it on a non-loopback host requires
+a Bearer token (fail-closed, same philosophy as ENGRAM_API_KEYS/ENGRAM_OPEN on the REST server).
+These tests exercise the pure ASGI gate and the fail-closed launch policy without starting a server.
+"""
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from engram.mcp.__main__ import _BearerGate, _require_http_token
+
+
+class _Inner:
+ def __init__(self) -> None:
+ self.called = False
+
+ async def __call__(self, scope, receive, send) -> None:
+ self.called = True
+ await send({"type": "http.response.start", "status": 200, "headers": []})
+ await send({"type": "http.response.body", "body": b"ok"})
+
+
+def _run(gate, scope):
+ sent = []
+
+ async def send(message):
+ sent.append(message)
+
+ async def receive():
+ return {"type": "http.request"}
+
+ asyncio.run(gate(scope, receive, send))
+ return sent
+
+
+def _http_scope(auth: str | None) -> dict:
+ headers = [(b"host", b"example.com")]
+ if auth is not None:
+ headers.append((b"authorization", auth.encode("latin-1")))
+ return {"type": "http", "headers": headers}
+
+
+def test_gate_rejects_missing_token():
+ inner = _Inner()
+ sent = _run(_BearerGate(inner, "secret"), _http_scope(None))
+ assert sent[0]["status"] == 401
+ assert not inner.called
+
+
+def test_gate_rejects_wrong_token():
+ inner = _Inner()
+ sent = _run(_BearerGate(inner, "secret"), _http_scope("Bearer nope"))
+ assert sent[0]["status"] == 401
+ assert not inner.called
+
+
+def test_gate_passes_valid_token():
+ inner = _Inner()
+ sent = _run(_BearerGate(inner, "secret"), _http_scope("Bearer secret"))
+ assert inner.called
+ assert sent[0]["status"] == 200
+
+
+def test_gate_ignores_non_http_scopes():
+ inner = _Inner()
+ _run(_BearerGate(inner, "secret"), {"type": "lifespan"})
+ assert inner.called # lifespan/websocket handshake pass through to the app
+
+
+def test_loopback_without_token_is_allowed():
+ _require_http_token("127.0.0.1", "", allow_open=False)
+ _require_http_token("localhost", "", allow_open=False)
+ _require_http_token("::1", "", allow_open=False)
+
+
+def test_non_loopback_without_token_fails_closed():
+ with pytest.raises(SystemExit):
+ _require_http_token("0.0.0.0", "", allow_open=False)
+
+
+def test_non_loopback_with_token_or_explicit_open_is_allowed():
+ _require_http_token("0.0.0.0", "some-token", allow_open=False)
+ _require_http_token("0.0.0.0", "", allow_open=True) # explicit operator override
diff --git a/tests/test_metrics.py b/tests/test_metrics.py
new file mode 100644
index 0000000..02ebd6e
--- /dev/null
+++ b/tests/test_metrics.py
@@ -0,0 +1,235 @@
+"""Live service metrics.
+
+Two properties carry the weight here. The savings ratio must be computed from calls that measured both
+sides -- mixing a full-history total over a context total drawn from a larger set of calls understates the
+saving, which is the kind of error that quietly makes a headline number look worse than it is. And the
+payload must stay aggregate-only, because the endpoint is unauthenticated: a namespace name leaking into
+it would tell any caller which tenants exist.
+"""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from engram.metrics import Metrics, timed
+
+
+def test_latency_percentiles_and_counts():
+ m = Metrics()
+ for seconds in (0.001, 0.002, 0.003, 0.010):
+ m.observe("recall", seconds)
+ snap = m.snapshot()["ops"]["recall"]
+ assert snap["n"] == 4
+ assert snap["window"] == 4
+ assert snap["max_ms"] == 10.0
+ assert snap["p50_ms"] <= snap["p95_ms"] <= snap["max_ms"]
+
+
+def test_window_is_bounded_so_memory_cannot_grow():
+ """Percentiles must describe current behaviour, and the process must not accumulate samples forever."""
+ m = Metrics(window=8)
+ for i in range(1000):
+ m.observe("remember", i / 1000)
+ snap = m.snapshot()["ops"]["remember"]
+ assert snap["window"] == 8
+ assert snap["n"] == 1000, "the count is monotonic even though the sample is windowed"
+
+
+def test_savings_ratio_uses_only_calls_that_measured_both_sides():
+ """The regression this guards.
+
+ Nine cheap recalls skip the baseline; one measures both. Dividing the full-history total by *every*
+ context served would report roughly 1x -- as if memory saved nothing -- when the one comparable call
+ shows 10x.
+ """
+ m = Metrics()
+ for _ in range(9):
+ m.tokens(100) # no baseline computed on this path
+ m.tokens(100, 1000)
+
+ tokens = m.snapshot()["tokens"]
+ assert tokens["context_total"] == 1000, "total volume still counts every served context"
+ assert tokens["calls_with_baseline"] == 1
+ assert tokens["savings_ratio"] == 10.0
+
+
+def test_savings_ratio_is_absent_until_measured():
+ """No pairs means no ratio. A fabricated number would be worse than none."""
+ m = Metrics()
+ assert m.snapshot()["tokens"]["savings_ratio"] is None
+ m.tokens(100)
+ assert m.snapshot()["tokens"]["savings_ratio"] is None
+
+
+def test_counters_are_separate_from_timed_operations():
+ m = Metrics()
+ m.observe("remember", 0.001)
+ m.count("remember_degraded")
+ snap = m.snapshot()
+ assert "remember" in snap["ops"]
+ assert snap["counts"]["remember_degraded"] == 1
+ assert "remember" not in snap["counts"], "a timed op should not be duplicated as a bare counter"
+
+
+def test_snapshot_is_json_serialisable():
+ m = Metrics()
+ m.observe("recall", 0.001)
+ m.tokens(10, 100)
+ json.dumps(m.snapshot()) # the endpoint returns this directly
+
+
+def test_timed_records_even_when_the_call_raises():
+ """A failing operation is exactly the one whose latency matters."""
+
+ class Svc:
+ def __init__(self):
+ self.metrics = Metrics()
+
+ @timed("boom")
+ def boom(self):
+ raise ValueError("nope")
+
+ svc = Svc()
+ with pytest.raises(ValueError):
+ svc.boom()
+ assert svc.metrics.snapshot()["ops"]["boom"]["n"] == 1
+
+
+def test_timed_is_a_noop_without_metrics():
+ """Objects constructed without a metrics attribute must still work."""
+
+ class Bare:
+ @timed("op")
+ def run(self):
+ return 42
+
+ assert Bare().run() == 42
+
+
+# --- wiring ---
+
+
+def _service(tmp_path):
+ from engram.service import MemoryService
+
+ return MemoryService(data_dir=str(tmp_path))
+
+
+def test_service_records_remember_and_recall(tmp_path):
+ svc = _service(tmp_path)
+ svc.remember("alice", "Alice works at Acme Corp.")
+ svc.recall("alice", "where does alice work")
+
+ ops = svc.metrics.snapshot()["ops"]
+ assert ops["remember"]["n"] == 1
+ assert ops["recall"]["n"] == 1
+
+
+def test_metrics_payload_leaks_no_tenant_identity(tmp_path):
+ """The endpoint is unauthenticated, so this is a privacy boundary, not a nicety."""
+ svc = _service(tmp_path)
+ secret_user = "acme-industries-prod"
+ svc.remember(secret_user, "The launch date is March 3rd.")
+ svc.recall(secret_user, "when is the launch")
+
+ payload = json.dumps(svc.metrics.snapshot())
+ assert secret_user not in payload
+ assert "launch" not in payload
+ assert "March" not in payload
+
+
+def test_metrics_endpoint_is_open_and_aggregate(tmp_path, monkeypatch):
+ fastapi = pytest.importorskip("fastapi")
+ del fastapi
+ from fastapi.testclient import TestClient
+
+ monkeypatch.setenv("ENGRAM_DATA_DIR", str(tmp_path))
+ monkeypatch.setenv("ENGRAM_OPEN", "1")
+ import engram.server.app as app_module
+
+ app_module._svc = None # force a rebuild against the temp data dir
+ client = TestClient(app_module.app)
+ assert app_module.svc().data_dir == str(tmp_path), "test must exercise its own service, not a leftover"
+
+ response = client.get("/metrics") # no Authorization header
+ assert response.status_code == 200
+ body = response.json()
+ assert set(body) == {"uptime_s", "ops", "counts", "tokens"}
+
+ # And it reflects real traffic through the app, not just an empty shell.
+ client.post("/v1/remember", json={"content": "hi"}, headers={"Authorization": "Bearer tenant-x"})
+ after = client.get("/metrics").json()
+ assert after["ops"]["remember"]["n"] >= 1
+ assert "tenant-x" not in json.dumps(after)
+
+
+# --- defence counters ---
+#
+# The rate limiter, the idempotency cache and key resolution are otherwise black boxes: an operator
+# cannot tell whether they are doing anything. These counters are aggregate on purpose — /metrics is
+# unauthenticated, so a per-tenant breakdown would reveal which tenants exist.
+
+
+def _guarded_client(tmp_path, monkeypatch, **env):
+ fastapi = pytest.importorskip("fastapi")
+ del fastapi
+ from fastapi.testclient import TestClient
+
+ monkeypatch.setenv("ENGRAM_DATA_DIR", str(tmp_path))
+ monkeypatch.delenv("ENGRAM_API_KEYS", raising=False)
+ monkeypatch.delenv("ENGRAM_ADMIN_TOKEN", raising=False)
+ monkeypatch.setenv("ENGRAM_OPEN", "1")
+ for name, value in env.items():
+ monkeypatch.setenv(name, value)
+
+ import engram.server.app as app_module
+
+ app_module._svc = None
+ app_module._keystore = None
+ app_module._keystore_path = None
+ app_module._limiter = None
+ app_module._idempotency = None
+ return TestClient(app_module.app), app_module
+
+
+def test_rate_limited_requests_are_counted(tmp_path, monkeypatch):
+ client, _ = _guarded_client(tmp_path, monkeypatch, ENGRAM_RATE_LIMIT_PER_MIN="1")
+ headers = {"Authorization": "Bearer tenant-a"}
+ client.post("/v1/remember", json={"content": "one"}, headers=headers)
+ assert client.post("/v1/remember", json={"content": "two"}, headers=headers).status_code == 429
+
+ assert client.get("/metrics").json()["counts"]["rate_limited"] == 1
+
+
+def test_idempotent_replays_are_counted(tmp_path, monkeypatch):
+ client, _ = _guarded_client(tmp_path, monkeypatch)
+ headers = {"Authorization": "Bearer tenant-a", "Idempotency-Key": "retry-1"}
+ body = {"content": "Alice visited Kyoto."}
+ client.post("/v1/remember", json=body, headers=headers)
+ counts = client.get("/metrics").json()["counts"]
+ assert "idempotent_replays" not in counts, "the first call is work, not a replay"
+
+ client.post("/v1/remember", json=body, headers=headers)
+ assert client.get("/metrics").json()["counts"]["idempotent_replays"] == 1
+
+
+def test_rejected_auth_is_counted(tmp_path, monkeypatch):
+ client, _ = _guarded_client(tmp_path, monkeypatch, ENGRAM_API_KEYS="carol:sk-carol")
+ monkeypatch.delenv("ENGRAM_OPEN", raising=False)
+ assert client.post(
+ "/v1/remember", json={"content": "hi"}, headers={"Authorization": "Bearer wrong-key"}
+ ).status_code == 401
+ assert client.get("/metrics").json()["counts"]["auth_rejected"] == 1
+
+
+def test_defence_counters_never_name_a_tenant(tmp_path, monkeypatch):
+ """The privacy boundary: /metrics is open, so counting must not enumerate tenants."""
+ client, _ = _guarded_client(tmp_path, monkeypatch, ENGRAM_RATE_LIMIT_PER_MIN="1")
+ secret_tenant = "acme-industries-prod"
+ headers = {"Authorization": f"Bearer {secret_tenant}", "Idempotency-Key": "k"}
+ client.post("/v1/remember", json={"content": "hi"}, headers=headers)
+ client.post("/v1/remember", json={"content": "hi"}, headers=headers)
+
+ payload = client.get("/metrics").text
+ assert secret_tenant not in payload
diff --git a/tests/test_noise_floor.py b/tests/test_noise_floor.py
new file mode 100644
index 0000000..cff3853
--- /dev/null
+++ b/tests/test_noise_floor.py
@@ -0,0 +1,63 @@
+"""Measuring the benchmark's own instability.
+
+The floor under every accuracy claim: if re-running an unchanged configuration moves N answers, a gain
+smaller than N is not observable. These tests pin that the tool reports the flips honestly and warns when
+two "identical" runs differ by more than chance — which means they were not identical.
+"""
+from __future__ import annotations
+
+from eval.noise_floor import compare_repeats, summarise
+
+
+def _log(system: str, correct_qids: set, total: int = 100):
+ return {
+ f"q{i}": {system: {"ok": f"q{i}" in correct_qids}}
+ for i in range(total)
+ }
+
+
+def test_identical_runs_report_no_flips():
+ correct = {f"q{i}" for i in range(80)}
+ logs = [_log("s", correct), _log("s", correct)]
+ summary = summarise(compare_repeats(logs, "s"))
+ assert summary["mean_flips"] == 0
+ assert summary["max_accuracy_spread"] == 0.0
+
+
+def test_flips_are_counted_in_both_directions():
+ """With one config, a flip to wrong and a flip to right are the same phenomenon."""
+ a = {f"q{i}" for i in range(80)}
+ b = (a - {"q0", "q1"}) | {"q90", "q91", "q92"}
+ comparisons = compare_repeats([_log("s", a), _log("s", b)], "s")
+ assert comparisons[0]["flipped_to_wrong"] == 2
+ assert comparisons[0]["flipped_to_right"] == 3
+ assert comparisons[0]["flips"] == 5
+
+
+def test_the_floor_rises_with_instability():
+ """A noisier apparatus can resolve less."""
+ base = {f"q{i}" for i in range(80)}
+ quiet = summarise(compare_repeats([_log("s", base), _log("s", base - {"q0"} | {"q90"})], "s"))
+ noisy = summarise(compare_repeats(
+ [_log("s", base), _log("s", base - {f"q{i}" for i in range(10)} | {f"q{90+i}" for i in range(10)})],
+ "s",
+ ))
+ assert noisy["mde_points"] > quiet["mde_points"]
+
+
+def test_every_pair_of_runs_is_compared():
+ correct = {f"q{i}" for i in range(80)}
+ comparisons = compare_repeats([_log("s", correct)] * 3, "s")
+ assert len(comparisons) == 3, "three runs give three pairings"
+
+
+def test_runs_that_differ_by_more_than_chance_are_flagged():
+ """Identical configs should not differ systematically. If they do, they were not identical."""
+ a = {f"q{i}" for i in range(50)}
+ b = {f"q{i}" for i in range(85)} # a large one-directional shift
+ summary = summarise(compare_repeats([_log("s", a), _log("s", b)], "s"))
+ assert summary["suspicious_pairs"], "a systematic shift must be reported, not averaged away"
+
+
+def test_no_comparisons_summarises_to_nothing():
+ assert summarise([]) == {}
diff --git a/tests/test_rate_limit_idempotency.py b/tests/test_rate_limit_idempotency.py
new file mode 100644
index 0000000..ec480ce
--- /dev/null
+++ b/tests/test_rate_limit_idempotency.py
@@ -0,0 +1,223 @@
+"""Per-tenant rate limiting and Idempotency-Key replay.
+
+Both defend the multi-tenant surface against a different failure. The limiter stops one caller spending
+the whole process; idempotency stops a client's timeout-and-retry storing the same episode twice and
+paying to consolidate it twice, because the first request did succeed and only its response was lost.
+
+Two properties are load-bearing and easy to get subtly wrong, so they are tested directly: a rejected
+request must not extend its own window (or a retrying client never recovers), and a cached response must
+never cross tenants.
+"""
+from __future__ import annotations
+
+import pytest
+
+from engram.server.limits import IdempotencyCache, RateLimiter
+
+# --- limiter ---
+
+
+def test_requests_are_allowed_up_to_the_limit_then_rejected():
+ limiter = RateLimiter(per_min=3)
+ assert [limiter.check("u1", now=100.0)[0] for _ in range(3)] == [True, True, True]
+ allowed, retry_after = limiter.check("u1", now=100.0)
+ assert allowed is False
+ assert 0 < retry_after <= 60
+
+
+def test_a_rejected_request_does_not_extend_the_window():
+ """The trap: counting rejections keeps a retrying client's window permanently full."""
+ limiter = RateLimiter(per_min=2)
+ limiter.check("u1", now=0.0)
+ limiter.check("u1", now=0.0)
+ for _ in range(10): # a client hammering while blocked
+ assert limiter.check("u1", now=30.0)[0] is False
+ assert limiter.check("u1", now=61.0)[0] is True, "the window must clear once the originals age out"
+
+
+def test_window_slides():
+ limiter = RateLimiter(per_min=2)
+ limiter.check("u1", now=0.0)
+ limiter.check("u1", now=59.0)
+ assert limiter.check("u1", now=59.5)[0] is False
+ assert limiter.check("u1", now=60.5)[0] is True, "the first hit has aged out"
+
+
+def test_tenants_have_independent_budgets():
+ limiter = RateLimiter(per_min=1)
+ assert limiter.check("u1", now=0.0)[0] is True
+ assert limiter.check("u1", now=0.0)[0] is False
+ assert limiter.check("u2", now=0.0)[0] is True, "one tenant must not consume another's budget"
+
+
+def test_disabled_limiter_allows_everything():
+ limiter = RateLimiter(per_min=0)
+ assert limiter.enabled is False
+ assert all(limiter.check("u1", now=0.0)[0] for _ in range(100))
+
+
+def test_pruning_stops_the_tenant_map_growing_without_bound():
+ """Every tenant that ever called would otherwise stay in the map forever -- a slow leak that only
+ bites the deployment with the most tenants."""
+ limiter = RateLimiter(per_min=5)
+ for i in range(50):
+ limiter.check(f"u{i}", now=0.0)
+ assert limiter.tracked_tenants == 50
+
+ limiter.check("recent", now=100.0)
+ assert limiter.prune(now=100.0) == 50, "the aged-out tenants should be swept"
+ assert limiter.tracked_tenants == 1, "the tenant still inside the window must survive"
+
+
+def test_retry_after_shrinks_as_the_window_drains():
+ limiter = RateLimiter(per_min=1)
+ limiter.check("u1", now=0.0)
+ _, early = limiter.check("u1", now=10.0)
+ _, late = limiter.check("u1", now=50.0)
+ assert early > late > 0
+
+
+# --- idempotency ---
+
+
+def test_replays_the_first_response_for_a_repeated_key():
+ cache = IdempotencyCache()
+ cache.put("u1", "k1", {"ok": True, "id": "ep_1"}, now=0.0)
+ assert cache.get("u1", "k1", now=1.0) == {"ok": True, "id": "ep_1"}
+
+
+def test_cached_responses_never_cross_tenants():
+ """Two namespaces picking the same key must not read each other's response."""
+ cache = IdempotencyCache()
+ cache.put("alice", "same-key", {"owner": "alice"}, now=0.0)
+ assert cache.get("bob", "same-key", now=0.0) is None
+
+
+def test_entries_expire():
+ cache = IdempotencyCache(ttl_seconds=10.0)
+ cache.put("u1", "k1", {"ok": True}, now=0.0)
+ assert cache.get("u1", "k1", now=9.0) is not None
+ assert cache.get("u1", "k1", now=11.0) is None
+
+
+def test_missing_key_is_never_cached():
+ cache = IdempotencyCache()
+ cache.put("u1", "", {"ok": True}, now=0.0)
+ assert len(cache) == 0
+ assert cache.get("u1", "", now=0.0) is None
+
+
+def test_oldest_entries_are_evicted_at_capacity():
+ cache = IdempotencyCache(max_entries=3)
+ for i in range(5):
+ cache.put("u1", f"k{i}", {"n": i}, now=float(i))
+ assert len(cache) == 3
+ assert cache.get("u1", "k0", now=5.0) is None
+ assert cache.get("u1", "k4", now=5.0) == {"n": 4}
+
+
+# --- wiring ---
+
+
+def _client(tmp_path, monkeypatch, **env):
+ pytest.importorskip("fastapi")
+ from fastapi.testclient import TestClient
+
+ monkeypatch.setenv("ENGRAM_DATA_DIR", str(tmp_path))
+ monkeypatch.setenv("ENGRAM_OPEN", "1")
+ for name, value in env.items():
+ monkeypatch.setenv(name, value)
+
+ import engram.server.app as app_module
+
+ app_module._svc = None # rebuild the service against the temp data dir
+ app_module._limiter = None # and the limiter against this test's configured limit
+ app_module._idempotency = None
+ return TestClient(app_module.app), app_module
+
+
+def test_endpoint_returns_429_with_retry_after(tmp_path, monkeypatch):
+ client, _ = _client(tmp_path, monkeypatch, ENGRAM_RATE_LIMIT_PER_MIN="2")
+ headers = {"Authorization": "Bearer tenant-a"}
+
+ assert client.post("/v1/remember", json={"content": "one"}, headers=headers).status_code == 200
+ assert client.post("/v1/remember", json={"content": "two"}, headers=headers).status_code == 200
+ blocked = client.post("/v1/remember", json={"content": "three"}, headers=headers)
+ assert blocked.status_code == 429
+ assert int(blocked.headers["Retry-After"]) >= 1, "a 429 without Retry-After leaves clients guessing"
+
+
+def test_one_tenant_cannot_exhaust_anothers_budget(tmp_path, monkeypatch):
+ client, _ = _client(tmp_path, monkeypatch, ENGRAM_RATE_LIMIT_PER_MIN="1")
+ assert client.post(
+ "/v1/remember", json={"content": "a"}, headers={"Authorization": "Bearer tenant-a"}
+ ).status_code == 200
+ assert client.post(
+ "/v1/remember", json={"content": "a again"}, headers={"Authorization": "Bearer tenant-a"}
+ ).status_code == 429
+ assert client.post(
+ "/v1/remember", json={"content": "b"}, headers={"Authorization": "Bearer tenant-b"}
+ ).status_code == 200
+
+
+def test_rate_limiting_is_off_by_default(tmp_path, monkeypatch):
+ """Existing deployments and the zero-setup demo must be unaffected."""
+ client, _ = _client(tmp_path, monkeypatch)
+ headers = {"Authorization": "Bearer tenant-a"}
+ for i in range(12):
+ assert client.post("/v1/remember", json={"content": f"m{i}"}, headers=headers).status_code == 200
+
+
+def test_health_stays_reachable_while_a_tenant_is_limited(tmp_path, monkeypatch):
+ """Probes must not be rate limited, or a busy tenant takes the deployment down with it."""
+ client, _ = _client(tmp_path, monkeypatch, ENGRAM_RATE_LIMIT_PER_MIN="1")
+ headers = {"Authorization": "Bearer tenant-a"}
+ client.post("/v1/remember", json={"content": "one"}, headers=headers)
+ assert client.post("/v1/remember", json={"content": "two"}, headers=headers).status_code == 429
+ assert client.get("/health").status_code == 200
+ assert client.get("/metrics").status_code == 200
+
+
+def test_retried_remember_stores_once(tmp_path, monkeypatch):
+ """The point of the header: the same call twice must leave one episode, not two."""
+ client, app_module = _client(tmp_path, monkeypatch)
+ headers = {"Authorization": "Bearer tenant-a", "Idempotency-Key": "retry-1"}
+ body = {"content": "Alice works at Acme Corp."}
+
+ first = client.post("/v1/remember", json=body, headers=headers)
+ second = client.post("/v1/remember", json=body, headers=headers)
+ assert first.status_code == second.status_code == 200
+ assert first.json() == second.json(), "a retry must replay the first response verbatim"
+
+ episodes = app_module.svc().get("tenant-a").episodes_doc.values()
+ assert len([ep for ep in episodes if "Acme" in ep.content]) == 1
+
+
+def test_without_the_header_a_repeat_is_a_new_write(tmp_path, monkeypatch):
+ """Idempotency is opt-in; two deliberate identical writes must both land."""
+ client, app_module = _client(tmp_path, monkeypatch)
+ headers = {"Authorization": "Bearer tenant-a"}
+ body = {"content": "Alice visited Kyoto."}
+ client.post("/v1/remember", json=body, headers=headers)
+ client.post("/v1/remember", json=body, headers=headers)
+
+ episodes = app_module.svc().get("tenant-a").episodes_doc.values()
+ assert len([ep for ep in episodes if "Kyoto" in ep.content]) == 2
+
+
+def test_idempotency_key_is_scoped_to_the_tenant(tmp_path, monkeypatch):
+ """Otherwise one tenant's reply could be served to another — a cross-tenant data leak."""
+ client, app_module = _client(tmp_path, monkeypatch)
+ key = {"Idempotency-Key": "shared"}
+ client.post(
+ "/v1/remember", json={"content": "alice secret"},
+ headers={"Authorization": "Bearer alice", **key},
+ )
+ client.post(
+ "/v1/remember", json={"content": "bob secret"},
+ headers={"Authorization": "Bearer bob", **key},
+ )
+
+ bob_episodes = [ep.content for ep in app_module.svc().get("bob").episodes_doc.values()]
+ assert any("bob secret" in text for text in bob_episodes)
+ assert not any("alice secret" in text for text in bob_episodes)
diff --git a/tests/test_rerank_segments.py b/tests/test_rerank_segments.py
new file mode 100644
index 0000000..2f2f564
--- /dev/null
+++ b/tests/test_rerank_segments.py
@@ -0,0 +1,158 @@
+"""Segment-level reranking of long documents.
+
+A cross-encoder reads ~512 tokens. Given a whole ~2000-token session it does not error -- it scores the
+first quarter and silently ignores the rest, so a session whose answer sits late ranks as irrelevant.
+`test_answer_late_in_a_long_document_still_ranks_first` reproduces exactly that and would fail against the
+old whole-document path.
+
+No cross-encoder is loaded here: the reranker is a stub that scores by query-term overlap, which is enough
+to exercise every decision `rerank_long` makes and keeps the suite runnable with no heavy dependency.
+"""
+from __future__ import annotations
+
+from engram.retrieve.rerank import rerank_long, segment_text
+
+
+class WindowedReranker:
+ """A stand-in cross-encoder with a hard reading window, like the real one.
+
+ `window` words are read and the rest is dropped, reproducing the truncation that makes whole-document
+ reranking wrong. Score is query-term overlap over the part it can see.
+ """
+
+ def __init__(self, window: int = 400) -> None:
+ self.window = window
+ self.seen: list[str] = []
+
+ def rerank(self, query, candidates, top_k):
+ terms = set(query.lower().split())
+ scored = []
+ for cid, text in candidates:
+ self.seen.append(text)
+ visible = " ".join(text.split()[: self.window]).lower()
+ scored.append((cid, float(sum(visible.count(t) for t in terms))))
+ scored.sort(key=lambda item: item[1], reverse=True)
+ return scored[:top_k]
+
+
+def _doc(filler_words: int, needle: str = "", needle_at: str = "end") -> str:
+ filler = " ".join(f"w{i}" for i in range(filler_words))
+ if not needle:
+ return filler
+ return f"{needle} {filler}" if needle_at == "start" else f"{filler} {needle}"
+
+
+# --- segment_text ---
+
+
+def test_short_text_is_one_segment():
+ """Documents already inside the window must be untouched, so short candidates behave as before."""
+ assert segment_text("alice works at acme", max_words=300) == ["alice works at acme"]
+
+
+def test_empty_text_yields_no_segments():
+ assert segment_text("", 300) == []
+ assert segment_text(" \n ", 300) == []
+
+
+def test_long_text_is_split_within_budget():
+ segments = segment_text(_doc(1000), max_words=300)
+ assert len(segments) > 1
+ assert all(len(s.split()) <= 300 for s in segments)
+
+
+def test_split_preserves_every_word():
+ """Segmentation must not drop content -- that would be the truncation bug in another costume."""
+ text = _doc(1000, needle="the answer is lisbon")
+ assert " ".join(segment_text(text, 300)).split() == text.split()
+
+
+def test_unpunctuated_wall_of_text_is_still_bounded():
+ """A single sentence longer than the budget has no natural boundary; it must still be cut."""
+ segments = segment_text(" ".join(f"w{i}" for i in range(900)), max_words=300)
+ assert all(len(s.split()) <= 300 for s in segments)
+
+
+def test_sentences_are_packed_not_slivered():
+ """Short sentences should be merged up to the budget, not emitted one per segment."""
+ text = " ".join(f"Sentence number {i} here." for i in range(60))
+ segments = segment_text(text, max_words=100)
+ assert len(segments) < 60
+ assert all(len(s.split()) <= 100 for s in segments)
+
+
+# --- rerank_long ---
+
+
+def test_answer_late_in_a_long_document_still_ranks_first():
+ """The regression this exists to prevent.
+
+ The answer-bearing document hides its match past the model's window; the decoy repeats an unrelated
+ filler term early. Scoring whole documents reads only the opening of each, so the decoy wins.
+ """
+ query = "lisbon"
+ answer_doc = _doc(900, needle="lisbon lisbon lisbon", needle_at="end")
+ decoy_doc = _doc(900)
+
+ reranker = WindowedReranker(window=400)
+ whole = reranker.rerank(query, [("decoy", decoy_doc), ("answer", answer_doc)], 2)
+ assert whole[0][0] == "decoy", "precondition: whole-document scoring must miss the late answer"
+
+ segmented = rerank_long(reranker, query, [("decoy", decoy_doc), ("answer", answer_doc)], 2)
+ assert segmented[0][0] == "answer"
+
+
+def test_every_segment_fits_the_reading_window():
+ reranker = WindowedReranker(window=400)
+ rerank_long(reranker, "lisbon", [("a", _doc(2000))], 1, max_words=300)
+ assert reranker.seen, "the reranker should have been called"
+ assert all(len(text.split()) <= 300 for text in reranker.seen)
+
+
+def test_document_scores_as_its_best_segment_not_its_average():
+ """One strong passage in a long document must outrank a uniformly mediocre one."""
+ query = "lisbon"
+ spike = _doc(600, needle="lisbon lisbon lisbon lisbon", needle_at="end")
+ diffuse = " ".join(["lisbon"] + [f"w{i}" for i in range(600)])
+
+ ranked = rerank_long(WindowedReranker(2000), query, [("diffuse", diffuse), ("spike", spike)], 2)
+ assert ranked[0][0] == "spike"
+
+
+def test_empty_and_blank_candidates_are_handled():
+ assert rerank_long(WindowedReranker(), "q", [], 5) == []
+ assert rerank_long(WindowedReranker(), "q", [("a", " ")], 5) == []
+
+
+def test_top_k_is_respected_and_ties_keep_incoming_order():
+ """With nothing to separate them, the upstream ranking decides -- reranking must not shuffle."""
+ docs = [(f"d{i}", "neutral text with no query terms") for i in range(5)]
+ ranked = rerank_long(WindowedReranker(), "lisbon", docs, 3)
+ assert [cid for cid, _ in ranked] == ["d0", "d1", "d2"]
+
+
+def test_ids_survive_round_trip():
+ """Segment keys are internal; callers must get their own ids back (memory.py passes list indices)."""
+ ranked = rerank_long(WindowedReranker(), "lisbon", [(7, _doc(800, needle="lisbon"))], 1)
+ assert ranked[0][0] == 7
+
+
+# --- wiring ---
+
+
+def test_memory_reranks_sessions_by_segment():
+ """The integration point where the defect actually lived.
+
+ Memory.retrieve_episodes passed whole `ep.content` to the reranker, and nothing covered that path.
+ A session whose answer sits past the reading window must still be retrieved.
+ """
+ from engram.memory import Memory
+
+ mem = Memory(reranker=WindowedReranker(window=400))
+ answer = _doc(900, needle="the conference was in lisbon", needle_at="end")
+ for i in range(4):
+ mem.add(_doc(900) if i else answer, user_id="u1", session_id=f"s{i}")
+
+ eps = mem.retrieve_episodes("lisbon", "u1", k=1)
+ assert eps, "reranked retrieval must return something"
+ assert "lisbon" in eps[0].content, "the session answering the query must survive reranking"
diff --git a/tests/test_retrieval_check.py b/tests/test_retrieval_check.py
new file mode 100644
index 0000000..0069a50
--- /dev/null
+++ b/tests/test_retrieval_check.py
@@ -0,0 +1,101 @@
+"""Was the answer's session retrieved, and how highly?
+
+The finding this guards is that 73% of failures had the answer session in the full-detail window and
+still failed — which retires every mechanism aimed at retrieving more. That conclusion rests on the rank
+being right, so `test_rank_is_the_position_of_the_first_answer_session` is the load-bearing test: a
+membership-only check would have said "retrieved" for a session shown as a one-line summary, and pointed
+the next mechanism at the wrong layer.
+"""
+from __future__ import annotations
+
+from eval.retrieval_check import failure_modes
+
+
+class _Embedder:
+ """Deterministic stand-in: no model download, no API."""
+
+ def embed(self, text: str) -> list[float]:
+ return [float(len(text) % 7), float(text.count("a")), 1.0]
+
+
+def test_failure_modes_skips_correct_and_errored_answers():
+ """Only wrong answers are diagnosed; an errored item is missing data, not a failure to explain."""
+ import json
+ import tempfile
+
+ rows = [
+ {"qid": "q1", "cat": "c", "sys": {"s": {"ok": True, "pred": "4", "gold": "4"}}},
+ {"qid": "q2", "cat": "c", "sys": {"s": {"ok": False, "pred": "I don't know", "gold": "4"}}},
+ {"qid": "q3", "cat": "c", "sys": {"s": {"err": "timeout"}}},
+ {"qid": "q4", "cat": "c", "sys": {"s": {"ok": False, "pred": "3", "gold": "4"}}},
+ ]
+ with tempfile.NamedTemporaryFile("w", suffix=".jsonl", delete=False) as fh:
+ for row in rows:
+ fh.write(json.dumps(row) + "\n")
+ path = fh.name
+
+ modes = failure_modes(path, "s")
+ assert modes == {"q2": "abstained", "q4": "numeric"}
+
+
+def _item(answer_index: int, sessions: int = 5) -> dict:
+ """A haystack where one session obviously answers the question."""
+ return {
+ "question_id": "q1",
+ "question_type": "temporal-reasoning",
+ "question": "aaaaaaaa", # the embedder above scores on 'a' count, so this ranks the marked one
+ "haystack_session_ids": [f"s{i}" for i in range(sessions)],
+ "haystack_sessions": [
+ [{"role": "user", "content": "aaaaaaaa" if i == answer_index else f"filler {i}"}]
+ for i in range(sessions)
+ ],
+ "answer_session_ids": [f"s{answer_index}"],
+ }
+
+
+def test_rank_is_the_position_of_the_first_answer_session():
+ """Membership alone cannot distinguish 'shown in full' from 'compressed to a summary line'."""
+ from eval.retrieval_check import check_item
+
+ row = check_item(_item(answer_index=0), _Embedder(), k_sessions=5)
+ assert row["hit"] is True
+ assert row["rank"] == 1
+
+
+def test_a_missing_answer_session_reports_no_rank():
+ from eval.retrieval_check import check_item
+
+ item = _item(answer_index=0)
+ item["answer_session_ids"] = ["nowhere"]
+ row = check_item(item, _Embedder(), k_sessions=5)
+ assert row["hit"] is False
+ assert row["rank"] is None
+
+
+def test_a_narrow_slice_can_miss_a_session_a_wide_one_finds():
+ """k is the width of the slice being tested, so it must actually bound what comes back."""
+ from eval.retrieval_check import check_item
+
+ row = check_item(_item(answer_index=0, sessions=8), _Embedder(), k_sessions=1)
+ assert row["retrieved"] <= 1
+
+
+def test_coverage_counts_all_answer_sessions_not_just_the_first():
+ """The correction this file exists to hold.
+
+ A counting question whose answer spans four sessions cannot be answered from the one that ranked
+ highest. Reporting only the first hit said "the evidence was retrieved" for exactly the questions
+ that could not possibly be counted correctly.
+ """
+ from eval.retrieval_check import check_item
+
+ item = _item(answer_index=0, sessions=5)
+ item["answer_session_ids"] = ["s0", "s3"] # answer spans two sessions, only one ranks first
+ row = check_item(item, _Embedder(), k_sessions=5)
+
+ assert row["answer_sessions"] == 2
+ assert row["rank"] == 1, "the top-ranked answer session is still found"
+ assert row["covered_top2"] < row["answer_sessions"], (
+ "and coverage must show that the full-detail window did not hold all of them"
+ )
+ assert row["covered_all"] == 2, "both were retrieved somewhere in the slice"
diff --git a/tests/test_server_import_export.py b/tests/test_server_import_export.py
new file mode 100644
index 0000000..3c9745a
--- /dev/null
+++ b/tests/test_server_import_export.py
@@ -0,0 +1,60 @@
+"""HTTP surface of the cross-instance migration path: POST an /v1/export payload straight back into
+/v1/import on another namespace/instance and get memory, not a 500."""
+from __future__ import annotations
+
+import os
+import shutil
+import tempfile
+
+import pytest
+
+pytest.importorskip("fastapi")
+from fastapi.testclient import TestClient # noqa: E402
+
+
+@pytest.fixture(scope="module")
+def client():
+ d = tempfile.mkdtemp(prefix="engram_port_")
+ os.environ.update(ENGRAM_DATA_DIR=d, ENGRAM_EMBEDDER="hashing", ENGRAM_OPEN="1")
+ for var in ("ENGRAM_LLM", "ENGRAM_API_KEYS", "ENGRAM_ALLOW_ANONYMOUS", "ENGRAM_STORAGE"):
+ os.environ.pop(var, None)
+ from engram.server import app as appmod
+ appmod._svc = None # fresh singleton bound to the test env
+ with TestClient(appmod.app) as c:
+ yield c
+ shutil.rmtree(d, ignore_errors=True)
+
+
+def hdr(ns: str) -> dict:
+ return {"Authorization": f"Bearer {ns}"}
+
+
+def test_rest_export_import_roundtrip(client):
+ src, dst = hdr("mover-src"), hdr("mover-dst")
+ r = client.post("/v1/remember", json={"content": "I live in Lisbon and prefer tea."}, headers=src)
+ assert r.status_code == 200
+
+ exported = client.get("/v1/export", params={"include_sensitive": "true"}, headers=src).json()
+ assert exported["engram_export_version"] == 1
+
+ r = client.post("/v1/import", json={"data": exported, "format": "auto"}, headers=dst)
+ assert r.status_code == 200, r.text
+ body = r.json()
+ assert body["ok"] is True and body["format"] == "engram"
+
+ out = client.get("/v1/export", params={"include_sensitive": "true"}, headers=dst).json()
+ assert {f["id"] for f in out["facts"]} == {f["id"] for f in exported["facts"]}
+
+
+def test_rest_import_bad_payload_is_400_not_500(client):
+ r = client.post("/v1/import", json={"data": {"engram_export_version": 99}, "format": "engram"},
+ headers=hdr("mover-bad"))
+ assert r.status_code == 400
+ assert "version" in r.json()["detail"]
+
+
+def test_rest_import_malformed_records_is_400_not_500(client):
+ # a dict that is neither a known chat shape nor a valid export used to escape as a raw 500
+ r = client.post("/v1/import", json={"data": {"whatever": 1}, "format": "records"},
+ headers=hdr("mover-bad"))
+ assert r.status_code == 400
diff --git a/tests/test_significance.py b/tests/test_significance.py
new file mode 100644
index 0000000..026c2aa
--- /dev/null
+++ b/tests/test_significance.py
@@ -0,0 +1,181 @@
+"""Telling a real benchmark gain from the answerer's noise.
+
+An identical configuration re-run on LongMemEval_S moves 6-10 of 500 answers, so a bare accuracy
+difference smaller than that is not evidence of anything. These tests pin the instrument that decides:
+the paired test must ignore the questions both systems answered the same way, must not call a coin-flip
+split significant, and must report how small a gain the run could have resolved at all.
+"""
+from __future__ import annotations
+
+import pytest
+
+from eval.significance import (
+ bootstrap_difference,
+ mcnemar_exact,
+ minimum_detectable_effect,
+ paired_outcomes,
+ verdict,
+)
+
+
+def _pairs(both: int, only_a: int, only_b: int, neither: int):
+ out = []
+ for i in range(both):
+ out.append((f"both{i}", True, True))
+ for i in range(only_a):
+ out.append((f"a{i}", True, False))
+ for i in range(only_b):
+ out.append((f"b{i}", False, True))
+ for i in range(neither):
+ out.append((f"n{i}", False, False))
+ return out
+
+
+# --- McNemar ---
+
+
+def test_agreements_carry_no_weight():
+ """The heart of the paired test: questions both systems get right or wrong say nothing about which
+ is better, so adding a thousand of them must not change the verdict."""
+ small = mcnemar_exact(_pairs(both=10, only_a=2, only_b=8, neither=10))
+ padded = mcnemar_exact(_pairs(both=1000, only_a=2, only_b=8, neither=1000))
+ assert small["p_value"] == padded["p_value"]
+
+
+def test_a_coin_flip_split_is_not_significant():
+ """Equal disagreements in both directions is exactly what two equivalent systems produce."""
+ result = mcnemar_exact(_pairs(both=400, only_a=25, only_b=25, neither=50))
+ assert result["p_value"] == 1.0
+ assert result["difference"] == 0.0
+
+
+def test_a_lopsided_split_is_significant():
+ result = mcnemar_exact(_pairs(both=337, only_a=29, only_b=81, neither=53))
+ assert result["p_value"] < 0.001
+ assert result["difference"] > 0, "positive means B is ahead"
+
+
+def test_a_small_lead_over_few_disagreements_is_not_significant():
+ """The regression this whole module exists to prevent: +3 points that chance explains."""
+ result = mcnemar_exact(_pairs(both=329, only_a=51, only_b=66, neither=54))
+ assert result["difference"] == pytest.approx(0.03, abs=0.005)
+ assert result["p_value"] > 0.05, "a 15-question edge over 117 disagreements is noise"
+
+
+def test_identical_runs_report_no_evidence():
+ result = mcnemar_exact(_pairs(both=450, only_a=0, only_b=0, neither=50))
+ assert result["discordant"] == 0
+ assert result["p_value"] == 1.0
+ assert "IDENTICAL" in verdict(result, {"low": 0.0, "high": 0.0})
+
+
+def test_accuracy_and_difference_match_the_counts():
+ result = mcnemar_exact(_pairs(both=337, only_a=29, only_b=81, neither=53))
+ assert result["n"] == 500
+ assert result["acc_a"] == pytest.approx((337 + 29) / 500)
+ assert result["acc_b"] == pytest.approx((337 + 81) / 500)
+ assert result["difference"] == pytest.approx((81 - 29) / 500)
+
+
+def test_empty_input_does_not_explode():
+ result = mcnemar_exact([])
+ assert result["n"] == 0
+ assert result["p_value"] == 1.0
+
+
+# --- bootstrap ---
+
+
+def test_interval_brackets_the_observed_difference():
+ pairs = _pairs(both=337, only_a=29, only_b=81, neither=53)
+ result = mcnemar_exact(pairs)
+ interval = bootstrap_difference(pairs, iterations=2000, seed=1)
+ assert interval["low"] < result["difference"] < interval["high"]
+
+
+def test_interval_is_reproducible_from_its_seed():
+ """A reported interval that cannot be recomputed is not evidence."""
+ pairs = _pairs(both=300, only_a=40, only_b=60, neither=100)
+ first = bootstrap_difference(pairs, iterations=1000, seed=7)
+ second = bootstrap_difference(pairs, iterations=1000, seed=7)
+ assert first == second
+ assert bootstrap_difference(pairs, iterations=1000, seed=8) != first
+
+
+def test_a_non_significant_difference_has_an_interval_spanning_zero():
+ pairs = _pairs(both=329, only_a=51, only_b=66, neither=54)
+ interval = bootstrap_difference(pairs, iterations=3000, seed=0)
+ assert interval["low"] < 0 < interval["high"], "if zero is plausible, the claim is not established"
+
+
+# --- planning ---
+
+
+def test_more_items_resolve_smaller_gains():
+ small = minimum_detectable_effect(500, 0.08)["mde_points"]
+ large = minimum_detectable_effect(5000, 0.08)["mde_points"]
+ assert large < small
+
+
+def test_only_disagreements_count_toward_resolution():
+ """Two systems that mostly agree have little to learn from, however many items were run."""
+ assert minimum_detectable_effect(500, 0.02)["discordant_items"] == pytest.approx(10)
+ assert minimum_detectable_effect(500, 0.20)["discordant_items"] == pytest.approx(100)
+
+
+def test_too_few_disagreements_resolves_nothing():
+ assert minimum_detectable_effect(100, 0.001)["mde_points"] == float("inf")
+
+
+def test_a_500_item_run_cannot_resolve_a_one_point_gain():
+ """The number that matters for this project: the contested gap to the top of the leaderboard is
+ smaller than what a run of this size can distinguish from chance."""
+ mde = minimum_detectable_effect(500, 0.08)["mde_points"]
+ assert mde > 1.6, "a 1.6-point gap is below this benchmark's resolution at n=500"
+
+
+# --- pairing ---
+
+
+def test_only_questions_both_runs_scored_are_compared():
+ """Comparing over different question sets compares two different exams."""
+ log_a = {
+ "q1": {"engram_lean": {"ok": True}},
+ "q2": {"engram_lean": {"ok": True}},
+ "q3": {"engram_lean": {"ok": False}},
+ }
+ log_b = {
+ "q1": {"engram_lean": {"ok": False}},
+ "q2": {"engram_lean": {"ok": True}},
+ }
+ pairs = paired_outcomes(log_a, log_b, "engram_lean", "engram_lean")
+ assert {qid for qid, _a, _b in pairs} == {"q1", "q2"}
+
+
+def test_errored_items_are_excluded_not_counted_wrong():
+ """An errored item is missing data. Scoring it as wrong would penalise whichever system crashed on
+ the hardest questions, which is backwards."""
+ log_a = {"q1": {"s": {"ok": True}}, "q2": {"s": {"err": "timeout"}}}
+ log_b = {"q1": {"s": {"ok": False}}, "q2": {"s": {"ok": True}}}
+ pairs = paired_outcomes(log_a, log_b, "s", "s")
+ assert [qid for qid, _a, _b in pairs] == ["q1"]
+
+
+def test_abstentions_are_scored_as_wrong_not_dropped():
+ """An abstention is an answer the system gave and the judge rejected — real data, unlike an error."""
+ log_a = {"q1": {"s": {"ok": False}}}
+ log_b = {"q1": {"s": {"ok": True}}}
+ pairs = paired_outcomes(log_a, log_b, "s", "s")
+ assert pairs == [("q1", False, True)]
+
+
+def test_the_interval_is_oriented_to_match_the_stated_winner():
+ """"A beats B by 10 points, plausibly [-13, -7]" tells a reader nothing about the sign of the
+ effect. When the sentence names A as the winner, the interval must be stated A-minus-B too."""
+ a_ahead = mcnemar_exact(_pairs(both=337, only_a=81, only_b=29, neither=53))
+ text = verdict(a_ahead, {"low": -0.144, "high": -0.064})
+ assert "A beats B" in text
+ assert "[+6.4, +14.4]" in text, text
+
+ b_ahead = mcnemar_exact(_pairs(both=337, only_a=29, only_b=81, neither=53))
+ assert "[+6.4, +14.4]" in verdict(b_ahead, {"low": 0.064, "high": 0.144})