lorekeep 0.7.0__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lorekeep-0.8.0/.release-please-manifest.json +3 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/AGENTS.md +1 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/CHANGELOG.md +14 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/PKG-INFO +26 -7
- {lorekeep-0.7.0 → lorekeep-0.8.0}/README.md +25 -6
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/evaluation.md +12 -3
- {lorekeep-0.7.0 → lorekeep-0.8.0}/pyproject.toml +1 -1
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/__init__.py +1 -1
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/cli.py +55 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/compile/extract.py +5 -2
- lorekeep-0.8.0/src/lorekeep/eval/locomo.py +360 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/mcp_server.py +19 -4
- lorekeep-0.8.0/tests/test_fts.py +109 -0
- lorekeep-0.8.0/tests/test_locomo_eval.py +314 -0
- lorekeep-0.7.0/.release-please-manifest.json +0 -3
- lorekeep-0.7.0/tests/test_fts.py +0 -31
- {lorekeep-0.7.0 → lorekeep-0.8.0}/.commitlintrc.json +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/.github/workflows/auto-merge-release-please.yml +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/.github/workflows/ci.yml +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/.github/workflows/lint-commits.yml +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/.github/workflows/release-please.yml +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/.gitignore +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/.lorekeep/config.yaml.example +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/.lorekeep/schema.json +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/.pre-commit-config.yaml +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/CLAUDE.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/LICENSE +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/SECURITY.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/cover.jpeg +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/README.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/agent.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/data-model.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/journal.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/overview.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/permission.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/pipeline.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/serve-mcp.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/architecture/temporal.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/guides/backup.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/guides/compile.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/guides/data-home.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/guides/getting-started.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/guides/import.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/guides/serve.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/guides/wiki.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/superpowers/plans/2026-06-26-lorekeep-dotdir-backup.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/docs/superpowers/specs/2026-06-26-lorekeep-dotdir-layout-design.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/release-please-config.json +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/scripts/check-conventional-commit.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/agent.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/backup.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/compile/__init__.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/compile/ingest.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/compile/providers.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/compile/resolve.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/compile/writer.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/config.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/defaults.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/eval/__init__.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/eval/construction.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/eval/gold.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/eval/retrieval.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/facts_io.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/importer/__init__.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/importer/claude.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/importer/codex.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/importer/cursor.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/importer/opencode.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/integrations/__init__.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/integrations/claude_code.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/integrations/codex.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/integrations/common.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/integrations/cursor.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/integrations/detect.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/integrations/opencode.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/journal.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/models.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/paths.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/perm/__init__.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/perm/ns.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/pipeline.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/schema_io.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/store/__init__.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/store/fts.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/store/graph.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/src/lorekeep/wiki.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/conftest.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/claude-session/memory/MEMORY.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/claude-session/memory/lorekeep-design.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/claude-session/memory/lorekeep-project-state.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/claude-session/test.jsonl +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/gold/payments.facts.jsonl +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/raw/backend/payments.md +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/retrieval/questions.json +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/fixtures/schema.json +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_agent_cli.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_agent_detect.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_backup.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_backup_cli.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_compile_cli.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_config.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_construction_eval.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_defaults.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_determinism.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_doctor_cli.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_eval_cli.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_extract.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_graph_store.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_import_claude.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_import_codex.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_import_cursor.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_import_opencode.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_ingest.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_init_chain.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_init_cli.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_integrations.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_mcp_add_cli.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_mcp_reload.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_mcp_server.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_mcp_write_tools.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_merge_journals.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_models.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_paths.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_perm.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_pipeline.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_providers.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_resolve.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_retrieval_eval.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_schema_io.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_scope_awareness.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_serve_cli.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_session_hook.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_smoke.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_watch_e2e.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_watch_sessions.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_wiki.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/tests/test_writer.py +0 -0
- {lorekeep-0.7.0 → lorekeep-0.8.0}/uv.lock +0 -0
|
@@ -28,6 +28,7 @@ uv run lorekeep <command> # run the CLI in dev mode
|
|
|
28
28
|
| `compile` | `raw/*.md` → `graph/facts.jsonl` + `manifest.json` + `wiki/` (runs the LLM pipeline, auto-generates wiki) |
|
|
29
29
|
| `check` | Validate compiled graph loads, no dangling edges (exit 1 on failure) |
|
|
30
30
|
| `eval` | Tier-1 construction P/R/F1 vs gold corpus + structure metrics |
|
|
31
|
+
| `eval-locomo` | Tier-2 LoCoMo retrieval/temporal/abstention eval |
|
|
31
32
|
| `wiki` | Regenerate `wiki/` from `facts.jsonl` (Obsidian-compatible markdown) |
|
|
32
33
|
| `serve [--transport stdio\|http]` | Run the MCP server (9 read + 5 write tools) |
|
|
33
34
|
| `mcp add --agent claude\|cursor\|codex\|opencode --ns NS` | Write agent MCP config |
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.0](https://github.com/manhhailua/lorekeep/compare/v0.7.1...v0.8.0) (2026-06-30)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add Tier-2 LoCoMo retrieval eval ([#76](https://github.com/manhhailua/lorekeep/issues/76)) ([a285409](https://github.com/manhhailua/lorekeep/commit/a285409f62584443907491c4cdb517e9dd4b9eea))
|
|
9
|
+
|
|
10
|
+
## [0.7.1](https://github.com/manhhailua/lorekeep/compare/v0.7.0...v0.7.1) (2026-06-30)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* wire FTS5 search index + include model in extraction cache key ([#74](https://github.com/manhhailua/lorekeep/issues/74)) ([e9cd60a](https://github.com/manhhailua/lorekeep/commit/e9cd60ab573ae42b172f888cf3af5647e747c3f7))
|
|
16
|
+
|
|
3
17
|
## [0.7.0](https://github.com/manhhailua/lorekeep/compare/v0.6.1...v0.7.0) (2026-06-30)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lorekeep
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Temporal knowledge graph for AI agents via MCP
|
|
5
5
|
Project-URL: Homepage, https://github.com/manhhailua/lorekeep
|
|
6
6
|
Project-URL: Repository, https://github.com/manhhailua/lorekeep
|
|
@@ -83,6 +83,8 @@ knowledge.
|
|
|
83
83
|
DashScope/Qwen / Ollama). Strict-privacy → Ollama, fully local.
|
|
84
84
|
- **Tier-1 eval** — extraction P/R/F1 vs a gold corpus, entity-resolution F1,
|
|
85
85
|
graph-structure metrics, determinism property tests.
|
|
86
|
+
- **Tier-2 LoCoMo eval** — long-term conversations → graph → retrieval QA.
|
|
87
|
+
F1 per category (single-hop, temporal, multi-hop, adversarial abstention).
|
|
86
88
|
|
|
87
89
|
## Install
|
|
88
90
|
|
|
@@ -250,11 +252,27 @@ For usage, see the [`docs/`](docs/README.md) index.
|
|
|
250
252
|
|
|
251
253
|
## Evaluation
|
|
252
254
|
|
|
253
|
-
Tier-1 (CI): extraction P/R/F1 vs a gold corpus, entity-resolution pairwise F1,
|
|
254
|
-
graph-structure metrics, determinism. Run: `uvx lorekeep eval`.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
255
|
+
**Tier-1** (CI): extraction P/R/F1 vs a gold corpus, entity-resolution pairwise F1,
|
|
256
|
+
graph-structure metrics, determinism. Run: `uvx lorekeep eval`.
|
|
257
|
+
|
|
258
|
+
**Tier-2 LoCoMo** (per-release): long-term conversation → graph → retrieval QA.
|
|
259
|
+
Run: `uvx lorekeep eval-locomo --data locomo10.json --compile`.
|
|
260
|
+
|
|
261
|
+
| Category | Recall | Description |
|
|
262
|
+
|---|---|---|
|
|
263
|
+
| Temporal | 0.71 | "When did X happen?" |
|
|
264
|
+
| Single-hop | 0.58 | "What is X's Y?" |
|
|
265
|
+
| Multi-hop | 0.49 | "Who did X's sister work with?" |
|
|
266
|
+
| Descriptive | 0.73 | "What did X do?" |
|
|
267
|
+
| Adversarial | 0.85 | Abstention — plausible-but-wrong answer not found |
|
|
268
|
+
| **Overall** | **0.71** | 199 QA, 1 conversation, DeepSeek extraction |
|
|
269
|
+
|
|
270
|
+
*Graph-guided retrieval: keyword search → get_node → neighbors(depth=1-2) →
|
|
271
|
+
source markdown enrichment. No agent-LLM synthesis (programmatic baseline).*
|
|
272
|
+
|
|
273
|
+
The north star is *systematic thinking with complete information* — memory-recall
|
|
274
|
+
benchmarks (LoCoMo, LongMemEval) are parity checks, not the optimization target.
|
|
275
|
+
See [`docs/architecture/evaluation.md`](docs/architecture/evaluation.md).
|
|
258
276
|
|
|
259
277
|
## Project layout
|
|
260
278
|
|
|
@@ -277,6 +295,7 @@ src/lorekeep/
|
|
|
277
295
|
integrations/{claude_code,cursor,codex,opencode,common}.py
|
|
278
296
|
pipeline.py, cli.py
|
|
279
297
|
eval/{gold,construction,retrieval}.py
|
|
298
|
+
eval/locomo.py Tier-2 LoCoMo eval
|
|
280
299
|
tests/ ~310 tests
|
|
281
300
|
docs/ README.md index, architecture/, guides/
|
|
282
301
|
```
|
|
@@ -285,7 +304,7 @@ docs/ README.md index, architecture/, guides/
|
|
|
285
304
|
|
|
286
305
|
**v1 (implemented)** — compile pipeline + serve (store/permission/MCP 9 read+5 write/4-agent integrations) + import (Claude/Cursor/Codex/opencode) + session-end hooks + agent daemon (watch/ingest/lint/suggest/status) + journal + resolve + data-home + dev mode + lazy-reload + backup + eval + scope awareness (`meta` tool) + **wiki** (Obsidian-compatible markdown output). Published to PyPI as `lorekeep`.
|
|
287
306
|
|
|
288
|
-
**Phase 2 (planned)** — streamable-HTTP team server, OIDC/SSO, embeddings/hybrid search, scheduled nightly lint/suggest in daemon, schema evolve,
|
|
307
|
+
**Phase 2 (planned)** — streamable-HTTP team server, OIDC/SSO, embeddings/hybrid search, scheduled nightly lint/suggest in daemon, schema evolve, HotpotQA/CronQuestions/LongMemEval benchmark datasets and the bespoke Tier-3 Lorekeep-Reason eval.
|
|
289
308
|
|
|
290
309
|
## Documentation
|
|
291
310
|
|
|
@@ -61,6 +61,8 @@ knowledge.
|
|
|
61
61
|
DashScope/Qwen / Ollama). Strict-privacy → Ollama, fully local.
|
|
62
62
|
- **Tier-1 eval** — extraction P/R/F1 vs a gold corpus, entity-resolution F1,
|
|
63
63
|
graph-structure metrics, determinism property tests.
|
|
64
|
+
- **Tier-2 LoCoMo eval** — long-term conversations → graph → retrieval QA.
|
|
65
|
+
F1 per category (single-hop, temporal, multi-hop, adversarial abstention).
|
|
64
66
|
|
|
65
67
|
## Install
|
|
66
68
|
|
|
@@ -228,11 +230,27 @@ For usage, see the [`docs/`](docs/README.md) index.
|
|
|
228
230
|
|
|
229
231
|
## Evaluation
|
|
230
232
|
|
|
231
|
-
Tier-1 (CI): extraction P/R/F1 vs a gold corpus, entity-resolution pairwise F1,
|
|
232
|
-
graph-structure metrics, determinism. Run: `uvx lorekeep eval`.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
233
|
+
**Tier-1** (CI): extraction P/R/F1 vs a gold corpus, entity-resolution pairwise F1,
|
|
234
|
+
graph-structure metrics, determinism. Run: `uvx lorekeep eval`.
|
|
235
|
+
|
|
236
|
+
**Tier-2 LoCoMo** (per-release): long-term conversation → graph → retrieval QA.
|
|
237
|
+
Run: `uvx lorekeep eval-locomo --data locomo10.json --compile`.
|
|
238
|
+
|
|
239
|
+
| Category | Recall | Description |
|
|
240
|
+
|---|---|---|
|
|
241
|
+
| Temporal | 0.71 | "When did X happen?" |
|
|
242
|
+
| Single-hop | 0.58 | "What is X's Y?" |
|
|
243
|
+
| Multi-hop | 0.49 | "Who did X's sister work with?" |
|
|
244
|
+
| Descriptive | 0.73 | "What did X do?" |
|
|
245
|
+
| Adversarial | 0.85 | Abstention — plausible-but-wrong answer not found |
|
|
246
|
+
| **Overall** | **0.71** | 199 QA, 1 conversation, DeepSeek extraction |
|
|
247
|
+
|
|
248
|
+
*Graph-guided retrieval: keyword search → get_node → neighbors(depth=1-2) →
|
|
249
|
+
source markdown enrichment. No agent-LLM synthesis (programmatic baseline).*
|
|
250
|
+
|
|
251
|
+
The north star is *systematic thinking with complete information* — memory-recall
|
|
252
|
+
benchmarks (LoCoMo, LongMemEval) are parity checks, not the optimization target.
|
|
253
|
+
See [`docs/architecture/evaluation.md`](docs/architecture/evaluation.md).
|
|
236
254
|
|
|
237
255
|
## Project layout
|
|
238
256
|
|
|
@@ -255,6 +273,7 @@ src/lorekeep/
|
|
|
255
273
|
integrations/{claude_code,cursor,codex,opencode,common}.py
|
|
256
274
|
pipeline.py, cli.py
|
|
257
275
|
eval/{gold,construction,retrieval}.py
|
|
276
|
+
eval/locomo.py Tier-2 LoCoMo eval
|
|
258
277
|
tests/ ~310 tests
|
|
259
278
|
docs/ README.md index, architecture/, guides/
|
|
260
279
|
```
|
|
@@ -263,7 +282,7 @@ docs/ README.md index, architecture/, guides/
|
|
|
263
282
|
|
|
264
283
|
**v1 (implemented)** — compile pipeline + serve (store/permission/MCP 9 read+5 write/4-agent integrations) + import (Claude/Cursor/Codex/opencode) + session-end hooks + agent daemon (watch/ingest/lint/suggest/status) + journal + resolve + data-home + dev mode + lazy-reload + backup + eval + scope awareness (`meta` tool) + **wiki** (Obsidian-compatible markdown output). Published to PyPI as `lorekeep`.
|
|
265
284
|
|
|
266
|
-
**Phase 2 (planned)** — streamable-HTTP team server, OIDC/SSO, embeddings/hybrid search, scheduled nightly lint/suggest in daemon, schema evolve,
|
|
285
|
+
**Phase 2 (planned)** — streamable-HTTP team server, OIDC/SSO, embeddings/hybrid search, scheduled nightly lint/suggest in daemon, schema evolve, HotpotQA/CronQuestions/LongMemEval benchmark datasets and the bespoke Tier-3 Lorekeep-Reason eval.
|
|
267
286
|
|
|
268
287
|
## Documentation
|
|
269
288
|
|
|
@@ -41,9 +41,18 @@ Evaluates the **compiler**, not the agent. `lorekeep eval` / `lorekeep check`.
|
|
|
41
41
|
|
|
42
42
|
Evaluates whether the **query path** returns correct facts.
|
|
43
43
|
|
|
44
|
-
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
- **LoCoMo** (shipped): 10 very long-term conversations (300 turns, 35 sessions).
|
|
45
|
+
Converts conversations → `raw/*.md` → compile → graph → programmatic retrieval QA.
|
|
46
|
+
5 categories: single-hop, temporal, multi-hop, descriptive, adversarial.
|
|
47
|
+
Token-level F1 scoring (HotpotQA-style). Adversarial scored on abstention
|
|
48
|
+
(system should NOT find supporting evidence for plausible-but-wrong answer).
|
|
49
|
+
Run: `lorekeep eval-locomo --data locomo10.json --compile`.
|
|
50
|
+
- HotpotQA / 2WikiMultihopQA / MuSiQue (planned): multi-hop QA with agent-LLM loop.
|
|
51
|
+
- CronQuestions / TimeQuestions (planned): temporal KG QA — measures `at_time` /
|
|
52
|
+
`history` / `changes`. Industry weak spot (specialized memory systems drop to
|
|
53
|
+
~20% on temporal reasoning per Atlas). Lorekeep's core bet.
|
|
54
|
+
- LongMemEval (planned): 5 abilities — extraction, multi-session reasoning,
|
|
55
|
+
temporal reasoning, knowledge updates, abstention.
|
|
47
56
|
|
|
48
57
|
### Tier 3 — Systematic-thinking reasoning eval (north star)
|
|
49
58
|
|
|
@@ -149,6 +149,61 @@ def eval_cmd() -> None:
|
|
|
149
149
|
typer.echo(json.dumps(report, indent=2, sort_keys=True))
|
|
150
150
|
|
|
151
151
|
|
|
152
|
+
@app.command(name="eval-locomo")
|
|
153
|
+
def eval_locomo_cmd(
|
|
154
|
+
data: str = typer.Option("", "--data", help="Path to locomo10.json"),
|
|
155
|
+
compile_first: bool = typer.Option(
|
|
156
|
+
False, "--compile",
|
|
157
|
+
help="Convert JSON to raw/ + compile before running eval",
|
|
158
|
+
),
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Run Tier-2 LoCoMo retrieval eval."""
|
|
161
|
+
from lorekeep.eval.locomo import convert_locomo, locomo_report
|
|
162
|
+
|
|
163
|
+
p = resolve_paths()
|
|
164
|
+
data_path = Path(data) if data else Path(
|
|
165
|
+
os.environ.get("LOREKEEP_LOCOMO", "locomo10.json")
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if compile_first:
|
|
169
|
+
if not data_path.exists():
|
|
170
|
+
typer.echo(f"eval-locomo: data file not found: {data_path}")
|
|
171
|
+
raise typer.Exit(code=1)
|
|
172
|
+
count = convert_locomo(data_path, p["raw"] / "locomo")
|
|
173
|
+
typer.echo(f"eval-locomo: converted {count} session files to {p['raw'] / 'locomo'}")
|
|
174
|
+
schema = load_schema(p["schema"])
|
|
175
|
+
config = load_config(p["config"])
|
|
176
|
+
provider = _make_provider(config)
|
|
177
|
+
manifest = compile_graph(
|
|
178
|
+
raw_root=p["raw"], out_dir=p["out"], schema=schema,
|
|
179
|
+
provider=provider, cache_path=p["cache"],
|
|
180
|
+
chunk_lines=config.compile.chunk_lines,
|
|
181
|
+
)
|
|
182
|
+
typer.echo(f"eval-locomo: compiled {manifest.node_count} nodes, {manifest.edge_count} edges")
|
|
183
|
+
|
|
184
|
+
raw_ns = os.environ.get("LOREKEEP_NS")
|
|
185
|
+
allowed = [x.strip() for x in raw_ns.split(",")] if raw_ns else ["locomo"]
|
|
186
|
+
report = locomo_report(p["out"], data_path, allowed, raw_dir=p["raw"])
|
|
187
|
+
if "error" in report:
|
|
188
|
+
typer.echo(f"eval-locomo: {report['error']}")
|
|
189
|
+
raise typer.Exit(code=1)
|
|
190
|
+
|
|
191
|
+
results_path = Path(os.environ.get(
|
|
192
|
+
"LOREKEEP_EVAL_RESULTS", ".lorekeep/eval/locomo-results.json"
|
|
193
|
+
))
|
|
194
|
+
results_path.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
results_path.write_text(json.dumps(report, indent=2, sort_keys=True))
|
|
196
|
+
|
|
197
|
+
s = report["summary"]
|
|
198
|
+
typer.echo(f"\nLoCoMo Tier-2 Eval ({s['total_questions']} questions)")
|
|
199
|
+
typer.echo(f"Overall F1: {s['overall_f1']}")
|
|
200
|
+
typer.echo("")
|
|
201
|
+
typer.echo(f"{'Category':<20} {'Count':>6} {'F1':>8}")
|
|
202
|
+
typer.echo("-" * 36)
|
|
203
|
+
for cat, stats in s["per_category"].items():
|
|
204
|
+
typer.echo(f"{cat:<20} {stats['count']:>6} {stats['f1']:>8.4f}")
|
|
205
|
+
|
|
206
|
+
|
|
152
207
|
@app.command()
|
|
153
208
|
def check() -> None:
|
|
154
209
|
"""Validate the compiled graph: loads, no dangling edges."""
|
|
@@ -119,10 +119,12 @@ class ExtractionCache:
|
|
|
119
119
|
if self.path.exists():
|
|
120
120
|
self._data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
121
121
|
|
|
122
|
-
def key(self, chunk: DocChunk, schema_version: int) -> str:
|
|
122
|
+
def key(self, chunk: DocChunk, schema_version: int, model: str = "") -> str:
|
|
123
123
|
h = hashlib.sha256()
|
|
124
124
|
h.update(str(schema_version).encode("utf-8"))
|
|
125
125
|
h.update(b"\n")
|
|
126
|
+
h.update(model.encode("utf-8"))
|
|
127
|
+
h.update(b"\n")
|
|
126
128
|
h.update(chunk.hash.encode("utf-8"))
|
|
127
129
|
return h.hexdigest()
|
|
128
130
|
|
|
@@ -142,7 +144,8 @@ class ExtractionCache:
|
|
|
142
144
|
def extract_chunk(
|
|
143
145
|
chunk: DocChunk, schema: Schema, provider: LLMProvider, cache: ExtractionCache,
|
|
144
146
|
) -> tuple[list[Node], list[Edge], dict[str, list[str]]]:
|
|
145
|
-
|
|
147
|
+
model = getattr(provider, "model", "")
|
|
148
|
+
key = cache.key(chunk, schema.version, model)
|
|
146
149
|
raw = cache.get(key)
|
|
147
150
|
if raw is None:
|
|
148
151
|
raw = provider.extract_json(SYSTEM_PROMPT, build_prompt(chunk, schema))
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""LoCoMo Tier-2 evaluation: convert, compile, retrieve, score.
|
|
2
|
+
|
|
3
|
+
Pipeline:
|
|
4
|
+
1. Converter: LoCoMo JSON → raw/<conv_id>/*.md (one file per session)
|
|
5
|
+
2. Compile: markdown → facts.jsonl (standard lorekeep pipeline)
|
|
6
|
+
3. Runner: answer QA questions via graph retrieval (programmatic, no agent LLM)
|
|
7
|
+
4. Scorer: token-level F1 (HotpotQA-style) per category + overall
|
|
8
|
+
|
|
9
|
+
QA categories (LoCoMo):
|
|
10
|
+
1 = single-hop descriptive ("What is X?")
|
|
11
|
+
2 = temporal ("When did X happen?")
|
|
12
|
+
3 = multi-hop ("Who did X's sister work with?")
|
|
13
|
+
4 = single-hop ("What did X do?")
|
|
14
|
+
5 = adversarial (unanswerable / distractor)
|
|
15
|
+
|
|
16
|
+
Scoring:
|
|
17
|
+
cat 1-4: F1 = token overlap between gold answer and retrieved fact text
|
|
18
|
+
cat 5: score 1.0 if search returns 0 results (correct abstention), else 0.0
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
from collections import Counter
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from lorekeep.models import Edge, Node
|
|
28
|
+
from lorekeep.perm.ns import ScopedGraph
|
|
29
|
+
from lorekeep.store.graph import GraphStore
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Converter ──────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def convert_locomo(json_path: Path, raw_dir: Path) -> int:
|
|
36
|
+
"""Convert LoCoMo JSON to markdown files under raw_dir.
|
|
37
|
+
|
|
38
|
+
Each conversation session becomes one .md file:
|
|
39
|
+
raw_dir/<conv_id>/session-<N>.md
|
|
40
|
+
|
|
41
|
+
Returns number of files written.
|
|
42
|
+
"""
|
|
43
|
+
data = json.loads(Path(json_path).read_text(encoding="utf-8"))
|
|
44
|
+
count = 0
|
|
45
|
+
for conv in data:
|
|
46
|
+
conv_id = conv["sample_id"]
|
|
47
|
+
c = conv["conversation"]
|
|
48
|
+
for n in range(1, 36):
|
|
49
|
+
session_key = f"session_{n}"
|
|
50
|
+
date_key = f"session_{n}_date_time"
|
|
51
|
+
if session_key not in c:
|
|
52
|
+
continue
|
|
53
|
+
date = c.get(date_key, "")
|
|
54
|
+
utterances = c[session_key]
|
|
55
|
+
if not utterances:
|
|
56
|
+
continue
|
|
57
|
+
lines = [
|
|
58
|
+
f"# {conv_id} Session {n}",
|
|
59
|
+
f"Date: {date}",
|
|
60
|
+
f"Speakers: {c.get('speaker_a', 'A')}, {c.get('speaker_b', 'B')}",
|
|
61
|
+
"",
|
|
62
|
+
]
|
|
63
|
+
for utt in utterances:
|
|
64
|
+
lines.append(f"**{utt['speaker']}** ({utt['dia_id']}): {utt['text']}")
|
|
65
|
+
path = raw_dir / conv_id / f"session-{n}.md"
|
|
66
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
68
|
+
count += 1
|
|
69
|
+
return count
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def extract_questions(json_path: Path) -> list[dict]:
|
|
73
|
+
"""Extract all QA items from LoCoMo JSON, flattened with conv_id."""
|
|
74
|
+
data = json.loads(Path(json_path).read_text(encoding="utf-8"))
|
|
75
|
+
questions: list[dict] = []
|
|
76
|
+
for conv in data:
|
|
77
|
+
conv_id = conv["sample_id"]
|
|
78
|
+
for qa in conv.get("qa", []):
|
|
79
|
+
cat = qa.get("category", 0)
|
|
80
|
+
if cat == 5:
|
|
81
|
+
gold = str(qa.get("adversarial_answer", qa.get("answer", "")))
|
|
82
|
+
else:
|
|
83
|
+
gold = str(qa.get("answer", ""))
|
|
84
|
+
questions.append({
|
|
85
|
+
"conv_id": conv_id,
|
|
86
|
+
"question": qa["question"],
|
|
87
|
+
"gold": gold,
|
|
88
|
+
"category": cat,
|
|
89
|
+
"evidence": qa.get("evidence", []),
|
|
90
|
+
"adversarial": cat == 5,
|
|
91
|
+
})
|
|
92
|
+
return questions
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ── Scorer ─────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _normalize(text: str) -> list[str]:
|
|
99
|
+
"""Lowercase, strip articles/punctuation, tokenize (HotpotQA-style)."""
|
|
100
|
+
text = text.lower()
|
|
101
|
+
text = re.sub(r"[^\w\s]", " ", text)
|
|
102
|
+
tokens = text.split()
|
|
103
|
+
articles = {"a", "an", "the", "is", "are", "was", "were", "to", "of", "in", "on", "at"}
|
|
104
|
+
return [t for t in tokens if t not in articles]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def token_f1(gold: str, prediction: str) -> float:
|
|
108
|
+
"""Token-level F1 between gold and prediction strings."""
|
|
109
|
+
g = _normalize(gold)
|
|
110
|
+
p = _normalize(prediction)
|
|
111
|
+
if not g and not p:
|
|
112
|
+
return 1.0
|
|
113
|
+
if not g or not p:
|
|
114
|
+
return 0.0
|
|
115
|
+
common = Counter(g) & Counter(p)
|
|
116
|
+
num_same = sum(common.values())
|
|
117
|
+
if num_same == 0:
|
|
118
|
+
return 0.0
|
|
119
|
+
precision = num_same / len(p)
|
|
120
|
+
recall = num_same / len(g)
|
|
121
|
+
return 2 * precision * recall / (precision + recall)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def token_recall(gold: str, prediction: str) -> float:
|
|
125
|
+
"""What fraction of gold answer tokens appear in prediction?
|
|
126
|
+
|
|
127
|
+
Better than F1 for retrieval eval: doesn't penalize for retrieving
|
|
128
|
+
extra context (low precision). Measures "can the answer be found?"
|
|
129
|
+
"""
|
|
130
|
+
g = _normalize(gold)
|
|
131
|
+
p = _normalize(prediction)
|
|
132
|
+
if not g:
|
|
133
|
+
return 1.0
|
|
134
|
+
if not p:
|
|
135
|
+
return 0.0
|
|
136
|
+
common = Counter(g) & Counter(p)
|
|
137
|
+
num_same = sum(common.values())
|
|
138
|
+
return num_same / len(g)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ── Runner ─────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _node_text(node: Node) -> str:
|
|
145
|
+
parts = [node.id, node.type]
|
|
146
|
+
for v in node.props.values():
|
|
147
|
+
parts.append(str(v))
|
|
148
|
+
if node.valid_from:
|
|
149
|
+
parts.append(node.valid_from.isoformat())
|
|
150
|
+
if node.valid_to:
|
|
151
|
+
parts.append(node.valid_to.isoformat())
|
|
152
|
+
return " ".join(parts)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _edge_text(edge: Edge, store: GraphStore) -> str:
|
|
156
|
+
from_node = store.get_node(edge.from_)
|
|
157
|
+
to_node = store.get_node(edge.to)
|
|
158
|
+
parts = [edge.type, edge.from_, edge.to]
|
|
159
|
+
if from_node:
|
|
160
|
+
parts.append(_node_text(from_node))
|
|
161
|
+
if to_node:
|
|
162
|
+
parts.append(_node_text(to_node))
|
|
163
|
+
for v in edge.props.values():
|
|
164
|
+
parts.append(str(v))
|
|
165
|
+
if edge.valid_from:
|
|
166
|
+
parts.append(edge.valid_from.isoformat())
|
|
167
|
+
if edge.valid_to:
|
|
168
|
+
parts.append(edge.valid_to.isoformat())
|
|
169
|
+
return " ".join(parts)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _load_src_text(src_refs: tuple[str, ...], raw_dir: Path | None) -> str:
|
|
173
|
+
"""Read full source markdown files referenced by src (path:line format)."""
|
|
174
|
+
if not raw_dir or not src_refs:
|
|
175
|
+
return ""
|
|
176
|
+
seen_files: set[str] = set()
|
|
177
|
+
parts: list[str] = []
|
|
178
|
+
for ref in src_refs:
|
|
179
|
+
path_str = ref.rpartition(":")[0]
|
|
180
|
+
if path_str in seen_files:
|
|
181
|
+
continue
|
|
182
|
+
path = raw_dir / path_str
|
|
183
|
+
if not path.exists():
|
|
184
|
+
continue
|
|
185
|
+
seen_files.add(path_str)
|
|
186
|
+
try:
|
|
187
|
+
parts.append(path.read_text(encoding="utf-8"))
|
|
188
|
+
except Exception:
|
|
189
|
+
continue
|
|
190
|
+
if len(seen_files) >= 5:
|
|
191
|
+
break
|
|
192
|
+
return " ".join(parts)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _search_raw_text(keywords: list[str], raw_dir: Path | None, limit: int = 3) -> str:
|
|
196
|
+
"""Direct keyword search over raw markdown files (graph-guided fallback)."""
|
|
197
|
+
if not raw_dir or not keywords:
|
|
198
|
+
return ""
|
|
199
|
+
parts: list[str] = []
|
|
200
|
+
count = 0
|
|
201
|
+
for md_file in sorted(raw_dir.rglob("*.md")):
|
|
202
|
+
try:
|
|
203
|
+
text = md_file.read_text(encoding="utf-8")
|
|
204
|
+
except Exception:
|
|
205
|
+
continue
|
|
206
|
+
lower = text.lower()
|
|
207
|
+
if any(kw.lower() in lower for kw in keywords):
|
|
208
|
+
parts.append(text)
|
|
209
|
+
count += 1
|
|
210
|
+
if count >= limit:
|
|
211
|
+
break
|
|
212
|
+
return " ".join(parts)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _extract_keywords(question: str) -> list[str]:
|
|
216
|
+
"""Extract proper nouns + key content words (one search per keyword)."""
|
|
217
|
+
tokens = question.replace("?", "").replace(".", "").replace("!", "").split()
|
|
218
|
+
common_caps = {
|
|
219
|
+
"What", "When", "Where", "Who", "Why", "How", "Did", "Do", "Does",
|
|
220
|
+
"Was", "Were", "Is", "Are", "The", "A", "An", "I", "My", "Me",
|
|
221
|
+
"Would", "Could", "Will", "Have", "Has", "Had", "They", "Them",
|
|
222
|
+
"Their", "Her", "His", "Its", "This", "That", "These", "Those",
|
|
223
|
+
"Which",
|
|
224
|
+
}
|
|
225
|
+
keywords = [t for t in tokens if t and t[0].isupper() and t not in common_caps]
|
|
226
|
+
stop = {"what", "when", "where", "who", "why", "how", "did", "do", "does",
|
|
227
|
+
"was", "were", "is", "are", "the", "a", "an", "to", "of", "in",
|
|
228
|
+
"on", "at", "for", "from", "about", "after", "before", "during",
|
|
229
|
+
"and", "or", "not", "no", "yes", "can", "could", "would", "will",
|
|
230
|
+
"have", "has", "had", "been", "being", "that", "this", "these",
|
|
231
|
+
"those", "it", "they", "them", "their", "her", "his", "its",
|
|
232
|
+
"ago", "long", "much", "many", "old", "new", "first", "last"}
|
|
233
|
+
content_words = [t.lower() for t in tokens
|
|
234
|
+
if t.lower() not in stop and len(t) > 2
|
|
235
|
+
and t not in common_caps and not t[0].isupper()]
|
|
236
|
+
keywords.extend(content_words[:3])
|
|
237
|
+
return keywords[:8]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def answer_question(
|
|
241
|
+
scoped: ScopedGraph,
|
|
242
|
+
store: GraphStore,
|
|
243
|
+
question: dict,
|
|
244
|
+
raw_dir: Path | None = None,
|
|
245
|
+
) -> dict:
|
|
246
|
+
"""Retrieve facts for a question and score against gold answer.
|
|
247
|
+
|
|
248
|
+
Uses graph-guided retrieval: search → get_node → neighbors(depth=1-2).
|
|
249
|
+
Enriches fact text with source markdown from ``src`` references.
|
|
250
|
+
"""
|
|
251
|
+
cat = question["category"]
|
|
252
|
+
keywords = _extract_keywords(question["question"])
|
|
253
|
+
all_node_ids: set[str] = set()
|
|
254
|
+
for kw in keywords:
|
|
255
|
+
ids = scoped.search(kw, limit=5)
|
|
256
|
+
all_node_ids.update(ids)
|
|
257
|
+
|
|
258
|
+
depth = 2 if cat == 3 else 1
|
|
259
|
+
fact_text_parts: list[str] = []
|
|
260
|
+
seen_src: set[str] = set()
|
|
261
|
+
|
|
262
|
+
for nid in sorted(all_node_ids):
|
|
263
|
+
node = scoped.get_node(nid)
|
|
264
|
+
if not node:
|
|
265
|
+
continue
|
|
266
|
+
fact_text_parts.append(_node_text(node))
|
|
267
|
+
if not question["adversarial"]:
|
|
268
|
+
for ref in node.src:
|
|
269
|
+
seen_src.add(ref)
|
|
270
|
+
|
|
271
|
+
res = scoped.neighbors(nid, depth=depth)
|
|
272
|
+
for n in res["nodes"]:
|
|
273
|
+
fact_text_parts.append(_node_text(n))
|
|
274
|
+
if not question["adversarial"]:
|
|
275
|
+
for ref in n.src:
|
|
276
|
+
seen_src.add(ref)
|
|
277
|
+
for e in res["edges"]:
|
|
278
|
+
fact_text_parts.append(_edge_text(e, store))
|
|
279
|
+
if not question["adversarial"]:
|
|
280
|
+
for ref in e.src:
|
|
281
|
+
seen_src.add(ref)
|
|
282
|
+
|
|
283
|
+
if not question["adversarial"]:
|
|
284
|
+
src_text = _load_src_text(tuple(seen_src), raw_dir)
|
|
285
|
+
if src_text:
|
|
286
|
+
fact_text_parts.append(src_text)
|
|
287
|
+
raw_hits = _search_raw_text(keywords, raw_dir)
|
|
288
|
+
if raw_hits:
|
|
289
|
+
fact_text_parts.append(raw_hits)
|
|
290
|
+
|
|
291
|
+
fact_text = " ".join(fact_text_parts)
|
|
292
|
+
|
|
293
|
+
if question["adversarial"]:
|
|
294
|
+
score = 1.0 - token_recall(question["gold"], fact_text)
|
|
295
|
+
else:
|
|
296
|
+
score = token_recall(question["gold"], fact_text)
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
"question": question["question"],
|
|
300
|
+
"gold": question["gold"],
|
|
301
|
+
"category": question["category"],
|
|
302
|
+
"f1": round(score, 4),
|
|
303
|
+
"matched_nodes": sorted(all_node_ids),
|
|
304
|
+
"adversarial": question["adversarial"],
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ── Full eval ──────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
CATEGORY_NAMES = {
|
|
312
|
+
1: "single-hop",
|
|
313
|
+
2: "temporal",
|
|
314
|
+
3: "multi-hop",
|
|
315
|
+
4: "descriptive",
|
|
316
|
+
5: "adversarial",
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def locomo_report(
|
|
321
|
+
graph_dir: Path, json_path: Path, allowed_ns: list[str],
|
|
322
|
+
raw_dir: Path | None = None,
|
|
323
|
+
) -> dict:
|
|
324
|
+
"""Run LoCoMo QA eval against a compiled graph.
|
|
325
|
+
|
|
326
|
+
If ``raw_dir`` is given, source markdown lines are included in the
|
|
327
|
+
fact text for each retrieved node/edge (graph-guided retrieval).
|
|
328
|
+
|
|
329
|
+
Returns per-category F1 + overall metrics.
|
|
330
|
+
"""
|
|
331
|
+
facts_path = Path(graph_dir) / "facts.jsonl"
|
|
332
|
+
if not facts_path.exists():
|
|
333
|
+
return {"error": f"facts.jsonl not found at {facts_path}"}
|
|
334
|
+
|
|
335
|
+
store = GraphStore.from_jsonl(facts_path)
|
|
336
|
+
scoped = ScopedGraph(store, allowed_ns)
|
|
337
|
+
questions = extract_questions(json_path)
|
|
338
|
+
|
|
339
|
+
results = [answer_question(scoped, store, q, raw_dir=raw_dir) for q in questions]
|
|
340
|
+
|
|
341
|
+
per_cat: dict[str, list[float]] = {}
|
|
342
|
+
for r in results:
|
|
343
|
+
cat = CATEGORY_NAMES.get(r["category"], f"cat-{r['category']}")
|
|
344
|
+
per_cat.setdefault(cat, []).append(r["f1"])
|
|
345
|
+
|
|
346
|
+
summary = {
|
|
347
|
+
"total_questions": len(results),
|
|
348
|
+
"overall_f1": round(
|
|
349
|
+
sum(r["f1"] for r in results) / len(results), 4
|
|
350
|
+
) if results else 0.0,
|
|
351
|
+
"per_category": {
|
|
352
|
+
cat: {
|
|
353
|
+
"count": len(scores),
|
|
354
|
+
"f1": round(sum(scores) / len(scores), 4),
|
|
355
|
+
}
|
|
356
|
+
for cat, scores in sorted(per_cat.items())
|
|
357
|
+
},
|
|
358
|
+
"graph_stats": scoped.stats(),
|
|
359
|
+
}
|
|
360
|
+
return {"summary": summary, "results": results}
|