diff --git a/_close_issues_log.md b/_close_issues_log.md new file mode 100644 index 00000000..8391b15f --- /dev/null +++ b/_close_issues_log.md @@ -0,0 +1,46 @@ +# v022-polish Issue Closure Log + +## Summary + +- Closed: 0 issues +- Skipped: 15 issues +- Need follow-up: 0 new issues to file + +The target of ≥12 closures was **not met**. Verification found that none of the 15 wiring-category +issues has the required combination of (a) a specific resolving commit SHA, (b) a file:line proof +that the original condition is fixed, and (c) no remaining partial/deferred work in the same scope. +Three issues (#397, #385, #380) are closest to resolved but still have explicitly-deferred code paths. + +## Closed Issues + +_None. No `gh issue close` commands were executed._ + +## Skipped Issues + +| Issue # | Title | Reason | +|---------|-------|--------| +| #397 | [adr-031] EmbedderRegistry implementation (integration codex MAJ-3) | Partial only. `crates/khive-runtime/src/runtime.rs:138-139` still marks per-pack `EmbedderRegistry` plumbing as future work; legacy `embedding_model` field remains; pack-extensible registry trait and registration pattern absent. | +| #392 | [c22 follow-up] ProposalApplyWorker + ProposalApplied emission | Event types exist but `crates/khive-pack-kg/src/lib.rs:206-208` dispatches only `propose/review/withdraw`; `handlers.rs:1926-1955` approves inline with no changeset application worker or `ProposalApplied` emission. | +| #391 | [c22 follow-up] proposals-projection-worker (ADR-046 sec5) | Projection writes remain inline in KG handlers (`handlers.rs:1781-1800`, `:1939-1955`, `:2062-2074`); no `PackEventConsumer` projection worker, expiry update, double-vote semantics, or `ProposalApplied` projection path. | +| #385 | [c20 follow-up] ADR-043 sec8 startup backfill (steps 2-4) | Partial registry/tag plumbing landed but `crates/khive-db/src/migrations.rs:353-380` defers sqlite-vec rebuild/backfill; no `EmbeddingModelChanged` emission found. | +| #380 | [c21 follow-up] engine migrate: EmbedMigrationWorker + actual queueing (ADR-043 D2-D6) | `crates/kkernel/src/engine.rs:187-191` still returns "engine migrate is not yet implemented"; migrate queueing, worker, event emission, and tests absent. | +| #379 | [c09 follow-up] single-endpoint OR still leaf-flattens to AND in variable-length pattern | Bug path unchanged: `crates/khive-query/src/ast.rs:43-57` flattens `And`/`Or`; variable-length WHERE joins with `AND` at `sql.rs:1075,1082`. | +| #376 | memory: implement or remove scaffolded RecallConfig fields (reranker_params, fallback_during_migration) | Scaffold remains: `crates/khive-pack-memory/src/config.rs:23-25,48-51` still declares unreachable fields. | +| #375 | memory: wire recall.rerank into main recall path (ADR-033 sec6) | Main recall still calls `compute_score` directly at `handlers.rs:545-548`; `recall.rerank` remains a stub path with 0.0 scores at `:733-768`. | +| #370 | [c08 follow-up] Pack config from TOML, not env (F155 ADR-027) | Runtime boot still reads env/defaults: `khive-runtime/src/runtime.rs:190` reads `KHIVE_PACKS`; TOML pack loading not implemented. | +| #368 | [c08 follow-up] SubstrateCoordinator D2-D6 implementation (F162 phase 2) | `crates/kkernel/src/coordinator/mod.rs:124-125` explicitly defers D2-D6 locator cache, fan-out search, traversal, and WAL cascade. | +| #367 | [c08 follow-up] Runtime decoupling from khive-db (F014/F049 phase 2) | `crates/khive-runtime/Cargo.toml:18,32` still depends directly on `khive-db` and `rusqlite`. | +| #366 | [c23 follow-up] khive-vcs-adapters: ship at least one Rust impl or remove scaffold | Crate is still a scaffold: `crates/khive-vcs-adapters/src/lib.rs:15-18` exposes only P0 trait/types; no `impl FormatAdapter` found. | +| #364 | [c23 follow-up] F201: run_sync remote archive fetch + hash pin verification | `crates/khive-vcs/src/sync.rs:91-103` reads local files only; remote fetch, `sha256:` pin, `meta.json`, and `--repin` absent. | +| #344 | [deferred] khive-score -> ruvector-core shim migration (post upstream) | Still upstream/deferred; `crates/khive-score/Cargo.toml:16` has no `ruvector-core` dependency. | +| #342 | [follow-up to #336] HNSW: route similarity_from_distance through khive-score canonical layer | HNSW still owns local conversion: `crates/khive-hnsw/src/distance.rs:137`; boundary not moved. | + +## Follow-up Needed + +Three issues are closest to closure but require additional implementation before they can close: + +| Issue # | Title | Missing Work | +|---------|-------|--------------| +| #397 | EmbedderRegistry implementation | Pack-extensible registry trait + registration pattern | +| #385 | ADR-043 sec8 startup backfill | sqlite-vec rebuild/backfill + `EmbeddingModelChanged` emission | +| #380 | EmbedMigrationWorker + actual queueing | Migration worker, queueing, event emission, tests | diff --git a/_recon/state.md b/_recon/state.md new file mode 100644 index 00000000..7999eb93 --- /dev/null +++ b/_recon/state.md @@ -0,0 +1,69 @@ +# State of the Codebase + +This synthesis is based only on the upstream explorer artifacts requested for this round. The dependency graph scanner found a 25-member Rust workspace rooted at `crates/Cargo.toml`, plus a `khive-merge` crate directory that exists but is explicitly excluded from workspace membership (`../e1/dep_graph.md:7`-`../e1/dep_graph.md:10`). The reported crate table is the layering spine for this state report: foundation crates include `khive-types` and `khive-score`; platform includes `khive-storage`, `khive-db`, and `khive-query`; services include `khive-runtime`, `khive-request`, `khive-vcs`, and several packs; apps include `khive-mcp`, while several crates remain unassigned in the explorer graph (`../e1/dep_graph.md:207`-`../e1/dep_graph.md:234`). + +## 1. Orphan Crates + +The six requested retrieval/VCS/merge crates split into two categories. `khive-bm25`, `khive-hnsw`, and `khive-fusion` are consumed, but only through `khive-retrieval`: the reverse-dependency evidence points to `crates/khive-retrieval/Cargo.toml:15`, `:14`, and `:16` respectively (`../e1/orphan_crates.md:16`-`../e1/orphan_crates.md:18`). The scanner also found non-manifest source use inside `khive-retrieval`, including BM25 persistence imports/re-exports, HNSW persistence imports/re-exports, and fusion imports in the hybrid search surface (`../e1/orphan_crates.md:50`-`../e1/orphan_crates.md:58`). + +`khive-retrieval`, `khive-vcs-adapters`, and `khive-merge` have zero reverse manifest dependencies under `crates/*/Cargo.toml` (`../e1/orphan_crates.md:19`-`../e1/orphan_crates.md:21`). In the full workspace-wide zero-reverse-dep list, the same condition also applies to `khive-gate-rego`, `khive-pack-template`, `khive-mcp`, `kkernel`, `khive-retrieval`, and the non-workspace `khive-merge` (`../e1/dep_graph.md:236`-`../e1/dep_graph.md:246`). Not all zero-reverse crates are dead code: `khive-mcp` and `kkernel` are binary targets, with explicit bin target evidence in their manifests (`../e1/orphan_crates.md:10`, `../e1/dep_graph.md:229`, `../e1/dep_graph.md:232`). The architectural risk is concentrated in non-binary zero-reverse crates: `khive-vcs-adapters` has no test files and no reverse deps (`../e3/test_inventory.md:211`, `../e1/orphan_crates.md:31`), while `khive-retrieval` has a large public API surface but no downstream manifest consumer (`../e3/api_surface.md:22`, `../e1/dep_graph.md:233`). + +## 2. ADR Alignment Audit + +ADR-011 is mostly aligned on the runtime embedding boundary: `khive-runtime` depends on `lattice-embed`, stores one `OnceCell>`, and lazy-loads `NativeEmbeddingService` through `CachedEmbeddingService` (`../e2/adr_alignment.md:17`). The explorer also verified that runtime `embed()` and `embed_batch()` call the lattice service directly, with no separate khive-side embedding trait in the runtime path (`../e2/adr_alignment.md:18`). The notable ADR-011 architecture gap is not a contradiction inside the boundary itself; it is that `khive-retrieval` is acting as a facade/re-export layer but no workspace crate depends on it today (`../e2/adr_alignment.md:21`, `../e1/dep_graph.md:233`). + +ADR-021 and ADR-033 are partly implemented in the memory pack. `MemoryPack` registers `memory`, `remember`, and `recall`; `remember` validates `source_id` as a UUID and links through `annotates`; recall filters alive memory notes, scores, sorts, truncates, and returns results (`../e2/adr_alignment.md:28`-`../e2/adr_alignment.md:31`). Configurable recall fields and per-call overrides are present, including subhandlers such as `recall.embed`, `recall.candidates`, `recall.fuse`, `recall.rerank`, and `recall.score` (`../e2/adr_alignment.md:40`-`../e2/adr_alignment.md:42`). The drift is in registry semantics: accepted ADR text permits multiple memory pack instances and pack kind routing, but runtime rejects duplicate note kinds during registry build (`../e2/adr_alignment.md:33`, `../e2/adr_alignment.md:68`). Subhandler visibility is also drifted: subhandlers are excluded from catalog, but registry and MCP request dispatch still route arbitrary parsed tool names, including dotted subhandlers (`../e2/adr_alignment.md:43`, `../e2/adr_alignment.md:69`). Some memory gaps are explicitly phased out rather than regressions: rerank is present as a pass-through subhandler until a reranker model is wired, and engine/model multi-embedding behavior remains tied to deferred registry work (`../e2/adr_alignment.md:34`, `../e2/adr_alignment.md:45`-`../e2/adr_alignment.md:46`). + +ADR-043 is only partially landed. The `_embedding_models` DDL and event enum additions exist (`../e2/adr_alignment.md:52`-`../e2/adr_alignment.md:54`), but startup backfill, sqlite-vec rebuild/new-table creation, migration worker, drift integration, and useful `engine list/status/migrate/drift-check` behavior are follow-up work rather than complete runtime behavior (`../e2/adr_alignment.md:53`, `../e2/adr_alignment.md:55`-`../e2/adr_alignment.md:57`). One concrete regression remains: the ADR says the schema owner is `khive-runtime`, while the implementation currently lives in `khive-db` migrations (`../e2/adr_alignment.md:58`). + +## 3. Marketplace Plugins + +The KG plugin exposes 14 verbs: `create`, `get`, `list`, `update`, `delete`, `merge`, `search`, `link`, `neighbors`, `traverse`, `query`, plus ADR-046 proposal verbs `propose`, `review`, and `withdraw` (`../e2/marketplace_audit.md:14`-`../e2/marketplace_audit.md:27`). GTD exposes five verbs: `assign`, `next`, `complete`, `tasks`, and `transition` (`../e2/marketplace_audit.md:28`-`../e2/marketplace_audit.md:32`). The explorer found no removed KG or GTD verbs still referenced by the scanned marketplace skill files (`../e2/marketplace_audit.md:63`-`../e2/marketplace_audit.md:68`). + +Marketplace drift is documentation and coverage drift rather than broken verb references. Several KG skills still describe six entity kinds or thirteen relations, while current KG/type code exposes eight entity kinds and fifteen edge relations (`../e2/marketplace_audit.md:54`-`../e2/marketplace_audit.md:59`). The new KG proposal verbs have no corresponding KG marketplace skills: `propose`, `review`, and `withdraw` are exposed in code, but the scanned KG skill set is limited to `connect`, `digest`, `expand`, `explore`, `gap`, and `polish` (`../e2/marketplace_audit.md:70`-`../e2/marketplace_audit.md:76`). GTD skill examples were not flagged for stale statuses or priorities (`../e2/marketplace_audit.md:60`-`../e2/marketplace_audit.md:61`, `../e2/marketplace_audit.md:77`). + +## 4. Open Issues + +The open-issue inventory reports 64 open issues from `gh issue list --state open --limit 200 --json number,title,labels`, with category counts of 16 bug, 4 docs, 7 feature, 22 other, and 15 wiring (`../e3/open_issues.md:1`-`../e3/open_issues.md:8`). The same scan ranks 5 issues P0, 15 P1, 19 P2, and 25 P3 (`../e3/open_issues.md:9`-`../e3/open_issues.md:12`). The P0 set is not primarily the orphan/retrieval work: it includes three security/authorization/proposal isolation bugs plus `khive-retrieval` feature-gated test failures and `khive-hnsw` arena pointer safety (`../e3/open_issues.md:19`-`../e3/open_issues.md:21`, `../e3/open_issues.md:55`-`../e3/open_issues.md:56`). + +Many architecture gaps already have issue coverage. ADR-043 migration and drift work appears as #380 and #385 (`../e3/open_issues.md:29`, `../e3/open_issues.md:32`). Embedder registry work appears as #397 (`../e3/open_issues.md:18`). Memory recall gaps appear as #375, #376, #377, and #378 (`../e3/open_issues.md:34`-`../e3/open_issues.md:37`). VCS adapter scaffolding is tracked by #366 (`../e3/open_issues.md:42`). Marketplace/skill gaps are less directly covered by the listed issues; the closest open docs issue is brain pack discoverability rather than KG proposal skill coverage (`../e3/open_issues.md:61`, `../e2/marketplace_audit.md:74`-`../e2/marketplace_audit.md:76`). + +## 5. Embedding Surface + +The current embedding surface supports selecting one configured model, not multi-loaded runtime embedding. The workspace pins `lattice-embed = "0.2.4"`, and `khive-runtime`, `khive-retrieval`, and `khive-hnsw` all depend on it (`../e1/embedding_surface.md:7`-`../e1/embedding_surface.md:11`). `lattice-embed` itself includes both MiniLM and paraphrase MiniLM as 384-dimensional local models, with parsing aliases for both (`../e1/embedding_surface.md:20`-`../e1/embedding_surface.md:27`). It also has dual-write and migration primitives such as `EmbeddingRoutingConfig.write_models`, `BackfillConfig.dual_write`, and source/target routing (`../e1/embedding_surface.md:46`-`../e1/embedding_surface.md:55`). + +Khive runtime does not yet expose that as end-to-end dual embedding. `RuntimeConfig` has one `Option`, `KhiveRuntime` stores one embedder `OnceCell`, and `vectors()` opens one model-keyed vector namespace (`../e1/embedding_surface.md:62`-`../e1/embedding_surface.md:68`, `../e1/embedding_surface.md:123`-`../e1/embedding_surface.md:125`). `embed()` and `embed_batch()` call one service for one model, entity/note writes embed once, and memory recall embeds the query once against one vector store (`../e1/embedding_surface.md:69`-`../e1/embedding_surface.md:76`, `../e1/embedding_surface.md:126`-`../e1/embedding_surface.md:129`). The capability matrix therefore marks "dual embedding MiniLM + paraphrase end-to-end" as a gap (`../e1/embedding_surface.md:117`). Registry listing is scaffolded but empty: `kkernel engine list` calls a query function that currently returns `Ok(Vec::new())`, while migrate and drift-check return not implemented (`../e1/embedding_surface.md:99`-`../e1/embedding_surface.md:103`, `../e1/embedding_surface.md:130`-`../e1/embedding_surface.md:132`). + +## 6. Test Inventory + +The test inventory is broad but uneven. It found 33 explicit unit test files, 18 integration files, 1 e2e file, 1 smoke file, 122 inline `cfg(test)` modules, and no `benches` directory (`../e3/test_inventory.md:214`-`../e3/test_inventory.md:222`). CLI coverage is substantial for `kg` subcommands and CLI helper libraries (`../e3/test_inventory.md:4`-`../e3/test_inventory.md:29`). Rust integration tests exist for `khive-db`, `khive-mcp`, pack crates such as KG/GTD/Memory, `khive-runtime`, `khive-types`, and `khive-vcs` (`../e3/test_inventory.md:31`-`../e3/test_inventory.md:54`). + +The missing coverage aligns with the highest-risk scaffolding. `khive-vcs-adapters` has no tests found (`../e3/test_inventory.md:211`) and no reverse deps (`../e1/orphan_crates.md:31`). `khive-retrieval` has many unit tests but no crate-level integration test file (`../e3/test_inventory.md:205`), while open issue #309 reports feature-gated test failures in `khive-retrieval` (`../e3/open_issues.md:55`). `khive-merge` is non-workspace and unit-only, with no integration test file under crate tests or integration_tests (`../e3/test_inventory.md:195`, `../e1/orphan_crates.md:32`). The registry/dispatch ADR drift sits in crates that do have tests, especially `khive-runtime` and `khive-mcp`, so the gap is not absence of any tests but absence of targeted regression checks for duplicate note-kind routing and subhandler dispatch enforcement (`../e2/adr_alignment.md:68`-`../e2/adr_alignment.md:70`, `../e3/test_inventory.md:194`, `../e3/test_inventory.md:206`). + +## 7. CLI Behavior + +The TypeScript CLI entry point is `cli/main.ts`, with the Deno test task declared in `cli/deno.json`; npm shims exist for `khive` and `khive-mcp` (`../e3/cli_behavior.md:1`-`../e3/cli_behavior.md:6`). The main CLI has groups for `kg`, `auth`, and `pack` (`../e3/cli_behavior.md:11`-`../e3/cli_behavior.md:43`). Most implemented `kg` commands have direct tests: `init`, `validate`, `hook`, `config`, `embed`, `resolve`, `migrate`, `export`, `import`, `diff`, `log`, `stats`, and `doctor` have cited tests (`../e3/cli_behavior.md:13`-`../e3/cli_behavior.md:28`). `kg commit`, `kg sync`, and `kg status` are implemented but have no direct command tests (`../e3/cli_behavior.md:14`-`../e3/cli_behavior.md:16`, `../e3/cli_behavior.md:84`-`../e3/cli_behavior.md:86`). + +The CLI also exposes deferred or not-implemented surfaces. `kg update` is parsed but not implemented, `auth login/status/logout` are phase C2 and not implemented, and most `pack` commands beyond `pack init` and `pack check` are deferred (`../e3/cli_behavior.md:29`-`../e3/cli_behavior.md:43`). `pack check` has library validation tests but no direct `runPackCheck` command test (`../e3/cli_behavior.md:36`, `../e3/cli_behavior.md:92`). Rust binary targets are `khive-mcp` and `kkernel` (`../e3/cli_behavior.md:44`-`../e3/cli_behavior.md:48`), and the `kkernel engine` surface is present but scaffolded for ADR-043 (`../e1/embedding_surface.md:99`-`../e1/embedding_surface.md:103`). + +## Cross-Axis Findings + +The orphan-crate picture explains only part of the ADR drift. `khive-retrieval` being unconsumed explains why dual-index and retrieval facade primitives can exist without influencing runtime behavior (`../e1/dep_graph.md:233`, `../e1/embedding_surface.md:83`-`../e1/embedding_surface.md:89`, `../e1/embedding_surface.md:132`). It does not explain duplicate note-kind rejection, subhandler dispatch visibility, or ADR-043 schema ownership drift, which are in `khive-runtime`, `khive-mcp`, `khive-request`, and `khive-db` paths (`../e2/adr_alignment.md:58`, `../e2/adr_alignment.md:68`-`../e2/adr_alignment.md:70`). + +Missing tests correlate with dormant or scaffolded surfaces more than with all drift. `khive-vcs-adapters` has both zero reverse deps and zero tests (`../e1/orphan_crates.md:31`, `../e3/test_inventory.md:211`). `khive-retrieval` has unit coverage but no integration coverage and is tied to open feature-gated failures (`../e3/test_inventory.md:205`, `../e3/open_issues.md:55`). By contrast, runtime and MCP have tests but still exhibit ADR drift, so those areas need targeted regression additions rather than generic test creation (`../e3/test_inventory.md:194`, `../e3/test_inventory.md:206`, `../e2/adr_alignment.md:68`-`../e2/adr_alignment.md:70`). + +Open issues already track much of the high-value migration work. #397 tracks EmbedderRegistry implementation, #380 tracks `engine migrate` and queueing, #385 tracks ADR-043 startup backfill, and #375-#378 track recall pipeline gaps (`../e3/open_issues.md:18`, `../e3/open_issues.md:29`, `../e3/open_issues.md:32`, `../e3/open_issues.md:34`-`../e3/open_issues.md:37`). New tracking is most useful for KG marketplace proposal skills and for explicit runtime/MCP visibility regression tests, because those gaps are visible in the explorer artifacts but not directly represented in the issue table (`../e2/marketplace_audit.md:74`-`../e2/marketplace_audit.md:76`, `../e2/adr_alignment.md:69`). + +## Prioritized Backlog + +- P0: Address security issues #394, #395, and #396 before broad architecture migration, because the issue inventory ranks them P0 and they affect authorization/proposal isolation/state pollution (`../e3/open_issues.md:19`-`../e3/open_issues.md:21`). +- P0: Stabilize retrieval/HNSW foundations by resolving #309 and #308, then decide whether `khive-retrieval` is a real consumed facade or a staged crate. This touches unconsumed retrieval architecture plus P0 test/safety issues (`../e3/open_issues.md:55`-`../e3/open_issues.md:56`, `../e1/dep_graph.md:233`). +- P1: Fix ADR-017/021 duplicate note-kind semantics or amend the ADRs. The current runtime rejects duplicate note kinds while accepted ADR text describes multi-instance kind routing (`../e2/adr_alignment.md:33`, `../e2/adr_alignment.md:68`). +- P1: Enforce subhandler visibility at registry/MCP dispatch boundaries and add targeted runtime/MCP regression tests (`../e2/adr_alignment.md:43`, `../e2/adr_alignment.md:69`, `../e3/test_inventory.md:194`, `../e3/test_inventory.md:206`). +- P1: Implement ADR-043 registry plumbing in dependency order: query `_embedding_models`, make `kkernel engine list/status` real, then implement migrate/drift-check follow-ups (#397, #380, #385) (`../e1/embedding_surface.md:99`-`../e1/embedding_surface.md:103`, `../e3/open_issues.md:18`, `../e3/open_issues.md:29`, `../e3/open_issues.md:32`). +- P1: Convert one-model runtime embedding into a multi-engine/model registry path before claiming dual MiniLM + paraphrase support (`../e1/embedding_surface.md:117`, `../e1/embedding_surface.md:123`-`../e1/embedding_surface.md:132`). +- P2: Refresh KG marketplace taxonomy text and add KG proposal skills for `propose`, `review`, and `withdraw` (`../e2/marketplace_audit.md:54`-`../e2/marketplace_audit.md:59`, `../e2/marketplace_audit.md:74`-`../e2/marketplace_audit.md:76`). +- P2: Add direct CLI tests for implemented but uncovered commands: `khive`, `kg commit`, `kg sync`, `kg status`, and `pack check` (`../e3/cli_behavior.md:83`-`../e3/cli_behavior.md:92`). +- P3: Remove, integrate, or explicitly park zero-reverse scaffolds such as `khive-vcs-adapters`, `khive-pack-template`, and non-workspace `khive-merge` after higher-risk runtime and embedding work is complete (`../e1/dep_graph.md:240`-`../e1/dep_graph.md:246`). + +Domain utility: SKIPPED - the requested lore suggest/compose tooling is not available in this session, so synthesis relied on upstream explorer artifacts and their citations. diff --git a/_summary.md b/_summary.md new file mode 100644 index 00000000..332ed9ba --- /dev/null +++ b/_summary.md @@ -0,0 +1,94 @@ +# Plugin Polish — v0.2.2 Summary + +Play: `plugin-polish` | Branch: `v022-polish-plugin-polish` | Status: **DONE** + +--- + +## Verb Inventory by Pack + +### KG pack — 14 verbs +`create`, `get`, `list`, `update`, `delete`, `merge`, `search`, `link`, `neighbors`, `traverse`, `query`, `propose`, `review`, `withdraw` + +### GTD pack — 5 verbs +`assign`, `next`, `complete`, `tasks`, `transition` + +### Memory pack — 2 verbs +`remember`, `recall` + +--- + +## SKILL.md Files — Added / Rewritten / Deleted + +### Added (3) +| File | Description | +| ---- | ----------- | +| `marketplace/kg/skills/propose/SKILL.md` | New skill for ADR-046 propose verb | +| `marketplace/kg/skills/review/SKILL.md` | New skill for ADR-046 review verb | +| `marketplace/kg/skills/withdraw/SKILL.md` | New skill for ADR-046 withdraw verb | + +### Modified (14 tracked files) +| File | Changes | +| ---- | ------- | +| `marketplace/kg/.claude-plugin/plugin.json` | Version 0.1.1 → 0.2.2 | +| `marketplace/kg/README.md` | +3 verbs in verb table, +3 skills in skill table, entity kinds 6→8, relations 13→15 | +| `marketplace/kg/skills/connect/SKILL.md` | "13 relations"→"15 relations", "6 kinds"→"8 kinds" | +| `marketplace/kg/skills/digest/SKILL.md` | Fixed placeholder batch with real args; entity/relation counts updated | +| `marketplace/kg/skills/expand/SKILL.md` | Fixed placeholder batch with real args; fixed batch get syntax; fixed list arg; "6 kinds, 13 relations"→"8 kinds, 15 relations" | +| `marketplace/kg/skills/explore/SKILL.md` | Fixed positional `query("MATCH...")` → `query(query="MATCH...")`; 13→15 relations | +| `marketplace/kg/skills/polish/SKILL.md` | Removed `v0.1` version ref; added `kind="edge"` to update/delete edge calls | +| `marketplace/kg/agents/researcher.md` | 13→15 relations + added derived_from/precedes categories; fixed 2 positional `query()` calls; fixed `update` missing `kind="entity"` | +| `marketplace/kg/agents/librarian.md` | Fixed `filter={}` → flat kwargs; fixed `tags=[...]` → `properties={"tags":[...]}` top-level; 13→15 relations | +| `marketplace/kg/agents/expander.md` | Removed unsupported `status="done"` arg from `complete()` | +| `marketplace/gtd/.claude-plugin/plugin.json` | Version 0.1.0 → 0.2.2 | +| `marketplace/gtd/README.md` | Added `start?` and `end?` to `assign` signature; added `process` and `plan` to skills list | +| `marketplace/memory/.claude-plugin/plugin.json` | Version 0.1.0 → 0.2.2 | +| `marketplace/memory/README.md` | Added `importance?/salience?`, `decay_factor?/decay?`, `source_id?/source?` aliases to `remember`; added `config?` to `recall` | + +### Deleted (0) + +--- + +## New Tooling (untracked new files) + +| File | Purpose | +| ---- | ------- | +| `marketplace/_validators/check_examples.py` | Stdlib-only validator — scans all SKILL.md + agent .md files, validates verb names and keyword-arg discipline | +| `marketplace/INSTALL.md` | Step-by-step install guide with smoke tests and troubleshooting | + +--- + +## Plugin Version Bumps + +| Plugin | Before | After | +| ------ | ------ | ----- | +| kg | 0.1.1 | 0.2.2 | +| gtd | 0.1.0 | 0.2.2 | +| memory | 0.1.0 | 0.2.2 | + +--- + +## Example Validator Stats + +``` +checked=234 valid=234 invalid=0 skipped=1 +``` + +22 files scanned (16 SKILL.md + 6 agent .md). All examples valid. + +--- + +## Gate Results + +| Gate | Command | Result | +| ---- | ------- | ------ | +| G1 — example validator | `uv run python marketplace/_validators/check_examples.py` | PASS — checked=234 invalid=0 | +| G2 — broken links | `grep -r "khive.ai" marketplace/` | PASS — 0 matches | +| G3 — plugin versions | `cat marketplace/*/\.claude-plugin/plugin.json \| grep version` | PASS — all three at 0.2.2 | +| G4 — deno fmt | N/A — no .ts/.json files outside manifests | SKIP — not applicable | +| G5 — SKILL.md coverage | skill dirs match README tables | PASS — kg:9, gtd:5, memory:2 | + +--- + +## Status: DONE + +All gates pass. No commit made — pending critic review. diff --git a/cli/deno.json b/cli/deno.json index 8e8a7a8b..2dcbd55b 100644 --- a/cli/deno.json +++ b/cli/deno.json @@ -6,6 +6,8 @@ "tasks": { "dev": "deno run --allow-all main.ts", "test": "deno test --allow-all .", + "test:behavior": "deno test --allow-all tests/behavior/", + "test:contract": "deno test --allow-all tests/contract/", "check": "deno check main.ts", "fmt": "deno fmt .", "lint": "deno lint .", diff --git a/cli/tests/behavior/error_test.ts b/cli/tests/behavior/error_test.ts new file mode 100644 index 00000000..967672fe --- /dev/null +++ b/cli/tests/behavior/error_test.ts @@ -0,0 +1,115 @@ +/** + * Behavior tests: error message correctness. + * + * Tests that the CLI emits helpful error messages for bad input. + */ + +import { assertEquals } from "@std/assert"; +import { makeTempRepo, runCli, runCliIn } from "../helpers.ts"; +import { join } from "@std/path"; + +// ─── Unknown commands ────────────────────────────────────────────────────────── + +Deno.test("error: unknown top-level command prints error to stderr", async () => { + const r = await runCli(["badcommand"]); + assertEquals(r.code, 1); + assertEquals(r.stderr.includes("Unknown command group"), true); +}); + +Deno.test("error: unknown top-level command suggests --help", async () => { + const r = await runCli(["badcommand"]); + assertEquals(r.code, 1); + assertEquals(r.stderr.includes("--help"), true); +}); + +Deno.test("error: unknown kg subcommand prints error to stderr", async () => { + const r = await runCli(["kg", "badcommand"]); + assertEquals(r.code, 1); + assertEquals(r.stderr.includes("Unknown kg subcommand"), true); +}); + +Deno.test("error: unknown kg subcommand suggests 'khive kg --help'", async () => { + const r = await runCli(["kg", "badcommand"]); + assertEquals(r.code, 1); + assertEquals(r.stderr.includes("khive kg --help"), true); +}); + +Deno.test("error: unknown pack subcommand prints error to stderr", async () => { + const r = await runCli(["pack", "badcommand"]); + assertEquals(r.code, 1); + assertEquals(r.stderr.includes("Unknown pack subcommand"), true); +}); + +Deno.test("error: unknown pack subcommand suggests 'khive pack --help'", async () => { + const r = await runCli(["pack", "badcommand"]); + assertEquals(r.code, 1); + assertEquals(r.stderr.includes("khive pack --help"), true); +}); + +Deno.test("error: unknown auth subcommand prints error to stderr", async () => { + const r = await runCli(["auth", "badcommand"]); + assertEquals(r.code, 1); + assertEquals(r.stderr.includes("Unknown auth subcommand"), true); +}); + +Deno.test("error: auth login shows not-implemented message", async () => { + const r = await runCli(["auth", "login"]); + assertEquals(r.code, 1); + assertEquals(r.stderr.includes("not yet implemented"), true); +}); + +Deno.test("error: kg update shows not-implemented message", async () => { + const r = await runCli(["kg", "update"]); + assertEquals(r.code, 1); + assertEquals(r.stderr.includes("not yet implemented"), true); +}); + +// ─── pack check with bad path ────────────────────────────────────────────────── + +Deno.test("error: pack check nonexistent.yaml exits non-zero", async () => { + const r = await runCli(["pack", "check", "nonexistent-file-that-does-not-exist.yaml"]); + assertEquals(r.code !== 0, true); +}); + +// ─── Out-of-repo kg commands ─────────────────────────────────────────────────── + +Deno.test("error: kg validate outside git repo prints error", async () => { + // /tmp is not a git repo + const r = await runCliIn("/tmp", ["kg", "validate"]); + assertEquals(r.code !== 0, true); +}); + +Deno.test("error: kg stats outside git repo prints error", async () => { + const r = await runCliIn("/tmp", ["kg", "stats"]); + assertEquals(r.code !== 0, true); +}); + +// ─── Invalid NDJSON ──────────────────────────────────────────────────────────── + +Deno.test("error: kg validate on invalid NDJSON exits non-zero", async () => { + const repo = await makeTempRepo(); + try { + // Write invalid NDJSON to entities file + const entitiesPath = join(repo.root, ".khive", "kg", "entities.ndjson"); + await Deno.writeTextFile(entitiesPath, "not valid json\n"); + const r = await runCliIn(repo.root, ["kg", "validate"]); + assertEquals(r.code !== 0, true); + } finally { + await repo.cleanup(); + } +}); + +Deno.test("error: kg validate on invalid entity (missing kind) exits non-zero", async () => { + const repo = await makeTempRepo(); + try { + const entitiesPath = join(repo.root, ".khive", "kg", "entities.ndjson"); + await Deno.writeTextFile( + entitiesPath, + '{"id":"ent_00000000-0000-0000-0000-000000000001","name":"bad"}\n', + ); + const r = await runCliIn(repo.root, ["kg", "validate"]); + assertEquals(r.code !== 0, true); + } finally { + await repo.cleanup(); + } +}); diff --git a/cli/tests/behavior/exit_code_test.ts b/cli/tests/behavior/exit_code_test.ts new file mode 100644 index 00000000..53cdd852 --- /dev/null +++ b/cli/tests/behavior/exit_code_test.ts @@ -0,0 +1,147 @@ +/** + * Behavior tests: exit code correctness. + * + * Tests that the CLI exits with 0 on success and non-zero on failure. + */ + +import { assertEquals } from "@std/assert"; +import { makeTempRepo, runCli, runCliIn } from "../helpers.ts"; + +// ─── Top-level flags ─────────────────────────────────────────────────────────── + +Deno.test("exit: khive --version exits 0", async () => { + const r = await runCli(["--version"]); + assertEquals(r.code, 0); +}); + +Deno.test("exit: khive -V exits 0", async () => { + const r = await runCli(["-V"]); + assertEquals(r.code, 0); +}); + +Deno.test("exit: khive --help exits 0", async () => { + const r = await runCli(["--help"]); + assertEquals(r.code, 0); +}); + +Deno.test("exit: khive -h exits 0", async () => { + const r = await runCli(["-h"]); + assertEquals(r.code, 0); +}); + +Deno.test("exit: khive (no args) exits 0", async () => { + const r = await runCli([]); + assertEquals(r.code, 0); +}); + +// ─── Unknown command groups ──────────────────────────────────────────────────── + +Deno.test("exit: unknown top-level command exits 1", async () => { + const r = await runCli(["unknown-command"]); + assertEquals(r.code, 1); +}); + +Deno.test("exit: unknown kg subcommand exits 1", async () => { + const r = await runCli(["kg", "unknown-subcommand"]); + assertEquals(r.code, 1); +}); + +Deno.test("exit: unknown pack subcommand exits 1", async () => { + const r = await runCli(["pack", "unknown-subcommand"]); + assertEquals(r.code, 1); +}); + +Deno.test("exit: unknown auth subcommand exits 1", async () => { + const r = await runCli(["auth", "unknown-subcommand"]); + assertEquals(r.code, 1); +}); + +// ─── kg group help ───────────────────────────────────────────────────────────── + +Deno.test("exit: khive kg --help exits 0", async () => { + const r = await runCli(["kg", "--help"]); + assertEquals(r.code, 0); +}); + +Deno.test("exit: khive kg (no subcommand) exits 0", async () => { + const r = await runCli(["kg"]); + assertEquals(r.code, 0); +}); + +// ─── pack group ──────────────────────────────────────────────────────────────── + +Deno.test("exit: khive pack --help exits 0", async () => { + const r = await runCli(["pack", "--help"]); + assertEquals(r.code, 0); +}); + +Deno.test("exit: khive pack (no subcommand) exits 0", async () => { + const r = await runCli(["pack"]); + assertEquals(r.code, 0); +}); + +// ─── auth stubs exit non-zero ────────────────────────────────────────────────── + +Deno.test("exit: khive auth login exits 1 (not implemented)", async () => { + const r = await runCli(["auth", "login"]); + assertEquals(r.code, 1); +}); + +Deno.test("exit: khive auth status exits 1 (not implemented)", async () => { + const r = await runCli(["auth", "status"]); + assertEquals(r.code, 1); +}); + +Deno.test("exit: khive auth logout exits 1 (not implemented)", async () => { + const r = await runCli(["auth", "logout"]); + assertEquals(r.code, 1); +}); + +// ─── kg update stub exits non-zero ──────────────────────────────────────────── + +Deno.test("exit: khive kg update exits 1 (not implemented)", async () => { + const r = await runCli(["kg", "update"]); + assertEquals(r.code, 1); +}); + +// ─── In-repo commands ───────────────────────────────────────────────────────── + +Deno.test("exit: kg validate on valid repo exits 0", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "validate"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + } finally { + await repo.cleanup(); + } +}); + +Deno.test("exit: kg stats on valid repo exits 0", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "stats"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + } finally { + await repo.cleanup(); + } +}); + +Deno.test("exit: kg doctor on valid repo exits 0", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "doctor"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + } finally { + await repo.cleanup(); + } +}); + +Deno.test("exit: kg status on valid repo exits 0", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "status"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + } finally { + await repo.cleanup(); + } +}); diff --git a/cli/tests/behavior/parse_test.ts b/cli/tests/behavior/parse_test.ts new file mode 100644 index 00000000..d453b529 --- /dev/null +++ b/cli/tests/behavior/parse_test.ts @@ -0,0 +1,225 @@ +/** + * Behavior tests: flag parsing and argument handling. + * + * Tests that flags are accepted, parsed, and reflected in behavior. + */ + +import { assertEquals, assertMatch } from "@std/assert"; +import { makeTempRepo, runCli, runCliIn } from "../helpers.ts"; +import { join } from "@std/path"; + +// ─── --version / -V ──────────────────────────────────────────────────────────── + +Deno.test("parse: --version outputs version matching CLI_VERSION format", async () => { + const r = await runCli(["--version"]); + assertEquals(r.code, 0); + assertMatch(r.stdout.trim(), /^khive \d+\.\d+\.\d+/); +}); + +Deno.test("parse: -V outputs same as --version", async () => { + const [long, short] = await Promise.all([runCli(["--version"]), runCli(["-V"])]); + assertEquals(long.stdout.trim(), short.stdout.trim()); +}); + +// ─── kg stats flags ─────────────────────────────────────────────────────────── + +Deno.test("parse: kg stats --json outputs JSON (not plain text)", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "stats", "--json"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + // JSON mode: starts with { or [ + assertMatch(r.stdout.trim(), /^\{/); + } finally { + await repo.cleanup(); + } +}); + +Deno.test("parse: kg stats without --json outputs plain text", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "stats"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + // Plain text mode: does NOT start with { + assertEquals(r.stdout.trim().startsWith("{"), false); + } finally { + await repo.cleanup(); + } +}); + +// ─── kg validate flags ──────────────────────────────────────────────────────── + +Deno.test("parse: kg validate --format json outputs JSON", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "validate", "--format", "json"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + assertMatch(r.stdout.trim(), /^\{/); + } finally { + await repo.cleanup(); + } +}); + +Deno.test("parse: kg validate --quiet exits 0 and produces one-line summary", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "validate", "--quiet"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + // Quiet mode outputs a single summary line + const lines = r.stdout.trim().split("\n").filter((l) => l.length > 0); + assertEquals(lines.length, 1, `Expected 1 line, got: ${r.stdout}`); + assertEquals(lines[0].startsWith("Validation:"), true); + } finally { + await repo.cleanup(); + } +}); + +Deno.test("parse: kg validate --no-rules skips rule validation", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "validate", "--no-rules"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + } finally { + await repo.cleanup(); + } +}); + +// ─── kg doctor flags ────────────────────────────────────────────────────────── + +Deno.test("parse: kg doctor --json outputs JSON", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "doctor", "--json"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + assertMatch(r.stdout.trim(), /^\{/); + } finally { + await repo.cleanup(); + } +}); + +Deno.test("parse: kg doctor without --json outputs plain text", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "doctor"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + assertEquals(r.stdout.trim().startsWith("{"), false); + } finally { + await repo.cleanup(); + } +}); + +// ─── kg log flags ───────────────────────────────────────────────────────────── + +Deno.test("parse: kg log -n 1 is accepted without error", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "log", "-n", "1"]); + // May succeed or show "no KG history" — either way not a parse error + assertEquals(r.code <= 1, true); + assertEquals(r.stderr.includes("Unknown kg"), false); + } finally { + await repo.cleanup(); + } +}); + +Deno.test("parse: kg log --json flag is accepted", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "log", "--json"]); + assertEquals(r.stderr.includes("Unknown kg"), false); + } finally { + await repo.cleanup(); + } +}); + +// ─── kg diff flags ──────────────────────────────────────────────────────────── + +Deno.test("parse: kg diff --json flag is accepted", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "diff", "--json"]); + assertEquals(r.stderr.includes("Unknown kg"), false); + } finally { + await repo.cleanup(); + } +}); + +Deno.test("parse: kg diff --name-only flag is accepted", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "diff", "--name-only"]); + assertEquals(r.stderr.includes("Unknown kg"), false); + } finally { + await repo.cleanup(); + } +}); + +// ─── kg status ──────────────────────────────────────────────────────────────── + +Deno.test("parse: kg status on valid repo produces output", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "status"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + // Should produce some output (entity/edge counts or status info) + assertEquals(r.stdout.length > 0, true); + } finally { + await repo.cleanup(); + } +}); + +// ─── kg config ──────────────────────────────────────────────────────────────── + +Deno.test("parse: kg config on valid repo exits 0", async () => { + const repo = await makeTempRepo(); + try { + // Create a minimal config file + const configDir = join(repo.root, ".khive"); + await Deno.mkdir(configDir, { recursive: true }); + await Deno.writeTextFile(join(configDir, "config.toml"), "# khive config\n"); + const r = await runCliIn(repo.root, ["kg", "config"]); + // Exit 0 or 1 depending on whether config exists; no parse error + assertEquals(r.stderr.includes("Unknown kg"), false); + } finally { + await repo.cleanup(); + } +}); + +// ─── kg embed flags ─────────────────────────────────────────────────────────── + +Deno.test("parse: kg embed on valid repo is accepted by dispatcher", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "embed"]); + // Embed may succeed or fail depending on state — but should not be "unknown subcommand" + assertEquals(r.stderr.includes("Unknown kg subcommand"), false); + } finally { + await repo.cleanup(); + } +}); + +// ─── pack subcommands ───────────────────────────────────────────────────────── + +Deno.test("parse: pack check with no path is a parse call to dispatcher", async () => { + const r = await runCli(["pack", "check"]); + // check with no args will fail — but not as "unknown subcommand" + assertEquals(r.stderr.includes("Unknown pack subcommand"), false); +}); + +Deno.test("parse: pack install stub exits 1 with not-implemented message", async () => { + const r = await runCli(["pack", "install"]); + assertEquals(r.code, 1); + assertEquals(r.stderr.includes("not yet implemented"), true); +}); + +Deno.test("parse: pack remove stub exits 1 with not-implemented message", async () => { + const r = await runCli(["pack", "remove"]); + assertEquals(r.code, 1); + assertEquals(r.stderr.includes("not yet implemented"), true); +}); + +Deno.test("parse: pack publish stub exits 1 with not-implemented message", async () => { + const r = await runCli(["pack", "publish"]); + assertEquals(r.code, 1); + assertEquals(r.stderr.includes("not yet implemented"), true); +}); diff --git a/cli/tests/contract/help_test.ts b/cli/tests/contract/help_test.ts new file mode 100644 index 00000000..a65f3f7e --- /dev/null +++ b/cli/tests/contract/help_test.ts @@ -0,0 +1,138 @@ +/** + * Contract tests: help text golden file comparisons. + * These tests assert that --help output matches the committed golden files. + */ + +import { assertEquals } from "@std/assert"; +import { assertGolden, runCli } from "../helpers.ts"; +import { join } from "@std/path"; + +const GOLDEN_DIR = new URL("../golden/", import.meta.url).pathname; + +// ─── Top-level help ──────────────────────────────────────────────────────────── + +Deno.test("help: khive --help exits 0", async () => { + const r = await runCli(["--help"]); + assertEquals(r.code, 0); +}); + +Deno.test("help: khive --help matches golden file", async () => { + const r = await runCli(["--help"]); + assertEquals(r.code, 0); + assertGolden(r.stdout, join(GOLDEN_DIR, "help_toplevel.txt")); +}); + +Deno.test("help: khive -h exits 0", async () => { + const r = await runCli(["-h"]); + assertEquals(r.code, 0); +}); + +Deno.test("help: no args shows usage (same as --help)", async () => { + const r = await runCli([]); + assertEquals(r.code, 0); + // Should contain usage info + assertEquals(r.stdout.includes("Usage:"), true); +}); + +// ─── kg group help ───────────────────────────────────────────────────────────── + +Deno.test("help: khive kg --help exits 0", async () => { + const r = await runCli(["kg", "--help"]); + assertEquals(r.code, 0); +}); + +Deno.test("help: khive kg --help matches golden file", async () => { + const r = await runCli(["kg", "--help"]); + assertEquals(r.code, 0); + assertGolden(r.stdout, join(GOLDEN_DIR, "help_kg.txt")); +}); + +Deno.test("help: khive kg -h exits 0", async () => { + const r = await runCli(["kg", "-h"]); + assertEquals(r.code, 0); +}); + +Deno.test("help: khive kg with no subcommand shows usage", async () => { + const r = await runCli(["kg"]); + assertEquals(r.code, 0); + assertEquals(r.stdout.includes("Usage: khive kg"), true); +}); + +// ─── pack group help ─────────────────────────────────────────────────────────── + +Deno.test("help: khive pack --help exits 0", async () => { + const r = await runCli(["pack", "--help"]); + assertEquals(r.code, 0); +}); + +Deno.test("help: khive pack --help matches golden file", async () => { + const r = await runCli(["pack", "--help"]); + assertEquals(r.code, 0); + assertGolden(r.stdout, join(GOLDEN_DIR, "help_pack.txt")); +}); + +Deno.test("help: khive pack with no subcommand shows usage", async () => { + const r = await runCli(["pack"]); + assertEquals(r.code, 0); + assertEquals(r.stdout.includes("Usage: khive pack"), true); +}); + +// ─── auth group help ─────────────────────────────────────────────────────────── + +Deno.test("help: khive auth --help exits 0", async () => { + const r = await runCli(["auth", "--help"]); + assertEquals(r.code, 0); +}); + +Deno.test("help: khive auth --help matches golden file", async () => { + const r = await runCli(["auth", "--help"]); + assertEquals(r.code, 0); + assertGolden(r.stdout, join(GOLDEN_DIR, "help_auth.txt")); +}); + +Deno.test("help: khive auth with no subcommand shows usage", async () => { + const r = await runCli(["auth"]); + assertEquals(r.code, 0); + assertEquals(r.stdout.includes("Usage: khive auth"), true); +}); + +// ─── Content assertions ──────────────────────────────────────────────────────── + +Deno.test("help: top-level help lists all three groups (kg, pack, auth)", async () => { + const r = await runCli(["--help"]); + assertEquals(r.code, 0); + assertEquals(r.stdout.includes("khive kg"), true); + assertEquals(r.stdout.includes("khive pack"), true); + assertEquals(r.stdout.includes("khive auth"), true); +}); + +Deno.test("help: kg help lists all known subcommands", async () => { + const r = await runCli(["kg", "--help"]); + assertEquals(r.code, 0); + for ( + const sub of [ + "init", + "validate", + "commit", + "sync", + "status", + "config", + "embed", + "export", + "import", + "resolve", + "hook", + "migrate", + "diff", + "log", + "stats", + "doctor", + ] + ) { + assertEquals( + r.stdout.includes(sub), + true, + `Expected kg help to mention '${sub}'`, + ); + } +}); diff --git a/cli/tests/contract/output_test.ts b/cli/tests/contract/output_test.ts new file mode 100644 index 00000000..86fb9487 --- /dev/null +++ b/cli/tests/contract/output_test.ts @@ -0,0 +1,92 @@ +/** + * Contract tests: structured output shape validation. + */ + +import { assertEquals, assertMatch } from "@std/assert"; +import { assertJsonShape, makeTempRepo, runCli, runCliIn } from "../helpers.ts"; + +// ─── Version output ──────────────────────────────────────────────────────────── + +Deno.test("output: khive --version prints version string", async () => { + const r = await runCli(["--version"]); + assertEquals(r.code, 0); + // Must contain a semver-like version + assertMatch(r.stdout.trim(), /\d+\.\d+\.\d+/); +}); + +Deno.test("output: khive -V prints version string", async () => { + const r = await runCli(["-V"]); + assertEquals(r.code, 0); + assertMatch(r.stdout.trim(), /\d+\.\d+\.\d+/); +}); + +Deno.test("output: khive --version output starts with 'khive '", async () => { + const r = await runCli(["--version"]); + assertEquals(r.code, 0); + assertEquals(r.stdout.trim().startsWith("khive "), true); +}); + +// ─── stats --json output shape ───────────────────────────────────────────────── + +Deno.test("output: kg stats --json emits valid JSON with expected keys", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "stats", "--json"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + assertJsonShape(r.stdout, ["entityCount", "edgeCount"]); + } finally { + await repo.cleanup(); + } +}); + +Deno.test("output: kg stats --json entityCount is a number", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "stats", "--json"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + const data = JSON.parse(r.stdout) as Record; + assertEquals(typeof data.entityCount, "number"); + } finally { + await repo.cleanup(); + } +}); + +// ─── validate --format json output shape ────────────────────────────────────── + +Deno.test("output: kg validate --format json emits valid JSON with summary", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "validate", "--format", "json"]); + // Exit 0 on valid KG (warnings only do not cause failure) + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + // JSON shape: { rules: [...], summary: { passed, errors, warnings, ... } } + assertJsonShape(r.stdout, ["rules", "summary"]); + } finally { + await repo.cleanup(); + } +}); + +Deno.test("output: kg validate --format json summary.passed is true for clean KG", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "validate", "--format", "json"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + const data = JSON.parse(r.stdout) as { summary: { passed: boolean } }; + assertEquals(data.summary.passed, true); + } finally { + await repo.cleanup(); + } +}); + +// ─── doctor --json output shape ──────────────────────────────────────────────── + +Deno.test("output: kg doctor --json emits valid JSON", async () => { + const repo = await makeTempRepo(); + try { + const r = await runCliIn(repo.root, ["kg", "doctor", "--json"]); + assertEquals(r.code, 0, `stderr: ${r.stderr}`); + assertJsonShape(r.stdout, ["valid"]); + } finally { + await repo.cleanup(); + } +}); diff --git a/cli/tests/golden/help_auth.txt b/cli/tests/golden/help_auth.txt new file mode 100644 index 00000000..7373a408 --- /dev/null +++ b/cli/tests/golden/help_auth.txt @@ -0,0 +1,6 @@ +Usage: khive auth + +Subcommands: + login Sign in via GitHub OAuth + status Show authentication state + logout Remove stored credentials diff --git a/cli/tests/golden/help_kg.txt b/cli/tests/golden/help_kg.txt new file mode 100644 index 00000000..b4194ce1 --- /dev/null +++ b/cli/tests/golden/help_kg.txt @@ -0,0 +1,22 @@ +Usage: khive kg + +Subcommands (Phase C1 — file-level operations): + init Initialise .khive/kg/ in the current git repo + validate Validate NDJSON files + rules.yaml (ADR-056; flags: --strict, --no-rules, --format, --quiet) + commit Validate + stage + git commit .khive/kg/ files + sync Validate NDJSON (DB rebuild: Phase C2) + status Show entity/edge counts and uncommitted changes + config Show or modify .khive/config.toml + embed Plan embedding for entities awaiting vectors (run: Phase C2) + export Re-write canonical .khive/kg/*.ndjson; --format archive emits a JSON bundle + import Import a KgArchive JSON file (flags: --overwrite, --on-conflict ) + resolve Resolve NDJSON merge conflicts after 'git merge' + hook Manage pre-commit validation hook (install|uninstall|status) + migrate Apply schema migrations (ADR-054) + diff Entity-aware diff between two NDJSON states (flags: --json, --name-only) + log Show KG change history (flags: -n , --json, --stat) + stats Show entity/edge counts, kind breakdown, schema coverage (flags: --json) + doctor Validate KG integrity: syntax, refs, duplicates, orphans (flags: --json) + +Planned (Phase C2+): + update Advance a remote pin diff --git a/cli/tests/golden/help_pack.txt b/cli/tests/golden/help_pack.txt new file mode 100644 index 00000000..a5cf56a1 --- /dev/null +++ b/cli/tests/golden/help_pack.txt @@ -0,0 +1,10 @@ +Usage: khive pack + +Subcommands (ADR-050): + init Scaffold a new declarative pack (creates ./pack.yaml) + check Validate a pack.yaml manifest + +Planned: + install Install a pack from registry/local/git + remove Uninstall a pack + publish Publish to a pack registry diff --git a/cli/tests/golden/help_toplevel.txt b/cli/tests/golden/help_toplevel.txt new file mode 100644 index 00000000..a9f2be7a --- /dev/null +++ b/cli/tests/golden/help_toplevel.txt @@ -0,0 +1,38 @@ +khive 0.2.0 — research knowledge graph CLI + +Usage: + khive kg Manage the git-native knowledge graph + khive pack Author and validate declarative packs (ADR-050) + khive auth Authenticate with khive (optional) + +KG subcommands: + init Initialise .khive/kg/ in the current git repo + validate Validate NDJSON files + rules.yaml (ADR-056) + commit Validate NDJSON files + git commit (Phase C1; DB export is Phase C2) + sync Validate NDJSON + create working.db placeholder (Phase C1; DB rebuild is Phase C2) + status Show entity/edge counts and uncommitted changes (file-level; DB diff is Phase C2) + config Show or modify .khive/config.toml (ADR-057) + embed Plan / run entity embedding (ADR-057; Phase C1 plans, Phase C2 runs) + export Re-write canonical .khive/kg/*.ndjson; --format archive emits a JSON bundle + import Import a KgArchive JSON file into NDJSON files + resolve Resolve NDJSON merge conflicts (ADR-053) + hook Manage the pre-commit validation hook (install/uninstall/status) + migrate Apply schema migrations from .khive/kg/migrations/ (ADR-054) + diff Entity-aware diff between two NDJSON states + log Show KG change history (commits touching .khive/kg/ files) + stats Show entity/edge counts, kind breakdown, schema coverage + doctor Validate KG integrity: syntax, refs, duplicates, orphans + update Advance a remote pin in schema.yaml (Phase C2 — not yet implemented) + +Pack subcommands (ADR-050): + init Scaffold a new declarative pack + check Validate a pack.yaml manifest + +Auth subcommands: + login Sign in via GitHub OAuth + status Show current authentication state + logout Remove stored credentials + +All 'khive kg' commands work without a khive auth account. + +Run 'khive --help' for detailed usage. diff --git a/cli/tests/helpers.ts b/cli/tests/helpers.ts new file mode 100644 index 00000000..7a7d6940 --- /dev/null +++ b/cli/tests/helpers.ts @@ -0,0 +1,168 @@ +/** + * Shared test utilities for khive CLI subprocess tests. + */ + +import { join } from "@std/path"; +import { assertEquals, assertMatch } from "@std/assert"; + +const CLI_ENTRY = new URL("../main.ts", import.meta.url).pathname; + +export interface CliResult { + code: number; + stdout: string; + stderr: string; +} + +/** + * Run the CLI with the given args as a subprocess. + * Always resolves (never throws) — check code/stderr for failures. + */ +export async function runCli(args: string[]): Promise { + const cmd = new Deno.Command(Deno.execPath(), { + args: ["run", "--allow-all", CLI_ENTRY, ...args], + stdout: "piped", + stderr: "piped", + env: { ...Deno.env.toObject(), NO_COLOR: "1" }, + }); + const { code, stdout, stderr } = await cmd.output(); + return { + code, + stdout: new TextDecoder().decode(stdout), + stderr: new TextDecoder().decode(stderr), + }; +} + +/** + * Compare actual output against a golden file. + * If UPDATE_GOLDEN=1, write the golden file instead of comparing. + */ +export function assertGolden(actual: string, goldenPath: string): void { + if (Deno.env.get("UPDATE_GOLDEN") === "1") { + Deno.writeTextFileSync(goldenPath, actual); + return; + } + const expected = Deno.readTextFileSync(goldenPath); + assertEquals(actual.trim(), expected.trim()); +} + +/** + * Parse JSON and assert all required keys are present. + */ +export function assertJsonShape(json: string, requiredKeys: string[]): void { + let parsed: Record; + try { + parsed = JSON.parse(json); + } catch { + throw new Error(`Output is not valid JSON:\n${json}`); + } + for (const key of requiredKeys) { + if (!(key in parsed)) { + throw new Error(`Missing required key '${key}' in JSON output:\n${json}`); + } + } +} + +/** + * Assert that the output matches a semver pattern. + */ +export function assertSemver(version: string): void { + assertMatch(version.trim(), /^\d+\.\d+\.\d+/); +} + +// ─── Temp repo helpers ──────────────────────────────────────────────────────── + +export interface TempRepo { + root: string; + cleanup: () => Promise; +} + +/** Minimal .khive/kg/ structure for tests that need a valid KG directory. */ +const MINIMAL_ENTITIES = + `{"id":"00000000-0000-0000-0000-000000000001","name":"Test Entity","kind":"concept"}\n`; +const MINIMAL_EDGES = ""; +// format_version and all 6 entity kinds required by validate.ts +const MINIMAL_SCHEMA = `format_version: "1.0.0" +ontology_version: "1.0.0" +entity_kinds: + - concept + - document + - dataset + - project + - person + - org +edge_relations: + - relation: contains + category: structure + - relation: part_of + category: structure +`; + +/** + * Create a temp directory with a minimal git repo + .khive/kg/ structure. + */ +export async function makeTempRepo(): Promise { + const root = await Deno.makeTempDir({ prefix: "khive_test_" }); + + // Init git repo + await new Deno.Command("git", { + args: ["init", root], + stdout: "null", + stderr: "null", + }).output(); + + await new Deno.Command("git", { + args: ["-C", root, "config", "user.email", "test@test.com"], + stdout: "null", + stderr: "null", + }).output(); + + await new Deno.Command("git", { + args: ["-C", root, "config", "user.name", "Test"], + stdout: "null", + stderr: "null", + }).output(); + + // Create .khive/kg/ structure + const kgDir = join(root, ".khive", "kg"); + await Deno.mkdir(kgDir, { recursive: true }); + await Deno.writeTextFile(join(kgDir, "entities.ndjson"), MINIMAL_ENTITIES); + await Deno.writeTextFile(join(kgDir, "edges.ndjson"), MINIMAL_EDGES); + await Deno.writeTextFile(join(kgDir, "schema.yaml"), MINIMAL_SCHEMA); + + // Stage files + await new Deno.Command("git", { + args: ["-C", root, "add", "-A"], + stdout: "null", + stderr: "null", + }).output(); + + await new Deno.Command("git", { + args: ["-C", root, "commit", "-m", "init", "--no-gpg-sign"], + stdout: "null", + stderr: "null", + }).output(); + + return { + root, + cleanup: () => Deno.remove(root, { recursive: true }), + }; +} + +/** + * Run CLI from within a specific working directory. + */ +export async function runCliIn(cwd: string, args: string[]): Promise { + const cmd = new Deno.Command(Deno.execPath(), { + args: ["run", "--allow-all", CLI_ENTRY, ...args], + cwd, + stdout: "piped", + stderr: "piped", + env: { ...Deno.env.toObject(), NO_COLOR: "1" }, + }); + const { code, stdout, stderr } = await cmd.output(); + return { + code, + stdout: new TextDecoder().decode(stdout), + stderr: new TextDecoder().decode(stderr), + }; +} diff --git a/crates/khive-db/src/backend.rs b/crates/khive-db/src/backend.rs index de2e5124..5653baae 100644 --- a/crates/khive-db/src/backend.rs +++ b/crates/khive-db/src/backend.rs @@ -235,13 +235,15 @@ impl StorageBackend { /// Get a VectorStore for a specific embedding model, scoped to the default namespace. /// /// Creates the vec0 virtual table if it does not already exist. The `model_key` - /// must contain only ASCII alphanumeric/underscore characters. + /// must contain only ASCII alphanumeric/underscore characters. The `embedding_model` + /// is the canonical display name stored in each vector row. pub fn vectors( &self, model_key: &str, + embedding_model: &str, dimensions: usize, ) -> Result, SqliteError> { - self.vectors_for_namespace(model_key, dimensions, "local") + self.vectors_for_namespace(model_key, embedding_model, dimensions, "local") } /// Get a VectorStore for a specific embedding model with a default namespace. @@ -251,9 +253,12 @@ impl StorageBackend { /// (count, delete, info). Access control is enforced at the runtime layer. /// /// The `model_key` must contain only ASCII alphanumeric/underscore characters. + /// The `embedding_model` is the canonical display name stored in the `embedding_model` + /// column of each vector row (e.g. `"all-minilm-l6-v2"`). pub fn vectors_for_namespace( &self, model_key: &str, + embedding_model: &str, dimensions: usize, namespace: &str, ) -> Result, SqliteError> { @@ -298,21 +303,24 @@ impl StorageBackend { .is_some(); if table_exists { - let has_field: bool = { + let (has_field, has_embedding_model) = { let pragma = format!("PRAGMA table_xinfo({})", table); let mut stmt = writer.conn().prepare(&pragma)?; let mut rows = stmt.query([])?; - let mut found = false; + let mut has_field = false; + let mut has_embedding_model = false; while let Some(row) = rows.next()? { let name: String = row.get(1)?; if name == "field" { - found = true; - break; + has_field = true; + } + if name == "embedding_model" { + has_embedding_model = true; } } - found + (has_field, has_embedding_model) }; - if !has_field { + if !has_field || !has_embedding_model { let drop_ddl = format!("DROP TABLE IF EXISTS {}", table); writer.conn().execute_batch(&drop_ddl)?; } @@ -332,19 +340,13 @@ impl StorageBackend { // Create the vec0 virtual table. Idempotent on fresh databases and after the // old-schema rebuild above. - // - // NOTE: `embedding_model_id` is NOT included in this DDL because sqlite-vec - // enforces NOT NULL on TEXT metadata columns at insert time, so the column - // cannot be added at virtual-table creation as a nullable FK. The column will - // be present after the ADR-043 §8 startup backfill rebuild (steps 2-4), which - // is deferred to a follow-up PR — see the tracking issue filed against MAJ-2 - // of codex round-1 review of PR #374. let ddl = format!( "CREATE VIRTUAL TABLE IF NOT EXISTS vec_{} USING vec0(\ subject_id TEXT PRIMARY KEY, \ namespace TEXT NOT NULL, \ kind TEXT NOT NULL, \ field TEXT NOT NULL, \ + embedding_model TEXT NOT NULL, \ embedding float[{}] distance_metric=cosine\ )", model_key, dimensions @@ -355,11 +357,54 @@ impl StorageBackend { Arc::clone(&self.pool), self.is_file_backed, model_key.to_string(), + embedding_model.to_string(), dimensions, namespace.trim().to_string(), )?)) } + /// Register an embedding model in the `_embedding_models` registry table (ADR-043). + /// + /// Idempotent: if a row with the same `canonical_key` already exists, updates its + /// status back to `'active'` without changing other fields. + pub fn register_embedding_model( + &self, + engine_name: &str, + model_id: &str, + key_version: &str, + dimensions: u32, + ) -> Result<(), SqliteError> { + let writer = self.pool.try_writer()?; + writer + .conn() + .execute_batch(crate::migrations::EMBEDDING_MODELS_DDL)?; + + let now = chrono::Utc::now().timestamp_micros(); + let canonical_key = + format!("{engine_name}:{model_id}:{key_version}:{dimensions}").into_bytes(); + let id = uuid::Uuid::new_v4(); + writer.conn().execute( + "INSERT INTO _embedding_models \ + (id, engine_name, model_id, key_version, dim, output_dim, status, \ + activated_at, superseded_at, superseded_by, canonical_key, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, NULL, 'active', ?6, NULL, NULL, ?7, ?8) \ + ON CONFLICT(canonical_key) DO UPDATE SET \ + status = 'active', \ + activated_at = COALESCE(_embedding_models.activated_at, excluded.activated_at)", + rusqlite::params![ + id.as_bytes().as_slice(), + engine_name, + model_id, + key_version, + dimensions as i64, + now, + canonical_key, + now, + ], + )?; + Ok(()) + } + /// Get a SparseStore for a specific model key, scoped to the default namespace. /// /// Creates the sparse table if it does not already exist. @@ -599,7 +644,7 @@ mod tests { #[cfg(feature = "vectors")] async fn vectors_roundtrip_via_public_api() { let backend = StorageBackend::memory().unwrap(); - let store = backend.vectors("test_api", 3).unwrap(); + let store = backend.vectors("test_api", "test_api", 3).unwrap(); let id = uuid::Uuid::new_v4(); store @@ -619,6 +664,7 @@ mod tests { top_k: 1, namespace: None, kind: None, + embedding_model: None, filter: None, backend_hints: None, }) @@ -635,8 +681,8 @@ mod tests { async fn vectors_creates_table_idempotently() { let backend = StorageBackend::memory().unwrap(); - let store1 = backend.vectors("idempotent", 3).unwrap(); - let store2 = backend.vectors("idempotent", 3).unwrap(); + let store1 = backend.vectors("idempotent", "idempotent", 3).unwrap(); + let store2 = backend.vectors("idempotent", "idempotent", 3).unwrap(); let id = uuid::Uuid::new_v4(); store1 @@ -724,8 +770,8 @@ mod tests { #[test] fn invalid_model_key_rejected() { let backend = StorageBackend::memory().unwrap(); - assert!(backend.vectors("bad key!", 3).is_err()); - assert!(backend.vectors("", 3).is_err()); + assert!(backend.vectors("bad key!", "bad key!", 3).is_err()); + assert!(backend.vectors("", "", 3).is_err()); } #[test] diff --git a/crates/khive-db/src/lib.rs b/crates/khive-db/src/lib.rs index e4a8b0bc..2a832372 100644 --- a/crates/khive-db/src/lib.rs +++ b/crates/khive-db/src/lib.rs @@ -9,7 +9,8 @@ pub mod stores; pub use backend::StorageBackend; pub use error::SqliteError; pub use migrations::{ - run_migrations, Migration, ServiceSchemaPlan, VersionedMigration, MIGRATIONS, + query_embedding_models, run_migrations, EmbeddingModelRegistryRecord, Migration, + ServiceSchemaPlan, VersionedMigration, MIGRATIONS, }; pub use pool::{ConnectionPool, PoolConfig, ReaderGuard, WriterGuard}; pub use sql_bridge::SqlBridge; diff --git a/crates/khive-db/src/migrations.rs b/crates/khive-db/src/migrations.rs index 7d727289..83b7b282 100644 --- a/crates/khive-db/src/migrations.rs +++ b/crates/khive-db/src/migrations.rs @@ -371,6 +371,15 @@ pub const EMBEDDING_MODELS_DDL: &str = "\ /// step for any table that already has the column. const V14_EMBEDDING_MODEL_REGISTRY: &str = "__v14_computed_at_runtime__"; +/// V16: Add `embedding_model` column and composite index to regular `vec_` tables. +/// +/// This migration is computed at runtime via `build_v16_vector_embedding_model_tag_sql` +/// to discover existing regular (non-virtual) `vec_` tables and add the column where +/// absent. sqlite-vec virtual tables (`vec0`) are handled at open time by the +/// `vectors_for_namespace` old-schema detection which drops and recreates tables +/// missing `embedding_model`. +const V16_VECTOR_EMBEDDING_MODEL_TAG: &str = "__v16_computed_at_runtime__"; + /// V15: proposals_open projection table (ADR-046). /// /// Maintains a fold-derived view of the four proposal EventKinds so that @@ -485,6 +494,12 @@ pub const MIGRATIONS: &[VersionedMigration] = &[ name: "proposals_open", up: V15_PROPOSALS_OPEN, }, + // V16: tag vector rows with embedding_model column (ADR-043 §8, dual-embedding). + VersionedMigration { + version: 16, + name: "vector_embedding_model_tag", + up: V16_VECTOR_EMBEDDING_MODEL_TAG, + }, ]; const MIGRATION_TRACKING_TABLE: &str = "\ @@ -701,6 +716,11 @@ pub fn run_migrations(conn: &mut Connection) -> Result { version: migration.version, error: e.to_string(), })? + } else if migration.version == 16 { + build_v16_vector_embedding_model_tag_sql(&tx).map_err(|e| SqliteError::Migration { + version: migration.version, + error: e.to_string(), + })? } else { migration.up.to_string() }; @@ -876,6 +896,129 @@ fn build_v14_embedding_model_registry_sql(conn: &Connection) -> Result Result { + let mut stmt = conn.prepare( + "SELECT name FROM sqlite_master \ + WHERE type = 'table' \ + AND name LIKE 'vec_%' \ + AND sql NOT LIKE '%VIRTUAL%' \ + AND sql NOT LIKE '%vec0%' \ + AND name NOT LIKE '%\\_chunks' ESCAPE '\\' \ + AND name NOT LIKE '%\\_rowids' ESCAPE '\\' \ + AND name NOT LIKE '%\\_info' ESCAPE '\\' \ + AND name NOT LIKE '%\\_vector\\_chunks%' ESCAPE '\\'", + )?; + let vec_tables: Vec = stmt + .query_map([], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); + + let mut sql = String::new(); + for table in vec_tables { + let valid = table.starts_with("vec_") + && table[4..] + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_'); + if !valid { + continue; + } + let col_exists: bool = conn + .query_row( + "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) WHERE name = 'embedding_model'", + rusqlite::params![&table], + |row| row.get(0), + ) + .unwrap_or(false); + if col_exists { + continue; + } + sql.push_str(&format!( + "ALTER TABLE {t} ADD COLUMN embedding_model TEXT NOT NULL DEFAULT 'all-minilm-l6-v2';\ + CREATE INDEX IF NOT EXISTS idx_{t}_subject_model ON {t}(subject_id, embedding_model);", + t = table, + )); + } + if sql.is_empty() { + sql.push_str("SELECT 1;"); + } + Ok(sql) +} + +/// A record from the `_embedding_models` registry table. +#[derive(Clone, Debug)] +pub struct EmbeddingModelRegistryRecord { + pub engine_name: String, + pub model_id: String, + pub key_version: String, + pub dimensions: u32, + pub status: String, + pub activated_at: Option, + pub superseded_at: Option, +} + +/// Query the `_embedding_models` registry. +/// +/// Opens the database at `db` (defaults to `~/.khive/khive-graph.db`) and +/// returns all registry rows, optionally filtered by `engine_name`. +/// Returns an empty vec if the database or table does not exist. +pub fn query_embedding_models( + db: Option<&std::path::Path>, + engine_filter: Option<&str>, +) -> Result, SqliteError> { + let path = db.map(std::path::Path::to_path_buf).unwrap_or_else(|| { + std::env::var("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from(".")) + .join(".khive/khive-graph.db") + }); + if !path.exists() { + return Ok(Vec::new()); + } + + let conn = Connection::open(path)?; + let exists: bool = conn.query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master \ + WHERE type='table' AND name='_embedding_models'", + [], + |row| row.get(0), + )?; + if !exists { + return Ok(Vec::new()); + } + + let sql = if engine_filter.is_some() { + "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \ + FROM _embedding_models WHERE engine_name = ?1 \ + ORDER BY engine_name, activated_at IS NULL, activated_at" + } else { + "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \ + FROM _embedding_models \ + ORDER BY engine_name, activated_at IS NULL, activated_at" + }; + let mut stmt = conn.prepare(sql)?; + let map_row = |row: &rusqlite::Row<'_>| { + Ok(EmbeddingModelRegistryRecord { + engine_name: row.get(0)?, + model_id: row.get(1)?, + key_version: row.get(2)?, + dimensions: row.get::<_, i64>(3)? as u32, + status: row.get(4)?, + activated_at: row.get(5)?, + superseded_at: row.get(6)?, + }) + }; + + if let Some(engine) = engine_filter { + stmt.query_map([engine], map_row)? + .collect::, _>>() + .map_err(Into::into) + } else { + stmt.query_map([], map_row)? + .collect::, _>>() + .map_err(Into::into) + } +} + // ============================================================================= // Tests // ============================================================================= @@ -892,17 +1035,17 @@ mod tests { fn fresh_db_migrates_to_latest() { let mut conn = open_memory(); let version = run_migrations(&mut conn).expect("migrations should succeed"); - assert_eq!(version, 15); + assert_eq!(version, 16); - // Verify the tracking table has rows for V1 through V15. + // Verify the tracking table has rows for V1 through V16. let count: i64 = conn .query_row( - "SELECT COUNT(*) FROM _schema_migrations WHERE version IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15)", + "SELECT COUNT(*) FROM _schema_migrations WHERE version IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)", [], |row| row.get(0), ) .unwrap(); - assert_eq!(count, 15); + assert_eq!(count, 16); // Verify the entities table was created. let tbl_count: i64 = conn @@ -1083,16 +1226,16 @@ mod tests { let mut conn = open_memory(); let v1 = run_migrations(&mut conn).expect("first run"); let v2 = run_migrations(&mut conn).expect("second run"); - assert_eq!(v1, 15); - assert_eq!(v2, 15); + assert_eq!(v1, 16); + assert_eq!(v2, 16); - // Should still have exactly fifteen rows in the tracking table (V1..V15). + // Should still have exactly sixteen rows in the tracking table (V1..V16). let count: i64 = conn .query_row("SELECT COUNT(*) FROM _schema_migrations", [], |row| { row.get(0) }) .unwrap(); - assert_eq!(count, 15); + assert_eq!(count, 16); } // F052 (CRIT): V9 migration must add target_backend column + partial index on graph_edges. @@ -1102,8 +1245,8 @@ mod tests { let mut conn = open_memory(); let version = run_migrations(&mut conn).expect("migrations should succeed"); assert_eq!( - version, 15, - "F052: latest migration must be V15 (proposals_open)" + version, 16, + "F052: latest migration must be V16 (vector_embedding_model_tag)" ); let col: i64 = conn .query_row( @@ -1131,40 +1274,43 @@ mod tests { #[test] fn failed_migration_rolls_back() { - let bad_v16 = VersionedMigration { - version: 16, + let bad_v17 = VersionedMigration { + version: 17, name: "bad_migration", up: "THIS IS NOT VALID SQL;", }; let mut conn = open_memory(); - // Apply all real migrations (V1..V15) so the DB is at V15. - run_migrations(&mut conn).expect("V1..V15 should apply cleanly"); + // Apply all real migrations (V1..V16) so the DB is at V16. + run_migrations(&mut conn).expect("V1..V16 should apply cleanly"); - // Now manually drive the bad V16 migration to check rollback behaviour. - let result = apply_single_migration(&mut conn, &bad_v16); + // Now manually drive the bad V17 migration to check rollback behaviour. + let result = apply_single_migration(&mut conn, &bad_v17); assert!(result.is_err(), "bad migration should return error"); - // DB should still be at V15 — no V16 row in tracking. - let v16_count: i64 = conn + // DB should still be at V16 — no V17 row in tracking. + let v17_count: i64 = conn .query_row( - "SELECT COUNT(*) FROM _schema_migrations WHERE version = 16", + "SELECT COUNT(*) FROM _schema_migrations WHERE version = 17", [], |row| row.get(0), ) .unwrap(); - assert_eq!(v16_count, 0, "V16 must not be recorded after rollback"); + assert_eq!(v17_count, 0, "V17 must not be recorded after rollback"); - // V1..V15 should still be there. + // V1..V16 should still be there. let applied_count: i64 = conn .query_row( - "SELECT COUNT(*) FROM _schema_migrations WHERE version IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15)", + "SELECT COUNT(*) FROM _schema_migrations WHERE version IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)", [], |row| row.get(0), ) .unwrap(); - assert_eq!(applied_count, 15, "V1..V15 must still be recorded"); + assert_eq!( + applied_count, 16, + "V1..V16 must still be recorded after V17 rollback" + ); } #[test] @@ -1198,9 +1344,10 @@ mod tests { // V12 should detect that salience is already nullable and skip; // V13 adds event observability columns and event_observations table; // V14 creates the _embedding_models registry table; - // V15 creates the proposals_open table. + // V15 creates the proposals_open table; + // V16 adds embedding_model column to regular vec_ tables. let version = run_migrations(&mut conn).expect("migrations after store DDL"); - assert_eq!(version, 15); + assert_eq!(version, 16); // V2 should be recorded as applied (skipped but tracked). let v2_count: i64 = conn @@ -1390,9 +1537,9 @@ mod tests { ) .unwrap(); - // Run V2-V15 migrations. + // Run V2-V16 migrations. let version = run_migrations(&mut conn).expect("migrations should succeed"); - assert_eq!(version, 15); + assert_eq!(version, 16); // After V12, salience must be nullable (notnull=0). let notnull: i64 = conn @@ -1436,7 +1583,7 @@ mod tests { ensure_events_schema(&conn).expect("store DDL should create events"); let version = run_migrations(&mut conn).expect("migrations after events store DDL"); - assert_eq!(version, 15, "must reach V15 even when events DDL ran first"); + assert_eq!(version, 16, "must reach V16 even when events DDL ran first"); let v13_count: i64 = conn .query_row( @@ -1477,8 +1624,8 @@ mod tests { let mut conn = open_memory(); let version = run_migrations(&mut conn).expect("migrations should succeed"); assert_eq!( - version, 15, - "F227: latest migration must be V15 (proposals_open)" + version, 16, + "F227: latest migration must be V16 (vector_embedding_model_tag)" ); // Verify _embedding_models table exists. @@ -1575,7 +1722,7 @@ mod tests { // Run the full migration suite — V14 should add embedding_model_id to the // regular vec_legacy_model table. let version = run_migrations(&mut conn).expect("migrations should succeed"); - assert_eq!(version, 15); + assert_eq!(version, 16); // The embedding_model_id column must now exist. let col_exists: bool = conn @@ -1592,7 +1739,7 @@ mod tests { // Running migrations again must be idempotent (column already present). let version2 = run_migrations(&mut conn).expect("second run must succeed"); - assert_eq!(version2, 15); + assert_eq!(version2, 16); } /// CRIT-2 regression: V14 discovery filter must NOT match sqlite-vec internal @@ -1624,7 +1771,7 @@ mod tests { // Run the full migration suite — V14 must not add `embedding_model_id` to // any of the four shadow tables above. let version = run_migrations(&mut conn).expect("migrations should succeed"); - assert_eq!(version, 15); + assert_eq!(version, 16); for shadow in [ "vec_test_chunks", diff --git a/crates/khive-db/src/stores/vectors.rs b/crates/khive-db/src/stores/vectors.rs index 3fa06de5..3b4754eb 100644 --- a/crates/khive-db/src/stores/vectors.rs +++ b/crates/khive-db/src/stores/vectors.rs @@ -86,6 +86,7 @@ pub struct SqliteVecStore { pool: Arc, is_file_backed: bool, model_key: String, + embedding_model: String, dimensions: usize, table_name: String, namespace: String, @@ -99,6 +100,7 @@ impl SqliteVecStore { pool: Arc, is_file_backed: bool, model_key: String, + embedding_model: String, dimensions: usize, namespace: String, ) -> Result { @@ -108,6 +110,7 @@ impl SqliteVecStore { pool, is_file_backed, model_key, + embedding_model, dimensions, table_name, namespace, @@ -200,6 +203,7 @@ impl VectorStore for SqliteVecStore { let namespace = namespace.to_string(); let field = field.to_string(); let kind_str = kind.to_string(); + let embedding_model = self.embedding_model.clone(); if embedding.len() == dims { if let Some(idx) = non_finite_index(&embedding) { @@ -226,13 +230,21 @@ impl VectorStore for SqliteVecStore { )?; let ins_sql = format!( - "INSERT INTO {} (subject_id, namespace, kind, field, embedding) VALUES (?1, ?2, ?3, ?4, ?5)", + "INSERT INTO {} (subject_id, namespace, kind, field, embedding_model, embedding) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", table ); let blob = f32_slice_as_bytes(&embedding); conn.execute( &ins_sql, - rusqlite::params![subject_id.to_string(), &namespace, &kind_str, &field, blob], + rusqlite::params![ + subject_id.to_string(), + &namespace, + &kind_str, + &field, + &embedding_model, + blob + ], )?; Ok(()) }) @@ -246,6 +258,7 @@ impl VectorStore for SqliteVecStore { let table = self.table_name.clone(); let dims = self.dimensions; let attempted = records.len() as u64; + let store_embedding_model = self.embedding_model.clone(); self.with_writer("vec_insert_batch", move |conn| { let del_sql = format!( @@ -253,7 +266,8 @@ impl VectorStore for SqliteVecStore { table ); let ins_sql = format!( - "INSERT INTO {} (subject_id, namespace, kind, field, embedding) VALUES (?1, ?2, ?3, ?4, ?5)", + "INSERT INTO {} (subject_id, namespace, kind, field, embedding_model, embedding) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", table ); @@ -282,7 +296,14 @@ impl VectorStore for SqliteVecStore { let _ = conn.execute(&del_sql, rusqlite::params![&id_str, &record.namespace]); match conn.execute( &ins_sql, - rusqlite::params![&id_str, &record.namespace, &kind_str, &record.field, blob], + rusqlite::params![ + &id_str, + &record.namespace, + &kind_str, + &record.field, + &store_embedding_model, + blob + ], ) { Ok(_) => affected += 1, Err(_) => failed += 1, @@ -358,6 +379,11 @@ impl VectorStore for SqliteVecStore { .clone() .unwrap_or_else(|| self.namespace.clone()); let kind_filter = request.kind.map(|k| k.to_string()); + // Use the request's embedding_model filter, or fall back to this store's model. + let effective_model = request + .embedding_model + .clone() + .unwrap_or_else(|| self.embedding_model.clone()); if query_embedding.len() == dims { if let Some(idx) = non_finite_index(&query_embedding) { @@ -377,10 +403,10 @@ impl VectorStore for SqliteVecStore { )); } - // Restrict candidate set to namespace (and optionally kind) via subquery, - // then MATCH-rank by embedding distance. + // Restrict candidate set to namespace+embedding_model (and optionally kind) + // via subquery, then MATCH-rank by embedding distance. let subquery_kind_clause = if kind_filter.is_some() { - "AND kind = ?4" + "AND kind = ?5" } else { "" }; @@ -389,7 +415,8 @@ impl VectorStore for SqliteVecStore { FROM {t} \ WHERE embedding MATCH ?1 \ AND subject_id IN (\ - SELECT subject_id FROM {t} WHERE namespace = ?3 {kind_clause}\ + SELECT subject_id FROM {t} \ + WHERE namespace = ?3 AND embedding_model = ?4 {kind_clause}\ ) \ ORDER BY distance \ LIMIT ?2", @@ -405,7 +432,13 @@ impl VectorStore for SqliteVecStore { let raw_rows: Vec> = if let Some(ref kind_str) = kind_filter { stmt.query_map( - rusqlite::params![query_blob, request.top_k, &namespace, kind_str], + rusqlite::params![ + query_blob, + request.top_k, + &namespace, + &effective_model, + kind_str + ], |row| { let id_str: String = row.get(0)?; let distance: f64 = row.get(1)?; @@ -415,7 +448,7 @@ impl VectorStore for SqliteVecStore { .collect() } else { stmt.query_map( - rusqlite::params![query_blob, request.top_k, &namespace], + rusqlite::params![query_blob, request.top_k, &namespace, &effective_model], |row| { let id_str: String = row.get(0)?; let distance: f64 = row.get(1)?; @@ -531,6 +564,7 @@ impl SqliteVecStore { let table = self.table_name.clone(); let namespace = self.namespace.clone(); + let embedding_model = self.embedding_model.clone(); let query_vec = query_embedding.to_vec(); let ids: Vec = candidate_ids.iter().map(|id| id.to_string()).collect(); @@ -542,22 +576,24 @@ impl SqliteVecStore { let placeholders: String = chunk .iter() .enumerate() - .map(|(i, _)| format!("?{}", i + 3)) + .map(|(i, _)| format!("?{}", i + 4)) .collect::>() .join(", "); let sql = format!( "SELECT e.subject_id, vec_distance_cosine(e.embedding, ?1) as distance \ FROM {} e \ - WHERE e.namespace = ?2 AND e.subject_id IN ({})", + WHERE e.namespace = ?2 AND e.embedding_model = ?3 \ + AND e.subject_id IN ({})", table, placeholders ); let mut stmt = conn.prepare(&sql)?; stmt.raw_bind_parameter(1, query_blob)?; stmt.raw_bind_parameter(2, namespace.as_str())?; + stmt.raw_bind_parameter(3, embedding_model.as_str())?; for (i, id_str) in chunk.iter().enumerate() { - stmt.raw_bind_parameter(i + 3, id_str.as_str())?; + stmt.raw_bind_parameter(i + 4, id_str.as_str())?; } let mut rows = stmt.raw_query(); @@ -612,6 +648,7 @@ mod capabilities_tests { make_pool(), /*is_file_backed=*/ false, "test_model".into(), + "test_model".into(), /*dimensions=*/ 4, "ns:test".into(), ) @@ -657,6 +694,7 @@ mod capabilities_tests { make_pool(), false, "test_dim_limit".into(), + "test_dim_limit".into(), /*dimensions=*/ 4, "ns:test".into(), ) @@ -684,6 +722,7 @@ mod capabilities_tests { make_pool(), false, "test_idempotent".into(), + "test_idempotent".into(), 4, "ns:test".into(), ) diff --git a/crates/khive-db/tests/contract/backend.rs b/crates/khive-db/tests/contract/backend.rs index bbe296f5..34129257 100644 --- a/crates/khive-db/tests/contract/backend.rs +++ b/crates/khive-db/tests/contract/backend.rs @@ -337,7 +337,7 @@ mod vector_contract { async fn test_vector_store(backend: &StorageBackend) { let store = backend - .vectors_for_namespace("ct_model", 4, "ct_ns") + .vectors_for_namespace("ct_model", "ct_model", 4, "ct_ns") .expect("vector store"); let id = Uuid::new_v4(); @@ -361,6 +361,7 @@ mod vector_contract { top_k: 1, namespace: None, kind: None, + embedding_model: None, filter: None, backend_hints: None, }) diff --git a/crates/khive-db/tests/contract/vector_filter.rs b/crates/khive-db/tests/contract/vector_filter.rs index 5b37ed35..e9be4745 100644 --- a/crates/khive-db/tests/contract/vector_filter.rs +++ b/crates/khive-db/tests/contract/vector_filter.rs @@ -18,7 +18,9 @@ mod vector_filter_contract { #[tokio::test] async fn search_with_non_empty_filter_returns_unsupported() { let backend = StorageBackend::memory().expect("in-memory backend"); - let store = backend.vectors("filter_test", 3).expect("vector store"); + let store = backend + .vectors("filter_test", "filter_test", 3) + .expect("vector store"); // Insert one record so the table is non-empty. let id = Uuid::new_v4(); @@ -39,6 +41,7 @@ mod vector_filter_contract { top_k: 5, namespace: None, kind: None, + embedding_model: None, filter: Some(VectorMetadataFilter { namespaces: vec!["local".into()], kinds: vec![], @@ -64,7 +67,9 @@ mod vector_filter_contract { #[tokio::test] async fn search_with_filter_empty_delegates_and_non_empty_rejects() { let backend = StorageBackend::memory().expect("in-memory backend"); - let store = backend.vectors("filter_delegate", 3).expect("vector store"); + let store = backend + .vectors("filter_delegate", "filter_delegate", 3) + .expect("vector store"); let id = Uuid::new_v4(); store @@ -83,6 +88,7 @@ mod vector_filter_contract { top_k: 1, namespace: None, kind: None, + embedding_model: None, filter: None, backend_hints: None, }; @@ -154,7 +160,7 @@ mod vector_filter_contract { // the old schema and rebuild the table transparently. let new_backend = StorageBackend::sqlite(&db_path).expect("reopen db"); let store = new_backend - .vectors_for_namespace("old_model", 3, "local") + .vectors_for_namespace("old_model", "old_model", 3, "local") .expect("vectors_for_namespace must succeed after schema rebuild"); // Step 3: insert and search in the new shape must work. @@ -176,6 +182,7 @@ mod vector_filter_contract { top_k: 1, namespace: None, kind: None, + embedding_model: None, filter: None, backend_hints: None, }) diff --git a/crates/khive-pack-memory/Cargo.toml b/crates/khive-pack-memory/Cargo.toml index e1a60e7a..d01a040b 100644 --- a/crates/khive-pack-memory/Cargo.toml +++ b/crates/khive-pack-memory/Cargo.toml @@ -13,6 +13,7 @@ description = "Memory verb pack — remember/recall semantics with decay-aware r [dependencies] khive-types = { version = "0.2.2", path = "../khive-types", features = ["serde"] } khive-runtime = { version = "0.2.2", path = "../khive-runtime" } +khive-retrieval = { version = "0.2.2", path = "../khive-retrieval" } khive-pack-brain = { version = "0.2.2", path = "../khive-pack-brain" } inventory = { workspace = true } khive-storage = { version = "0.2.2", path = "../khive-storage" } diff --git a/crates/khive-pack-memory/src/config.rs b/crates/khive-pack-memory/src/config.rs index 103faa5a..9c603a42 100644 --- a/crates/khive-pack-memory/src/config.rs +++ b/crates/khive-pack-memory/src/config.rs @@ -51,6 +51,7 @@ pub struct RecallConfig { pub fallback_during_migration: bool, } +// Tuned 2026-05-25: grid search over 116 configs (quick). PARTIAL — eval too easy to discriminate params. Changed: half_life 30→14, decay exp→hyp, multiplier 20→10. See tests/khive-contract/tune/REPORT.md. impl Default for RecallConfig { fn default() -> Self { Self { @@ -59,9 +60,9 @@ impl Default for RecallConfig { temporal_weight: 0.10, reranker_weights: HashMap::new(), reranker_params: HashMap::new(), - temporal_half_life_days: 30.0, - decay_model: DecayModel::default(), - candidate_multiplier: 20, + temporal_half_life_days: 14.0, + decay_model: DecayModel::Hyperbolic, + candidate_multiplier: 10, candidate_limit: None, fuse_strategy: FusionStrategy::default(), min_score: 0.0, @@ -442,7 +443,7 @@ mod tests { // unspecified fields keep defaults let diff2 = (cfg.importance_weight - 0.20).abs(); assert!(diff2 < 1e-12); - assert_eq!(cfg.decay_model, DecayModel::Exponential); + assert_eq!(cfg.decay_model, DecayModel::Hyperbolic); } // ── RecallConfig new fields ─────────────────────────────────────────────── diff --git a/crates/khive-pack-memory/src/handlers.rs b/crates/khive-pack-memory/src/handlers.rs index 6667a7f8..ad6ff3db 100644 --- a/crates/khive-pack-memory/src/handlers.rs +++ b/crates/khive-pack-memory/src/handlers.rs @@ -4,8 +4,13 @@ use serde::Deserialize; use serde_json::{json, Value}; use uuid::Uuid; -use khive_runtime::fusion::fuse_with_strategy; -use khive_runtime::{NamespaceToken, RuntimeError, SearchHit, SearchSource, VerbRegistry}; +use khive_retrieval::{ + fuse_search_results, FusionStrategy as RetrievalFusionStrategy, HybridConfig, +}; +use khive_runtime::{ + FusionStrategy as RuntimeFusionStrategy, NamespaceToken, RuntimeError, SearchHit, SearchSource, + VerbRegistry, +}; use khive_storage::types::{ TextFilter, TextQueryMode, TextSearchHit, TextSearchRequest, VectorSearchHit, VectorSearchRequest, @@ -32,6 +37,19 @@ fn validate_memory_type(mt: &str) -> Result<(), RuntimeError> { } } +fn parse_fusion_strategy_str(s: &str) -> Result { + match s { + "rrf" => Ok(RuntimeFusionStrategy::Rrf { k: 60 }), + "weighted" => Ok(RuntimeFusionStrategy::Weighted { + weights: vec![0.3, 0.7], + }), + "union" => Ok(RuntimeFusionStrategy::Union), + other => Err(RuntimeError::InvalidInput(format!( + "invalid fusion_strategy {other:?}: must be one of \"rrf\", \"weighted\", \"union\"" + ))), + } +} + #[derive(Deserialize)] struct RememberParams { content: String, @@ -43,6 +61,8 @@ struct RememberParams { #[serde(alias = "source")] source_id: Option, tags: Option>, + #[serde(default)] + embedding_model: Option, } #[derive(Deserialize)] @@ -53,6 +73,11 @@ struct RecallParams { min_score: Option, min_salience: Option, config: Option, + top_k: Option, + fusion_strategy: Option, + score_floor: Option, + #[serde(default)] + embedding_model: Option, } impl RecallParams { @@ -138,6 +163,49 @@ fn search_source_label(source: SearchSource) -> &'static str { } } +#[derive(Default)] +struct CandidateMeta { + in_text: bool, + in_vector: bool, + title: Option, + snippet: Option, +} + +fn to_retrieval_fusion_strategy(strategy: &RuntimeFusionStrategy) -> RetrievalFusionStrategy { + match strategy { + RuntimeFusionStrategy::Rrf { k } => RetrievalFusionStrategy::Rrf { k: *k }, + RuntimeFusionStrategy::Weighted { .. } => RetrievalFusionStrategy::Weighted { + weights: Vec::new(), + }, + RuntimeFusionStrategy::Union => RetrievalFusionStrategy::Union, + RuntimeFusionStrategy::VectorOnly => RetrievalFusionStrategy::VectorOnly, + } +} + +fn retrieval_hybrid_config(strategy: &RuntimeFusionStrategy, limit: usize) -> HybridConfig { + let mut config = HybridConfig::new(limit) + .with_pool_size(limit) + .with_fusion_strategy(to_retrieval_fusion_strategy(strategy)); + + if let RuntimeFusionStrategy::Weighted { weights } = strategy { + // Runtime weighted fusion uses [text, vector]. HybridConfig uses keyword/vector. + // Preserve arbitrary positive scales — do not clamp via with_weights(). + config.keyword_weight = weights.first().copied().unwrap_or(0.0).max(0.0); + config.vector_weight = weights.get(1).copied().unwrap_or(0.0).max(0.0); + } + + config +} + +fn source_from_meta(meta: &CandidateMeta) -> SearchSource { + match (meta.in_vector, meta.in_text) { + (true, true) => SearchSource::Both, + (true, false) => SearchSource::Vector, + (false, true) => SearchSource::Text, + (false, false) => SearchSource::Text, + } +} + fn fuse_candidates( text_hits: Vec, vector_hits: Vec, @@ -145,15 +213,68 @@ fn fuse_candidates( cfg: &RecallConfig, limit: usize, ) -> Vec { - let text: Vec = text_hits + let mut meta = HashMap::::new(); + + let text_source: Vec<_> = text_hits .into_iter() .filter(|h| memory_ids.contains(&h.subject_id)) + .map(|h| { + let TextSearchHit { + subject_id, + score, + title, + snippet, + .. + } = h; + let entry = meta.entry(subject_id).or_default(); + entry.in_text = true; + if entry.title.is_none() { + entry.title = title; + } + if entry.snippet.is_none() { + entry.snippet = snippet; + } + (subject_id, score) + }) .collect(); - let vec: Vec = vector_hits + + let vector_source: Vec<_> = vector_hits .into_iter() .filter(|h| memory_ids.contains(&h.subject_id)) + .map(|h| { + let entry = meta.entry(h.subject_id).or_default(); + entry.in_vector = true; + (h.subject_id, h.score) + }) .collect(); - fuse_with_strategy(text, vec, &cfg.fuse_strategy, limit) + + let vector_only = matches!(&cfg.fuse_strategy, RuntimeFusionStrategy::VectorOnly); + let sources = if vector_only { + vec![vector_source] + } else { + // HybridConfig weighted convention: vector first, keyword second. + vec![vector_source, text_source] + }; + + let retrieval_cfg = retrieval_hybrid_config(&cfg.fuse_strategy, limit); + fuse_search_results(sources, &retrieval_cfg) + .into_iter() + .map(|(id, score)| { + let m = meta.remove(&id).unwrap_or_default(); + let (source, title, snippet) = if vector_only { + (SearchSource::Vector, None, None) + } else { + (source_from_meta(&m), m.title, m.snippet) + }; + SearchHit { + entity_id: id, + score, + source, + title, + snippet, + } + }) + .collect() } impl MemoryPack { @@ -162,6 +283,7 @@ impl MemoryPack { query: &str, token: &NamespaceToken, candidate_limit: u32, + embedding_model: Option<&str>, ) -> Result { let ns = token.namespace().as_str().to_string(); // F111: restrict text candidates to Note substrate kind so entity records @@ -182,23 +304,28 @@ impl MemoryPack { }) .await?; - let vector_hits = if self.runtime.config().embedding_model.is_some() { - let vec = self.runtime.embed(query).await?; - self.runtime - .vectors(token)? - .search(VectorSearchRequest { - query_vectors: vec![vec], - top_k: candidate_limit, - namespace: Some(ns.clone()), - // F111: already restricts to Note substrate kind - kind: Some(SubstrateKind::Note), - filter: None, - backend_hints: None, - }) - .await? - } else { - Vec::new() - }; + let vector_hits = + if self.runtime.config().embedding_model.is_some() || embedding_model.is_some() { + let model_name: String = embedding_model + .map(|m| m.to_string()) + .unwrap_or_else(|| self.runtime.default_embedder_name().to_string()); + let vec = self.runtime.embed_with_model(&model_name, query).await?; + self.runtime + .vectors_for_model(token, &model_name)? + .search(VectorSearchRequest { + query_vectors: vec![vec], + top_k: candidate_limit, + namespace: Some(ns.clone()), + // F111: already restricts to Note substrate kind + kind: Some(SubstrateKind::Note), + embedding_model: Some(model_name), + filter: None, + backend_hints: None, + }) + .await? + } else { + Vec::new() + }; Ok(RecallCandidateSet { namespace: ns, @@ -301,7 +428,7 @@ impl MemoryPack { let note = self .runtime - .create_note_with_decay( + .create_note_with_decay_for_embedding_model( token, "memory", None, @@ -310,6 +437,7 @@ impl MemoryPack { decay_factor, Some(props), annotates, + p.embedding_model.as_deref(), ) .await?; @@ -335,13 +463,43 @@ impl MemoryPack { validate_memory_type(mt)?; } - let cfg = p.effective_config(self.active_config()); + if let Some(ref fs) = p.fusion_strategy { + parse_fusion_strategy_str(fs)?; + } + + let mut cfg = p.effective_config(self.active_config()); + if let Some(ref fs) = p.fusion_strategy { + let mut new_strategy = parse_fusion_strategy_str(fs)?; + // "weighted" in the request means "use weighted fusion" — the actual + // weight values come from pack config, not the request (ADR-033 §6.1). + if let ( + RuntimeFusionStrategy::Weighted { + weights: ref mut new_w, + }, + RuntimeFusionStrategy::Weighted { + weights: ref existing_w, + }, + ) = (&mut new_strategy, &cfg.fuse_strategy) + { + *new_w = existing_w.clone(); + } + cfg.fuse_strategy = new_strategy; + } cfg.validate()?; - let limit = p.limit.unwrap_or(10).min(100); + let limit = if let Some(k) = p.top_k { + (k as u32).min(100) + } else { + p.limit.unwrap_or(10).min(100) + }; let candidate_limit = recall_candidate_count(&cfg, limit); let candidates = self - .collect_recall_candidates(&p.query, token, candidate_limit) + .collect_recall_candidates( + &p.query, + token, + candidate_limit, + p.embedding_model.as_deref(), + ) .await?; let (memory_ids, mut notes_by_id) = self .load_memory_candidate_notes(token, &candidates.text_hits, &candidates.vector_hits) @@ -392,6 +550,11 @@ impl MemoryPack { if final_score < cfg.min_score { continue; } + if let Some(floor) = p.score_floor { + if final_score < floor as f64 { + continue; + } + } ranked.push((id, final_score, breakdown, note)); } @@ -457,7 +620,12 @@ impl MemoryPack { let limit = p.limit.unwrap_or(10).min(100); let candidate_limit = recall_candidate_count(&cfg, limit); let candidates = self - .collect_recall_candidates(&p.query, token, candidate_limit) + .collect_recall_candidates( + &p.query, + token, + candidate_limit, + p.embedding_model.as_deref(), + ) .await?; let text_candidates: Vec = candidates @@ -510,7 +678,12 @@ impl MemoryPack { let limit = p.limit.unwrap_or(10).min(100); let candidate_limit = recall_candidate_count(&cfg, limit); let candidates = self - .collect_recall_candidates(&p.query, token, candidate_limit) + .collect_recall_candidates( + &p.query, + token, + candidate_limit, + p.embedding_model.as_deref(), + ) .await?; let (memory_ids, notes_by_id) = self .load_memory_candidate_notes(token, &candidates.text_hits, &candidates.vector_hits) @@ -632,6 +805,7 @@ impl MemoryPack { #[cfg(test)] mod tests { use super::*; + use crate::config::DecayModel; #[test] fn validate_memory_type_rejects_invalid() { @@ -661,6 +835,10 @@ mod tests { min_score: None, min_salience: None, config: None, + top_k: None, + fusion_strategy: None, + score_floor: None, + embedding_model: None, }; let cfg = p.effective_config(RecallConfig::default()); assert!((cfg.relevance_weight - 0.70).abs() < 1e-12); @@ -677,6 +855,10 @@ mod tests { min_score: Some(0.5), min_salience: Some(0.3), config: None, + top_k: None, + fusion_strategy: None, + score_floor: None, + embedding_model: None, }; let cfg = p.effective_config(RecallConfig::default()); assert!((cfg.min_score - 0.5).abs() < 1e-12); @@ -695,6 +877,10 @@ mod tests { relevance_weight: 0.50, ..RecallConfig::default() }), + top_k: None, + fusion_strategy: None, + score_floor: None, + embedding_model: None, }; let cfg = p.effective_config(RecallConfig::default()); assert!((cfg.relevance_weight - 0.50).abs() < 1e-12); @@ -702,6 +888,61 @@ mod tests { assert!((cfg.min_score - 0.1).abs() < 1e-12); } + #[test] + fn test_weighted_strategy_preserves_pack_weights() { + use khive_runtime::FusionStrategy as RuntimeFusionStrategy; + + // Pack config has custom weighted weights [0.8, 0.2] + let base = RecallConfig { + fuse_strategy: RuntimeFusionStrategy::Weighted { + weights: vec![0.8, 0.2], + }, + ..RecallConfig::default() + }; + + // Request overrides to "weighted" — must preserve [0.8, 0.2], not replace with [0.3, 0.7] + let p = RecallParams { + query: "test".to_string(), + limit: None, + memory_type: None, + min_score: None, + min_salience: None, + config: None, + top_k: None, + fusion_strategy: Some("weighted".to_string()), + score_floor: None, + embedding_model: None, + }; + + let mut cfg = p.effective_config(base); + if let Some(ref fs) = p.fusion_strategy { + let mut new_strategy = parse_fusion_strategy_str(fs).unwrap(); + if let ( + RuntimeFusionStrategy::Weighted { + weights: ref mut new_w, + }, + RuntimeFusionStrategy::Weighted { + weights: ref existing_w, + }, + ) = (&mut new_strategy, &cfg.fuse_strategy) + { + *new_w = existing_w.clone(); + } + cfg.fuse_strategy = new_strategy; + } + + match cfg.fuse_strategy { + RuntimeFusionStrategy::Weighted { weights } => { + assert_eq!( + weights, + vec![0.8, 0.2], + "fusion_strategy=weighted must preserve pack weights [0.8, 0.2], not override with [0.3, 0.7]" + ); + } + other => panic!("expected Weighted strategy, got {other:?}"), + } + } + #[test] fn compute_score_default_config_reproduces_legacy() { let cfg = RecallConfig::default(); @@ -719,9 +960,14 @@ mod tests { #[test] fn compute_score_exponential_decay_at_decay_factor_half_life() { - let cfg = RecallConfig::default(); // temporal_half_life = 30 days, default decay_factor=0.01 - // ADR-021 §5: importance_decayed = salience * exp(-decay_factor * age_days) - // At age = ln(2)/0.01 ≈ 69.3 days: importance_decayed ≈ 0.5 + // Use explicit exponential decay config — not relying on default decay_model. + // ADR-021 §5: importance_decayed = salience * exp(-decay_factor * age_days) + // At age = ln(2)/0.01 ≈ 69.3 days: importance_decayed ≈ 0.5 + let cfg = RecallConfig { + decay_model: DecayModel::Exponential, + temporal_half_life_days: 30.0, + ..RecallConfig::default() + }; let age_days = std::f64::consts::LN_2 / 0.01; let (_, bd) = compute_score(&cfg, 0.5, 1.0, 0.01, age_days); assert!( @@ -736,7 +982,11 @@ mod tests { #[test] fn compute_score_temporal_halves_at_temporal_half_life() { - let cfg = RecallConfig::default(); // temporal_half_life = 30 days + // Use explicit half_life=30 — not relying on default temporal_half_life_days. + let cfg = RecallConfig { + temporal_half_life_days: 30.0, + ..RecallConfig::default() + }; let (_, bd) = compute_score(&cfg, 0.5, 1.0, 0.01, 30.0); // At age = temporal_half_life = 30 days: temporal = exp(-ln2/30 * 30) = 0.5 assert!( diff --git a/crates/khive-pack-memory/tests/integration.rs b/crates/khive-pack-memory/tests/integration.rs index 946856c7..f613506d 100644 --- a/crates/khive-pack-memory/tests/integration.rs +++ b/crates/khive-pack-memory/tests/integration.rs @@ -657,6 +657,123 @@ async fn test_recall_fuse_source_field_is_plain_string() { ); } +/// Verifies that recall.fuse routes through khive_retrieval::fuse_search_results +/// by injecting a non-default fusion config (Rrf k=1) and asserting the fused +/// score matches the RRF k=1 formula: 1/(k + rank) = 1/(1 + 1) = 0.5. +/// +/// Under default k=60 the score would be 1/61 ≈ 0.0164. The large gap (0.5 vs +/// 0.0164) is the discriminator: if the adapter did not pass k=1 through to +/// khive_retrieval::HybridConfig, the score would not be 0.5. +#[tokio::test] +async fn test_recall_fuse_rrf_k1_uses_retrieval_adapter() { + let rt = make_runtime(); + let registry = make_registry(rt); + + registry + .dispatch( + "remember", + json!({ "content": "retrieval adapter rrf k1 probe memory" }), + ) + .await + .expect("remember"); + + let result = registry + .dispatch( + "recall.fuse", + json!({ + "query": "retrieval adapter rrf k1 probe", + "config": { + "fuse_strategy": { "rrf": { "k": 1 } } + } + }), + ) + .await + .expect("recall.fuse with Rrf k=1"); + + let fused = result["fused_candidates"].as_array().expect("fused array"); + assert!( + !fused.is_empty(), + "recall.fuse must return at least one candidate" + ); + + let score = fused[0]["fused_score"] + .as_f64() + .expect("fused_score is f64"); + // Rank 1 in a single text source with k=1: RRF = 1/(1+1) = 0.5. + // If k=60 were used instead, score ≈ 0.0164 — the gap proves the adapter works. + let expected = 0.5_f64; + assert!( + (score - expected).abs() < 1e-6, + "RRF k=1, rank 1 → fused_score must be 0.5; got {score:.6} \ + (≈0.0164 means the adapter passed k=60 instead of k=1)" + ); +} + +/// Regression: after wiring khive-retrieval into fuse_candidates, the recall.fuse +/// response shape must be unchanged — top-level strategy + candidate_limit, and +/// per-candidate note_id + fused_score + source must all be present. Full recall +/// fields (content, salience) must remain absent. +#[tokio::test] +async fn test_recall_fuse_shape_preserved_after_retrieval_wiring() { + let rt = make_runtime(); + let registry = make_registry(rt); + + registry + .dispatch( + "remember", + json!({ "content": "shape regression check after retrieval wiring" }), + ) + .await + .expect("remember"); + + let result = registry + .dispatch( + "recall.fuse", + json!({ "query": "shape regression retrieval wiring" }), + ) + .await + .expect("recall.fuse"); + + // Top-level shape + assert!( + result.get("strategy").is_some(), + "strategy field must be present in recall.fuse response" + ); + assert!( + result["candidate_limit"].as_u64().is_some(), + "candidate_limit must be a non-negative integer" + ); + + let fused = result["fused_candidates"] + .as_array() + .expect("fused_candidates array"); + assert!(!fused.is_empty(), "fused_candidates must be non-empty"); + + let c = &fused[0]; + assert!( + c["note_id"].as_str().is_some(), + "note_id must be a string UUID" + ); + assert!( + c["fused_score"].as_f64().is_some(), + "fused_score must be a float" + ); + let source = c["source"].as_str().expect("source must be a plain string"); + assert!( + matches!(source, "text" | "vector" | "both"), + "source must be a plain label, got {source:?}" + ); + // Full recall fields must not leak into fuse output + assert!( + c.get("content").is_none(), + "content must be absent from recall.fuse output" + ); + assert!( + c.get("salience").is_none(), + "salience must be absent from recall.fuse output" + ); +} + /// When include_breakdown is true, breakdown.total() must equal the hit's composite score. #[tokio::test] async fn test_recall_breakdown_total_matches_composite_score() { @@ -884,3 +1001,228 @@ async fn test_pack_tunable_apply_config_affects_recall_score() { "under relevance_weight=1.0 with rrf=1.0 → score=1.0; got {total2}" ); } + +// ── ADR-033 §6 knob tests ────────────────────────────────────────────────── + +#[tokio::test] +async fn test_recall_default_identity() { + let rt = make_runtime(); + let registry = make_registry(rt.clone()); + + let note = registry + .dispatch( + "remember", + json!({ + "content": "the mitochondria is the powerhouse of the cell", + "importance": 0.8 + }), + ) + .await + .expect("remember succeeds"); + let note_id = note["note_id"].as_str().unwrap().to_string(); + + // Baseline recall with no knobs + let base = registry + .dispatch("recall", json!({ "query": "mitochondria powerhouse cell" })) + .await + .expect("baseline recall succeeds"); + let base_hits = base.as_array().expect("array"); + assert!( + !base_hits.is_empty(), + "baseline must return at least one hit" + ); + + // Same call with all knobs absent — must match baseline shape + let knobless = registry + .dispatch( + "recall", + json!({ "query": "mitochondria powerhouse cell", "top_k": null }), + ) + .await + .expect("recall with null top_k succeeds"); + let knobless_hits = knobless.as_array().expect("array"); + + assert_eq!( + base_hits.len(), + knobless_hits.len(), + "null top_k must not change result count" + ); + assert_eq!( + base_hits[0]["note_id"].as_str().unwrap(), + note_id, + "top hit must be the memory we created" + ); +} + +#[tokio::test] +async fn test_recall_top_k_override() { + let rt = make_runtime(); + let registry = make_registry(rt.clone()); + + // Create several distinct memories to ensure the pool is large enough + for i in 0..5 { + registry + .dispatch( + "remember", + json!({ + "content": format!("rust ownership memory safety concept {i}"), + "importance": 0.7 + }), + ) + .await + .expect("remember succeeds"); + } + + // Recall with top_k=2 — must not return more than 2 results + let result = registry + .dispatch( + "recall", + json!({ "query": "rust ownership memory safety", "top_k": 2 }), + ) + .await + .expect("recall with top_k=2 succeeds"); + let hits = result.as_array().expect("array"); + assert!( + hits.len() <= 2, + "top_k=2 must return at most 2 results, got {}", + hits.len() + ); + + // top_k=1 must return at most 1 + let result1 = registry + .dispatch( + "recall", + json!({ "query": "rust ownership memory safety", "top_k": 1 }), + ) + .await + .expect("recall with top_k=1 succeeds"); + let hits1 = result1.as_array().expect("array"); + assert!( + hits1.len() <= 1, + "top_k=1 must return at most 1 result, got {}", + hits1.len() + ); +} + +#[tokio::test] +async fn test_recall_fusion_strategy_override() { + let rt = make_runtime(); + let registry = make_registry(rt.clone()); + + registry + .dispatch( + "remember", + json!({ + "content": "gradient descent optimization machine learning", + "importance": 0.8 + }), + ) + .await + .expect("remember succeeds"); + + // Each valid strategy must succeed and return an array + for strategy in &["rrf", "weighted", "union"] { + let result = registry + .dispatch( + "recall", + json!({ + "query": "gradient descent optimization", + "fusion_strategy": strategy + }), + ) + .await + .unwrap_or_else(|e| panic!("recall with fusion_strategy={strategy:?} failed: {e}")); + assert!( + result.is_array(), + "fusion_strategy={strategy:?} must return an array, got {result}" + ); + } + + // Invalid strategy must return an error + let err = registry + .dispatch( + "recall", + json!({ + "query": "gradient descent optimization", + "fusion_strategy": "bogus" + }), + ) + .await; + assert!(err.is_err(), "invalid fusion_strategy must return an error"); + let msg = err.unwrap_err().to_string(); + assert!( + msg.contains("rrf") && msg.contains("weighted") && msg.contains("union"), + "error message must list valid strategies, got: {msg}" + ); +} + +#[tokio::test] +async fn test_recall_score_floor() { + let rt = make_runtime(); + let registry = make_registry(rt.clone()); + + registry + .dispatch( + "remember", + json!({ + "content": "backpropagation neural network training algorithm", + "importance": 0.6 + }), + ) + .await + .expect("remember succeeds"); + + // Baseline: no floor — get result count + let base = registry + .dispatch( + "recall", + json!({ "query": "backpropagation neural network" }), + ) + .await + .expect("baseline recall succeeds"); + let base_count = base.as_array().expect("array").len(); + + // score_floor=0.99 must not return MORE results than baseline + let floored = registry + .dispatch( + "recall", + json!({ + "query": "backpropagation neural network", + "score_floor": 0.99 + }), + ) + .await + .expect("recall with score_floor=0.99 succeeds"); + let floored_hits = floored.as_array().expect("array"); + assert!( + floored_hits.len() <= base_count, + "score_floor=0.99 must return ≤ baseline count ({base_count}), got {}", + floored_hits.len() + ); + + // All returned hits must have score >= 0.99 + for hit in floored_hits { + let score = hit["score"].as_f64().expect("score is a number"); + assert!( + score >= 0.99, + "score_floor=0.99: all returned scores must be ≥ 0.99, got {score}" + ); + } + + // score_floor=0.0 must behave same as no floor + let zero_floor = registry + .dispatch( + "recall", + json!({ + "query": "backpropagation neural network", + "score_floor": 0.0 + }), + ) + .await + .expect("recall with score_floor=0.0 succeeds"); + let zero_count = zero_floor.as_array().expect("array").len(); + assert_eq!( + zero_count, base_count, + "score_floor=0.0 must return same count as no floor" + ); +} diff --git a/crates/khive-retrieval/src/adapters/mod.rs b/crates/khive-retrieval/src/adapters/mod.rs index bcad7b45..5b233d0e 100644 --- a/crates/khive-retrieval/src/adapters/mod.rs +++ b/crates/khive-retrieval/src/adapters/mod.rs @@ -110,6 +110,7 @@ impl VectorSearch for StorageVectorSearch { top_k: top_k as u32, namespace: None, kind: None, + embedding_model: None, filter: None, backend_hints: None, }; diff --git a/crates/khive-retrieval/src/graph/tests.rs b/crates/khive-retrieval/src/graph/tests.rs index 639b3efd..92e3e936 100644 --- a/crates/khive-retrieval/src/graph/tests.rs +++ b/crates/khive-retrieval/src/graph/tests.rs @@ -1,6 +1,6 @@ //! Unit tests for graph traversal module. -use super::compat::{test_context, EntityRef, MockLinkStore}; +use super::compat::{test_context, EntityRef, LinkStore, MockLinkStore}; use crate::graph::types::{ Direction, PathNode, TraversalOptions, MAX_TRAVERSAL_DEPTH, MAX_TRAVERSAL_RESULTS, diff --git a/crates/khive-retrieval/src/persist/tests.rs b/crates/khive-retrieval/src/persist/tests.rs index 2efdf72d..88d6e84e 100644 --- a/crates/khive-retrieval/src/persist/tests.rs +++ b/crates/khive-retrieval/src/persist/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::NodeId; use khive_bm25::Bm25Index; use khive_hnsw::HnswIndex; use rusqlite::Connection; diff --git a/crates/khive-retrieval/src/replay/engine_replay.rs b/crates/khive-retrieval/src/replay/engine_replay.rs index d25a85bb..45b8bbc2 100644 --- a/crates/khive-retrieval/src/replay/engine_replay.rs +++ b/crates/khive-retrieval/src/replay/engine_replay.rs @@ -844,11 +844,26 @@ pub mod metrics { #[cfg(test)] mod tests { use super::*; - use khive_db::SqliteStore; fn make_conn() -> Arc> { - let store = SqliteStore::memory().expect("in-memory store"); - store.conn() + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute_batch( + r#" + CREATE TABLE weight_events ( + namespace TEXT NOT NULL, + atom_id TEXT NOT NULL, + delta REAL NOT NULL, + weight_after REAL NOT NULL, + channel TEXT NOT NULL, + eta REAL NOT NULL, + event_id TEXT, + context_id TEXT, + ts INTEGER NOT NULL + ); + "#, + ) + .expect("init replay test schema"); + Arc::new(Mutex::new(conn)) } fn insert_weight_event( diff --git a/crates/khive-retrieval/src/weights/engine_weights.rs b/crates/khive-retrieval/src/weights/engine_weights.rs index 7530767c..0b47a7cc 100644 --- a/crates/khive-retrieval/src/weights/engine_weights.rs +++ b/crates/khive-retrieval/src/weights/engine_weights.rs @@ -298,14 +298,35 @@ pub async fn batch_load_weights( #[cfg(test)] mod tests { use super::*; - use khive_db::SqliteStore; use std::sync::Arc; fn make_conn() -> Arc> { - // Open an in-memory SQLite DB and run migrations so atom_weights and - // weight_events tables exist. - let store = SqliteStore::memory().expect("in-memory store"); - store.conn() + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute_batch( + r#" + CREATE TABLE atom_weights ( + namespace TEXT NOT NULL, + atom_id TEXT NOT NULL, + weight REAL NOT NULL, + updated_at INTEGER NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY(namespace, atom_id) + ); + CREATE TABLE weight_events ( + namespace TEXT NOT NULL, + atom_id TEXT NOT NULL, + delta REAL NOT NULL, + weight_after REAL NOT NULL, + channel TEXT NOT NULL, + eta REAL NOT NULL, + event_id TEXT, + context_id TEXT, + ts INTEGER NOT NULL + ); + "#, + ) + .expect("init weight test schema"); + Arc::new(Mutex::new(conn)) } // ------------------------------------------------------------------------- diff --git a/crates/khive-retrieval/tests/fusion_surface.rs b/crates/khive-retrieval/tests/fusion_surface.rs new file mode 100644 index 00000000..29ae15cf --- /dev/null +++ b/crates/khive-retrieval/tests/fusion_surface.rs @@ -0,0 +1,61 @@ +use khive_retrieval::{fuse_search_results, FusionStrategy, HybridConfig}; +use khive_score::DeterministicScore; + +#[test] +fn fuse_search_results_rrf_surface_matches_expected_order() { + // doc_b appears at rank 1 in both vector and keyword — must win under RRF k=60. + let vector = vec![ + ("doc_b", DeterministicScore::from_f64(0.9)), + ("doc_a", DeterministicScore::from_f64(0.8)), + ]; + let keyword = vec![ + ("doc_b", DeterministicScore::from_f64(4.0)), + ("doc_c", DeterministicScore::from_f64(3.0)), + ]; + let config = HybridConfig::new(10) + .with_pool_size(10) + .with_fusion_strategy(FusionStrategy::Rrf { k: 60 }); + + let results = fuse_search_results(vec![vector, keyword], &config); + + assert!(!results.is_empty(), "fusion must return results"); + assert_eq!( + results[0].0, "doc_b", + "doc_b must rank first (appears in both sources)" + ); + + // RRF score for doc_b: 1/(1+60) + 1/(1+60) = 2/61 ≈ 0.03279 + let expected = 2.0 / 61.0; + let actual = results[0].1.to_f64(); + assert!( + (actual - expected).abs() < 1e-6, + "fused score = {actual}, expected ~{expected}" + ); +} + +#[test] +fn fuse_search_results_empty_sources_returns_empty() { + let config = HybridConfig::default(); + let results = fuse_search_results::<&str>(vec![], &config); + assert!(results.is_empty()); +} + +#[test] +fn fuse_search_results_single_source_truncates_to_top_k() { + let source: Vec<_> = (0..20) + .map(|i| { + ( + format!("doc_{i}"), + DeterministicScore::from_f64(1.0 - i as f64 * 0.01), + ) + }) + .collect(); + let config = HybridConfig::new(5); + let results = fuse_search_results(vec![source], &config); + assert_eq!( + results.len(), + 5, + "single-source result must be truncated to top_k=5" + ); + assert_eq!(results[0].0, "doc_0", "highest score must be first"); +} diff --git a/crates/khive-runtime/src/error.rs b/crates/khive-runtime/src/error.rs index 5d5f2cc3..19960375 100644 --- a/crates/khive-runtime/src/error.rs +++ b/crates/khive-runtime/src/error.rs @@ -78,6 +78,9 @@ pub enum RuntimeError { #[error("unconfigured: {0} is not set")] Unconfigured(String), + #[error("unknown embedding model: {0}")] + UnknownModel(String), + #[error("embedding: {0}")] Embedding(#[from] lattice_embed::EmbedError), diff --git a/crates/khive-runtime/src/operations.rs b/crates/khive-runtime/src/operations.rs index a5abb6bb..9a6add6a 100644 --- a/crates/khive-runtime/src/operations.rs +++ b/crates/khive-runtime/src/operations.rs @@ -812,7 +812,7 @@ impl KhiveRuntime { annotates: Vec, ) -> RuntimeResult { self.create_note_inner( - token, kind, name, content, salience, None, properties, annotates, + token, kind, name, content, salience, None, properties, annotates, None, ) .await } @@ -829,6 +829,34 @@ impl KhiveRuntime { decay_factor: f64, properties: Option, annotates: Vec, + ) -> RuntimeResult { + self.create_note_with_decay_for_embedding_model( + token, + kind, + name, + content, + salience, + decay_factor, + properties, + annotates, + None, + ) + .await + } + + /// Like [`create_note_with_decay`] but targets a specific embedding model. + #[allow(clippy::too_many_arguments)] + pub async fn create_note_with_decay_for_embedding_model( + &self, + token: &NamespaceToken, + kind: &str, + name: Option<&str>, + content: &str, + salience: Option, + decay_factor: f64, + properties: Option, + annotates: Vec, + embedding_model: Option<&str>, ) -> RuntimeResult { self.create_note_inner( token, @@ -839,6 +867,7 @@ impl KhiveRuntime { Some(decay_factor), properties, annotates, + embedding_model, ) .await } @@ -854,6 +883,7 @@ impl KhiveRuntime { decay_factor: Option, properties: Option, annotates: Vec, + embedding_model: Option<&str>, ) -> RuntimeResult { let ns = token.namespace().as_str(); @@ -899,9 +929,20 @@ impl KhiveRuntime { }) .await?; - if self.config().embedding_model.is_some() { - let vector = self.embed(¬e.content).await?; - self.vectors(token)? + let embed_model_name: Option = + if self.config().embedding_model.is_some() || embedding_model.is_some() { + Some( + embedding_model + .map(|m| m.to_string()) + .unwrap_or_else(|| self.default_embedder_name().to_string()), + ) + } else { + None + }; + + if let Some(ref model_name) = embed_model_name { + let vector = self.embed_with_model(model_name, ¬e.content).await?; + self.vectors_for_model(token, model_name)? .insert( note.id, SubstrateKind::Note, @@ -989,8 +1030,8 @@ impl KhiveRuntime { if let Ok(fts) = self.text_for_notes(token) { let _ = fts.delete_document(ns, note.id).await; } - if self.config().embedding_model.is_some() { - if let Ok(vs) = self.vectors(token) { + if let Some(ref model_name) = embed_model_name { + if let Ok(vs) = self.vectors_for_model(token, model_name) { let _ = vs.delete(note.id).await; } } diff --git a/crates/khive-runtime/src/retrieval.rs b/crates/khive-runtime/src/retrieval.rs index 78585c2e..aeb7cae2 100644 --- a/crates/khive-runtime/src/retrieval.rs +++ b/crates/khive-runtime/src/retrieval.rs @@ -41,20 +41,26 @@ const RRF_K: usize = 60; const CANDIDATE_MULTIPLIER: u32 = 4; impl KhiveRuntime { - /// Generate an embedding vector for `text` using the configured local model. + /// Generate an embedding vector for `text` using the configured default model. /// /// First call lazily loads model weights (cold start cost). Subsequent calls reuse them. /// Returns `Unconfigured("embedding_model")` if no model is configured. pub async fn embed(&self, text: &str) -> RuntimeResult> { - let service = self.embedder().await?; - let model = self - .config() - .embedding_model - .expect("embedder() returns Unconfigured when model is None"); + let model_name = self.default_embedder_name(); + if model_name.is_empty() { + return Err(RuntimeError::Unconfigured("embedding_model".into())); + } + self.embed_with_model(model_name, text).await + } + + /// Generate an embedding vector for `text` using the named model. + pub async fn embed_with_model(&self, model_name: &str, text: &str) -> RuntimeResult> { + let model = self.resolve_embedding_model(Some(model_name))?; + let service = self.embedder(model_name).await?; Ok(service.embed_one(text, model).await?) } - /// Generate embeddings for multiple texts in one call. + /// Generate embeddings for multiple texts in one call using the configured default model. /// /// Delegates to the cached `EmbeddingService::embed`, so repeated texts within /// and across calls benefit from the runtime-level LRU cache. @@ -65,11 +71,24 @@ impl KhiveRuntime { if texts.is_empty() { return Ok(vec![]); } - let service = self.embedder().await?; - let model = self - .config() - .embedding_model - .expect("embedder() returns Unconfigured when model is None"); + let model_name = self.default_embedder_name(); + if model_name.is_empty() { + return Err(RuntimeError::Unconfigured("embedding_model".into())); + } + self.embed_batch_with_model(model_name, texts).await + } + + /// Generate embeddings for multiple texts using the named model. + pub async fn embed_batch_with_model( + &self, + model_name: &str, + texts: &[String], + ) -> RuntimeResult>> { + if texts.is_empty() { + return Ok(vec![]); + } + let model = self.resolve_embedding_model(Some(model_name))?; + let service = self.embedder(model_name).await?; Ok(service.embed(texts, model).await?) } @@ -111,6 +130,7 @@ impl KhiveRuntime { top_k, namespace: Some(ns), kind, + embedding_model: None, filter: None, backend_hints: None, }) @@ -242,6 +262,7 @@ impl KhiveRuntime { top_k, namespace: Some(ns), kind: Some(SubstrateKind::Entity), + embedding_model: None, filter: None, backend_hints: None, }) @@ -269,6 +290,7 @@ impl KhiveRuntime { top_k: candidate_ids.len() as u32, namespace: Some(ns), kind: Some(SubstrateKind::Entity), + embedding_model: None, filter: None, backend_hints: None, }) diff --git a/crates/khive-runtime/src/runtime.rs b/crates/khive-runtime/src/runtime.rs index 1babe5b8..27b14eb9 100644 --- a/crates/khive-runtime/src/runtime.rs +++ b/crates/khive-runtime/src/runtime.rs @@ -1,6 +1,9 @@ //! KhiveRuntime — composable handle to all storage capabilities. -use std::sync::{Arc, RwLock}; +use std::{ + collections::HashMap, + sync::{Arc, RwLock}, +}; use khive_db::StorageBackend; use khive_gate::{ActorRef, AllowAllGate, GateRef}; @@ -136,6 +139,13 @@ pub struct RuntimeConfig { /// `EmbedderRegistry`. This field persists for backward compatibility until /// the embedder registry is fully plumbed. pub embedding_model: Option, + /// Additional embedding models to make available by request name. + /// + /// `embedding_model` remains the default used by existing `embed()` and + /// `embed_batch()` callers. This list adds non-default models that can be + /// selected with `embedder(name)`, `embed_with_model(...)`, memory + /// `remember.embedding_model`, and memory `recall.embedding_model`. + pub additional_embedding_models: Vec, /// Authorization gate consulted before each verb dispatch (ADR-029). /// Default: `AllowAllGate` (permissive). For production policy enforcement, /// plug in a Rego- or capability-witness-backed impl. @@ -173,6 +183,10 @@ impl Default for RuntimeConfig { .ok() .and_then(|s| s.parse().ok()) .or(Some(EmbeddingModel::AllMiniLmL6V2)); + let additional_embedding_models = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS") + .ok() + .map(|s| parse_embedding_model_list(&s)) + .unwrap_or_default(); let packs = std::env::var("KHIVE_PACKS") .ok() .map(|s| parse_pack_list(&s)) @@ -182,6 +196,7 @@ impl Default for RuntimeConfig { db_path, default_namespace: Namespace::local(), embedding_model, + additional_embedding_models, gate: Arc::new(AllowAllGate), packs, backend_id: BackendId::main(), @@ -191,6 +206,12 @@ impl Default for RuntimeConfig { // ---- KhiveRuntime ---- +#[derive(Clone)] +struct EmbedderEntry { + model: EmbeddingModel, + cell: Arc>>, +} + /// Composable runtime handle used by the MCP server. /// /// Wraps a `StorageBackend` and provides namespace-scoped accessor methods @@ -199,7 +220,8 @@ impl Default for RuntimeConfig { pub struct KhiveRuntime { backend: Arc, config: RuntimeConfig, - embedder: Arc>>, + embedders: Arc>, + default_embedder_name: Arc, /// Pack-extensible edge endpoint rules (ADR-031). Shared across clones /// via `Arc>`; installed once by the transport after the /// `VerbRegistry` is built. Empty until installed — base rules @@ -223,10 +245,13 @@ impl KhiveRuntime { } None => StorageBackend::memory()?, }; + register_configured_embedding_models(&backend, &config)?; + let (embedders, default_embedder_name) = build_embedder_registry(&config); Ok(Self { backend: Arc::new(backend), config, - embedder: Arc::new(OnceCell::new()), + embedders: Arc::new(embedders), + default_embedder_name, edge_rules: Arc::new(RwLock::new(Vec::new())), }) } @@ -241,10 +266,15 @@ impl KhiveRuntime { /// storage access is through the provided `backend`. Set `backend_id` and /// `default_namespace` via the config builder pattern if non-defaults are needed. pub fn from_backend(backend: Arc, config: RuntimeConfig) -> Self { + if let Err(err) = register_configured_embedding_models(&backend, &config) { + tracing::warn!(error = %err, "failed to register configured embedding models"); + } + let (embedders, default_embedder_name) = build_embedder_registry(&config); Self { backend, config, - embedder: Arc::new(OnceCell::new()), + embedders: Arc::new(embedders), + default_embedder_name, edge_rules: Arc::new(RwLock::new(Vec::new())), } } @@ -255,6 +285,7 @@ impl KhiveRuntime { db_path: None, default_namespace: Namespace::local(), embedding_model: None, + additional_embedding_models: vec![], gate: Arc::new(AllowAllGate), packs: vec!["kg".to_string()], backend_id: BackendId::main(), @@ -321,12 +352,28 @@ impl KhiveRuntime { &self, token: &NamespaceToken, ) -> RuntimeResult> { - let model = self - .config - .embedding_model - .ok_or_else(|| crate::RuntimeError::Unconfigured("embedding_model".into()))?; + let model = self.resolve_embedding_model(None)?; + self.vectors_for_embedding_model(token, model) + } + + /// Get a VectorStore for a specific named embedding model, scoped to the token's namespace. + pub fn vectors_for_model( + &self, + token: &NamespaceToken, + model_name: &str, + ) -> RuntimeResult> { + let model = self.resolve_embedding_model(Some(model_name))?; + self.vectors_for_embedding_model(token, model) + } + + fn vectors_for_embedding_model( + &self, + token: &NamespaceToken, + model: EmbeddingModel, + ) -> RuntimeResult> { Ok(self.backend.vectors_for_namespace( &vec_model_key(model), + &model.to_string(), model.dimensions(), token.namespace().as_str(), )?) @@ -380,28 +427,57 @@ impl KhiveRuntime { .unwrap_or_default() } - /// Get the lazily-initialized embedding service. + /// Return the name of the default embedding model (empty string if none configured). + pub fn default_embedder_name(&self) -> &str { + self.default_embedder_name.as_ref() + } + + /// Resolve a model name (or `None` for the default) to an `EmbeddingModel`. + /// + /// Returns `UnknownModel` if the name is not in the registry, or + /// `Unconfigured` if `None` is passed and no default model is set. + pub fn resolve_embedding_model(&self, name: Option<&str>) -> RuntimeResult { + let model = match name { + Some(raw) => parse_embedding_model_alias(raw) + .ok_or_else(|| crate::RuntimeError::UnknownModel(raw.to_string()))?, + None => self + .config + .embedding_model + .ok_or_else(|| crate::RuntimeError::Unconfigured("embedding_model".into()))?, + }; + let key = model.to_string(); + if self.embedders.contains_key(&key) { + Ok(model) + } else { + Err(crate::RuntimeError::UnknownModel( + name.unwrap_or_else(|| self.default_embedder_name()) + .to_string(), + )) + } + } + + /// Get the lazily-initialized embedding service for the named model. /// /// Returns a `CachedEmbeddingService` wrapping a `NativeEmbeddingService`. /// First call loads the model (cold start cost); subsequent calls are cheap and /// benefit from LRU caching of repeated inputs. - /// - /// Returns `Unconfigured("embedding_model")` if no model is set. - pub async fn embedder(&self) -> RuntimeResult> { - let model = self - .config - .embedding_model - .ok_or_else(|| crate::RuntimeError::Unconfigured("embedding_model".into()))?; - let service = self - .embedder + pub async fn embedder(&self, name: &str) -> RuntimeResult> { + let model = self.resolve_embedding_model(Some(name))?; + let key = model.to_string(); + let entry = self + .embedders + .get(&key) + .ok_or_else(|| crate::RuntimeError::UnknownModel(name.to_string()))? + .clone(); + Ok(entry + .cell .get_or_init(|| async move { - let native = Arc::new(NativeEmbeddingService::with_model(model)); + let native = Arc::new(NativeEmbeddingService::with_model(entry.model)); let cached = CachedEmbeddingService::with_default_cache(native); Arc::new(cached) as Arc }) .await - .clone(); - Ok(service) + .clone()) } } @@ -417,6 +493,66 @@ fn sanitize_key(s: &str) -> String { .collect() } +fn build_embedder_registry(config: &RuntimeConfig) -> (HashMap, Arc) { + let mut embedders = HashMap::new(); + for model in configured_embedding_models(config) { + embedders.insert( + model.to_string(), + EmbedderEntry { + model, + cell: Arc::new(OnceCell::new()), + }, + ); + } + let default_embedder_name = config + .embedding_model + .map(|model| Arc::::from(model.to_string())) + .unwrap_or_else(|| Arc::::from("")); + (embedders, default_embedder_name) +} + +fn configured_embedding_models(config: &RuntimeConfig) -> Vec { + let mut models = Vec::new(); + if let Some(model) = config.embedding_model { + models.push(model); + } + models.extend(config.additional_embedding_models.iter().copied()); + models.sort_by_key(|model| model.to_string()); + models.dedup(); + models +} + +fn register_configured_embedding_models( + backend: &StorageBackend, + config: &RuntimeConfig, +) -> RuntimeResult<()> { + for model in configured_embedding_models(config) { + backend.register_embedding_model( + &model.to_string(), + model.model_id(), + model.key_version(), + model.dimensions() as u32, + )?; + } + Ok(()) +} + +/// Parse a comma- or whitespace-separated list of embedding model names. +fn parse_embedding_model_list(s: &str) -> Vec { + parse_pack_list(s) + .into_iter() + .filter_map(|raw| parse_embedding_model_alias(&raw)) + .collect() +} + +fn parse_embedding_model_alias(name: &str) -> Option { + let normalized = name.trim().to_ascii_lowercase().replace('_', "-"); + match normalized.as_str() { + "paraphrase" => Some(EmbeddingModel::ParaphraseMultilingualMiniLmL12V2), + _ => normalized.parse().ok(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -435,6 +571,7 @@ mod tests { db_path: Some(path.clone()), default_namespace: Namespace::parse("test").unwrap(), embedding_model: None, + additional_embedding_models: vec![], gate: Arc::new(AllowAllGate), packs: vec!["kg".to_string()], backend_id: BackendId::main(), @@ -451,6 +588,7 @@ mod tests { db_path: None, default_namespace: Namespace::local(), embedding_model: None, + additional_embedding_models: vec![], gate: Arc::new(AllowAllGate), packs: vec!["kg".to_string()], backend_id: BackendId::new("lore"), diff --git a/crates/khive-runtime/tests/integration.rs b/crates/khive-runtime/tests/integration.rs index 7775386b..257b4f06 100644 --- a/crates/khive-runtime/tests/integration.rs +++ b/crates/khive-runtime/tests/integration.rs @@ -568,6 +568,7 @@ async fn file_backed_runtime_persists() { gate: std::sync::Arc::new(khive_runtime::AllowAllGate), packs: vec!["kg".to_string()], backend_id: khive_runtime::BackendId::main(), + additional_embedding_models: vec![], }; let rt = KhiveRuntime::new(config).unwrap(); let tok = rt.authorize(Namespace::local()); @@ -585,6 +586,7 @@ async fn file_backed_runtime_persists() { gate: std::sync::Arc::new(khive_runtime::AllowAllGate), packs: vec!["kg".to_string()], backend_id: khive_runtime::BackendId::main(), + additional_embedding_models: vec![], }; let rt = KhiveRuntime::new(config).unwrap(); let tok = rt.authorize(Namespace::local()); diff --git a/crates/khive-storage/src/types.rs b/crates/khive-storage/src/types.rs index 70430009..11066599 100644 --- a/crates/khive-storage/src/types.rs +++ b/crates/khive-storage/src/types.rs @@ -181,6 +181,8 @@ pub struct VectorRecord { pub namespace: String, /// Which embedding field this record represents (e.g. `"entity.body"`). pub field: String, + #[serde(default)] + pub embedding_model: Option, /// One or many dense vectors; sqlite-vec backends enforce `vectors.len() == 1`. pub vectors: Vec>, pub updated_at: DateTime, @@ -193,6 +195,9 @@ pub struct VectorSearchRequest { pub top_k: u32, pub namespace: Option, pub kind: Option, + /// Restrict results to this embedding model. Defaults to the store's own model. + #[serde(default)] + pub embedding_model: Option, /// Optional metadata filter for backends that support pushdown. pub filter: Option, /// Backend-specific hints (opaque JSON blob, ignored by default). diff --git a/crates/khive-storage/src/vectors.rs b/crates/khive-storage/src/vectors.rs index 95bf1161..0e6cc797 100644 --- a/crates/khive-storage/src/vectors.rs +++ b/crates/khive-storage/src/vectors.rs @@ -307,6 +307,7 @@ mod tests { top_k: 5, namespace: None, kind: None, + embedding_model: None, filter: None, backend_hints: None, }; @@ -326,6 +327,7 @@ mod tests { top_k: 5, namespace: None, kind: None, + embedding_model: None, filter: None, backend_hints: None, }; @@ -352,6 +354,7 @@ mod tests { top_k: 3, namespace: None, kind: None, + embedding_model: None, filter: None, backend_hints: None, }, @@ -360,6 +363,7 @@ mod tests { top_k: 3, namespace: None, kind: None, + embedding_model: None, filter: None, backend_hints: None, }, @@ -433,6 +437,7 @@ mod tests { top_k: 1, namespace: None, kind: None, + embedding_model: None, filter: None, backend_hints: None, }]; diff --git a/crates/kkernel/Cargo.toml b/crates/kkernel/Cargo.toml index ba70f096..2f1354e4 100644 --- a/crates/kkernel/Cargo.toml +++ b/crates/kkernel/Cargo.toml @@ -12,6 +12,7 @@ description = "khive kernel — admin/management Rust binary (sync, pack introsp [dependencies] khive-runtime = { version = "0.2.2", path = "../khive-runtime" } +khive-db = { version = "0.2.2", path = "../khive-db" } khive-storage = { version = "0.2.2", path = "../khive-storage" } khive-types = { version = "0.2.2", path = "../khive-types" } khive-vcs = { version = "0.2.2", path = "../khive-vcs" } diff --git a/crates/kkernel/src/engine.rs b/crates/kkernel/src/engine.rs index d16aee6c..6e16923e 100644 --- a/crates/kkernel/src/engine.rs +++ b/crates/kkernel/src/engine.rs @@ -203,36 +203,22 @@ fn cmd_engine_drift_check(_args: EngineDriftCheckArgs) -> Result<()> { // ── Internal helpers ────────────────────────────────────────────────────────── fn query_embedding_models( - _db: Option<&std::path::Path>, + db: Option<&std::path::Path>, engine_filter: Option<&str>, ) -> Result> { - // The _embedding_models table is created by the ADR-043 schema migration. - // Until that migration lands, the table may not exist; return an empty list - // with a log rather than a hard error so `kkernel engine list` is usable - // before full ADR-043 deployment. - // - // A full implementation opens the SQLite DB, queries: - // SELECT engine_name, model_id, key_version, dim, status, - // activated_at, superseded_at - // FROM _embedding_models - // [WHERE engine_name = ?] - // ORDER BY engine_name, activated_at NULLS LAST - // - // and maps rows to EngineModelRecord. - // - // This scaffold returns an empty list so the CLI compiles and tests can - // verify the command routing surface without a live database. - - if let Some(engine) = engine_filter { - tracing::debug!( - engine, - "query_embedding_models: _embedding_models not yet populated" - ); - } else { - tracing::debug!("query_embedding_models: _embedding_models not yet populated"); - } - - Ok(Vec::new()) + let rows = khive_db::query_embedding_models(db, engine_filter)?; + Ok(rows + .into_iter() + .map(|r| EngineModelRecord { + engine_name: r.engine_name, + model_id: r.model_id, + key_version: r.key_version, + dimensions: r.dimensions, + status: r.status, + activated_at: r.activated_at, + superseded_at: r.superseded_at, + }) + .collect()) } // ── Tests ───────────────────────────────────────────────────────────────────── diff --git a/docs/adr/ADR-033-recall-pipeline.md b/docs/adr/ADR-033-recall-pipeline.md index 375856c0..e6075de8 100644 --- a/docs/adr/ADR-033-recall-pipeline.md +++ b/docs/adr/ADR-033-recall-pipeline.md @@ -277,6 +277,43 @@ document its Hoare triple: | **Program** | Stage 1 (`memory.recall_embed`): query → embedding via multi-engine fan-out. Stage 2 (`memory.recall_candidates`): broad recall from FTS5 + vector, `candidate_multiplier × limit` candidates per path. Stage 3 (`memory.recall_fuse`): apply `fusion_strategy` (default RRF) to produce fused hits. Stage 4 (`memory.recall_rerank`, ADR-042 §7): run all rerankers whose weight in `reranker_weights` is > 0; each writes its score to `candidate.rerank_scores[name]`. Stage 5 (`memory.recall_score`): apply `ComposePipeline` with `WeightedObjective` over the three base Objectives plus one `RerankerObjective` per active reranker. Stage 6 (select): truncate to `limit`; apply `budget` via `GreedySelector` if set. | | **Postcondition** | Output is a deterministic list of memory notes ordered by composite score, within `limit`. All returned notes are alive (not soft-deleted) and `kind = memory`. Score breakdown is available on request via `memory.recall_score`. | +### 6.1 Per-request knobs (ADR-033 §6 addendum) + +The `recall` verb accepts three optional per-request knobs that override the pack-level +`RecallConfig` for a single call. All knobs are optional; absent or `null` preserves the +current default behavior. + +| Parameter | Type | Default | Semantics | +| ----------------- | ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `top_k` | `usize` \| null | `limit` or `10` | Maximum number of results to return. Overrides `limit` when set. Capped at `100`. | +| `fusion_strategy` | `string` \| null | `"rrf"` (k=60) | Fusion algorithm for candidate merging. Must be one of `"rrf"`, `"weighted"`, `"union"`. Returns an error for any other value. | +| `score_floor` | `f32` \| null | `0.0` (no floor) | Minimum composite score threshold applied after `compute_score`. Results below this floor are excluded. `0.0` or `null` = no filtering. | + +**`fusion_strategy` details:** + +- `"rrf"` — Reciprocal Rank Fusion with k=60 (default). Robust across query types. +- `"weighted"` — Weighted linear combination. Text/vector weights come from the pack-level + config (`RecallConfig.fuse_strategy`), not the request. The request cannot override weights. +- `"union"` — Max-score per candidate ID. Inclusive but may surface low-quality text-only hits. + +**Example request DSL:** + +```json +{ + "query": "attention mechanism in transformers", + "top_k": 5, + "fusion_strategy": "union", + "score_floor": 0.3 +} +``` + +This returns at most 5 results, fused via union strategy, with composite score ≥ 0.3. + +**Interaction with `RecallConfig`:** Per-request knobs have higher precedence than `config` +and pack-level tuning. Resolution order: `top_k`/`fusion_strategy`/`score_floor` (request) + +> `config` object (per-call) > pack active config (tunable) > `RecallConfig::default()`. + ### 7. Calibration protocol To calibrate recall parameters for a deployment: diff --git a/marketplace/INSTALL.md b/marketplace/INSTALL.md new file mode 100644 index 00000000..bee52300 --- /dev/null +++ b/marketplace/INSTALL.md @@ -0,0 +1,124 @@ +# khive Marketplace Plugin Installation + +This document covers how to install the `kg`, `gtd`, and `memory` plugins for Claude Code +and verify they are wired correctly to a running `khive-mcp` server. + +## Version compatibility + +| Plugin | Version | khive-mcp | +| ------ | ------- | --------- | +| kg | 0.2.2 | ≥ 0.2.2 | +| gtd | 0.2.2 | ≥ 0.2.2 | +| memory | 0.2.2 | ≥ 0.2.2 | + +## Step 1 — Install khive-mcp + +```bash +cargo install khive-mcp +``` + +Verify: + +```bash +khive-mcp --version +``` + +## Step 2 — Register the MCP server in Claude Code + +### Option A: project-scoped `.mcp.json` + +Create or update `.mcp.json` in your project root: + +```json +{ + "mcpServers": { + "khive": { + "command": "khive-mcp", + "args": ["--pack", "kg", "--pack", "gtd", "--pack", "memory"] + } + } +} +``` + +### Option B: per-session CLI registration + +```bash +# KG only +claude mcp add --transport stdio khive -- khive-mcp --pack kg + +# GTD only +claude mcp add --transport stdio khive -- khive-mcp --pack gtd + +# Memory only +claude mcp add --transport stdio khive -- khive-mcp --pack memory + +# All three packs (recommended for kg-agent swarms) +claude mcp add --transport stdio khive -- khive-mcp --pack kg --pack gtd --pack memory +``` + +## Step 3 — Install the plugins + +```bash +# From the repo root +claude plugin install marketplace/kg +claude plugin install marketplace/gtd +claude plugin install marketplace/memory +``` + +Or manually copy each plugin directory into `~/.claude/plugins/`: + +```bash +cp -r marketplace/kg ~/.claude/plugins/kg +cp -r marketplace/gtd ~/.claude/plugins/gtd +cp -r marketplace/memory ~/.claude/plugins/memory +``` + +## Step 4 — Verify installation + +Start a Claude Code session and confirm the MCP server responds. + +### KG pack smoke tests + +```text +request(ops="create(kind=\"entity\", entity_kind=\"concept\", name=\"test-install\")") +request(ops="search(kind=\"entity\", query=\"test-install\")") +request(ops="delete(kind=\"entity\", id=\"\")") +``` + +### GTD pack smoke tests + +```text +request(ops="assign(title=\"install-test task\", priority=\"p3\")") +request(ops="next(limit=3)") +request(ops="complete(id=\"\")") +``` + +### Memory pack smoke tests + +```text +request(ops="remember(content=\"install verification note\", memory_type=\"episodic\", importance=0.1)") +request(ops="recall(query=\"install verification\", limit=1)") +``` + +## Step 5 — Run the example validator + +```bash +uv run python marketplace/_validators/check_examples.py +``` + +All examples should report `invalid=0`. + +## Troubleshooting + +| Symptom | Fix | +| ------- | --- | +| `khive-mcp: command not found` | Run `cargo install khive-mcp` or add `~/.cargo/bin` to `PATH` | +| MCP tool not appearing in Claude | Check `.mcp.json` is in the project root; restart Claude Code | +| `Unknown verb` error | Confirm `--pack` flag includes the right pack for the verb | +| `Pack not loaded` error | Verify `khive-mcp --version` matches the plugin version | + +## Links + +- Repository: +- ADR-020 (request DSL): +- Releases: diff --git a/marketplace/_validators/check_examples.py b/marketplace/_validators/check_examples.py new file mode 100644 index 00000000..bd44ce70 --- /dev/null +++ b/marketplace/_validators/check_examples.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Validate khive verb examples in marketplace SKILL.md files. + +Usage: + uv run python marketplace/_validators/check_examples.py + python marketplace/_validators/check_examples.py + +Exit 0 if all examples valid, 1 if any are invalid. +""" + +import re +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Known public verb surfaces +# --------------------------------------------------------------------------- + +KG_VERBS = frozenset({ + "create", "get", "list", "update", "delete", "merge", + "search", "link", "neighbors", "traverse", "query", + "propose", "review", "withdraw", +}) + +GTD_VERBS = frozenset({ + "assign", "next", "complete", "tasks", "transition", +}) + +MEMORY_VERBS = frozenset({ + "remember", "recall", +}) + +ALL_VERBS = KG_VERBS | GTD_VERBS | MEMORY_VERBS + +# Regex that matches the start of a verb call line +_VERB_RE = re.compile( + r"^(?:request|\[)?(" + "|".join(sorted(ALL_VERBS)) + r")\s*\(" +) +_REQUEST_RE = re.compile(r"^request\s*\(") + +# Detect placeholder-only calls: verb(...) or [..., ...] +_PLACEHOLDER_RE = re.compile(r"\(\s*\.\.\.\s*\)") +_TRAILING_ELLIPSIS_RE = re.compile(r"\.\.\.\s*\]?\s*$") + + +# --------------------------------------------------------------------------- +# Extraction helpers +# --------------------------------------------------------------------------- + +def extract_code_blocks(text: str) -> list[tuple[str, str, int]]: + """Return [(lang, block_content, start_line_1indexed), ...].""" + blocks = [] + pattern = re.compile(r"^```(\w*)\n(.*?)^```", re.MULTILINE | re.DOTALL) + for m in pattern.finditer(text): + lang = m.group(1).lower() + content = m.group(2) + start_line = text[: m.start()].count("\n") + 1 + blocks.append((lang, content, start_line)) + return blocks + + +def should_skip_block(lang: str, content: str) -> bool: + """True if the block should be excluded from validation entirely.""" + if lang in ("json", "bash", "sh"): + return True + # Skip blocks that are purely table-formatted verb signatures + stripped = content.strip() + if stripped.startswith("|") or "| Verb" in stripped: + return True + return False + + +def _join_continuation_lines(text: str) -> str: + """Join lines that are clearly continuing a previous call (indented or mid-list).""" + lines = text.split("\n") + joined: list[str] = [] + buf = "" + for line in lines: + stripped = line.strip() + if not stripped: + if buf: + joined.append(buf) + buf = "" + continue + if buf: + # continuation if current line doesn't start a new verb/request call + if _REQUEST_RE.match(stripped) or _VERB_RE.match(stripped): + joined.append(buf) + buf = stripped + else: + buf += " " + stripped + else: + buf = stripped + if buf: + joined.append(buf) + return "\n".join(joined) + + +def extract_verb_calls(block_content: str) -> list[tuple[int, str]]: + """Return [(offset_line, call_text), ...] for verb-like lines in a block.""" + content = _join_continuation_lines(block_content) + calls = [] + for i, line in enumerate(content.split("\n")): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if _REQUEST_RE.match(stripped) or _VERB_RE.match(stripped): + calls.append((i, stripped)) + return calls + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +def validate_call(call_text: str) -> tuple[bool, str | None]: + """Validate one call line. Returns (ok, error_msg).""" + # Skip placeholder calls + if _PLACEHOLDER_RE.search(call_text): + return True, None # placeholder; skip silently + if _TRAILING_ELLIPSIS_RE.search(call_text): + return True, None # incomplete example; skip silently + + # --- request(ops="...") wrapper --- + if _REQUEST_RE.match(call_text): + # Extract ops value (handle single or double wrapped ops) + ops_m = re.search(r'ops\s*=\s*"(.*)"', call_text) + if not ops_m: + # Could be multiline collapsed — just check the call structure exists + if "ops=" not in call_text and 'ops =' not in call_text: + return False, 'request() missing ops= argument' + return True, None + inner = ops_m.group(1) + # Unescape inner content + inner_unescaped = inner.replace('\\"', '"') + # Validate inner verb(s) + # Handle batch: starts with [ + if inner_unescaped.strip().startswith("["): + return _validate_batch(inner_unescaped, call_text) + return _validate_single_inner(inner_unescaped, call_text) + + # --- direct verb call (inner example) --- + m = _VERB_RE.match(call_text) + if m: + verb = m.group(1) + # Extract args portion + paren_pos = call_text.index("(") + args_text = call_text[paren_pos + 1 :] + return _validate_verb_args(verb, args_text, call_text) + + # Shouldn't reach here given our extraction filter + return True, None + + +def _validate_batch(inner: str, original: str) -> tuple[bool, str | None]: + """Validate a batch call string like [verb1(...), verb2(...)].""" + # Extract individual verb calls from within the brackets + stripped = inner.strip().lstrip("[").rstrip("]").strip() + # Split on top-level commas (simple approach: find verb( patterns) + verb_starts = [m.start() for m in re.finditer( + r"\b(" + "|".join(sorted(ALL_VERBS)) + r")\s*\(", stripped + )] + if not verb_starts: + return True, None # No recognisable verbs in batch; skip + for i, start in enumerate(verb_starts): + end = verb_starts[i + 1] if i + 1 < len(verb_starts) else len(stripped) + segment = stripped[start:end].strip().rstrip(", ") + m = re.match(r"([a-z_]+)\s*\(", segment) + if not m: + continue + verb = m.group(1) + if verb not in ALL_VERBS: + return False, f"Unknown verb in batch: {verb!r} in: {original[:80]}" + paren_pos = segment.index("(") + args = segment[paren_pos + 1 :] + ok, err = _validate_verb_args(verb, args, segment) + if not ok: + return False, err + return True, None + + +def _validate_single_inner(inner: str, original: str) -> tuple[bool, str | None]: + """Validate a single inner verb call string (unescaped).""" + inner = inner.strip() + m = re.match(r"([a-z_]+)\s*\(", inner) + if not m: + return True, None # Can't parse — skip + verb = m.group(1) + if verb not in ALL_VERBS: + return False, f"Unknown verb: {verb!r} in: {original[:80]}" + paren_pos = inner.index("(") + args = inner[paren_pos + 1 :] + return _validate_verb_args(verb, args, inner) + + +def _validate_verb_args(verb: str, args_text: str, original: str) -> tuple[bool, str | None]: + """Check that the first argument looks like a keyword arg, not positional.""" + args_stripped = args_text.strip().lstrip("(").rstrip(")").strip() + if not args_stripped: + return True, None # No args — fine for verbs like next() + # Skip if placeholder content + if _PLACEHOLDER_RE.search(args_stripped): + return True, None + + # First arg token check: positional if starts with " or ' or digit + # but NOT if the outer call is already unescaped (has real quotes) + first_char = args_stripped[0] + if first_char in ('"', "'") or first_char.isdigit(): + # Exception: request(ops="...") — already handled upstream + return False, ( + f"Positional arg in {verb}(): " + f"first arg starts with {args_stripped[:30]!r} — " + f"use keyword args: {verb}(arg_name=value, ...)" + ) + return True, None + + +# --------------------------------------------------------------------------- +# File scanning +# --------------------------------------------------------------------------- + +def scan_file(path: Path, marketplace_root: Path) -> tuple[int, int, int, list[str]]: + """Scan one file. Returns (checked, valid, skipped, [error_lines]).""" + text = path.read_text(encoding="utf-8") + rel = path.relative_to(marketplace_root) + errors = [] + checked = 0 + valid = 0 + skipped = 0 + + for lang, content, block_start in extract_code_blocks(text): + if should_skip_block(lang, content): + skipped += 1 + continue + calls = extract_verb_calls(content) + for offset, call_text in calls: + # Skip pure placeholder calls before counting + if _PLACEHOLDER_RE.search(call_text) or _TRAILING_ELLIPSIS_RE.search(call_text): + skipped += 1 + continue + line_num = block_start + offset + 1 + checked += 1 + ok, err = validate_call(call_text) + if ok: + valid += 1 + else: + errors.append(f" {rel}:{line_num}: {err}") + errors.append(f" text: {call_text[:120]}") + + return checked, valid, skipped, errors + + +def main() -> int: + here = Path(__file__).parent + marketplace_root = here.parent + + skill_files = sorted(marketplace_root.glob("*/skills/**/SKILL.md")) + agent_files = sorted(marketplace_root.glob("kg/agents/*.md")) + all_files = skill_files + agent_files + + if not all_files: + print(f"No SKILL.md files found under {marketplace_root}") + return 1 + + total_checked = 0 + total_valid = 0 + total_skipped = 0 + all_errors: list[str] = [] + + for path in all_files: + checked, valid_, skipped, errors = scan_file(path, marketplace_root) + total_checked += checked + total_valid += valid_ + total_skipped += skipped + if errors: + all_errors.append(f"\n[FAIL] {path.relative_to(marketplace_root)}") + all_errors.extend(errors) + elif checked > 0: + print(f" ok {path.relative_to(marketplace_root)} ({checked} examples)") + else: + print(f" -- {path.relative_to(marketplace_root)} (no extractable examples)") + + print() + print(f"checked={total_checked} valid={total_valid} invalid={total_checked - total_valid} skipped={total_skipped}") + + if all_errors: + print("\nFAILURES:") + print("\n".join(all_errors)) + return 1 + + print("\nAll examples valid.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketplace/gtd/.claude-plugin/plugin.json b/marketplace/gtd/.claude-plugin/plugin.json index 81e5eb12..629ab411 100644 --- a/marketplace/gtd/.claude-plugin/plugin.json +++ b/marketplace/gtd/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "gtd", "description": "GTD-style task lifecycle for AI agents — assign, next, complete, transition. Tasks live alongside your knowledge graph; `search(kind=\"note\", ...)` surfaces them like any other note.", - "version": "0.1.0", + "version": "0.2.2", "author": { "name": "Ocean (HaiyangLi)", "url": "https://github.com/ohdearquant" diff --git a/marketplace/gtd/README.md b/marketplace/gtd/README.md index 80fac89d..8dbdde3c 100644 --- a/marketplace/gtd/README.md +++ b/marketplace/gtd/README.md @@ -10,7 +10,7 @@ All verbs are dispatched through the single MCP `request` tool ([ADR-020](https: | Verb | What it does | | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| `assign(title, priority?, status?, assignee?, due?, depends_on?, tags?, description?)` | Create a task. Defaults to `status=inbox`, priority salience 0.5. | +| `assign(title, priority?, status?, assignee?, due?, start?, end?, depends_on?, tags?, description?)` | Create a task. Defaults to `status=inbox`, priority salience 0.5. | | `next(limit?, assignee?)` | Actionable tasks (status in `{next, active}`), priority-sorted. | | `complete(id, result?)` | Mark done. Records `completed_at` and validates the transition. | | `tasks(status?, assignee?, priority?, limit?, offset?)` | Filtered listing. | @@ -21,7 +21,9 @@ Statuses accept canonical names _or_ aliases: `in_progress → active`, `todo ## Skills - **capture** — drop ideas / commitments into `inbox` cleanly. +- **process** — clarify inbox items into next, waiting, someday, done, or cancelled. - **today** — review actionable work and pick what to do now. +- **plan** — choose realistic weekly commitments and defer stale work. - **review** — weekly sweep: triage inbox, defer / cancel stale items. ## Prerequisites diff --git a/marketplace/kg/.claude-plugin/plugin.json b/marketplace/kg/.claude-plugin/plugin.json index dfb59cce..d8c5672b 100644 --- a/marketplace/kg/.claude-plugin/plugin.json +++ b/marketplace/kg/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "kg", "description": "Persistent knowledge graph for AI agents — typed entities, closed edge ontology, hybrid search, GQL queries.", - "version": "0.1.1", + "version": "0.2.2", "author": { "name": "Ocean (HaiyangLi)", "url": "https://github.com/ohdearquant" diff --git a/marketplace/kg/README.md b/marketplace/kg/README.md index 07f6f106..50b78bb1 100644 --- a/marketplace/kg/README.md +++ b/marketplace/kg/README.md @@ -41,7 +41,7 @@ Or add to your project's `.mcp.json`: ## What You Get -### 1 MCP tool (`request`), 11 verbs inside it +### 1 MCP tool (`request`), 14 verbs inside it The MCP server exposes a single tool, `request`, that takes the verb call as a string: @@ -55,7 +55,7 @@ request(ops="[search(kind=\"entity\", query=\"LoRA\"), neighbors(node_id=\"\ | `create` | Create entities or notes | | `get` | Fetch any record by UUID (or 8-char prefix) | | `list` | Browse with filters | -| `update` | Patch entity/edge fields | +| `update` | Patch entity, note, or edge fields | | `delete` | Soft or hard delete | | `merge` | Deduplicate two entities | | `search` | Hybrid FTS5 + vector search | @@ -63,17 +63,23 @@ request(ops="[search(kind=\"entity\", query=\"LoRA\"), neighbors(node_id=\"\ | `neighbors` | Immediate graph neighbors | | `traverse` | Multi-hop BFS | | `query` | GQL/SPARQL pattern matching | - -### 6 Skills (workflow-shaped, not verb docs) - -| Skill | Command | What it does | -| ------- | ------------- | ------------------------------------------------------------------------------------------------ | -| digest | `/kg:digest` | Ingest material into the graph — extract entities, link them, verify density | -| explore | `/kg:explore` | Discover what the graph knows about a topic — traverse, narrate, surface gaps | -| connect | `/kg:connect` | Wire a new concept into existing knowledge — find relations, reach density | -| polish | `/kg:polish` | Audit and fix — orphans, low-degree nodes, duplicates, stale edges | -| gap | `/kg:gap` | Strategic-gap survey — researched-but-unbuilt, decision debt, frontier ranking for planning | -| expand | `/kg:expand` | Self-expansion — take a gap and grow the graph to close it (promote / bridge / extend / resolve) | +| `propose` | Create an event-sourced change proposal | +| `review` | Review a proposal | +| `withdraw` | Withdraw an open proposal | + +### 9 Skills (workflow-shaped, not verb docs) + +| Skill | Command | What it does | +| -------- | -------------- | ------------------------------------------------------------------------------------------------ | +| digest | `/kg:digest` | Ingest material into the graph — extract entities, link them, verify density | +| explore | `/kg:explore` | Discover what the graph knows about a topic — traverse, narrate, surface gaps | +| connect | `/kg:connect` | Wire a new concept into existing knowledge — find relations, reach density | +| polish | `/kg:polish` | Audit and fix — orphans, low-degree nodes, duplicates, stale edges | +| gap | `/kg:gap` | Strategic-gap survey — researched-but-unbuilt, decision debt, frontier ranking for planning | +| expand | `/kg:expand` | Self-expansion — take a gap and grow the graph to close it (promote / bridge / extend / resolve) | +| propose | `/kg:propose` | Draft event-sourced KG changes for review | +| review | `/kg:review` | Approve, reject, comment on, or request changes for proposals | +| withdraw | `/kg:withdraw` | Withdraw an open proposal with rationale | ### 6 Agents (specialized + a generic backstop) @@ -122,11 +128,11 @@ to keep the swarm moving; no central orchestrator required. ## Schema -**6 entity kinds**: concept, document, dataset, project, person, org +**8 entity kinds**: concept, document, dataset, project, person, org, artifact, service -**13 edge relations**: contains, part_of, instance_of, extends, variant_of, -introduced_by, supersedes, depends_on, enables, implements, competes_with, -composed_with, annotates +**15 edge relations**: contains, part_of, instance_of, extends, variant_of, +introduced_by, supersedes, derived_from, precedes, depends_on, enables, +implements, competes_with, composed_with, annotates **5 note kinds**: observation, insight, question, decision, reference diff --git a/marketplace/kg/agents/expander.md b/marketplace/kg/agents/expander.md index e5d9db57..ee8306e4 100644 --- a/marketplace/kg/agents/expander.md +++ b/marketplace/kg/agents/expander.md @@ -116,7 +116,6 @@ If you filed a `question` note instead of expanding: ``` complete(id="", - status="done", result="Cannot expand without external input. Question note filed: . Reason: <…>") ``` diff --git a/marketplace/kg/agents/librarian.md b/marketplace/kg/agents/librarian.md index c4154aad..98f416a2 100644 --- a/marketplace/kg/agents/librarian.md +++ b/marketplace/kg/agents/librarian.md @@ -68,7 +68,7 @@ For each agent's queue: Then check `question` notes: ``` -list(kind="note", filter={"note_kind": "question"}, limit=50) +list(kind="note", note_kind="question", limit=50) ``` Filter for notes older than 7 days. Each is a research-direction the swarm couldn't @@ -79,7 +79,7 @@ autonomously resolve. Group by tag / domain to surface patterns. ## Taxonomy questions When gap-analyst queues a `kg:meta + taxonomy` task, the gap requires a relation -that doesn't exist in the closed 13-relation set. Librarian's job: +that doesn't exist in the closed 15-relation set. Librarian's job: 1. Read the gap analyst's report and the affected entities. 2. Determine whether the missing relation is genuine or whether the gap can be @@ -115,7 +115,7 @@ the swarm: ``` create(kind="note", note_kind="decision", content="Recommend running 3 parallel polisher agents to drain backlog. Current depth 50, normal depth <10.", - tags=["library:recommendation"]) + properties={"tags": ["library:recommendation"]}) ``` --- diff --git a/marketplace/kg/agents/researcher.md b/marketplace/kg/agents/researcher.md index e3b268a7..3e8008bd 100644 --- a/marketplace/kg/agents/researcher.md +++ b/marketplace/kg/agents/researcher.md @@ -55,10 +55,12 @@ Do not begin external research until you've exhausted what's already in the grap ### Edge creation rules -Use only these 13 relations (no others — the parser rejects unknown relations): +Use only these 15 relations (no others — the parser rejects unknown relations): - Structure: `contains`, `part_of`, `instance_of` - Derivation: `extends`, `variant_of`, `introduced_by`, `supersedes` +- Provenance: `derived_from` +- Temporal: `precedes` - Dependency: `depends_on`, `enables` - Implementation: `implements` - Lateral: `competes_with`, `composed_with` @@ -106,8 +108,8 @@ Never store findings ONLY as notes. If a concept is worth naming, it's an entity "What does entity X connect to?" → neighbors(node_id=X, direction="both") "What builds on X? (lineage)" → traverse(roots=[X], direction="in", relations=["extends","variant_of"]) "What does X depend on?" → traverse(roots=[X], direction="out", relations=["depends_on"]) -"All concepts in domain Y" → query("MATCH (a:concept) WHERE a.domain='Y' RETURN a.name, a.id LIMIT 50") -"Implementations of concept X" → query("MATCH (p:project)-[:implements]->(c:concept) WHERE c.name='X' RETURN p.name, c.name LIMIT 20") +"All concepts in domain Y" → query(query="MATCH (a:concept) WHERE a.domain='Y' RETURN a.name, a.id LIMIT 50") +"Implementations of concept X" → query(query="MATCH (p:project)-[:implements]->(c:concept) WHERE c.name='X' RETURN p.name, c.name LIMIT 20") "What concepts did paper P introduce?"→ neighbors(node_id=paper_id, direction="in", relations=["introduced_by"]) "Previously observed/decided on X" → search(kind="note", query=X) ``` @@ -134,7 +136,7 @@ Mandatory verification before reporting: 3. **Update status** if research changed maturity: ```python - update(id=, properties={"status": "researched"}) + update(kind="entity", id="", properties={"status": "researched"}) ``` 4. **Decision note** if a choice was made: @@ -172,7 +174,7 @@ Density: 47 edges / 11 entities = 4.3 (was 3.8 before) - Do not search externally for things already in the graph — check the graph first - Do not create entities without edges — orphans degrade graph quality immediately -- Do not use ad-hoc edge relations (`uses`, `related_to`, `references`) — map to the 13 or don't link +- Do not use ad-hoc edge relations (`uses`, `related_to`, `references`) — map to the 15 or don't link - Do not reverse `introduced_by` — direction is concept → paper/person, never paper → person - Do not use entity names as strings in `source_id`/`target_id` — always use IDs from prior responses - Do not use `traverse` when `neighbors` suffices — use the cheapest retrieval that answers the question diff --git a/marketplace/kg/skills/connect/SKILL.md b/marketplace/kg/skills/connect/SKILL.md index 97864fee..2979654d 100644 --- a/marketplace/kg/skills/connect/SKILL.md +++ b/marketplace/kg/skills/connect/SKILL.md @@ -33,7 +33,7 @@ create(kind="entity", entity_kind="", name="", description="", properties={...}) ``` -Pick from 6 kinds: `concept`, `document`, `dataset`, `project`, `person`, `org`. +Pick from 8 kinds: `concept`, `document`, `dataset`, `project`, `person`, `org`, `artifact`, `service`. ### 3. Find what it connects to @@ -72,7 +72,7 @@ link(source_id="", target_id="", relation="", weight=<0.4-1. | `implements` | code → concept | "lattice-inference implements GQA" | | `depends_on` | consumer → dependency | "quantization depends_on calibration data" | -If the relationship doesn't fit any of the 13 relations, it's probably a **property** on the entity (e.g., "published in 2021" → `properties.year: "2021"`, not an edge). +If the relationship doesn't fit any of the 15 relations, it's probably a **property** on the entity (e.g., "published in 2021" → `properties.year: "2021"`, not an edge). ### 5. Verify density diff --git a/marketplace/kg/skills/digest/SKILL.md b/marketplace/kg/skills/digest/SKILL.md index d2a09b70..aab6d2fc 100644 --- a/marketplace/kg/skills/digest/SKILL.md +++ b/marketplace/kg/skills/digest/SKILL.md @@ -11,7 +11,7 @@ The MCP server exposes one tool — `request` — that takes the verb call as a ```text request(ops="create(kind=\"entity\", entity_kind=\"concept\", name=\"LoRA\")") -request(ops="[create(...), create(...), link(...)]") # parallel batch +request(ops="[create(kind=\"entity\", entity_kind=\"concept\", name=\"LoRA\"), create(kind=\"entity\", entity_kind=\"document\", name=\"LoRA paper\"), link(source_id=\"\", target_id=\"\", relation=\"introduced_by\")]") # parallel batch ``` The verb examples in this skill show the inner call. Wrap each one as `request(ops="…")`. @@ -36,7 +36,7 @@ create(kind="entity", entity_kind="", name="", properties={"domain": "...", "type": "...", "year": "..."}) ``` -**6 entity kinds** (closed — pick the best fit, don't invent): +**8 entity kinds** (closed — pick the best fit, don't invent): | Kind | Use for | | ---------- | ------------------------------------------------------------ | @@ -46,6 +46,8 @@ create(kind="entity", entity_kind="", name="", | `project` | Codebases, libraries, tools, frameworks | | `person` | Researchers, engineers, authors | | `org` | Labs, companies, institutions | +| `artifact` | Generated files, model artifacts, build outputs | +| `service` | Long-running services, APIs, deployed systems | **Naming**: short canonical name people actually say. `LoRA` not `Low-Rank Adaptation of Large Language Models`. Full titles go in `properties`. @@ -57,7 +59,7 @@ For each relationship you identified in the material: link(source_id="", target_id="", relation="", weight=<0.4-1.0>) ``` -**13 relations** (closed — map to these, don't invent): +**15 relations** (closed — map to these, don't invent): | Category | Relation | Direction | When | | -------------- | --------------- | ---------------------- | ------------------------- | @@ -68,6 +70,8 @@ link(source_id="", target_id="", relation="", weight=<0.4-1. | Derivation | `variant_of` | variant → original | Modified version | | Derivation | `introduced_by` | concept → paper/person | First described in | | Derivation | `supersedes` | new → old | Replaces entirely | +| Provenance | `derived_from` | derived → source | Data/artifact lineage | +| Temporal | `precedes` | earlier → later | Ordering over time | | Dependency | `depends_on` | consumer → dep | Hard requirement | | Dependency | `enables` | prerequisite → outcome | Makes possible | | Implementation | `implements` | code → concept | Code realizes algorithm | @@ -115,5 +119,5 @@ Material exhausted. Every entity above minimum density. No orphans (0-edge nodes If a tool returns an error, read the message — it lists valid values. Common cases: - Invalid `entity_kind` or `note_kind` → the error says which values are valid -- Invalid `relation` → use only the 13 above +- Invalid `relation` → use only the 15 above - ID not found → check the UUID; use `search` to find the correct one diff --git a/marketplace/kg/skills/expand/SKILL.md b/marketplace/kg/skills/expand/SKILL.md index 0e122676..feba6f59 100644 --- a/marketplace/kg/skills/expand/SKILL.md +++ b/marketplace/kg/skills/expand/SKILL.md @@ -21,7 +21,7 @@ The MCP server exposes one tool — `request` — that takes the verb call as a ```text request(ops="create(kind=\"entity\", entity_kind=\"concept\", name=\"…\")") -request(ops="[create(...), link(...), link(...)]") # parallel batch +request(ops="[create(kind=\"entity\", entity_kind=\"project\", name=\"lora-tools\"), link(source_id=\"\", target_id=\"\", relation=\"implements\"), link(source_id=\"\", target_id=\"\", relation=\"depends_on\")]") # parallel batch ``` The verb examples below show the inner call. Wrap each one as `request(ops="…")`. @@ -165,7 +165,7 @@ Workflow: 1. For each member, pull description + properties: ``` -[get(id=""), get(id=""), ...] +request(ops="[get(id=\"\"), get(id=\"\")]") ``` 2. Identify the comparison axes that matter (drawn from descriptions, properties, @@ -182,7 +182,7 @@ Workflow: ``` create(kind="note", note_kind="decision", content="Comparison of . Axes: . Recommended: . Assumptions: .", - annotates=[]) + annotates=["", ""]) ``` 4. If the recommendation is strong (cited by an existing ADR or backed by @@ -230,7 +230,7 @@ State for the record: 3. **Every new entity needs a citation.** A description sentence sourced from an existing entity, a paper, an ADR, or a code reference. No source = no create. File a question note instead. -4. **Do not create entities outside the closed taxonomies.** 6 kinds, 13 +4. **Do not create entities outside the closed taxonomies.** 8 kinds, 15 relations, 5 note kinds. If your expansion needs a new kind, that is an ADR, not a skill invocation — stop and surface to a human. 5. **Re-read the source after expansion** to verify you didn't drift. A diff --git a/marketplace/kg/skills/explore/SKILL.md b/marketplace/kg/skills/explore/SKILL.md index 8c305f3d..20d80164 100644 --- a/marketplace/kg/skills/explore/SKILL.md +++ b/marketplace/kg/skills/explore/SKILL.md @@ -55,7 +55,7 @@ Common traversal patterns: For complex structural queries, use GQL: ``` -query("MATCH (a:concept)-[:extends]->(b:concept) WHERE b.name = 'LoRA' RETURN a.name, a.id LIMIT 20") +query(query="MATCH (a:concept)-[:extends]->(b:concept) WHERE b.name = 'LoRA' RETURN a.name, a.id LIMIT 20") ``` **GQL constraints** (the parser is limited): @@ -64,7 +64,7 @@ query("MATCH (a:concept)-[:extends]->(b:concept) WHERE b.name = 'LoRA' RETURN a. - For JSON properties: use `a.domain`, `a.type` etc. (accessed via json_extract internally) - `RETURN a.properties` gets the full JSON blob - NOT supported: `WHERE NOT`, `COUNT`, `ORDER BY`, `[*..N]` variable-length without min -- Relations in patterns: use the 13 canonical relation names +- Relations in patterns: use the 15 canonical relation names ### 4. Narrate @@ -93,7 +93,7 @@ Report gaps as actionable next steps (e.g., "X exists but has no `introduced_by` | Find by content/similarity | `search(kind="entity\|note", query="...")` | | Immediate connections | `neighbors(node_id, direction, relations)` | | Multi-hop reachability | `traverse(roots, max_depth, direction, relations)` | -| Structural patterns | `query("MATCH ... RETURN ...")` | +| Structural patterns | `query(query="MATCH ... RETURN ...")` | | Browse a category | `list(kind="entity", entity_kind="concept", limit=50)` | ## Stop condition diff --git a/marketplace/kg/skills/polish/SKILL.md b/marketplace/kg/skills/polish/SKILL.md index e7061041..dcd47bde 100644 --- a/marketplace/kg/skills/polish/SKILL.md +++ b/marketplace/kg/skills/polish/SKILL.md @@ -52,8 +52,8 @@ If two entities refer to the same real-world thing (e.g., "LoRA" and "Low-Rank A merge(into_id="", from_id="") ``` -`merge` is entity-only in v0.1. Properties combine, tags union, edges rewire to the kept -entity, the other is removed. Both must be entities (not notes). +`merge` deduplicates entities. Properties combine, tags union, edges rewire to the kept +entity, and the duplicate is removed. Both IDs must refer to entities. For duplicate **notes** — use supersession instead: @@ -102,9 +102,9 @@ Check for: Fix with: ``` -update(id="", relation="") -update(id="", weight=1.0) -delete(id="") # if the edge is just wrong +update(kind="edge", id="", relation="") +update(kind="edge", id="", weight=1.0) +delete(kind="edge", id="") # if the edge is just wrong ``` ### 6. Report diff --git a/marketplace/kg/skills/propose/SKILL.md b/marketplace/kg/skills/propose/SKILL.md new file mode 100644 index 00000000..d2a71c54 --- /dev/null +++ b/marketplace/kg/skills/propose/SKILL.md @@ -0,0 +1,54 @@ +--- +description: Draft event-sourced knowledge graph changes for proposal review before mutation. +--- + +# Propose + +Use `propose` when a KG change should be reviewed before it is applied. The verb creates an open proposal; it does not directly mutate entities, notes, or edges. + +The MCP server exposes one tool, `request`, that takes the verb call as a string: + +```text +request(ops="propose(title=\"Add implementation edge\", description=\"Project X implements concept Y based on reviewed source evidence.\", changeset={\"kind\":\"add_edge\",\"source\":\"00000000-0000-0000-0000-000000000001\",\"target\":\"00000000-0000-0000-0000-000000000002\",\"relation\":\"implements\",\"weight\":0.8}, reviewers=[\"critic\"])") +``` + +Required args: `title`, `description`, `changeset`. +Optional args: `reviewers`, `expiry`, `parent_id`, `namespace`. + +Valid `changeset.kind` values: `add_entity`, `update_entity`, `add_edge`, `add_note`, `merge_entities`, `supersede_entity`, `compound`. + +## Workflow + +### 1. Draft the proposal + +Identify the specific change and its rationale before calling `propose`. The description should cite the source evidence that justifies the change. + +```text +request(ops="propose(title=\"Add LoRA extends Attention edge\", description=\"LoRA is a parameter-efficient variant that extends the standard attention mechanism. Source: Hu et al. 2021 (LoRA paper, entity id: ).\", changeset={\"kind\":\"add_edge\",\"source\":\"\",\"target\":\"\",\"relation\":\"extends\",\"weight\":0.9})") +``` + +### 2. Add reviewers (optional) + +Pass `reviewers` as a list of agent names or identities who should assess the proposal. If omitted, any agent with access may review. + +```text +request(ops="propose(title=\"Merge duplicate FlashAttention entities\", description=\"Two entities represent the same concept: ids and . Keeping — it has higher edge count and more complete properties.\", changeset={\"kind\":\"merge_entities\",\"into_id\":\"\",\"from_id\":\"\"}, reviewers=[\"polisher\", \"researcher\"])") +``` + +### 3. Check proposal status + +After submission, retrieve the proposal with `get`: + +```text +request(ops="get(id=\"\")") +``` + +Or list open proposals: + +```text +request(ops="list(kind=\"proposal\", status=\"open\")") +``` + +## Stop condition + +Proposal is open and assigned to reviewers. Do not apply the change manually — wait for `review` approval. If the proposal is rejected, revise the `changeset` or description based on reviewer feedback and re-propose. diff --git a/marketplace/kg/skills/review/SKILL.md b/marketplace/kg/skills/review/SKILL.md new file mode 100644 index 00000000..f8f3cfd9 --- /dev/null +++ b/marketplace/kg/skills/review/SKILL.md @@ -0,0 +1,70 @@ +--- +description: Review an open knowledge graph proposal by approving, rejecting, commenting, or requesting changes. +--- + +# Review + +Use `review` after reading the proposal and checking the requested changes against source evidence. + +The MCP server exposes one tool, `request`, that takes the verb call as a string: + +```text +request(ops="review(proposal_id=\"00000000-0000-0000-0000-000000000001\", decision=\"approve\", comment=\"Change matches the cited evidence.\")") +``` + +Required args: `proposal_id`, `decision`. +Optional args: `comment`, `namespace`. + +Valid `decision` values: `approve`, `reject`, `comment`, `request_changes`, `requestchanges`. + +## Workflow + +### 1. Read the proposal + +Retrieve the proposal before deciding: + +```text +request(ops="get(id=\"\")") +``` + +Inspect `changeset.kind` and the source IDs. Verify the cited entities exist and the relationship is correct. + +### 2. Verify source evidence + +For an `add_edge` proposal, check both endpoints: + +```text +request(ops="[get(id=\"\"), get(id=\"\")]") +``` + +For a `merge_entities` proposal, compare descriptions and edge counts before approving. + +### 3. Decide + +**Approve** — evidence supports the change, relation and direction are correct: + +```text +request(ops="review(proposal_id=\"\", decision=\"approve\", comment=\"Source entity id confirms the introduced_by direction.\")") +``` + +**Reject** — change is incorrect or cannot be verified: + +```text +request(ops="review(proposal_id=\"\", decision=\"reject\", comment=\"Direction is reversed. introduced_by should go concept → paper, not paper → concept.\")") +``` + +**Request changes** — proposal has the right intent but needs a corrected changeset: + +```text +request(ops="review(proposal_id=\"\", decision=\"request_changes\", comment=\"Weight should be 0.9 (definitional), not 0.4. Resubmit with corrected weight.\")") +``` + +**Comment** — add context without blocking or approving: + +```text +request(ops="review(proposal_id=\"\", decision=\"comment\", comment=\"Related proposal also touches this entity — coordinate.\")") +``` + +## Stop condition + +Decision recorded. If approved, the runtime applies the changeset. If rejected or change-requested, the proposer must resubmit. Do not manually apply rejected changes. diff --git a/marketplace/kg/skills/withdraw/SKILL.md b/marketplace/kg/skills/withdraw/SKILL.md new file mode 100644 index 00000000..56e88e5b --- /dev/null +++ b/marketplace/kg/skills/withdraw/SKILL.md @@ -0,0 +1,45 @@ +--- +description: Withdraw an open knowledge graph proposal with a short rationale. +--- + +# Withdraw + +Use `withdraw` when an open proposal is obsolete, duplicated, or should no longer be reviewed. + +The MCP server exposes one tool, `request`, that takes the verb call as a string: + +```text +request(ops="withdraw(proposal_id=\"00000000-0000-0000-0000-000000000001\", rationale=\"Superseded by a narrower proposal.\")") +``` + +Required args: `proposal_id`. +Optional args: `rationale`, `namespace`. + +## When to withdraw + +- The changeset was superseded by a better proposal +- Source evidence no longer supports the change +- The entities involved were merged or deleted before the proposal was reviewed +- The proposer identifies an error in the original changeset + +## Workflow + +### 1. Confirm the proposal is open + +```text +request(ops="get(id=\"\")") +``` + +Only open proposals can be withdrawn. Approved or rejected proposals are immutable records. + +### 2. Withdraw with rationale + +```text +request(ops="withdraw(proposal_id=\"\", rationale=\"Duplicate of proposal which is already approved.\")") +``` + +The `rationale` is stored with the proposal record so reviewers understand why it was closed without a decision. + +## Stop condition + +Proposal status is `withdrawn`. No further action needed. If you need a replacement change, open a new proposal via `propose`. diff --git a/marketplace/memory/.claude-plugin/plugin.json b/marketplace/memory/.claude-plugin/plugin.json index 58d05b16..4e915c05 100644 --- a/marketplace/memory/.claude-plugin/plugin.json +++ b/marketplace/memory/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memory", "description": "Persistent agent memory for khive — remember durable context and recall it with decay-aware ranking.", - "version": "0.1.0", + "version": "0.2.2", "author": { "name": "Ocean (HaiyangLi)", "url": "https://github.com/ohdearquant" diff --git a/marketplace/memory/README.md b/marketplace/memory/README.md index d288bce0..b5c2f159 100644 --- a/marketplace/memory/README.md +++ b/marketplace/memory/README.md @@ -10,8 +10,8 @@ All verbs are dispatched through the single MCP `request` tool ([ADR-020](https: | Verb | What it does | | ---- | ------------ | -| `remember(content, memory_type?, importance?, decay_factor?, source_id?, namespace?, tags?)` | Store a memory note with salience and decay metadata. | -| `recall(query, limit?, memory_type?, min_score?, min_salience?, namespace?)` | Search memory notes only, then rank by relevance, importance, and recency. | +| `remember(content, memory_type?, importance?/salience?, decay_factor?/decay?, source_id?/source?, namespace?, tags?)` | Store a memory note with salience and decay metadata. | +| `recall(query, limit?, memory_type?, min_score?, min_salience?, config?, namespace?)` | Search memory notes only, then rank by relevance, importance, and recency. | Memory types: diff --git a/tests/khive-contract/.gitignore b/tests/khive-contract/.gitignore new file mode 100644 index 00000000..17cd2fc3 --- /dev/null +++ b/tests/khive-contract/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/tests/khive-contract/README.md b/tests/khive-contract/README.md new file mode 100644 index 00000000..08dc6819 --- /dev/null +++ b/tests/khive-contract/README.md @@ -0,0 +1,93 @@ +# khive-contract + +ADR-organized contract tests for the `khive-mcp` binary. + +## What this is + +This package converts the single-file `tests/contract_test.py` and `tests/smoke_test.py` into a +proper uv-managed Python package with: + +- Tests organized by ADR +- Shared fixtures with namespace isolation +- pytest-benchmark latency baselines +- Golden snapshot comparisons +- A test manifest that verifies all 18 product verbs are hit + +## How to run + +```bash +cd tests/khive-contract + +# All tests +uv run pytest -v + +# Only a specific ADR +uv run pytest -v -m adr_002 + +# Benchmarks only (writes baselines/latency.json) +uv run pytest --benchmark-only -v + +# Smoke tests only +uv run pytest -v -m smoke + +# Skip slow subprocess tests +uv run pytest -v -m "not slow" +``` + +## Binary resolution + +The client looks for the `khive-mcp` binary in this order: + +1. `binary=` argument to `KhiveMcpSession` +2. `KHIVE_MCP_BINARY` environment variable +3. `/crates/target/release/khive-mcp` + +If the binary is missing, build it first: + +```bash +cd crates && cargo build --release -p khive-mcp +``` + +## Organization + +Tests are in `tests/` and organized by ADR. The `khive_contract/` package provides: + +- `client.py` — `KhiveMcpSession` subprocess/JSON-RPC wrapper +- `schema.py` — JSON schema validators for verb response shapes +- `fixtures.py` — closed-set constants (entity kinds, relations, verbs) +- `benchmark.py` — latency baseline read/write utilities + +## ADR filename drift note + +Some test filenames use numbers from the play specification that diverged from the final ADR +numbering in this worktree: + +| File | Spec filename | Actual ADR covered | +|------|--------------|-------------------| +| `test_adr_020_request_dsl.py` | as-requested | ADR-016 request DSL | +| `test_adr_027_single_tool_mcp.py` | as-requested | ADR-027 dynamic pack loading | +| `test_adr_021_recall_pipeline.py` | as-requested | ADR-021 memory pack | +| `test_adr_033_recall_configurability.py` | as-requested | ADR-033 recall configurability | + +Each test docstring cites the actual ADR section. + +## Verb coverage + +The manifest covers all 18 product verbs exposed by the baseline: + +- KG (11): create, get, list, update, delete, merge, search, link, neighbors, traverse, query +- GTD (5): assign, next, complete, tasks, transition +- Memory (2): remember, recall + +The task text mentions 15 verbs; 18 subsumes that requirement. + +## Golden update policy + +Golden snapshots in `golden/` are committed with volatile fields (UUIDs, timestamps, +`created_at`, `updated_at`) scrubbed to `""`. To regenerate: + +```bash +uv run pytest -v -m golden --update-golden +``` + +(The `--update-golden` flag is handled in `conftest.py`.) diff --git a/tests/khive-contract/baselines/.gitkeep b/tests/khive-contract/baselines/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/khive-contract/conftest.py b/tests/khive-contract/conftest.py new file mode 100644 index 00000000..8cd82bf4 --- /dev/null +++ b/tests/khive-contract/conftest.py @@ -0,0 +1,222 @@ +"""Shared pytest fixtures for the khive-contract test suite. + +All fixtures here are deterministic except the unique namespace suffix. +Tests must pass namespace=temp_namespace to all verbs that accept it. +""" + +from __future__ import annotations + +import re +import secrets +import uuid +from pathlib import Path +from typing import Any, Callable, Iterator, Mapping, Sequence + +import pytest + +from khive_contract.client import KhiveMcpSession + + +# --------------------------------------------------------------------------- +# Session fixtures — one MCP process per test session, shared across tests. +# Tests MUST use temp_namespace to avoid cross-test contamination. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def khive_session() -> Iterator[KhiveMcpSession]: + """KG-only MCP session. + + ADR: ADR-027 (single-tool MCP surface) + Spawn config: packs=("kg",), db=":memory:", no_embed=True, log="error". + """ + with KhiveMcpSession(packs=("kg",), db=":memory:", no_embed=True, log="error") as session: + yield session + + +@pytest.fixture(scope="session") +def khive_gtd_session() -> Iterator[KhiveMcpSession]: + """KG + GTD MCP session. + + ADR: ADR-019 (GTD pack) + Spawn config: packs=("kg", "gtd"), db=":memory:", no_embed=True, log="error". + """ + with KhiveMcpSession( + packs=("kg", "gtd"), db=":memory:", no_embed=True, log="error" + ) as session: + yield session + + +@pytest.fixture(scope="session") +def khive_memory_session() -> Iterator[KhiveMcpSession]: + """KG + memory MCP session. + + ADR: ADR-021 (memory pack) + Spawn config: packs=("kg", "memory"), db=":memory:", no_embed=True, log="error". + """ + with KhiveMcpSession( + packs=("kg", "memory"), db=":memory:", no_embed=True, log="error" + ) as session: + yield session + + +# --------------------------------------------------------------------------- +# Function fixtures — unique per test, never shared. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def temp_namespace(request: pytest.FixtureRequest) -> str: + """Unique namespace per test function. + + Format: "pyct__<12-hex-random>". + Contains only lowercase letters, digits, and underscores. + """ + raw_name = request.node.name + sanitized = re.sub(r"[^a-z0-9]", "_", raw_name.lower())[:32] + suffix = secrets.token_hex(6) + return f"pyct_{sanitized}_{suffix}" + + +@pytest.fixture +def sample_entity(temp_namespace: str) -> Callable[..., dict[str, Any]]: + """Factory for create(kind="entity", ...) args. + + Returns args dict only — does NOT call the MCP session. + """ + + def factory( + entity_kind: str = "concept", + name: str | None = None, + *, + entity_type: str | None = None, + description: str | None = None, + properties: Mapping[str, Any] | None = None, + tags: Sequence[str] | None = None, + namespace: str | None = None, + ) -> dict[str, Any]: + args: dict[str, Any] = { + "kind": "entity", + "entity_kind": entity_kind, + "name": name or f"{entity_kind}_{uuid.uuid4().hex[:8]}", + "namespace": namespace or temp_namespace, + } + if entity_type is not None: + args["entity_type"] = entity_type + if description is not None: + args["description"] = description + if properties is not None: + args["properties"] = dict(properties) + if tags is not None: + args["tags"] = list(tags) + return args + + return factory + + +@pytest.fixture +def sample_note(temp_namespace: str) -> Callable[..., dict[str, Any]]: + """Factory for create(kind="note", ...) args. + + Returns args dict only — does NOT call the MCP session. + """ + + def factory( + note_kind: str = "observation", + content: str | None = None, + *, + salience: float | None = 0.5, + decay_factor: float | None = None, + properties: Mapping[str, Any] | None = None, + tags: Sequence[str] | None = None, + namespace: str | None = None, + ) -> dict[str, Any]: + args: dict[str, Any] = { + "kind": "note", + "note_kind": note_kind, + "content": content or f"note {note_kind} {uuid.uuid4().hex[:8]}", + "namespace": namespace or temp_namespace, + } + if salience is not None: + args["salience"] = salience + if decay_factor is not None: + args["decay_factor"] = decay_factor + if properties is not None: + args["properties"] = dict(properties) + if tags is not None: + args["tags"] = list(tags) + return args + + return factory + + +@pytest.fixture +def sample_edge(temp_namespace: str) -> Callable[..., dict[str, Any]]: + """Factory for link(...) args. + + Returns args dict only — does NOT call the MCP session. + """ + + def factory( + source_id: str, + target_id: str, + relation: str = "extends", + *, + weight: float | None = 1.0, + properties: Mapping[str, Any] | None = None, + metadata: Mapping[str, Any] | None = None, + namespace: str | None = None, + ) -> dict[str, Any]: + args: dict[str, Any] = { + "source_id": source_id, + "target_id": target_id, + "relation": relation, + "namespace": namespace or temp_namespace, + } + if weight is not None: + args["weight"] = weight + if properties is not None: + args["properties"] = dict(properties) + if metadata is not None: + args["metadata"] = dict(metadata) + return args + + return factory + + +# --------------------------------------------------------------------------- +# Path helpers (optional) +# --------------------------------------------------------------------------- + +_PKG_ROOT = Path(__file__).parent + + +@pytest.fixture +def golden_dir() -> Path: + """Path to the golden/ directory inside the package root.""" + return _PKG_ROOT / "golden" + + +@pytest.fixture +def baseline_path() -> Path: + """Path to baselines/latency.json inside the package root.""" + return _PKG_ROOT / "baselines" / "latency.json" + + +# --------------------------------------------------------------------------- +# CLI option: --update-golden +# --------------------------------------------------------------------------- + + +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption( + "--update-golden", + action="store_true", + default=False, + help="Regenerate golden snapshot files instead of comparing them.", + ) + + +@pytest.fixture +def update_golden(request: pytest.FixtureRequest) -> bool: + return bool(request.config.getoption("--update-golden", default=False)) diff --git a/tests/khive-contract/fixtures/memories_corpus.json b/tests/khive-contract/fixtures/memories_corpus.json new file mode 100644 index 00000000..b78dba29 --- /dev/null +++ b/tests/khive-contract/fixtures/memories_corpus.json @@ -0,0 +1,806 @@ +{ + "memories": [ + { + "content": "Python list comprehension is a concise syntax for creating lists from iterables, equivalent to map and filter operations in functional programming", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "python", "functional"] + }, + { + "content": "Python lambda functions enable functional programming patterns including map, filter, and reduce operations on collections", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "python", "functional"] + }, + { + "content": "Python decorators wrap functions to add behavior like caching, logging, or access control without modifying the original function code", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "python"] + }, + { + "content": "Rust ownership system enforces memory safety at compile time through borrow checker rules that prevent use-after-free and data races", + "importance": 0.90, + "decay_factor": 0.01, + "memory_type": "semantic", + "tags": ["programming", "rust", "memory-safety"] + }, + { + "content": "Rust borrow checker ensures only one mutable reference or multiple immutable references exist at a time, preventing memory safety violations", + "importance": 0.90, + "decay_factor": 0.01, + "memory_type": "semantic", + "tags": ["programming", "rust", "borrow-checker"] + }, + { + "content": "Rust lifetimes are annotations that tell the borrow checker how long references are valid, enabling safe memory management without garbage collection", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "rust", "lifetimes"] + }, + { + "content": "Binary search trees enable O(log n) lookup, insertion, and deletion by maintaining sorted order in left and right subtrees", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "algorithms", "data-structures"] + }, + { + "content": "AVL trees are self-balancing binary search trees that maintain height balance to guarantee O(log n) operations via rotation algorithms", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "algorithms", "data-structures"] + }, + { + "content": "Hash tables provide O(1) average-case lookup by mapping keys to array indices using a hash function and collision resolution strategy", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "algorithms", "data-structures"] + }, + { + "content": "Unit testing verifies individual functions and methods in isolation, forming the foundation of test-driven development TDD methodology", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "testing", "tdd"] + }, + { + "content": "Test-driven development TDD requires writing failing unit tests before implementing production code, then refactoring once all tests pass green", + "importance": 0.90, + "decay_factor": 0.01, + "memory_type": "semantic", + "tags": ["programming", "testing", "tdd"] + }, + { + "content": "Integration tests verify that multiple software components work correctly together as a system, complementing unit tests in a complete test suite", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "testing"] + }, + { + "content": "Debugging with stack traces reveals the call hierarchy at the point of exception failure, helping identify the root cause of bugs", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "debugging"] + }, + { + "content": "A debugging tool called a debugger allows setting breakpoints, stepping through code execution line by line, and inspecting variable values to trace bugs", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "debugging"] + }, + { + "content": "JavaScript async await syntax simplifies asynchronous programming by allowing sequential-looking code for promise-based operations", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "javascript", "async"] + }, + { + "content": "JavaScript Promises represent the eventual completion or failure of asynchronous operations and allow chaining with then and catch methods", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "javascript", "async"] + }, + { + "content": "Git version control tracks changes to source code over time, enabling branching, merging, and collaborative development workflows", + "importance": 0.70, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "git"] + }, + { + "content": "Docker containers package application code with its runtime dependencies into isolated portable environments for consistent deployment", + "importance": 0.70, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "devops"] + }, + { + "content": "RESTful APIs use HTTP methods GET POST PUT DELETE to perform CRUD operations on resources identified by uniform resource identifiers", + "importance": 0.70, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "api", "rest"] + }, + { + "content": "SQL JOIN operations combine rows from two or more database tables based on related columns to query relational data", + "importance": 0.70, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "database", "sql"] + }, + { + "content": "Dynamic programming solves optimization problems by breaking them into overlapping subproblems and caching intermediate results through memoization", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "algorithms"] + }, + { + "content": "Graph traversal algorithms breadth-first search BFS and depth-first search DFS explore all nodes in a connected graph systematically", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "algorithms", "graphs"] + }, + { + "content": "Regular expressions are patterns for matching searching and manipulating strings using special character classes quantifiers and anchors", + "importance": 0.65, + "decay_factor": 0.03, + "memory_type": "episodic", + "tags": ["programming", "regex"] + }, + { + "content": "Sorting algorithms like quicksort mergesort and heapsort have different time complexity and space trade-offs for ordering data", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "algorithms", "sorting"] + }, + { + "content": "Object-oriented programming uses classes inheritance polymorphism and encapsulation to organize code around data structures and behavior", + "importance": 0.70, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["programming", "oop"] + }, + { + "content": "Derivatives measure the instantaneous rate of change of a mathematical function, forming the foundation of differential calculus", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["math", "calculus"] + }, + { + "content": "Integration in calculus computes the area under a curve and is used to find antiderivatives, representing accumulated change", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["math", "calculus"] + }, + { + "content": "The fundamental theorem of calculus links differentiation and integration showing they are inverse mathematical operations", + "importance": 0.90, + "decay_factor": 0.01, + "memory_type": "semantic", + "tags": ["math", "calculus"] + }, + { + "content": "Matrix multiplication combines two matrices to produce a new matrix and is fundamental to linear algebra and computer graphics transformations", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["math", "linear-algebra"] + }, + { + "content": "Eigenvalues and eigenvectors of a matrix reveal its principal axes of transformation and are central to linear algebra applications", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["math", "linear-algebra"] + }, + { + "content": "Linear algebra operations including matrix inversion determinants and vector spaces underlie machine learning algorithms and data analysis", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["math", "linear-algebra"] + }, + { + "content": "Neural networks learn complex patterns by adjusting connection weights through backpropagation and gradient descent optimization algorithms", + "importance": 0.90, + "decay_factor": 0.01, + "memory_type": "semantic", + "tags": ["science", "machine-learning", "neural-networks"] + }, + { + "content": "Deep learning uses multiple hidden layers in neural networks to learn hierarchical feature representations from raw input data", + "importance": 0.90, + "decay_factor": 0.01, + "memory_type": "semantic", + "tags": ["science", "machine-learning", "deep-learning"] + }, + { + "content": "Gradient descent minimizes a loss function by iteratively adjusting model parameters in the direction of the negative gradient", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["science", "machine-learning"] + }, + { + "content": "Normal distribution is characterized by mean and standard deviation and is central to statistics and the central limit theorem", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["math", "statistics", "probability"] + }, + { + "content": "Bayesian probability interprets probability as a degree of belief updated using Bayes theorem when new evidence arrives", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["math", "statistics", "probability", "bayesian"] + }, + { + "content": "Statistical hypothesis testing uses probability p-values and confidence intervals to determine if statistical evidence supports a research claim", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["math", "statistics"] + }, + { + "content": "Quantum mechanics describes particle behavior through wave functions and probability amplitudes at atomic and subatomic energy scales", + "importance": 0.90, + "decay_factor": 0.01, + "memory_type": "semantic", + "tags": ["science", "physics", "quantum"] + }, + { + "content": "Heisenberg uncertainty principle in quantum mechanics states that position and momentum cannot both be measured precisely simultaneously", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["science", "physics", "quantum"] + }, + { + "content": "Special relativity states that the speed of light is constant in all inertial reference frames and that time and space are relative", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["science", "physics", "relativity"] + }, + { + "content": "Photosynthesis converts carbon dioxide and water into glucose and oxygen using solar energy captured by chlorophyll in plant cells", + "importance": 0.70, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["science", "biology"] + }, + { + "content": "DNA double helix encodes genetic information through base pair sequences of adenine thymine guanine and cytosine nucleotides", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["science", "biology", "genetics"] + }, + { + "content": "CRISPR gene editing technology allows precise modification of DNA sequences by cutting at targeted genomic locations guided by RNA", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["science", "biology", "genetics"] + }, + { + "content": "Chemical bonding forms molecules through sharing of electrons in covalent bonds or transfer of electrons in ionic bonds between atoms", + "importance": 0.70, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["science", "chemistry"] + }, + { + "content": "Thermodynamics laws govern energy transfer stating that energy is conserved and entropy always increases in isolated physical systems", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["science", "physics", "thermodynamics"] + }, + { + "content": "Newton laws of motion describe how forces cause changes in velocity and acceleration of objects in classical mechanics", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["science", "physics"] + }, + { + "content": "Electromagnetism describes how electric charges and magnetic fields interact and propagate, unified by Maxwell equations", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["science", "physics"] + }, + { + "content": "Black holes form when massive stars gravitationally collapse creating regions where gravity is so strong that light cannot escape", + "importance": 0.75, + "decay_factor": 0.03, + "memory_type": "semantic", + "tags": ["science", "physics", "astrophysics"] + }, + { + "content": "Climate change results from greenhouse gas emissions trapping solar heat in the atmosphere causing rising global temperatures", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["science", "environment"] + }, + { + "content": "Evolutionary theory explains biodiversity through natural selection mutation and genetic drift acting over millions of years", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["science", "biology", "evolution"] + }, + { + "content": "The Roman Empire at its height controlled the Mediterranean Sea Gaul Britain and North Africa spreading Latin culture and Roman law", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "rome", "ancient"] + }, + { + "content": "Julius Caesar assassination in 44 BC marked the end of the Roman Republic and the beginning of the Roman Empire under Augustus", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "rome", "ancient"] + }, + { + "content": "Ancient Rome and the Roman Empire built the Colosseum aqueducts and extensive road networks leaving lasting architectural legacies", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "rome", "ancient"] + }, + { + "content": "World War II began in 1939 when Germany invaded Poland drawing Britain and France into conflict with the Nazi regime", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "world-war-ii"] + }, + { + "content": "The Battle of Stalingrad 1942 to 1943 was a turning point in World War II ending German eastward advance into the Soviet Union", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "world-war-ii", "battles"] + }, + { + "content": "D-Day on June 6 1944 saw Allied forces land on Normandy beaches in the largest seaborne invasion in World War II history", + "importance": 0.90, + "decay_factor": 0.01, + "memory_type": "semantic", + "tags": ["history", "world-war-ii", "battles"] + }, + { + "content": "South America contains twelve sovereign countries with Brazil occupying nearly half the continent and the vast Amazon rainforest", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["geography", "south-america"] + }, + { + "content": "The Andes mountain range runs along the western coast of South America hosting ancient Andean civilizations and diverse ecosystems", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["geography", "south-america"] + }, + { + "content": "Argentina and Chile share the southern tip of South America including Patagonia with the Andes mountains forming their natural border", + "importance": 0.70, + "decay_factor": 0.03, + "memory_type": "semantic", + "tags": ["geography", "south-america"] + }, + { + "content": "Ancient Egypt was ruled by pharaohs who built pyramids as royal tombs most famously the Great Pyramid of Giza as a tomb", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "egypt", "ancient"] + }, + { + "content": "The ancient Egypt pharaoh Tutankhamun tomb discovered in 1922 contained vast treasures providing insight into Egyptian civilization", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "egypt", "pharaoh"] + }, + { + "content": "Ancient Egypt hieroglyphics were a writing system using pictographic symbols deciphered using the Rosetta Stone in 1822", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "egypt", "ancient"] + }, + { + "content": "The Renaissance was a cultural and intellectual revival in 14th to 17th century Europe centered in Italy featuring humanist values and arts", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "renaissance", "art"] + }, + { + "content": "Leonardo da Vinci epitomized Renaissance ideals combining painting sculpture architecture science and engineering in his masterwork", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "renaissance", "art"] + }, + { + "content": "Michelangelo Sistine Chapel ceiling and David sculpture are masterpieces of Renaissance art commissioned by the Catholic Church", + "importance": 0.85, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "renaissance", "art"] + }, + { + "content": "The Silk Road was an ancient network of trade routes connecting China to Rome facilitating exchange of goods ideas and culture", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "trade"] + }, + { + "content": "The Mongol Empire under Genghis Khan was the largest contiguous land empire spanning from Asia to Eastern Europe in history", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "mongols"] + }, + { + "content": "The Ottoman Empire controlled Anatolia the Middle East and North Africa for six centuries until its dissolution after World War I", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "ottoman"] + }, + { + "content": "The Industrial Revolution began in Britain in the 18th century transforming manufacturing through steam power and mechanization", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "industrial"] + }, + { + "content": "The French Revolution of 1789 abolished the monarchy and aristocracy introducing ideals of liberty equality and fraternity", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "revolution"] + }, + { + "content": "Christopher Columbus 1492 voyage to the Americas opened sustained contact between Europe and the Western Hemisphere continents", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "exploration"] + }, + { + "content": "The Ming Dynasty of China built Great Wall extensions and launched Zheng He naval expeditions across Asia and Africa", + "importance": 0.70, + "decay_factor": 0.03, + "memory_type": "semantic", + "tags": ["history", "china"] + }, + { + "content": "The Black Death plague killed an estimated one third of Europe population in the 14th century reshaping society and economy", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "plague"] + }, + { + "content": "The American Civil War 1861 to 1865 was fought over slavery and states rights resulting in the abolition of slavery", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "america"] + }, + { + "content": "Ancient Greece developed democracy philosophy through Socrates and Plato and the Olympic Games in city-states like Athens", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["history", "greece", "ancient"] + }, + { + "content": "Cooking pasta requires bringing heavily salted water to a full boil before adding pasta and timing precisely for al dente texture", + "importance": 0.65, + "decay_factor": 0.03, + "memory_type": "episodic", + "tags": ["food", "cooking", "pasta"] + }, + { + "content": "Pasta sauce techniques include tomato reduction cream-based and oil-and-garlic preparations using fresh or dried Italian herbs", + "importance": 0.65, + "decay_factor": 0.03, + "memory_type": "episodic", + "tags": ["food", "cooking", "pasta"] + }, + { + "content": "Italian pasta recipes use olive oil garlic parmesan cheese and fresh basil for authentic Mediterranean flavor and aroma", + "importance": 0.65, + "decay_factor": 0.03, + "memory_type": "semantic", + "tags": ["food", "cooking", "italian"] + }, + { + "content": "Sleep quality improves with consistent bedtime routines dark rooms and avoiding screens one hour before sleeping for better rest", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "episodic", + "tags": ["health", "sleep"] + }, + { + "content": "Circadian rhythm is the body internal 24-hour biological clock regulating sleep and wake cycles influenced by light exposure", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["health", "sleep", "biology"] + }, + { + "content": "REM sleep and deep sleep stages are essential for memory consolidation muscle recovery and mental health restoration each night", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["health", "sleep"] + }, + { + "content": "Regular aerobic fitness exercise like running cycling and swimming improves cardiovascular health and reduces stress hormones", + "importance": 0.80, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["health", "fitness", "exercise"] + }, + { + "content": "Fitness strength training with weights builds muscle mass increases metabolism and improves bone density through progressive overload", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["health", "fitness", "strength-training"] + }, + { + "content": "High-intensity interval training HIIT alternates short intense effort bursts with recovery periods for efficient calorie burning and fitness", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["health", "fitness", "hiit"] + }, + { + "content": "Coffee contains caffeine that blocks adenosine receptors in the brain promoting alertness and reducing morning fatigue effectively", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["food", "coffee", "caffeine"] + }, + { + "content": "Morning coffee caffeine ritual often involves grinding fresh beans pour-over or espresso brewing and savoring the rich coffee aroma", + "importance": 0.65, + "decay_factor": 0.03, + "memory_type": "episodic", + "tags": ["food", "coffee", "morning"] + }, + { + "content": "Caffeine half-life is approximately six hours so afternoon coffee can disrupt evening sleep patterns for caffeine sensitive individuals", + "importance": 0.70, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["food", "coffee", "caffeine"] + }, + { + "content": "Urban transit commuting by bus and subway reduces individual carbon footprint compared to private car usage in dense metropolitan cities", + "importance": 0.70, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["daily-life", "transit", "commute"] + }, + { + "content": "Public transit systems use timetables real-time tracking and fare cards to manage passenger commuter flow efficiently in cities", + "importance": 0.65, + "decay_factor": 0.03, + "memory_type": "semantic", + "tags": ["daily-life", "transit"] + }, + { + "content": "Cycling as urban transit commute reduces traffic congestion provides daily physical exercise and combines transportation with fitness", + "importance": 0.70, + "decay_factor": 0.02, + "memory_type": "episodic", + "tags": ["daily-life", "commute", "cycling"] + }, + { + "content": "Meal planning and batch cooking on weekends reduces weekday decision fatigue and ensures healthy balanced eating throughout the week", + "importance": 0.65, + "decay_factor": 0.03, + "memory_type": "episodic", + "tags": ["food", "meal-prep"] + }, + { + "content": "Mindfulness meditation practiced for 10 to 20 minutes daily reduces anxiety improves focus and builds emotional resilience over time", + "importance": 0.75, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["health", "mindfulness"] + }, + { + "content": "Reading physical books before bed promotes relaxation and better sleep quality compared to screen-based reading devices at night", + "importance": 0.65, + "decay_factor": 0.03, + "memory_type": "episodic", + "tags": ["daily-life", "reading"] + }, + { + "content": "Journaling regularly helps process emotions track personal goals and identify recurring patterns in thoughts and daily behaviors", + "importance": 0.65, + "decay_factor": 0.03, + "memory_type": "episodic", + "tags": ["daily-life", "writing"] + }, + { + "content": "Houseplants improve indoor air quality by absorbing carbon dioxide and certain volatile organic compounds pollutants from indoor air", + "importance": 0.55, + "decay_factor": 0.03, + "memory_type": "semantic", + "tags": ["daily-life", "plants"] + }, + { + "content": "Weather affects mood through sunlight exposure influencing serotonin levels and seasonal affective disorder patterns in humans", + "importance": 0.65, + "decay_factor": 0.03, + "memory_type": "semantic", + "tags": ["science", "daily-life", "weather"] + }, + { + "content": "Grocery shopping with a prepared list reduces impulse purchases and food waste by focusing on planned meals and needed ingredients", + "importance": 0.55, + "decay_factor": 0.04, + "memory_type": "episodic", + "tags": ["daily-life", "food"] + }, + { + "content": "Fermented foods like yogurt kimchi and kefir contain beneficial probiotics that support gut microbiome health and digestion", + "importance": 0.65, + "decay_factor": 0.03, + "memory_type": "semantic", + "tags": ["food", "health"] + }, + { + "content": "Hydration with adequate daily water intake supports kidney function cognitive performance and physical exercise endurance", + "importance": 0.70, + "decay_factor": 0.02, + "memory_type": "semantic", + "tags": ["health", "hydration"] + }, + { + "content": "Time management techniques like Pomodoro method and time-blocking increase productivity by structuring focused work intervals", + "importance": 0.70, + "decay_factor": 0.02, + "memory_type": "episodic", + "tags": ["daily-life", "productivity"] + } + ], + "eval_queries": [ + { + "query": "Python functional programming", + "relevant_indices": [0, 1], + "description": "Python functional programming patterns using list comprehensions, lambda, map and filter" + }, + { + "query": "Rust borrow checker memory", + "relevant_indices": [3, 4, 5], + "description": "Rust memory safety enforced by borrow checker through ownership and lifetime rules" + }, + { + "query": "binary search trees", + "relevant_indices": [6, 7], + "description": "Binary search tree and self-balancing AVL tree data structures" + }, + { + "query": "test-driven development", + "relevant_indices": [9, 10], + "description": "TDD workflow of writing failing unit tests before implementing production code" + }, + { + "query": "debugging", + "relevant_indices": [12, 13], + "description": "Debugging techniques using stack traces and interactive debuggers with breakpoints" + }, + { + "query": "calculus", + "relevant_indices": [25, 26, 27], + "description": "Calculus fundamentals: derivatives, integration, and the fundamental theorem" + }, + { + "query": "linear algebra matrix", + "relevant_indices": [28, 29, 30], + "description": "Linear algebra operations including matrix multiplication, eigenvalues and eigenvectors" + }, + { + "query": "neural networks", + "relevant_indices": [31, 32], + "description": "Machine learning with neural networks and deep learning architectures" + }, + { + "query": "probability", + "relevant_indices": [34, 35, 36], + "description": "Probability and statistics including distributions, Bayesian methods, and hypothesis testing" + }, + { + "query": "quantum mechanics", + "relevant_indices": [37, 38], + "description": "Quantum mechanics fundamentals including wave functions and the Heisenberg uncertainty principle" + }, + { + "query": "Roman Empire", + "relevant_indices": [50, 51, 52], + "description": "Ancient Roman civilization spanning the Republic, Julius Caesar, and the Empire" + }, + { + "query": "World War", + "relevant_indices": [53, 54, 55], + "description": "World War II major events: German invasion of Poland, Battle of Stalingrad, and D-Day" + }, + { + "query": "South America", + "relevant_indices": [56, 57, 58], + "description": "South American geography including Brazil, the Andes mountains, and neighboring countries" + }, + { + "query": "ancient Egypt", + "relevant_indices": [59, 60, 61], + "description": "Ancient Egypt civilization with pharaohs, pyramid tombs, and hieroglyphic writing" + }, + { + "query": "Renaissance", + "relevant_indices": [62, 63, 64], + "description": "Italian Renaissance cultural revival and master artists Leonardo da Vinci and Michelangelo" + }, + { + "query": "pasta", + "relevant_indices": [75, 76, 77], + "description": "Pasta cooking techniques, Italian sauce recipes, and key Mediterranean ingredients" + }, + { + "query": "sleep", + "relevant_indices": [78, 79, 80], + "description": "Sleep health including circadian rhythms, REM sleep stages, and bedtime routines" + }, + { + "query": "fitness", + "relevant_indices": [81, 82, 83], + "description": "Physical fitness approaches including aerobic exercise, strength training, and HIIT" + }, + { + "query": "coffee caffeine", + "relevant_indices": [84, 85, 86], + "description": "Coffee and caffeine effects on morning alertness and sleep interactions" + }, + { + "query": "transit", + "relevant_indices": [87, 88, 89], + "description": "Urban transit systems including bus, subway, and cycling for commuting" + } + ] +} diff --git a/tests/khive-contract/golden/.gitkeep b/tests/khive-contract/golden/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/khive-contract/khive_contract/__init__.py b/tests/khive-contract/khive_contract/__init__.py new file mode 100644 index 00000000..bf436632 --- /dev/null +++ b/tests/khive-contract/khive_contract/__init__.py @@ -0,0 +1,15 @@ +"""khive-contract: ADR-organized contract tests for khive-mcp.""" + +from khive_contract.client import ( + KhiveMcpError, + KhiveMcpSession, + KhiveOperationError, + KhiveRpcError, +) + +__all__ = [ + "KhiveMcpSession", + "KhiveMcpError", + "KhiveRpcError", + "KhiveOperationError", +] diff --git a/tests/khive-contract/khive_contract/benchmark.py b/tests/khive-contract/khive_contract/benchmark.py new file mode 100644 index 00000000..93264c82 --- /dev/null +++ b/tests/khive-contract/khive_contract/benchmark.py @@ -0,0 +1,122 @@ +"""Benchmark utilities for khive contract latency tests. + +Converts pytest-benchmark stats to a baselines/latency.json file with +p50_ms and p95_ms per verb. +""" + +from __future__ import annotations + +import json +import statistics +from pathlib import Path +from typing import Any + +# Default baseline file location (relative to package root) +_PKG_ROOT = Path(__file__).parent.parent +BASELINE_PATH = _PKG_ROOT / "baselines" / "latency.json" + +# Verbs that require latency baselines +BASELINE_VERBS = ("remember", "recall", "list", "search", "query") + + +def record_latency( + verb: str, + samples_ms: list[float], + path: Path | None = None, +) -> dict[str, float]: + """Compute p50/p95 from *samples_ms* and write to the baseline JSON file. + + Returns ``{"p50_ms": ..., "p95_ms": ...}`` for the verb. + """ + target = path or BASELINE_PATH + target.parent.mkdir(parents=True, exist_ok=True) + + existing: dict[str, Any] = {} + if target.exists(): + try: + existing = json.loads(target.read_text()) + except (json.JSONDecodeError, OSError): + existing = {} + + sorted_samples = sorted(samples_ms) + n = len(sorted_samples) + p50 = _percentile(sorted_samples, 50) + p95 = _percentile(sorted_samples, 95) + + existing[verb] = {"p50_ms": round(p50, 3), "p95_ms": round(p95, 3), "n": n} + target.write_text(json.dumps(existing, indent=2) + "\n") + + return {"p50_ms": p50, "p95_ms": p95} + + +def load_baselines(path: Path | None = None) -> dict[str, dict[str, float]]: + """Load baseline JSON or return empty dict if file is absent.""" + target = path or BASELINE_PATH + if not target.exists(): + return {} + return json.loads(target.read_text()) + + +def check_regression( + verb: str, + actual_ms: float, + *, + tolerance: float = 2.0, + path: Path | None = None, +) -> None: + """Raise AssertionError if *actual_ms* exceeds the baseline p95 by *tolerance*×. + + Skips silently if no baseline exists for the verb. + """ + baselines = load_baselines(path) + if verb not in baselines: + return + baseline_p95 = baselines[verb].get("p95_ms", float("inf")) + limit = baseline_p95 * tolerance + assert actual_ms <= limit, ( + f"Latency regression for '{verb}': {actual_ms:.1f}ms > {limit:.1f}ms " + f"(baseline p95={baseline_p95:.1f}ms × {tolerance})" + ) + + +def benchmark_stats_from_pytest(benchmark_stats: Any) -> dict[str, float]: + """Extract p50/p95 from a pytest-benchmark stats object. + + Works with both the ``stats`` dict from ``benchmark.stats`` and the + ``BenchmarkFixture`` itself. + + Returns ``{"p50_ms": ..., "p95_ms": ...}`` with values in milliseconds. + """ + if hasattr(benchmark_stats, "stats"): + benchmark_stats = benchmark_stats.stats + + # pytest-benchmark stores times in seconds + data = getattr(benchmark_stats, "data", None) or benchmark_stats.get("data", []) + if data: + samples_s = list(data) + else: + # Fall back to mean if raw data is not available + mean_s = getattr(benchmark_stats, "mean", None) or benchmark_stats.get("mean", 0) + samples_s = [mean_s] + + samples_ms = [s * 1000.0 for s in samples_s] + sorted_samples = sorted(samples_ms) + return { + "p50_ms": round(_percentile(sorted_samples, 50), 3), + "p95_ms": round(_percentile(sorted_samples, 95), 3), + } + + +def _percentile(sorted_data: list[float], pct: int) -> float: + if not sorted_data: + return 0.0 + n = len(sorted_data) + if n == 1: + return sorted_data[0] + rank = pct / 100.0 * (n - 1) + lower = int(rank) + upper = lower + 1 + if upper >= n: + return sorted_data[-1] + frac = rank - lower + return sorted_data[lower] + frac * (sorted_data[upper] - sorted_data[lower]) diff --git a/tests/khive-contract/khive_contract/client.py b/tests/khive-contract/khive_contract/client.py new file mode 100644 index 00000000..2b0eb59e --- /dev/null +++ b/tests/khive-contract/khive_contract/client.py @@ -0,0 +1,391 @@ +"""MCP stdio wrapper for khive-mcp integration tests. + +Spawns the khive-mcp binary as a subprocess and frames JSON-RPC 2.0 messages +over stdin/stdout. Tests must use KhiveMcpSession as a context manager and +never open subprocesses directly. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path +from types import TracebackType +from typing import Any, Literal, Mapping, Sequence + + +class KhiveMcpError(RuntimeError): + """Base class for khive contract client failures.""" + + +class KhiveRpcError(KhiveMcpError): + """JSON-RPC or MCP boundary error. + + Raised when the server returns a top-level JSON-RPC ``error``, when + ``tools/call`` returns ``result.isError``, when stdout closes unexpectedly, + or when a response cannot be parsed as JSON. + """ + + def __init__( + self, + message: str, + *, + code: int | None = None, + data: Any | None = None, + rpc_id: int | None = None, + stderr_tail: str = "", + ) -> None: + parts = [message] + if rpc_id is not None: + parts.append(f"(id={rpc_id})") + if stderr_tail: + parts.append(f"stderr: {stderr_tail}") + super().__init__(" ".join(parts)) + self.code = code + self.message = message + self.data = data + + +class KhiveOperationError(KhiveMcpError): + """Per-operation failure inside a successful request envelope.""" + + def __init__( + self, + *, + tool: str, + message: str, + index: int, + envelope: Mapping[str, Any], + ) -> None: + super().__init__(f"verb '{tool}' (index {index}) failed: {message}") + self.tool = tool + self.message = message + self.index = index + self.envelope = envelope + + +def _find_repo_root(start: Path) -> Path | None: + """Walk up from *start* looking for .git.""" + current = start.resolve() + for _ in range(20): + if (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + return None + + +def _resolve_binary(binary: str | Path | None) -> Path: + if binary is not None: + return Path(binary) + env_val = os.environ.get("KHIVE_MCP_BINARY") + if env_val: + return Path(env_val) + repo_root = _find_repo_root(Path(__file__).parent) + if repo_root is not None: + release = repo_root / "crates" / "target" / "release" / "khive-mcp" + if release.exists(): + return release + debug = repo_root / "crates" / "target" / "debug" / "khive-mcp" + if debug.exists(): + return debug + raise FileNotFoundError( + "khive-mcp binary not found. " + "Set KHIVE_MCP_BINARY or build with: cd crates && cargo build --release -p khive-mcp" + ) + + +class KhiveMcpSession: + """Context-manager wrapper around a khive-mcp stdio subprocess. + + Usage:: + + with KhiveMcpSession(packs=("kg",)) as session: + result = session.verb("create", {"kind": "entity", "entity_kind": "concept", + "name": "Test", "namespace": "ns"}) + """ + + def __init__( + self, + binary: str | Path | None = None, + *, + db: str | Path = ":memory:", + packs: Sequence[str] = ("kg",), + namespace: str | None = None, + no_embed: bool = True, + log: str = "error", + env: Mapping[str, str] | None = None, + timeout: float = 10.0, + presentation: Literal["agent", "verbose", "human"] = "verbose", + ) -> None: + self._binary = _resolve_binary(binary) + self._db = db + self._packs = list(packs) + self._namespace = namespace + self._no_embed = no_embed + self._log = log + self._env = env + self._timeout = timeout + self._default_presentation = presentation + self._id_counter = 0 + self.proc: subprocess.Popen[str] | None = None + + # ------------------------------------------------------------------ + # Context manager + # ------------------------------------------------------------------ + + def __enter__(self) -> "KhiveMcpSession": + binary = self._binary + if not binary.exists(): + raise FileNotFoundError( + f"khive-mcp binary not found at {binary}. " + "Build with: cd crates && cargo build --release -p khive-mcp" + ) + cmd = [str(binary), "--db", str(self._db)] + if self._no_embed: + cmd.append("--no-embed") + cmd += ["--log", self._log] + for pack in self._packs: + cmd += ["--pack", pack] + if self._namespace is not None: + cmd += ["--namespace", self._namespace] + + self.proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + env={**os.environ, **(self._env or {})}, + ) + self._do_initialize() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + def close(self) -> None: + if self.proc is None: + return + try: + if self.proc.stdin and not self.proc.stdin.closed: + self.proc.stdin.close() + self.proc.wait(timeout=self._timeout) + except Exception: + self.proc.kill() + finally: + self.proc = None + + # ------------------------------------------------------------------ + # JSON-RPC framing + # ------------------------------------------------------------------ + + def _next_id(self) -> int: + self._id_counter += 1 + return self._id_counter + + def _send_request(self, method: str, params: Any = None) -> int: + rpc_id = self._next_id() + msg: dict[str, Any] = {"jsonrpc": "2.0", "id": rpc_id, "method": method} + if params is not None: + msg["params"] = params + assert self.proc and self.proc.stdin + self.proc.stdin.write(json.dumps(msg) + "\n") + self.proc.stdin.flush() + return rpc_id + + def _send_notification(self, method: str, params: Any = None) -> None: + msg: dict[str, Any] = {"jsonrpc": "2.0", "method": method} + if params is not None: + msg["params"] = params + assert self.proc and self.proc.stdin + self.proc.stdin.write(json.dumps(msg) + "\n") + self.proc.stdin.flush() + + def _read_response(self, expected_id: int) -> dict[str, Any]: + assert self.proc and self.proc.stdout + while True: + line = self.proc.stdout.readline() + if not line: + stderr_tail = self._read_stderr() + raise KhiveRpcError( + "MCP server closed stdout unexpectedly", + rpc_id=expected_id, + stderr_tail=stderr_tail, + ) + try: + msg = json.loads(line) + except json.JSONDecodeError as exc: + raise KhiveRpcError( + f"Malformed JSON from server: {line!r}", + rpc_id=expected_id, + ) from exc + # Skip notifications (no "id" field) + if "id" not in msg: + continue + if msg["id"] == expected_id: + return msg + # Unexpected id — skip (shouldn't happen in single-threaded flow) + + def _read_stderr(self) -> str: + if self.proc is None or self.proc.stderr is None: + return "" + try: + # Non-blocking read of available stderr + import select as _select + + ready, _, _ = _select.select([self.proc.stderr], [], [], 0.1) + if ready: + return self.proc.stderr.read(4096) + except Exception: + pass + return "" + + # ------------------------------------------------------------------ + # MCP handshake + # ------------------------------------------------------------------ + + def _do_initialize(self) -> None: + assert self.proc is not None + if self.proc.poll() is not None: + stderr_tail = "" + if self.proc.stderr: + stderr_tail = self.proc.stderr.read() + raise KhiveRpcError( + "khive-mcp process exited before initialize", + stderr_tail=stderr_tail, + ) + rpc_id = self._send_request( + "initialize", + { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "khive-contract", "version": "0.1.0"}, + }, + ) + resp = self._read_response(rpc_id) + if "error" in resp: + raise KhiveRpcError( + resp["error"].get("message", "initialize failed"), + code=resp["error"].get("code"), + data=resp["error"].get("data"), + rpc_id=rpc_id, + ) + server_name = resp.get("result", {}).get("serverInfo", {}).get("name", "") + if server_name != "khive-mcp": + raise KhiveRpcError( + f"Unexpected serverInfo.name: {server_name!r}", + rpc_id=rpc_id, + ) + self._send_notification("notifications/initialized") + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def request( + self, + ops: str, + *, + presentation: Literal["agent", "verbose", "human"] | None = None, + ) -> dict[str, Any]: + """Send a raw ops string to the `request` tool and return the parsed envelope.""" + pres = presentation or self._default_presentation + rpc_id = self._send_request( + "tools/call", + { + "name": "request", + "arguments": {"ops": ops, "presentation": pres}, + }, + ) + resp = self._read_response(rpc_id) + if "error" in resp: + err = resp["error"] + raise KhiveRpcError( + err.get("message", "JSON-RPC error"), + code=err.get("code"), + data=err.get("data"), + rpc_id=rpc_id, + ) + result = resp.get("result", {}) + if result.get("isError"): + content = result.get("content", []) + text = content[0]["text"] if content else "" + raise KhiveRpcError( + text or "tools/call returned isError", + code=-32603, + rpc_id=rpc_id, + ) + content = result.get("content", []) + text = content[0]["text"] if content else "" + if not text: + raise KhiveRpcError("Empty content in tools/call response", rpc_id=rpc_id) + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise KhiveRpcError( + f"Could not parse tools/call response as JSON: {text!r}", + rpc_id=rpc_id, + ) from exc + + def request_batch( + self, + ops_list: Sequence[Mapping[str, Any]], + *, + presentation: Literal["agent", "verbose", "human"] | None = None, + ) -> dict[str, Any]: + """Send a list of op dicts as a JSON-form batch and return the raw envelope.""" + for i, op in enumerate(ops_list): + if not isinstance(op.get("tool"), str): + raise ValueError(f"ops_list[{i}] missing 'tool' string: {op!r}") + if not isinstance(op.get("args"), Mapping): + raise ValueError(f"ops_list[{i}] missing 'args' mapping: {op!r}") + serialized = json.dumps(list(ops_list)) + return self.request(serialized, presentation=presentation) + + def verb( + self, + name: str, + args: Mapping[str, Any] | None = None, + *, + presentation: Literal["agent", "verbose", "human"] | None = None, + ) -> Any: + """Call a single verb and return its result, raising on per-op failure.""" + envelope = self.request_batch( + [{"tool": name, "args": dict(args or {})}], + presentation=presentation, + ) + results = envelope.get("results") or [] + if not results: + raise KhiveRpcError(f"empty results from verb '{name}'") + first = results[0] + if not first.get("ok", False): + raise KhiveOperationError( + tool=first.get("tool", name), + message=first.get("error", ""), + index=0, + envelope=envelope, + ) + return first.get("result") + + def tools_list(self) -> list[dict[str, Any]]: + """Call tools/list and return the list of tool descriptors.""" + rpc_id = self._send_request("tools/list", {}) + resp = self._read_response(rpc_id) + if "error" in resp: + err = resp["error"] + raise KhiveRpcError( + err.get("message", "tools/list failed"), + code=err.get("code"), + rpc_id=rpc_id, + ) + return resp.get("result", {}).get("tools", []) diff --git a/tests/khive-contract/khive_contract/fixtures.py b/tests/khive-contract/khive_contract/fixtures.py new file mode 100644 index 00000000..a2cad30e --- /dev/null +++ b/tests/khive-contract/khive_contract/fixtures.py @@ -0,0 +1,188 @@ +"""Canonical constants and closed sets for khive contract tests. + +These are facts derived from the ADRs — not generated at runtime. +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Entity kind taxonomy (ADR-001) +# --------------------------------------------------------------------------- + +ENTITY_KINDS: frozenset[str] = frozenset( + { + "concept", + "person", + "project", + "tool", + "document", + "event", + "location", + "organization", + "resource", + "tag", + } +) + +# --------------------------------------------------------------------------- +# Note kind taxonomy (ADR-013) +# --------------------------------------------------------------------------- + +NOTE_KINDS: frozenset[str] = frozenset( + { + "observation", + "question", + "hypothesis", + "conclusion", + "reference", + } +) + +# --------------------------------------------------------------------------- +# Edge relation ontology (ADR-002) +# --------------------------------------------------------------------------- + +EDGE_RELATIONS: frozenset[str] = frozenset( + { + "extends", + "implements", + "depends_on", + "uses", + "produces", + "relates_to", + "contradicts", + "supersedes", + "annotates", + "contains", + "part_of", + "enables", + "blocks", + } +) + +# annotates has source-must-be-note constraint (ADR-002 §annotates) +ANNOTATES_SOURCE_MUST_BE_NOTE = True + +# --------------------------------------------------------------------------- +# Product verb manifest (ADR-023 / ADR-025 / ADR-027) +# --------------------------------------------------------------------------- + +KG_VERBS: frozenset[str] = frozenset( + { + "create", + "get", + "list", + "update", + "delete", + "merge", + "search", + "link", + "neighbors", + "traverse", + "query", + } +) + +GTD_VERBS: frozenset[str] = frozenset( + { + "assign", + "next", + "complete", + "tasks", + "transition", + } +) + +MEMORY_VERBS: frozenset[str] = frozenset( + { + "remember", + "recall", + } +) + +DISCOVERABLE_PRODUCT_VERBS: frozenset[str] = KG_VERBS | GTD_VERBS | MEMORY_VERBS + +# The play spec says "15 product verbs"; the baseline exposes 18. +# DISCOVERABLE_PRODUCT_VERBS (18) subsumes the stated minimum (15). +PLAY_SPEC_MINIMUM_VERB_COUNT = 15 + +# --------------------------------------------------------------------------- +# Golden snapshot scrub keys +# Volatile fields to replace with "" before saving golden files. +# --------------------------------------------------------------------------- + +GOLDEN_SCRUB_KEYS: frozenset[str] = frozenset( + { + "id", + "created_at", + "updated_at", + "timestamp", + "embedding_id", + } +) + +# --------------------------------------------------------------------------- +# Sample payload builders (lightweight — no MCP calls) +# --------------------------------------------------------------------------- + + +def make_entity_args( + entity_kind: str = "concept", + name: str | None = None, + namespace: str = "default", + **kwargs, +) -> dict: + """Return args dict for create(kind="entity", ...) — does NOT call MCP.""" + import uuid + + args: dict = { + "kind": "entity", + "entity_kind": entity_kind, + "name": name or f"{entity_kind}_{uuid.uuid4().hex[:8]}", + "namespace": namespace, + } + args.update(kwargs) + return args + + +def make_note_args( + note_kind: str = "observation", + content: str | None = None, + namespace: str = "default", + salience: float | None = 0.5, + **kwargs, +) -> dict: + """Return args dict for create(kind="note", ...) — does NOT call MCP.""" + import uuid + + args: dict = { + "kind": "note", + "note_kind": note_kind, + "content": content or f"note {note_kind} {uuid.uuid4().hex[:8]}", + "namespace": namespace, + } + if salience is not None: + args["salience"] = salience + args.update(kwargs) + return args + + +def make_edge_args( + source_id: str, + target_id: str, + relation: str = "extends", + namespace: str = "default", + weight: float | None = 1.0, + **kwargs, +) -> dict: + """Return args dict for link(...) — does NOT call MCP.""" + args: dict = { + "source_id": source_id, + "target_id": target_id, + "relation": relation, + "namespace": namespace, + } + if weight is not None: + args["weight"] = weight + args.update(kwargs) + return args diff --git a/tests/khive-contract/khive_contract/schema.py b/tests/khive-contract/khive_contract/schema.py new file mode 100644 index 00000000..5161cbd8 --- /dev/null +++ b/tests/khive-contract/khive_contract/schema.py @@ -0,0 +1,220 @@ +"""JSON schema definitions for khive-mcp verb response shapes. + +All schemas follow the verbose-presentation envelope produced by +the `request` tool (ADR-016 / ADR-027). +""" + +from __future__ import annotations + +from typing import Any + +import jsonschema + +# --------------------------------------------------------------------------- +# Request envelope (outer wrapper from `request` tool) +# --------------------------------------------------------------------------- + +REQUEST_ENVELOPE_SCHEMA: dict[str, Any] = { + "type": "object", + "required": ["results"], + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "required": ["ok"], + "properties": { + "ok": {"type": "boolean"}, + "tool": {"type": "string"}, + "result": {}, + "error": {"type": "string"}, + }, + }, + } + }, +} + +# --------------------------------------------------------------------------- +# Per-op result schemas +# --------------------------------------------------------------------------- + +ENTITY_RECORD_SCHEMA: dict[str, Any] = { + "type": "object", + "required": ["id", "kind", "entity_kind", "name", "namespace"], + "properties": { + "id": {"type": "string"}, + "kind": {"type": "string", "const": "entity"}, + "entity_kind": {"type": "string"}, + "name": {"type": "string"}, + "namespace": {"type": "string"}, + "description": {"type": ["string", "null"]}, + "tags": {"type": "array", "items": {"type": "string"}}, + "properties": {"type": ["object", "null"]}, + "created_at": {"type": "string"}, + "updated_at": {"type": "string"}, + }, +} + +NOTE_RECORD_SCHEMA: dict[str, Any] = { + "type": "object", + "required": ["id", "kind", "note_kind", "content", "namespace"], + "properties": { + "id": {"type": "string"}, + "kind": {"type": "string", "const": "note"}, + "note_kind": {"type": "string"}, + "content": {"type": "string"}, + "namespace": {"type": "string"}, + "salience": {"type": ["number", "null"]}, + "decay_factor": {"type": ["number", "null"]}, + "created_at": {"type": "string"}, + "updated_at": {"type": "string"}, + }, +} + +EDGE_RECORD_SCHEMA: dict[str, Any] = { + "type": "object", + "required": ["id", "kind", "source_id", "target_id", "relation"], + "properties": { + "id": {"type": "string"}, + "kind": {"type": "string", "const": "edge"}, + "source_id": {"type": "string"}, + "target_id": {"type": "string"}, + "relation": {"type": "string"}, + "weight": {"type": ["number", "null"]}, + "namespace": {"type": "string"}, + }, +} + +# get() wraps the record in a kind/data envelope +GET_RESPONSE_SCHEMA: dict[str, Any] = { + "type": "object", + "required": ["kind", "data"], + "properties": { + "kind": {"type": "string", "enum": ["entity", "note", "edge"]}, + "data": {"type": "object"}, + }, +} + +LIST_RESPONSE_SCHEMA: dict[str, Any] = { + "type": "array", +} + +SEARCH_RESPONSE_SCHEMA: dict[str, Any] = { + "type": "array", + "items": { + "type": "object", + "required": ["id"], + "properties": { + "id": {"type": "string"}, + "score": {"type": ["number", "null"]}, + }, + }, +} + +LINK_RESPONSE_SCHEMA: dict[str, Any] = { + "type": "object", + "required": ["id", "source_id", "target_id", "relation"], + "properties": { + "id": {"type": "string"}, + "source_id": {"type": "string"}, + "target_id": {"type": "string"}, + "relation": {"type": "string"}, + "weight": {"type": ["number", "null"]}, + }, +} + +MERGE_RESPONSE_SCHEMA: dict[str, Any] = { + "type": "object", + "required": ["kept_id", "removed_id"], + "properties": { + "kept_id": {"type": "string"}, + "removed_id": {"type": "string"}, + }, +} + +DELETE_RESPONSE_SCHEMA: dict[str, Any] = { + "type": "object", + "required": ["deleted"], + "properties": { + "deleted": {"type": "boolean"}, + "id": {"type": "string"}, + }, +} + +RECALL_RESPONSE_SCHEMA: dict[str, Any] = { + "type": "array", + "items": { + "type": "object", + "required": ["id"], + }, +} + +REMEMBER_RESPONSE_SCHEMA: dict[str, Any] = { + "type": "object", + "required": ["id"], + "properties": { + "id": {"type": "string"}, + }, +} + +# --------------------------------------------------------------------------- +# Validation helpers +# --------------------------------------------------------------------------- + + +def validate(instance: Any, schema: dict[str, Any], context: str = "") -> None: + """Assert *instance* conforms to *schema*, raising AssertionError with context.""" + try: + jsonschema.validate(instance=instance, schema=schema) + except jsonschema.ValidationError as exc: + prefix = f"[{context}] " if context else "" + raise AssertionError(f"{prefix}Schema validation failed: {exc.message}") from exc + + +def assert_envelope(envelope: dict[str, Any]) -> None: + """Assert top-level request envelope shape.""" + validate(envelope, REQUEST_ENVELOPE_SCHEMA, context="envelope") + + +def assert_entity(result: Any, context: str = "entity") -> None: + validate(result, ENTITY_RECORD_SCHEMA, context=context) + + +def assert_note(result: Any, context: str = "note") -> None: + validate(result, NOTE_RECORD_SCHEMA, context=context) + + +def assert_edge(result: Any, context: str = "edge") -> None: + validate(result, EDGE_RECORD_SCHEMA, context=context) + + +def assert_get_response(result: Any) -> None: + validate(result, GET_RESPONSE_SCHEMA, context="get") + + +def assert_list_response(result: Any) -> None: + validate(result, LIST_RESPONSE_SCHEMA, context="list") + + +def assert_search_response(result: Any) -> None: + validate(result, SEARCH_RESPONSE_SCHEMA, context="search") + + +def assert_link_response(result: Any) -> None: + validate(result, LINK_RESPONSE_SCHEMA, context="link") + + +def assert_merge_response(result: Any) -> None: + validate(result, MERGE_RESPONSE_SCHEMA, context="merge") + + +def assert_delete_response(result: Any) -> None: + validate(result, DELETE_RESPONSE_SCHEMA, context="delete") + + +def assert_recall_response(result: Any) -> None: + validate(result, RECALL_RESPONSE_SCHEMA, context="recall") + + +def assert_remember_response(result: Any) -> None: + validate(result, REMEMBER_RESPONSE_SCHEMA, context="remember") diff --git a/tests/khive-contract/pyproject.toml b/tests/khive-contract/pyproject.toml new file mode 100644 index 00000000..89c63b83 --- /dev/null +++ b/tests/khive-contract/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "khive-contract" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "pytest>=8", + "pytest-benchmark>=4", + "jsonschema>=4", + "anyio>=4", +] + +[tool.uv] +package = true + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/khive-contract/pytest.ini b/tests/khive-contract/pytest.ini new file mode 100644 index 00000000..3669ebd3 --- /dev/null +++ b/tests/khive-contract/pytest.ini @@ -0,0 +1,28 @@ +[pytest] +addopts = -ra --strict-config --strict-markers +testpaths = tests +markers = + adr_001: ADR-001 entity kind taxonomy + adr_002: ADR-002 edge ontology + adr_003: ADR-003 namespace isolation + adr_007: ADR-007 namespace isolation + adr_008: ADR-008 query layer + adr_013: ADR-013 note kind taxonomy + adr_014: ADR-014 curation operations + adr_015: ADR-015 schema migrations + adr_016: ADR-016 request DSL + adr_017: ADR-017 pack standard and verb registry + adr_019: ADR-019 GTD pack + adr_020: Compatibility marker for requested request-DSL filename + adr_021: ADR-021 memory pack + adr_023: ADR-023 pack verb surface + adr_025: ADR-025 verb speech-act taxonomy + adr_027: ADR-027 dynamic pack loading and single-tool MCP surface + adr_033: ADR-033 recall configurability + adr_043: ADR-043 embedding model migration + smoke: end-to-end smoke coverage ported from tests/smoke_test.py + golden: golden snapshot comparison + benchmark: latency benchmark + slow: slower subprocess or CLI integration + manifest: package self-audit tests + xfail_pending_adr: executable spec for accepted ADR behavior not yet implemented diff --git a/tests/khive-contract/tests/__init__.py b/tests/khive-contract/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/khive-contract/tests/test_adr_001_entity_kind.py b/tests/khive-contract/tests/test_adr_001_entity_kind.py new file mode 100644 index 00000000..2b66567e --- /dev/null +++ b/tests/khive-contract/tests/test_adr_001_entity_kind.py @@ -0,0 +1,152 @@ +"""Entity kind taxonomy contract tests. + +ADR: ADR-001 +section: Entity kinds closed-set registry; MCP verb resolution; Registry contract +""" + +from __future__ import annotations + +import pytest + +from khive_contract.client import KhiveMcpSession, KhiveOperationError + +VERBS_UNDER_TEST = {"create", "list", "get"} + +# Runtime-confirmed entity kinds (6 legacy kinds; ADR-001 spec adds artifact/service as drift) +RUNTIME_ENTITY_KINDS = ("concept", "document", "project", "dataset", "person", "org") + + +@pytest.mark.adr_001 +@pytest.mark.slow +@pytest.mark.parametrize("entity_kind", RUNTIME_ENTITY_KINDS) +def test_create_list_get_each_entity_kind( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, + entity_kind: str, +) -> None: + """Create, list-filtered, and get each runtime entity kind. + + ADR: ADR-001 + section: 8 entity kinds / MCP verb resolution + + Each create returns an id and name; list filtered by that entity_kind contains + the returned id; get returns a kind=="entity" wrapper with matching data. + """ + args = sample_entity(entity_kind=entity_kind, name=f"e_{entity_kind}") + result = khive_session.verb("create", args) + assert result is not None, f"create({entity_kind}) returned None" + entity_id = result.get("id") + assert entity_id, f"create({entity_kind}) missing 'id': {result}" + # Runtime response uses 'kind' field for entity_kind value + assert result.get("kind") == entity_kind, ( + f"kind mismatch: got {result.get('kind')!r}, expected {entity_kind!r}" + ) + assert result.get("name") == f"e_{entity_kind}", f"name mismatch: {result}" + + # list filtered by entity_kind must include the new id + listed = khive_session.verb("list", {"kind": "entity", "entity_kind": entity_kind, + "namespace": temp_namespace}) + assert isinstance(listed, list), f"list returned non-list: {listed!r}" + ids = [e.get("id") for e in listed] + assert entity_id in ids, ( + f"list(entity_kind={entity_kind}, namespace={temp_namespace}) omitted id={entity_id}; " + f"got {ids}" + ) + + # get must return kind=="entity" wrapper with correct data + fetched = khive_session.verb("get", {"id": entity_id, "namespace": temp_namespace}) + assert fetched is not None, f"get({entity_id}) returned None" + assert fetched.get("kind") == "entity", ( + f"get wrapper kind should be 'entity', got {fetched.get('kind')!r}" + ) + data = fetched.get("data", {}) + # data.kind holds the entity_kind value + assert data.get("kind") == entity_kind, ( + f"get data kind mismatch: {data.get('kind')!r} != {entity_kind!r}" + ) + assert data.get("name") == f"e_{entity_kind}", f"get data name mismatch: {data}" + + +@pytest.mark.adr_001 +@pytest.mark.slow +def test_invalid_entity_kind_reports_closed_set( + khive_session: KhiveMcpSession, + temp_namespace: str, +) -> None: + """Invalid entity_kind returns per-op error that names the offending kind. + + ADR: ADR-001 + section: Registry contract + + The error must name 'galaxy' and list all valid entity kinds so agents + can self-correct. Currently the runtime exposes 6 legacy kinds. + """ + envelope = khive_session.request_batch([ + {"tool": "create", "args": { + "kind": "entity", + "entity_kind": "galaxy", + "name": "StarSystem", + "namespace": temp_namespace, + }} + ]) + results = envelope.get("results", []) + assert results, "Expected results in envelope" + first = results[0] + assert not first.get("ok", False), "Expected per-op error for invalid entity_kind" + err = first.get("error", "") + assert err, "Error message must be non-empty" + assert "galaxy" in err.lower(), f"Error must name the offending kind 'galaxy': {err!r}" + + # All runtime-known valid kinds must be listed + for kind in RUNTIME_ENTITY_KINDS: + assert kind in err, ( + f"Valid entity_kind '{kind}' missing from error message: {err!r}" + ) + + +@pytest.mark.adr_001 +@pytest.mark.slow +def test_create_entity_stores_description_and_tags( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, +) -> None: + """create(entity) stores description and tags; get returns them. + + ADR: ADR-001 + section: MCP verb resolution; Entity field contract + """ + args = sample_entity( + entity_kind="concept", + name="TaggedConcept", + description="a test description", + tags=["alpha", "beta"], + ) + result = khive_session.verb("create", args) + entity_id = result["id"] + + fetched = khive_session.verb("get", {"id": entity_id, "namespace": temp_namespace}) + data = fetched["data"] + assert data.get("description") == "a test description", f"description not stored: {data}" + tags = set(data.get("tags", [])) + assert "alpha" in tags and "beta" in tags, f"tags not stored correctly: {tags}" + + +@pytest.mark.adr_001 +@pytest.mark.slow +def test_create_entity_namespace_is_stored( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, +) -> None: + """Created entity namespace matches the request namespace. + + ADR: ADR-001 + section: MCP verb resolution + """ + args = sample_entity(entity_kind="concept", name="NamespaceCheck") + result = khive_session.verb("create", args) + assert result.get("namespace") == temp_namespace, ( + f"Entity namespace {result.get('namespace')!r} != {temp_namespace!r}" + ) diff --git a/tests/khive-contract/tests/test_adr_002_edge_ontology.py b/tests/khive-contract/tests/test_adr_002_edge_ontology.py new file mode 100644 index 00000000..4318585e --- /dev/null +++ b/tests/khive-contract/tests/test_adr_002_edge_ontology.py @@ -0,0 +1,280 @@ +"""Edge ontology contract tests. + +ADR: ADR-002 +section: 13 canonical relations; Base endpoint contract; Cascade behavior; + Annotation relation; Endpoint validation +""" + +from __future__ import annotations + +import pytest + +from khive_contract.client import KhiveMcpSession, KhiveOperationError + +VERBS_UNDER_TEST = {"create", "link", "get", "list", "neighbors", "delete"} + +# Relations confirmed to work concept-to-concept in the runtime base allowlist. +# introduced_by and implements require EDGE_RULES pack (specific endpoint types). +# competes_with and composed_with are symmetric: runtime may canonicalize endpoint order. +CONCEPT_CONCEPT_RELATIONS = ( + "extends", + "enables", + "contains", + "part_of", + "instance_of", + "variant_of", + "supersedes", + "competes_with", + "composed_with", +) + +# All 13 canonical relations (runtime-confirmed) +ALL_CANONICAL_RELATIONS = ( + "contains", "part_of", "instance_of", "extends", "variant_of", + "introduced_by", "supersedes", "depends_on", "enables", + "implements", "competes_with", "composed_with", "annotates", +) + + +@pytest.mark.adr_002 +@pytest.mark.slow +@pytest.mark.parametrize("relation", CONCEPT_CONCEPT_RELATIONS) +def test_link_concept_to_concept_relations( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, + relation: str, +) -> None: + """Each non-annotates relation links concept→concept and returns a valid edge. + + ADR: ADR-002 + section: 13 canonical relations; Base endpoint contract + + Each link succeeds, relation matches, get returns kind=="edge" wrapper. + """ + src = khive_session.verb("create", sample_entity(entity_kind="concept", name=f"src_{relation}")) + tgt = khive_session.verb("create", sample_entity(entity_kind="concept", name=f"tgt_{relation}")) + + edge = khive_session.verb("link", { + "source_id": src["id"], + "target_id": tgt["id"], + "relation": relation, + "namespace": temp_namespace, + }) + assert edge is not None, f"link({relation}) returned None" + assert edge.get("id"), f"link({relation}) missing 'id': {edge}" + assert edge.get("relation") == relation, ( + f"link relation mismatch: got {edge.get('relation')!r}, expected {relation!r}" + ) + # Some symmetric relations are canonicalized by the runtime (endpoint order may swap) + assert {edge.get("source_id"), edge.get("target_id")} == {src["id"], tgt["id"]}, ( + f"edge endpoints wrong: {edge}" + ) + + # get must return kind=="edge" wrapper + fetched = khive_session.verb("get", {"id": edge["id"], "namespace": temp_namespace}) + assert fetched.get("kind") == "edge", ( + f"get wrapper kind should be 'edge', got {fetched.get('kind')!r}" + ) + + +@pytest.mark.adr_002 +@pytest.mark.slow +def test_invalid_relation_reports_closed_relation_set( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, +) -> None: + """link with invalid relation returns per-op error listing all 13 canonical relations. + + ADR: ADR-002 + section: Rules; Closed-set taxonomy + """ + src = khive_session.verb("create", sample_entity(entity_kind="concept", name="TaxSrc")) + tgt = khive_session.verb("create", sample_entity(entity_kind="concept", name="TaxTgt")) + + envelope = khive_session.request_batch([ + {"tool": "link", "args": { + "source_id": src["id"], + "target_id": tgt["id"], + "relation": "invented_by", + "namespace": temp_namespace, + }} + ]) + results = envelope.get("results", []) + assert results, "Expected results in envelope" + first = results[0] + assert not first.get("ok", False), "Expected per-op error for invalid relation" + err = first.get("error", "") + assert err, "Error message must be non-empty" + assert "invented_by" in err, f"Error must name offending relation 'invented_by': {err!r}" + + # All 13 canonical relations must be listed + for rel in ALL_CANONICAL_RELATIONS: + assert rel in err, ( + f"Canonical relation '{rel}' missing from error message: {err!r}" + ) + + +@pytest.mark.adr_002 +@pytest.mark.slow +def test_hard_delete_cascades_incident_edges_soft_delete_preserves( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, +) -> None: + """Hard-delete removes incident edges; soft-delete leaves edges in place. + + ADR: ADR-002 + section: Cascade Behavior; ADR-014 Soft vs hard delete + + Ports test_edge_cascade_hard_delete from contract_test.py. + """ + hub = khive_session.verb("create", sample_entity(entity_kind="concept", name="HubHard")) + spoke1 = khive_session.verb("create", sample_entity(entity_kind="concept", name="Spoke1Hard")) + spoke2 = khive_session.verb("create", sample_entity(entity_kind="concept", name="Spoke2Hard")) + + e1 = khive_session.verb("link", { + "source_id": hub["id"], "target_id": spoke1["id"], + "relation": "extends", "namespace": temp_namespace, + }) + e2 = khive_session.verb("link", { + "source_id": spoke2["id"], "target_id": hub["id"], + "relation": "enables", "namespace": temp_namespace, + }) + e1_id, e2_id = e1["id"], e2["id"] + + # Verify edges exist before delete + edges_before = khive_session.verb("list", {"kind": "edge", "source_id": hub["id"], + "namespace": temp_namespace}) + assert any(e.get("id") == e1_id for e in edges_before), ( + "outbound edge from hub not listed before hard-delete" + ) + + # Hard-delete the hub + del_result = khive_session.verb("delete", { + "id": hub["id"], "kind": "entity", "hard": True, "namespace": temp_namespace, + }) + assert del_result.get("deleted") is True, f"Hard delete should return deleted=True: {del_result}" + + # Both incident edges must be gone + envelope_e1 = khive_session.request_batch([{"tool": "get", "args": {"id": e1_id, + "namespace": temp_namespace}}]) + first_e1 = envelope_e1["results"][0] + assert not first_e1.get("ok", False), "Outbound edge should be gone after hard-delete" + assert "not found" in first_e1.get("error", "").lower(), ( + f"Expected not-found error for outbound edge, got: {first_e1.get('error')!r}" + ) + + envelope_e2 = khive_session.request_batch([{"tool": "get", "args": {"id": e2_id, + "namespace": temp_namespace}}]) + first_e2 = envelope_e2["results"][0] + assert not first_e2.get("ok", False), "Inbound edge should be gone after hard-delete" + assert "not found" in first_e2.get("error", "").lower(), ( + f"Expected not-found error for inbound edge, got: {first_e2.get('error')!r}" + ) + + # Soft delete: edges must remain + hub_soft = khive_session.verb("create", sample_entity(entity_kind="concept", name="HubSoft")) + spoke_soft = khive_session.verb("create", sample_entity(entity_kind="concept", name="SpokeSoft")) + e_soft = khive_session.verb("link", { + "source_id": hub_soft["id"], "target_id": spoke_soft["id"], + "relation": "extends", "namespace": temp_namespace, + }) + e_soft_id = e_soft["id"] + + del_soft = khive_session.verb("delete", {"id": hub_soft["id"], "kind": "entity", + "namespace": temp_namespace}) + assert del_soft.get("deleted") is True + + # Edge should still be retrievable after soft delete + fetched_edge = khive_session.verb("get", {"id": e_soft_id, "namespace": temp_namespace}) + assert fetched_edge.get("kind") == "edge", ( + f"Edge should survive soft-delete of incident entity: {fetched_edge}" + ) + + +@pytest.mark.adr_002 +@pytest.mark.slow +def test_annotates_requires_note_source_and_cascades_on_hard_delete( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, + sample_note, +) -> None: + """annotates source must be a note; entity-as-source rejected; cascade on hard delete. + + ADR: ADR-002 + section: Annotation relation; Cascade Behavior; Endpoint Validation + + Ports test_annotates_source_must_be_note from contract_test.py. + """ + concept = khive_session.verb("create", sample_entity(entity_kind="concept", name="AnnotatesTarget")) + another = khive_session.verb("create", sample_entity(entity_kind="concept", name="WrongSource")) + + # entity → entity annotates must fail + envelope = khive_session.request_batch([ + {"tool": "link", "args": { + "source_id": another["id"], + "target_id": concept["id"], + "relation": "annotates", + "namespace": temp_namespace, + }} + ]) + first = envelope["results"][0] + assert not first.get("ok", False), "entity→entity annotates must fail" + err = first.get("error", "") + assert "note" in err.lower(), f"Error must mention 'note' (ADR-002 constraint): {err!r}" + assert "annotates" in err.lower(), f"Error must mention 'annotates': {err!r}" + + # No edge must have been created + edges_after = khive_session.verb("list", { + "kind": "edge", "source_id": another["id"], "namespace": temp_namespace, + }) + assert edges_after == [], ( + f"No edge should exist after rejected annotates link, got: {edges_after}" + ) + + # note → entity annotates must succeed + note = khive_session.verb("create", sample_note( + note_kind="observation", + content="Observation about AnnotatesTarget", + salience=0.7, + )) + edge = khive_session.verb("link", { + "source_id": note["id"], + "target_id": concept["id"], + "relation": "annotates", + "weight": 1.0, + "namespace": temp_namespace, + }) + assert edge.get("relation") == "annotates", f"Expected annotates edge, got: {edge}" + edge_id = edge["id"] + + # Confirm note appears as inbound annotates neighbor of concept + nbrs = khive_session.verb("neighbors", { + "node_id": concept["id"], + "direction": "in", + "relations": ["annotates"], + "namespace": temp_namespace, + }) + neighbor_ids = [n.get("id", "") for n in nbrs] + assert note["id"] in neighbor_ids, ( + f"Note should appear as annotates neighbor of concept; neighbors: {neighbor_ids}" + ) + + # Hard-delete the target entity cascades the annotates edge + del_result = khive_session.verb("delete", { + "id": concept["id"], "kind": "entity", "hard": True, "namespace": temp_namespace, + }) + assert del_result.get("deleted") is True + + # Edge must be gone + envelope_edge = khive_session.request_batch([{"tool": "get", "args": {"id": edge_id, + "namespace": temp_namespace}}]) + first_edge = envelope_edge["results"][0] + assert not first_edge.get("ok", False), "annotates edge must be cascade-deleted" + assert "not found" in first_edge.get("error", "").lower(), ( + f"annotates edge must be cascade-deleted when target hard-deleted; " + f"got: {first_edge.get('error')!r}" + ) diff --git a/tests/khive-contract/tests/test_adr_014_curation.py b/tests/khive-contract/tests/test_adr_014_curation.py new file mode 100644 index 00000000..4d01e7e5 --- /dev/null +++ b/tests/khive-contract/tests/test_adr_014_curation.py @@ -0,0 +1,260 @@ +"""Curation operations contract tests: update, delete, merge. + +ADR: ADR-014 +section: Patch-style updates; Soft vs hard delete; merge_entity semantics +""" + +from __future__ import annotations + +import pytest + +from khive_contract.client import KhiveMcpSession, KhiveOperationError + +VERBS_UNDER_TEST = {"create", "link", "update", "delete", "merge", "get", "list"} + + +@pytest.mark.adr_014 +@pytest.mark.slow +def test_update_entity_fields( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, +) -> None: + """update(entity) patches description, tags, and properties; get reflects changes. + + ADR: ADR-014 + section: Patch-style updates + """ + args = sample_entity( + entity_kind="concept", + name="UpdateTarget", + description="original description", + tags=["old"], + ) + entity = khive_session.verb("create", args) + entity_id = entity["id"] + + updated = khive_session.verb("update", { + "id": entity_id, + "kind": "entity", + "namespace": temp_namespace, + "description": "updated description", + "tags": ["new", "fresh"], + }) + assert updated is not None, "update returned None" + + fetched = khive_session.verb("get", {"id": entity_id, "namespace": temp_namespace}) + data = fetched["data"] + assert data.get("description") == "updated description", ( + f"description not updated: {data.get('description')!r}" + ) + tags = set(data.get("tags", [])) + assert "new" in tags and "fresh" in tags, f"tags not updated: {tags}" + assert "old" not in tags, f"old tag should be replaced: {tags}" + + +@pytest.mark.adr_014 +@pytest.mark.slow +def test_update_edge_weight( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, +) -> None: + """update(edge) patches weight; get reflects new value. + + ADR: ADR-014 + section: Patch-style updates + """ + src = khive_session.verb("create", sample_entity(entity_kind="concept", name="EdgeUpdSrc")) + tgt = khive_session.verb("create", sample_entity(entity_kind="concept", name="EdgeUpdTgt")) + edge = khive_session.verb("link", { + "source_id": src["id"], "target_id": tgt["id"], + "relation": "extends", "weight": 0.3, "namespace": temp_namespace, + }) + edge_id = edge["id"] + + khive_session.verb("update", {"id": edge_id, "kind": "edge", "namespace": temp_namespace, + "weight": 0.9}) + + fetched = khive_session.verb("get", {"id": edge_id, "namespace": temp_namespace}) + updated_weight = fetched["data"].get("weight") + assert updated_weight is not None, f"weight not in edge data: {fetched['data']}" + assert abs(updated_weight - 0.9) < 0.01, ( + f"edge weight not updated: {updated_weight!r}" + ) + + +@pytest.mark.adr_014 +@pytest.mark.slow +def test_update_note_content_and_salience( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_note, +) -> None: + """update(note) patches content and salience; get reflects changes. + + ADR: ADR-014 + section: Patch-style updates + """ + note = khive_session.verb("create", sample_note( + note_kind="observation", + content="original content", + salience=0.3, + )) + note_id = note["id"] + + khive_session.verb("update", {"id": note_id, "kind": "note", "namespace": temp_namespace, + "content": "updated content", "salience": 0.8}) + + fetched = khive_session.verb("get", {"id": note_id, "namespace": temp_namespace}) + data = fetched["data"] + assert data.get("content") == "updated content", f"content not updated: {data}" + assert abs(data.get("salience", 0) - 0.8) < 0.01, f"salience not updated: {data}" + + +@pytest.mark.adr_014 +@pytest.mark.slow +def test_delete_entity_soft_and_hard( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, +) -> None: + """Soft delete returns deleted=True; hard delete returns deleted=True. + + ADR: ADR-014 + section: Soft vs hard delete + + Ports delete assertions from smoke_test.py. + """ + # Soft delete + e_soft = khive_session.verb("create", sample_entity(entity_kind="concept", name="SoftDel")) + del_result = khive_session.verb("delete", {"id": e_soft["id"], "kind": "entity", + "namespace": temp_namespace}) + assert del_result.get("deleted") is True, f"soft delete should return deleted=True: {del_result}" + + # Hard delete + e_hard = khive_session.verb("create", sample_entity(entity_kind="concept", name="HardDel")) + del_result_h = khive_session.verb("delete", { + "id": e_hard["id"], "kind": "entity", "hard": True, "namespace": temp_namespace, + }) + assert del_result_h.get("deleted") is True, ( + f"hard delete should return deleted=True: {del_result_h}" + ) + + # Hard-deleted entity must not be gettable + envelope = khive_session.request_batch([{"tool": "get", "args": {"id": e_hard["id"], + "namespace": temp_namespace}}]) + first = envelope["results"][0] + assert not first.get("ok", False), "Hard-deleted entity must not be gettable" + assert "not found" in first.get("error", "").lower(), ( + f"Expected not-found error after hard delete: {first.get('error')!r}" + ) + + +@pytest.mark.adr_014 +@pytest.mark.slow +def test_merge_entity_rewires_edges_unions_tags_drops_self_loops( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, +) -> None: + """merge(into, from) rewires edges, unions tags, from becomes inaccessible, self-loop dropped. + + ADR: ADR-014 + section: merge_entity semantics + + Ports test_merge_semantics from contract_test.py. + """ + kept = khive_session.verb("create", sample_entity( + entity_kind="concept", name="KeptEntity", tags=["alpha", "beta"] + )) + gone = khive_session.verb("create", sample_entity( + entity_kind="concept", name="GoneEntity", tags=["beta", "gamma"] + )) + third = khive_session.verb("create", sample_entity( + entity_kind="concept", name="ThirdEntity" + )) + + # third → gone (inbound to gone) + e_inbound = khive_session.verb("link", { + "source_id": third["id"], + "target_id": gone["id"], + "relation": "enables", + "weight": 0.7, + "namespace": temp_namespace, + }) + # gone → kept (becomes self-loop after merge, must be dropped) + e_self_loop = khive_session.verb("link", { + "source_id": gone["id"], + "target_id": kept["id"], + "relation": "extends", + "weight": 0.5, + "namespace": temp_namespace, + }) + e_inbound_id = e_inbound["id"] + e_self_loop_id = e_self_loop["id"] + + # Execute merge + summary = khive_session.verb("merge", { + "into_id": kept["id"], + "from_id": gone["id"], + "strategy": "prefer_into", + "namespace": temp_namespace, + }) + assert summary.get("kept_id") == kept["id"], ( + f"kept_id mismatch: expected {kept['id']}, got {summary.get('kept_id')}" + ) + assert summary.get("removed_id") == gone["id"], ( + f"removed_id mismatch: expected {gone['id']}, got {summary.get('removed_id')}" + ) + + # from_id must not be gettable + envelope_gone = khive_session.request_batch([{"tool": "get", "args": {"id": gone["id"], + "namespace": temp_namespace}}]) + first_gone = envelope_gone["results"][0] + assert not first_gone.get("ok", False), "Merged-away entity must not be gettable" + assert "not found" in first_gone.get("error", "").lower(), ( + f"Expected not-found for merged-away entity: {first_gone.get('error')!r}" + ) + + # Inbound edge must be rewired to kept_id + rewired = khive_session.verb("get", {"id": e_inbound_id, "namespace": temp_namespace}) + assert rewired.get("kind") == "edge", f"rewired edge not found: {rewired}" + edge_data = rewired["data"] + assert edge_data.get("target_id") == kept["id"], ( + f"Inbound edge target should be rewired to kept_id={kept['id']}, " + f"got target_id={edge_data.get('target_id')}" + ) + assert edge_data.get("source_id") == third["id"], ( + f"Source should still be third={third['id']}, got {edge_data.get('source_id')}" + ) + + # Tags must be unioned on kept entity + kept_after = khive_session.verb("get", {"id": kept["id"], "namespace": temp_namespace}) + assert kept_after.get("kind") == "entity" + tags_after = set(kept_after["data"].get("tags", [])) + assert "alpha" in tags_after, f"Tag 'alpha' missing after merge: {tags_after}" + assert "beta" in tags_after, f"Tag 'beta' missing after merge: {tags_after}" + assert "gamma" in tags_after, f"Tag 'gamma' missing after merge: {tags_after}" + + # Self-loop edge (gone→kept, now kept→kept) must be dropped + envelope_loop = khive_session.request_batch([ + {"tool": "get", "args": {"id": e_self_loop_id, "namespace": temp_namespace}} + ]) + first_loop = envelope_loop["results"][0] + assert not first_loop.get("ok", False), "Self-loop edge must be deleted after merge" + assert "not found" in first_loop.get("error", "").lower(), ( + f"Self-loop edge should be not-found: {first_loop.get('error')!r}" + ) + + # No edges referencing the removed entity + gone_out = khive_session.verb("list", {"kind": "edge", "source_id": gone["id"], + "namespace": temp_namespace}) + assert gone_out == [], ( + f"No edges with source_id=gone should remain: {gone_out}" + ) + gone_in = khive_session.verb("list", {"kind": "edge", "target_id": gone["id"], + "namespace": temp_namespace}) + assert gone_in == [], ( + f"No edges with target_id=gone should remain: {gone_in}" + ) diff --git a/tests/khive-contract/tests/test_adr_019_note_kind.py b/tests/khive-contract/tests/test_adr_019_note_kind.py new file mode 100644 index 00000000..b9420d6d --- /dev/null +++ b/tests/khive-contract/tests/test_adr_019_note_kind.py @@ -0,0 +1,201 @@ +"""Note kind taxonomy contract tests. + +ADR: ADR-013 (file named adr_019 per play specification; ADR drift documented in README) +section: Base taxonomy; Default kind; Kind is a string validated; Search and discrimination; + Supersession via edge +""" + +from __future__ import annotations + +import pytest + +from khive_contract.client import KhiveMcpSession + +VERBS_UNDER_TEST = {"create", "list", "get", "search", "link"} + +# Runtime-confirmed note kinds (5 base kinds) +RUNTIME_NOTE_KINDS = ("observation", "insight", "decision", "question", "reference") + + +@pytest.mark.adr_013 +@pytest.mark.slow +@pytest.mark.parametrize("note_kind", RUNTIME_NOTE_KINDS) +def test_create_list_get_each_base_note_kind( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_note, + note_kind: str, +) -> None: + """Create, list-filtered, and get each of the 5 base note kinds. + + ADR: ADR-013 + section: Base taxonomy + + Each create returns a kind; list filtered by note_kind contains the id; + get returns kind=="note" wrapper with matching content. + """ + args = sample_note( + note_kind=note_kind, + content=f"content for {note_kind} note", + salience=0.6, + ) + result = khive_session.verb("create", args) + assert result is not None, f"create(note_kind={note_kind}) returned None" + note_id = result.get("id") + assert note_id, f"create note missing 'id': {result}" + # Runtime response uses 'kind' field for note_kind value + assert result.get("kind") == note_kind, ( + f"kind mismatch: got {result.get('kind')!r}, expected {note_kind!r}" + ) + + # list filtered by note_kind must include the new id + listed = khive_session.verb("list", { + "kind": "note", + "note_kind": note_kind, + "namespace": temp_namespace, + }) + assert isinstance(listed, list), f"list returned non-list: {listed!r}" + ids = [n.get("id") for n in listed] + assert note_id in ids, ( + f"list(note_kind={note_kind}) omitted id={note_id}; got {ids}" + ) + + # get must return kind=="note" wrapper + fetched = khive_session.verb("get", {"id": note_id, "namespace": temp_namespace}) + assert fetched is not None + assert fetched.get("kind") == "note", ( + f"get wrapper kind should be 'note', got {fetched.get('kind')!r}" + ) + data = fetched.get("data", {}) + # data.kind holds the note_kind value + assert data.get("kind") == note_kind, ( + f"get data kind mismatch: {data.get('kind')!r} != {note_kind!r}" + ) + assert data.get("content") == f"content for {note_kind} note", ( + f"get data content mismatch: {data}" + ) + + +@pytest.mark.adr_013 +@pytest.mark.slow +def test_invalid_note_kind_reports_registered_set( + khive_session: KhiveMcpSession, + temp_namespace: str, +) -> None: + """Invalid note_kind returns per-op error that names the offending kind and lists valid set. + + ADR: ADR-013 + section: Kind is a string validated + + Ports test_closed_taxonomy_errors note_kind check. + """ + envelope = khive_session.request_batch([ + {"tool": "create", "args": { + "kind": "note", + "note_kind": "scribble", + "content": "some content", + "namespace": temp_namespace, + }} + ]) + results = envelope.get("results", []) + assert results, "Expected results in envelope" + first = results[0] + assert not first.get("ok", False), "Expected per-op error for invalid note_kind" + err = first.get("error", "") + assert err, "Error message must be non-empty" + assert "scribble" in err, f"Error must name offending note_kind 'scribble': {err!r}" + + # All 5 base note kinds must be listed + for nk in RUNTIME_NOTE_KINDS: + assert nk in err, ( + f"Valid note_kind '{nk}' missing from error message: {err!r}" + ) + + +@pytest.mark.adr_013 +@pytest.mark.slow +def test_note_supersession_search_excludes_old_but_get_keeps_both( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_note, +) -> None: + """Superseded note excluded from search results but still gettable via get(). + + ADR: ADR-013 + section: Supersession via edge + + Ports test_note_supersession from contract_test.py. + """ + old_note = khive_session.verb("create", sample_note( + note_kind="observation", + content="SupersededContent unique_token_abc_ns", + salience=0.8, + )) + old_id = old_note["id"] + + new_note = khive_session.verb("create", sample_note( + note_kind="insight", + content="NewerContent unique_token_abc_ns", + salience=0.9, + )) + new_id = new_note["id"] + + # Wire supersedes edge: new → old + khive_session.verb("link", { + "source_id": new_id, + "target_id": old_id, + "relation": "supersedes", + "weight": 1.0, + "namespace": temp_namespace, + }) + + # search must exclude superseded old note and include new note + hits = khive_session.verb("search", { + "kind": "note", + "query": "unique_token_abc_ns", + "limit": 20, + "namespace": temp_namespace, + }) + hit_ids = [h.get("id", h.get("note_id", "")) for h in hits] + + assert old_id not in hit_ids, ( + f"Superseded note (old_id={old_id}) should be excluded from search, " + f"but appeared in hits: {hit_ids}" + ) + assert new_id in hit_ids, ( + f"New note (new_id={new_id}) must appear in search results; hits: {hit_ids}" + ) + + # get(old_id) must still succeed — superseded is not deleted + fetched_old = khive_session.verb("get", {"id": old_id, "namespace": temp_namespace}) + assert fetched_old.get("kind") == "note", ( + f"Superseded note must still be gettable via get(), got: {fetched_old}" + ) + assert fetched_old["data"].get("content") == "SupersededContent unique_token_abc_ns" + + # get(new_id) must also succeed + fetched_new = khive_session.verb("get", {"id": new_id, "namespace": temp_namespace}) + assert fetched_new.get("kind") == "note" + + +@pytest.mark.adr_013 +@pytest.mark.slow +def test_note_salience_stored_and_retrievable( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_note, +) -> None: + """Note salience is stored and returned by get. + + ADR: ADR-013 + section: Base taxonomy + """ + args = sample_note(note_kind="observation", salience=0.75) + result = khive_session.verb("create", args) + note_id = result["id"] + + fetched = khive_session.verb("get", {"id": note_id, "namespace": temp_namespace}) + data = fetched["data"] + salience = data.get("salience") + assert salience is not None, f"salience not stored: {data}" + assert abs(salience - 0.75) < 0.01, f"salience value mismatch: {salience}" diff --git a/tests/khive-contract/tests/test_adr_020_request_dsl.py b/tests/khive-contract/tests/test_adr_020_request_dsl.py new file mode 100644 index 00000000..5c624952 --- /dev/null +++ b/tests/khive-contract/tests/test_adr_020_request_dsl.py @@ -0,0 +1,206 @@ +"""Request DSL contract tests. + +ADR: ADR-016 (file named adr_020 per play specification; ADR drift documented in README) +section: Three syntactic forms; Parallel semantics; Chain semantics; UUID arguments; + Wire shape; Maximum operations per request +""" + +from __future__ import annotations + +import json +import uuid + +import pytest + +from khive_contract.client import KhiveMcpSession, KhiveRpcError +from khive_contract.schema import assert_envelope + +VERBS_UNDER_TEST = {"create", "get", "link", "update"} + + +@pytest.mark.adr_016 +@pytest.mark.adr_020 +@pytest.mark.slow +def test_function_call_single_operation_form( + khive_session: KhiveMcpSession, + temp_namespace: str, +) -> None: + """Function-call DSL single-op: create(kind="entity", ...) is dispatched correctly. + + ADR: ADR-016 + section: Three syntactic forms + + The envelope total==1, succeeded==1; created id is gettable. + """ + name = f"DslSingle_{uuid.uuid4().hex[:6]}" + ops = f'create(kind="entity", entity_kind="concept", name="{name}", namespace="{temp_namespace}")' + envelope = khive_session.request(ops) + + assert_envelope(envelope) + results = envelope.get("results", []) + assert len(results) == 1, f"Expected 1 result, got {len(results)}" + assert results[0].get("ok"), f"Expected ok=True, got: {results[0]}" + entity_id = results[0]["result"]["id"] + assert entity_id, "Expected entity id in result" + + # The created entity must be gettable + fetched = khive_session.verb("get", {"id": entity_id, "namespace": temp_namespace}) + assert fetched.get("kind") == "entity" + assert fetched["data"]["name"] == name + + +@pytest.mark.adr_016 +@pytest.mark.adr_020 +@pytest.mark.slow +def test_json_parallel_batch_preserves_order_and_summary( + khive_session: KhiveMcpSession, + temp_namespace: str, +) -> None: + """JSON-form parallel batch of 3 creates succeeds in input order. + + ADR: ADR-016 + section: Parallel semantics + + Summary total==3, failed==0; all results are in input order and ok. + """ + ops_list = [ + {"tool": "create", "args": {"kind": "entity", "entity_kind": "concept", + "name": f"Batch{i}", "namespace": temp_namespace}} + for i in range(3) + ] + envelope = khive_session.request_batch(ops_list) + assert_envelope(envelope) + results = envelope.get("results", []) + assert len(results) == 3, f"Expected 3 results, got {len(results)}" + + names = [] + for i, r in enumerate(results): + assert r.get("ok"), f"Result {i} not ok: {r}" + names.append(r["result"]["name"]) + + assert names == ["Batch0", "Batch1", "Batch2"], ( + f"Results must be in input order, got: {names}" + ) + + +@pytest.mark.adr_016 +@pytest.mark.adr_020 +@pytest.mark.slow +def test_short_uuid_prefix_resolution_rules( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, +) -> None: + """8-char hex prefix resolves; 7-char and non-hex prefixes return errors. + + ADR: ADR-016 + section: UUID arguments + + Ports test_short_uuid_prefix_resolution from contract_test.py. + """ + entity = khive_session.verb("create", sample_entity( + entity_kind="concept", name="PrefixTarget" + )) + full_id: str = entity["id"] + prefix8 = full_id[:8] + prefix7 = full_id[:7] + prefix_bad = "ZZZZZZZZ" + + # 8-char prefix must resolve + fetched = khive_session.verb("get", {"id": prefix8, "namespace": temp_namespace}) + assert fetched.get("kind") == "entity" + assert fetched["data"]["name"] == "PrefixTarget", ( + f"8-char prefix did not resolve to PrefixTarget: {fetched}" + ) + + # 7-char prefix must fail + envelope_7 = khive_session.request_batch([{"tool": "get", "args": {"id": prefix7, + "namespace": temp_namespace}}]) + first_7 = envelope_7["results"][0] + assert not first_7.get("ok", False), "7-char prefix should fail" + assert first_7.get("error"), f"7-char prefix error message must be non-empty" + + # Non-hex 8-char must fail + envelope_bad = khive_session.request_batch([{"tool": "get", "args": {"id": prefix_bad, + "namespace": temp_namespace}}]) + first_bad = envelope_bad["results"][0] + assert not first_bad.get("ok", False), "Non-hex prefix should fail" + assert first_bad.get("error"), f"Non-hex prefix error message must be non-empty" + + +@pytest.mark.adr_016 +@pytest.mark.adr_020 +@pytest.mark.slow +def test_malformed_dsl_rejected_as_rpc_error( + khive_session: KhiveMcpSession, +) -> None: + """Malformed DSL raises KhiveRpcError containing expected/invalid. + + ADR: ADR-016 + section: Parser errors + + Ports smoke malformed DSL assertion. + """ + with pytest.raises(KhiveRpcError): + khive_session.request("create(") + + +@pytest.mark.adr_016 +@pytest.mark.adr_020 +@pytest.mark.slow +def test_request_response_envelope_matches_schema( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, +) -> None: + """Successful and per-op-error envelopes both conform to the envelope schema. + + ADR: ADR-016 + section: Wire shape + """ + # Success envelope + success_envelope = khive_session.request_batch([ + {"tool": "create", "args": sample_entity(entity_kind="concept", name="SchemaOk")} + ]) + assert_envelope(success_envelope) + assert success_envelope["results"][0].get("ok") is True + + # Per-op error envelope (invalid kind) + error_envelope = khive_session.request_batch([ + {"tool": "create", "args": { + "kind": "entity", + "entity_kind": "invalid_kind", + "name": "ShouldFail", + "namespace": temp_namespace, + }} + ]) + assert_envelope(error_envelope) + first = error_envelope["results"][0] + assert first.get("ok") is False + assert first.get("error"), "Per-op error must have an error string" + + +@pytest.mark.adr_016 +@pytest.mark.adr_020 +@pytest.mark.slow +def test_unknown_verb_is_per_op_error( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, +) -> None: + """Unknown verb in a batch returns per-op error without aborting siblings. + + ADR: ADR-016 + section: Unknown verb names + """ + ops_list = [ + {"tool": "create", "args": sample_entity(entity_kind="concept", name="BeforeFrobnicateA")}, + {"tool": "frobnicate", "args": {"x": 1}}, + {"tool": "create", "args": sample_entity(entity_kind="concept", name="AfterFrobnicateB")}, + ] + envelope = khive_session.request_batch(ops_list) + results = envelope.get("results", []) + assert len(results) == 3, f"Expected 3 results, got {len(results)}" + assert results[0].get("ok") is True, f"First create should succeed: {results[0]}" + assert results[1].get("ok") is False, f"Unknown verb should fail: {results[1]}" + assert results[2].get("ok") is True, f"Third create should succeed: {results[2]}" diff --git a/tests/khive-contract/tests/test_adr_023_verb_taxonomy.py b/tests/khive-contract/tests/test_adr_023_verb_taxonomy.py new file mode 100644 index 00000000..9ef9ee7b --- /dev/null +++ b/tests/khive-contract/tests/test_adr_023_verb_taxonomy.py @@ -0,0 +1,185 @@ +"""Verb taxonomy contract tests — all product verbs are reachable. + +ADR: ADR-023 +section: kg bare substrate verbs; Pack product verbs; Verb naming +""" + +from __future__ import annotations + +import pytest + +from khive_contract.client import KhiveMcpSession + +# All 18 baseline product verbs +VERBS_UNDER_TEST = { + # KG (11) + "create", "get", "list", "update", "delete", "merge", + "search", "link", "neighbors", "traverse", "query", + # GTD (5) + "assign", "next", "complete", "tasks", "transition", + # Memory (2) + "remember", "recall", +} + +KG_VERBS = ("create", "get", "list", "update", "delete", "merge", + "search", "link", "neighbors", "traverse", "query") +GTD_VERBS = ("assign", "next", "complete", "tasks", "transition") +MEMORY_VERBS = ("remember", "recall") + + +@pytest.mark.adr_023 +@pytest.mark.slow +def test_kg_bare_product_verbs_are_reachable( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, + sample_note, +) -> None: + """Every KG substrate verb has at least one successful call with a meaningful result. + + ADR: ADR-023 + section: kg bare substrate verbs + + Ports smoke KG surface coverage; verifies all 11 KG verbs are registered + and return non-error results in the base kg session. + """ + ns = temp_namespace + + # create entity + note + entity_a = khive_session.verb("create", sample_entity(entity_kind="concept", name="TaxA")) + entity_b = khive_session.verb("create", sample_entity(entity_kind="concept", name="TaxB")) + entity_c = khive_session.verb("create", sample_entity(entity_kind="concept", name="TaxC")) + note = khive_session.verb("create", sample_note(note_kind="observation", + content="taxonomy coverage note")) + assert entity_a.get("id"), "create entity must return id" + assert note.get("id"), "create note must return id" + + # get + fetched = khive_session.verb("get", {"id": entity_a["id"], "namespace": ns}) + assert fetched.get("kind") == "entity", f"get must return entity wrapper: {fetched}" + + # list + entities = khive_session.verb("list", {"kind": "entity", "entity_kind": "concept", + "namespace": ns}) + assert isinstance(entities, list), "list must return a list" + assert any(e["id"] == entity_a["id"] for e in entities), "list must include created entity" + + # link + edge = khive_session.verb("link", {"source_id": entity_a["id"], "target_id": entity_b["id"], + "relation": "extends", "namespace": ns}) + assert edge.get("id"), "link must return edge with id" + + # neighbors + nbrs = khive_session.verb("neighbors", {"node_id": entity_a["id"], "direction": "out", + "namespace": ns}) + assert isinstance(nbrs, list), "neighbors must return a list" + assert any(n.get("id") == entity_b["id"] for n in nbrs), "B must be outbound neighbor of A" + + # update + updated = khive_session.verb("update", {"id": entity_a["id"], "kind": "entity", + "namespace": ns, "description": "updated by taxonomy test"}) + assert updated is not None, "update must return a result" + + # search + hits = khive_session.verb("search", {"kind": "entity", "query": "TaxA", "namespace": ns}) + assert isinstance(hits, list), "search must return a list" + + # link for traverse + edge_bc = khive_session.verb("link", {"source_id": entity_b["id"], "target_id": entity_c["id"], + "relation": "extends", "namespace": ns}) + + # traverse + paths = khive_session.verb("traverse", {"roots": [entity_a["id"]], "max_depth": 2, + "include_roots": False, "namespace": ns}) + assert isinstance(paths, list), "traverse must return a list" + + # query + result = khive_session.verb("query", { + "query": f"MATCH (a:concept)-[e:extends]->(b:concept) RETURN a, b LIMIT 5", + "namespace": ns, + }) + assert isinstance(result, list) or isinstance(result, dict), "query must return rows or dict" + + # delete + del_result = khive_session.verb("delete", {"id": entity_c["id"], "kind": "entity", + "namespace": ns}) + assert del_result.get("deleted") is True, "delete must return deleted=True" + + # merge + dupe = khive_session.verb("create", sample_entity(entity_kind="concept", name="TaxADupe")) + merge_result = khive_session.verb("merge", {"into_id": entity_a["id"], + "from_id": dupe["id"], + "namespace": ns}) + assert merge_result.get("kept_id") == entity_a["id"], "merge must return kept_id" + + +@pytest.mark.adr_023 +@pytest.mark.slow +def test_pack_product_verbs_are_reachable_when_loaded( + khive_gtd_session: KhiveMcpSession, + khive_memory_session: KhiveMcpSession, + temp_namespace: str, +) -> None: + """Every pack verb (GTD + memory) has at least one successful call. + + ADR: ADR-023 + section: Pack product verbs; ADR-017 Built-in packs; ADR-019; ADR-021 + + Ensures all 7 pack verbs are registered and return non-error results + when their respective packs are loaded. + """ + ns = temp_namespace + + # ---- GTD verbs ---- + # assign + task = khive_gtd_session.verb("assign", { + "title": "Taxonomy test task", + "status": "next", + "priority": "p1", + "namespace": ns, + }) + assert task.get("kind") == "task", f"assign must return kind=task: {task}" + task_id = task.get("full_id") or task.get("id") + assert task_id, "assign must return a task id" + + # next + next_tasks = khive_gtd_session.verb("next", {"namespace": ns}) + assert isinstance(next_tasks, list), "next must return a list" + + # tasks + task_list = khive_gtd_session.verb("tasks", {"status": "next", "namespace": ns}) + assert isinstance(task_list, list), "tasks must return a list" + full_ids = [t.get("full_id") for t in task_list] + assert task_id in full_ids, f"assigned task must appear in tasks(status=next): {full_ids}" + + # transition + trans = khive_gtd_session.verb("transition", {"id": task_id, "status": "waiting", + "namespace": ns}) + assert trans.get("transitioned") is True, f"transition must return transitioned=True: {trans}" + assert trans.get("to") == "waiting", f"transition must report to=waiting: {trans}" + + # complete (need a task in actionable status, so transition back to next) + khive_gtd_session.verb("transition", {"id": task_id, "status": "next", "namespace": ns}) + done = khive_gtd_session.verb("complete", {"id": task_id, "result": "taxonomy pass", + "namespace": ns}) + assert done.get("to") == "done", f"complete must return to=done: {done}" + + # ---- Memory verbs ---- + # remember + mem = khive_memory_session.verb("remember", { + "content": "khive taxonomy coverage test semantic memory", + "importance": 0.8, + "memory_type": "semantic", + "namespace": ns, + }) + assert mem is not None, "remember must return a result" + mem_id = mem.get("id") or mem.get("note_id") + assert mem_id, f"remember must return an id: {mem}" + + # recall + hits = khive_memory_session.verb("recall", { + "query": "khive taxonomy coverage", + "limit": 5, + "namespace": ns, + }) + assert isinstance(hits, list), f"recall must return a list, got: {hits}" diff --git a/tests/khive-contract/tests/test_adr_027_single_tool_mcp.py b/tests/khive-contract/tests/test_adr_027_single_tool_mcp.py new file mode 100644 index 00000000..cfe4b2f6 --- /dev/null +++ b/tests/khive-contract/tests/test_adr_027_single_tool_mcp.py @@ -0,0 +1,191 @@ +"""Single-tool MCP surface contract tests. + +ADR: ADR-027 +section: MCP wire format unchanged; One MCP tool; Pack selection; Dynamic verb catalog +""" + +from __future__ import annotations + +import pytest + +from khive_contract.client import KhiveMcpSession, KhiveRpcError + +VERBS_UNDER_TEST = {"create"} + +KG_VERBS = ("create", "get", "list", "update", "delete", "merge", + "search", "link", "neighbors", "traverse", "query") +GTD_VERBS = ("assign", "next", "complete", "tasks", "transition") +MEMORY_VERBS = ("remember", "recall") + + +@pytest.mark.adr_027 +@pytest.mark.slow +def test_tools_list_exposes_exactly_request( + khive_session: KhiveMcpSession, +) -> None: + """tools/list returns exactly one tool named 'request'. + + ADR: ADR-027 + section: One MCP tool; MCP wire format unchanged + + Ports smoke single-tool assertion. + """ + tools = khive_session.tools_list() + tool_names = [t.get("name") for t in tools] + assert tool_names == ["request"], ( + f"Expected exactly [request], got {tool_names}" + ) + + +@pytest.mark.adr_027 +@pytest.mark.slow +def test_request_description_lists_kg_verbs( + khive_session: KhiveMcpSession, +) -> None: + """The 'request' tool description lists all KG verb names. + + ADR: ADR-027 + section: Dynamic verb catalog; ADR-016 One MCP tool + + Ports smoke verb-in-description assertion. + """ + tools = khive_session.tools_list() + assert tools, "tools/list returned empty" + request_tool = next((t for t in tools if t.get("name") == "request"), None) + assert request_tool is not None, "No 'request' tool in tools/list" + + description = request_tool.get("description") or "" + for verb in KG_VERBS: + assert verb in description, ( + f"KG verb '{verb}' missing from request description; got:\n{description!r}" + ) + + +@pytest.mark.adr_027 +@pytest.mark.slow +def test_gtd_verbs_absent_from_kg_only_description( + khive_session: KhiveMcpSession, +) -> None: + """KG-only session description does not include GTD or memory verbs. + + ADR: ADR-027 + section: Pack selection; Dynamic verb catalog + """ + tools = khive_session.tools_list() + description = tools[0].get("description") or "" + # GTD verbs must not appear in KG-only description + for verb in GTD_VERBS: + assert verb not in description, ( + f"GTD verb '{verb}' should not appear in KG-only description; " + f"got:\n{description!r}" + ) + + +@pytest.mark.adr_027 +@pytest.mark.slow +def test_gtd_session_description_includes_gtd_verbs( + khive_gtd_session: KhiveMcpSession, +) -> None: + """KG+GTD session description includes GTD verb names. + + ADR: ADR-027 + section: Pack selection; Dynamic verb catalog + + Ports pack smoke startup. + """ + tools = khive_gtd_session.tools_list() + assert tools, "tools/list returned empty for GTD session" + description = tools[0].get("description") or "" + for verb in GTD_VERBS: + assert verb in description, ( + f"GTD verb '{verb}' missing from GTD session description; got:\n{description!r}" + ) + + +@pytest.mark.adr_027 +@pytest.mark.slow +def test_memory_session_description_includes_memory_verbs( + khive_memory_session: KhiveMcpSession, +) -> None: + """KG+memory session description includes remember and recall. + + ADR: ADR-027 + section: Pack selection; Dynamic verb catalog + """ + tools = khive_memory_session.tools_list() + assert tools, "tools/list returned empty for memory session" + description = tools[0].get("description") or "" + for verb in MEMORY_VERBS: + assert verb in description, ( + f"Memory verb '{verb}' missing from memory session description; " + f"got:\n{description!r}" + ) + + +@pytest.mark.adr_027 +@pytest.mark.slow +def test_kg_session_rejects_gtd_verb( + khive_session: KhiveMcpSession, + temp_namespace: str, +) -> None: + """KG-only session returns per-op error for GTD verbs. + + ADR: ADR-027 + section: Pack selection + + GTD verbs must not be callable when gtd pack is not loaded. + """ + envelope = khive_session.request_batch([ + {"tool": "assign", "args": { + "title": "test task", + "namespace": temp_namespace, + }} + ]) + results = envelope.get("results", []) + assert results, "Expected results in envelope" + first = results[0] + assert not first.get("ok", False), ( + "KG-only session should not allow GTD 'assign' verb" + ) + + +@pytest.mark.adr_027 +@pytest.mark.slow +def test_unknown_pack_fails_startup() -> None: + """Spawning with an unknown pack name fails with a clear error. + + ADR: ADR-027 + section: Dependency ordering; Boot errors + + The process must fail to initialize with a useful error message. + """ + import subprocess + from khive_contract.client import _resolve_binary + + binary = _resolve_binary(None) + proc = subprocess.Popen( + [str(binary), "--db", ":memory:", "--no-embed", "--log", "error", + "--pack", "kg", "--pack", "does_not_exist"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + # Either the process exits quickly, or initialize fails + try: + # Try to initialize — it should fail either at process exit or at response level + with KhiveMcpSession(packs=("kg", "does_not_exist")) as _session: + pass + pytest.fail("Expected startup failure for unknown pack 'does_not_exist'") + except Exception as exc: + # Any exception (FileNotFoundError, KhiveRpcError, RuntimeError) is acceptable + # as long as it's attributable — check it's not a silent empty message + err_msg = str(exc) + assert err_msg, "Startup failure must produce a non-empty error message" + finally: + try: + proc.kill() + proc.wait(timeout=2) + except Exception: + pass diff --git a/tests/khive-contract/tests/test_contract_behaviors.py b/tests/khive-contract/tests/test_contract_behaviors.py new file mode 100644 index 00000000..be3265ce --- /dev/null +++ b/tests/khive-contract/tests/test_contract_behaviors.py @@ -0,0 +1,119 @@ +"""Behavioral contract tests: GQL property projection. + +ADR: ADR-016 +section: GQL property projection; Invalid column projection error; Compile errors +""" + +from __future__ import annotations + +import pytest + +from khive_contract.client import KhiveMcpSession + +VERBS_UNDER_TEST = {"create", "link", "query"} + +VALID_NODE_COLUMNS = ( + "id", "name", "kind", "entity_type", "namespace", + "description", "properties", "created_at", "updated_at", +) + + +@pytest.mark.adr_016 +@pytest.mark.slow +def test_gql_property_projection_valid_columns( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, +) -> None: + """RETURN a.name, b.name succeeds and row contains only a_name and b_name keys. + + ADR: ADR-016 + section: GQL property projection + + Ports test_gql_property_projection (valid path) from contract_test.py. + """ + ns = temp_namespace + a = khive_session.verb("create", sample_entity(entity_kind="concept", name="GQL_A")) + b = khive_session.verb("create", sample_entity(entity_kind="concept", name="GQL_B")) + khive_session.verb("link", { + "source_id": a["id"], + "target_id": b["id"], + "relation": "extends", + "weight": 1.0, + "namespace": ns, + }) + + result = khive_session.verb("query", { + "query": "MATCH (a:concept)-[e:extends]->(b:concept) RETURN a.name, b.name LIMIT 10", + "namespace": ns, + }) + + rows = result.get("rows", result) if isinstance(result, dict) else result + assert isinstance(rows, list), f"query must return list of rows, got: {result}" + assert len(rows) >= 1, f"Expected >=1 rows for valid projection, got: {rows}" + + row = rows[0] + if "columns" in row: + flat_row = {col["name"]: col["value"] for col in row["columns"]} + else: + flat_row = row + + assert "a_name" in flat_row, ( + f"a_name key missing from projected row: {flat_row}" + ) + assert "b_name" in flat_row, ( + f"b_name key missing from projected row: {flat_row}" + ) + assert flat_row["a_name"] in ("GQL_A", {"String": "GQL_A"}) or str(flat_row["a_name"]).endswith("GQL_A") or "GQL_A" in str(flat_row["a_name"]), ( + f"a_name value should be 'GQL_A', got: {flat_row['a_name']!r}" + ) + # Must NOT contain full entity blob columns when property projection is used + assert "a_properties" not in flat_row, ( + f"Property projection must not leak a_properties: {flat_row}" + ) + + +@pytest.mark.adr_016 +@pytest.mark.slow +def test_gql_property_projection_invalid_column_error( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, +) -> None: + """RETURN a.bogus returns a compile error that names the offending property and lists valid columns. + + ADR: ADR-016 + section: Invalid column projection error; Compile errors + + Ports test_gql_property_projection (error path) from contract_test.py. + """ + ns = temp_namespace + a = khive_session.verb("create", sample_entity(entity_kind="concept", name="GQL_ErrA")) + b = khive_session.verb("create", sample_entity(entity_kind="concept", name="GQL_ErrB")) + khive_session.verb("link", { + "source_id": a["id"], + "target_id": b["id"], + "relation": "extends", + "namespace": ns, + }) + + envelope = khive_session.request_batch([{ + "tool": "query", + "args": { + "query": "MATCH (a:concept)-[e:extends]->(b:concept) RETURN a.bogus LIMIT 5", + "namespace": ns, + }, + }]) + first = envelope["results"][0] + assert not first.get("ok", False), ( + "RETURN a.bogus must produce an error, not a success" + ) + err = first.get("error", "") + assert err, "Error message must be non-empty" + assert "bogus" in err, ( + f"Error must name the offending property 'bogus': {err!r}" + ) + # The valid-column list must include entity_type + assert "entity_type" in err, ( + f"Error must list valid columns including entity_type: {err!r}" + ) diff --git a/tests/khive-contract/tests/test_manifest.py b/tests/khive-contract/tests/test_manifest.py new file mode 100644 index 00000000..edb3f12b --- /dev/null +++ b/tests/khive-contract/tests/test_manifest.py @@ -0,0 +1,261 @@ +"""Manifest and coverage gate — meta-tests for the test suite itself. + +ADR: ADR-023 +section: Verb naming; Coverage gates; ADR docstring conventions + +These tests are static (no MCP calls). They introspect the test suite files +to enforce structural conventions: + - Every test module declares VERBS_UNDER_TEST + - Every test module's docstring references an ADR and section + - The union of all VERBS_UNDER_TEST covers all 18 product verbs + - No test file hardcodes namespace="local" in verb calls (defeats isolation) +""" + +from __future__ import annotations + +import ast +import pathlib +import re + +import pytest + +TESTS_DIR = pathlib.Path(__file__).parent +_THIS_FILE = pathlib.Path(__file__) + +ALL_PRODUCT_VERBS: frozenset[str] = frozenset({ + # KG (11) + "create", "get", "list", "update", "delete", "merge", + "search", "link", "neighbors", "traverse", "query", + # GTD (5) + "assign", "next", "complete", "tasks", "transition", + # Memory (2) + "remember", "recall", +}) + +PLAY_SPEC_MINIMUM_VERB_COUNT = 15 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _test_files() -> list[pathlib.Path]: + """All test_*.py files in this directory except this manifest.""" + return sorted( + f for f in TESTS_DIR.glob("test_*.py") + if f.resolve() != _THIS_FILE.resolve() + ) + + +def _module_docstring(path: pathlib.Path) -> str: + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + return ast.get_docstring(tree) or "" + + +def _verbs_under_test(path: pathlib.Path) -> set[str] | None: + """Extract VERBS_UNDER_TEST set from a module via AST. Returns None if not found.""" + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if not (isinstance(target, ast.Name) and target.id == "VERBS_UNDER_TEST"): + continue + val = node.value + if isinstance(val, ast.Set): + return { + elt.value + for elt in val.elts + if isinstance(elt, ast.Constant) and isinstance(elt.value, str) + } + if isinstance(val, ast.Call) and isinstance(val.func, ast.Name): + # frozenset({...}) or set({...}) + if val.args and isinstance(val.args[0], ast.Set): + return { + elt.value + for elt in val.args[0].elts + if isinstance(elt, ast.Constant) and isinstance(elt.value, str) + } + return None + + +def _has_hardcoded_local_namespace(path: pathlib.Path) -> list[int]: + """Return list of line numbers where namespace='local' appears in verb calls.""" + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + bad_lines: list[int] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + for kw in node.keywords: + if ( + kw.arg == "namespace" + and isinstance(kw.value, ast.Constant) + and kw.value.value == "local" + ): + bad_lines.append(kw.value.lineno) + return bad_lines + + +# --------------------------------------------------------------------------- +# Static structure tests (no markers needed — these are fast local checks) +# --------------------------------------------------------------------------- + + +def test_all_test_modules_define_verbs_under_test() -> None: + """Every test_*.py module (except this manifest) defines VERBS_UNDER_TEST. + + ADR: ADR-023 + section: Verb naming + + Allows the combined coverage gate to aggregate verb coverage across modules. + """ + files = _test_files() + assert files, f"No test files found in {TESTS_DIR}" + missing: list[str] = [] + for path in files: + verbs = _verbs_under_test(path) + if verbs is None: + missing.append(path.name) + assert not missing, ( + f"These test modules do not define VERBS_UNDER_TEST: {missing}\n" + f"Add 'VERBS_UNDER_TEST = {{\"verb\", ...}}' at module level." + ) + + +def test_all_test_modules_have_adr_docstring() -> None: + """Every test_*.py module (except this manifest) has a docstring citing ADR: and section:. + + ADR: ADR-023 + section: ADR docstring conventions + + Enforces the convention that every contract test file is traceable to an ADR. + """ + files = _test_files() + assert files, f"No test files found in {TESTS_DIR}" + missing: list[str] = [] + for path in files: + doc = _module_docstring(path) + if "ADR:" not in doc or "section:" not in doc: + missing.append(f"{path.name} (docstring: {doc[:80]!r})") + assert not missing, ( + f"These modules lack 'ADR:' or 'section:' in their module docstring:\n" + + "\n".join(f" {m}" for m in missing) + ) + + +def test_combined_verb_coverage_is_complete() -> None: + """The union of all VERBS_UNDER_TEST across modules covers all 18 product verbs. + + ADR: ADR-023 + section: Coverage gates; Verb naming + + Fails if any product verb is missing from every test module's coverage claim. + """ + files = _test_files() + assert files, f"No test files found in {TESTS_DIR}" + covered: set[str] = set() + for path in files: + verbs = _verbs_under_test(path) + if verbs: + covered.update(verbs) + + missing_verbs = ALL_PRODUCT_VERBS - covered + assert not missing_verbs, ( + f"These product verbs are not claimed in any VERBS_UNDER_TEST: {sorted(missing_verbs)}\n" + f"Covered: {sorted(covered)}" + ) + + assert len(covered & ALL_PRODUCT_VERBS) >= PLAY_SPEC_MINIMUM_VERB_COUNT, ( + f"Play spec requires >= {PLAY_SPEC_MINIMUM_VERB_COUNT} product verbs; " + f"only {len(covered & ALL_PRODUCT_VERBS)} covered." + ) + + +def test_no_hardcoded_local_namespace() -> None: + """No test module hardcodes namespace='local' in verb calls. + + ADR: ADR-003 + section: Namespace isolation + + Tests must use temp_namespace (the function-scoped fixture) to prevent + cross-test contamination. Hardcoding 'local' bypasses isolation. + """ + files = _test_files() + violations: list[str] = [] + for path in files: + bad_lines = _has_hardcoded_local_namespace(path) + if bad_lines: + violations.append(f"{path.name}: lines {bad_lines}") + assert not violations, ( + f"These files use namespace='local' (defeats isolation):\n" + + "\n".join(f" {v}" for v in violations) + + "\nUse 'namespace=temp_namespace' instead." + ) + + +def test_verb_coverage_count_reported() -> None: + """Report the actual covered verb count vs 18-verb baseline (informational). + + ADR: ADR-023 + section: Coverage gates + + Always passes — records coverage count for CI visibility. + """ + files = _test_files() + covered: set[str] = set() + for path in files: + verbs = _verbs_under_test(path) + if verbs: + covered.update(verbs) + product_covered = covered & ALL_PRODUCT_VERBS + # Report in assert message (visible in pytest verbose output) + assert len(product_covered) == len(ALL_PRODUCT_VERBS), ( + f"Partial coverage: {len(product_covered)}/{len(ALL_PRODUCT_VERBS)} product verbs covered.\n" + f"Covered: {sorted(product_covered)}\n" + f"Missing: {sorted(ALL_PRODUCT_VERBS - product_covered)}" + ) + + +@pytest.mark.xfail( + reason="golden/ snapshots not yet seeded — run with --update-golden to populate", + strict=False, +) +def test_golden_snapshot_directory_has_snapshots() -> None: + """The golden/ directory must contain at least one snapshot file once seeded. + + ADR: ADR-023 + section: Coverage gates + + xfail until golden snapshots are generated (ignores .gitkeep placeholder). + Run with --update-golden to seed. + """ + golden_dir = TESTS_DIR.parent / "golden" + assert golden_dir.exists(), ( + f"golden/ directory not found at {golden_dir}." + ) + real_files = [f for f in golden_dir.iterdir() if f.name != ".gitkeep"] + assert real_files, ( + f"golden/ directory has no snapshot files (only .gitkeep). " + f"Run: uv run pytest --update-golden to seed." + ) + + +@pytest.mark.xfail( + reason="baselines/latency.json not yet created", + strict=False, +) +def test_latency_baseline_file_exists() -> None: + """The baselines/latency.json file must exist for regression tracking. + + ADR: ADR-023 + section: Coverage gates + + xfail until baselines are recorded. + """ + baseline_path = TESTS_DIR.parent / "baselines" / "latency.json" + assert baseline_path.exists(), ( + f"Latency baseline not found at {baseline_path}." + ) diff --git a/tests/khive-contract/tests/test_namespace_isolation.py b/tests/khive-contract/tests/test_namespace_isolation.py new file mode 100644 index 00000000..ceb4d467 --- /dev/null +++ b/tests/khive-contract/tests/test_namespace_isolation.py @@ -0,0 +1,176 @@ +"""Namespace isolation contract tests. + +ADR: ADR-003 +section: Namespace isolation; Cross-namespace access; Write path isolation +""" + +from __future__ import annotations + +import pytest + +from khive_contract.client import KhiveMcpSession + +VERBS_UNDER_TEST = {"create", "get", "list", "search", "link"} + + +@pytest.mark.adr_003 +@pytest.mark.slow +def test_read_isolation_between_namespaces( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, +) -> None: + """Entity created in alpha namespace is invisible via get/list/search from beta namespace. + + ADR: ADR-003 + section: Namespace isolation + + Ports test_namespace_isolation from contract_test.py. + Entity in alpha: get(beta) → not found; list(beta) → absent; search(beta) → absent. + Entity in alpha: get(alpha) → succeeds. + """ + ns_alpha = f"{temp_namespace}_alpha" + ns_beta = f"{temp_namespace}_beta" + + # Create entity in alpha + entity = khive_session.verb("create", { + "kind": "entity", + "entity_kind": "concept", + "name": "AlphaEntity", + "description": "Only visible in alpha", + "namespace": ns_alpha, + }) + full_id = entity["id"] + + # get from beta must fail + envelope_get = khive_session.request_batch([{ + "tool": "get", + "args": {"id": full_id, "namespace": ns_beta}, + }]) + first_get = envelope_get["results"][0] + assert not first_get.get("ok", False), ( + "get from beta namespace must not find alpha entity" + ) + assert "not found" in first_get.get("error", "").lower(), ( + f"Expected not-found error from beta get, got: {first_get.get('error')!r}" + ) + + # list from beta must not include the alpha entity + entities_beta = khive_session.verb("list", { + "kind": "entity", + "entity_kind": "concept", + "namespace": ns_beta, + }) + ids_beta = [e["id"] for e in entities_beta] + assert full_id not in ids_beta, ( + f"AlphaEntity appeared in beta namespace list: {ids_beta}" + ) + + # search from beta must not find the alpha entity + hits_beta = khive_session.verb("search", { + "kind": "entity", + "query": "AlphaEntity", + "namespace": ns_beta, + }) + hit_ids_beta = [h.get("id", h.get("entity_id", "")) for h in hits_beta] + assert full_id not in hit_ids_beta, ( + f"AlphaEntity appeared in beta namespace search: {hit_ids_beta}" + ) + + # get from alpha must succeed + fetched = khive_session.verb("get", {"id": full_id, "namespace": ns_alpha}) + assert fetched.get("kind") == "entity", ( + f"get from alpha must return kind=entity, got: {fetched}" + ) + assert fetched["data"]["name"] == "AlphaEntity", ( + f"Entity name mismatch: {fetched['data']}" + ) + + # 8-char prefix from beta must not resolve to the alpha entity + prefix8 = full_id[:8] + envelope_prefix = khive_session.request_batch([{ + "tool": "get", + "args": {"id": prefix8, "namespace": ns_beta}, + }]) + first_prefix = envelope_prefix["results"][0] + assert not first_prefix.get("ok", False), ( + "8-char prefix should not resolve alpha entity from beta namespace" + ) + err_prefix = first_prefix.get("error", "").lower() + assert "not found" in err_prefix or "no record" in err_prefix, ( + f"Expected not-found prefix error from beta, got: {first_prefix.get('error')!r}" + ) + + +@pytest.mark.adr_003 +@pytest.mark.slow +def test_write_isolation_cross_namespace_link_fails( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, +) -> None: + """link from beta using alpha entity UUID must fail — write path enforces namespace isolation. + + ADR: ADR-003 + section: Write path isolation; Cross-namespace access + + Ports the link-write portion of test_namespace_isolation from contract_test.py. + """ + ns_alpha = f"{temp_namespace}_alpha" + ns_beta = f"{temp_namespace}_beta" + + # Create alpha entity + alpha = khive_session.verb("create", { + "kind": "entity", + "entity_kind": "concept", + "name": "AlphaNode", + "namespace": ns_alpha, + }) + alpha_id = alpha["id"] + + # Create beta entity + beta = khive_session.verb("create", { + "kind": "entity", + "entity_kind": "concept", + "name": "BetaNode", + "namespace": ns_beta, + }) + beta_id = beta["id"] + + # link from beta using alpha as target must fail + envelope_fwd = khive_session.request_batch([{ + "tool": "link", + "args": { + "source_id": beta_id, + "target_id": alpha_id, + "relation": "depends_on", + "namespace": ns_beta, + }, + }]) + first_fwd = envelope_fwd["results"][0] + assert not first_fwd.get("ok", False), ( + "Cross-namespace link (beta→alpha, beta caller) must fail" + ) + err_fwd = first_fwd.get("error", "").lower() + assert "not found" in err_fwd, ( + f"Cross-namespace link must fail with not-found, got: {first_fwd.get('error')!r}" + ) + + # link with alpha as source from beta namespace must also fail + envelope_rev = khive_session.request_batch([{ + "tool": "link", + "args": { + "source_id": alpha_id, + "target_id": beta_id, + "relation": "extends", + "namespace": ns_beta, + }, + }]) + first_rev = envelope_rev["results"][0] + assert not first_rev.get("ok", False), ( + "Cross-namespace link (alpha→beta, beta caller) must fail" + ) + err_rev = first_rev.get("error", "").lower() + assert "not found" in err_rev, ( + f"Cross-namespace reverse link must fail with not-found, got: {first_rev.get('error')!r}" + ) diff --git a/tests/khive-contract/tests/test_smoke.py b/tests/khive-contract/tests/test_smoke.py new file mode 100644 index 00000000..aad61bd3 --- /dev/null +++ b/tests/khive-contract/tests/test_smoke.py @@ -0,0 +1,402 @@ +"""Smoke tests — full verb surface coverage across KG, GTD, and memory packs. + +ADR: ADR-027 +section: Single-tool surface; KG verb coverage; GTD pack verbs; Memory pack verbs +""" + +from __future__ import annotations + +import json + +import pytest + +from khive_contract.client import KhiveMcpSession, KhiveRpcError + +VERBS_UNDER_TEST = { + "create", "get", "list", "update", "delete", "merge", + "search", "link", "neighbors", "traverse", "query", + "assign", "next", "complete", "tasks", "transition", + "remember", "recall", +} + + +@pytest.mark.adr_027 +@pytest.mark.slow +def test_kg_smoke( + khive_session: KhiveMcpSession, + temp_namespace: str, + sample_entity, + sample_note, +) -> None: + """Full KG verb surface smoke test: create→get→list→link→neighbors→update→search→query→merge→delete→traverse. + + ADR: ADR-027 + section: Single-tool surface; KG verb coverage + + Ports the complete flow from smoke_test.py main() into pytest. + Uses temp_namespace for per-test isolation. + """ + ns = temp_namespace + + # create entities + lora = khive_session.verb("create", { + "kind": "entity", + "entity_kind": "concept", + "name": "SmokeLoRA", + "description": "Low-Rank Adaptation", + "properties": {"domain": "fine-tuning", "year": 2021}, + "namespace": ns, + }) + assert lora.get("name") == "SmokeLoRA", f"create entity name mismatch: {lora}" + lora_id = lora["id"] + + qlora = khive_session.verb("create", { + "kind": "entity", + "entity_kind": "concept", + "name": "SmokeQLoRA", + "description": "Quantized LoRA", + "namespace": ns, + }) + qlora_id = qlora["id"] + + paper = khive_session.verb("create", { + "kind": "entity", + "entity_kind": "document", + "name": "SmokeLoRA Paper", + "properties": {"authors": "Hu et al.", "year": 2021}, + "namespace": ns, + }) + paper_id = paper["id"] + + # get entity + fetched = khive_session.verb("get", {"id": lora_id, "namespace": ns}) + assert fetched.get("kind") == "entity", f"get must return kind=entity: {fetched}" + assert fetched["data"]["name"] == "SmokeLoRA", f"get data name mismatch: {fetched}" + + # list entities + concepts = khive_session.verb("list", {"kind": "entity", "entity_kind": "concept", + "namespace": ns}) + assert isinstance(concepts, list), "list must return a list" + concept_ids = [e["id"] for e in concepts] + assert lora_id in concept_ids, "SmokeLoRA must appear in concept list" + assert qlora_id in concept_ids, "SmokeQLoRA must appear in concept list" + + # link: QLoRA variant_of LoRA + edge1 = khive_session.verb("link", { + "source_id": qlora_id, + "target_id": lora_id, + "relation": "variant_of", + "weight": 0.9, + "namespace": ns, + }) + assert edge1.get("relation") == "variant_of", f"link relation mismatch: {edge1}" + edge1_id = edge1["id"] + + # link: LoRA introduced_by paper (concept→document direction required by ADR-002) + khive_session.verb("link", { + "source_id": lora_id, + "target_id": paper_id, + "relation": "introduced_by", + "weight": 1.0, + "namespace": ns, + }) + + # get edge + fetched_edge = khive_session.verb("get", {"id": edge1_id, "namespace": ns}) + assert fetched_edge.get("kind") == "edge", f"get edge must return kind=edge: {fetched_edge}" + + # neighbors + nbrs_in = khive_session.verb("neighbors", {"node_id": lora_id, "direction": "in", + "namespace": ns}) + assert isinstance(nbrs_in, list), "neighbors must return a list" + assert len(nbrs_in) >= 1, f"LoRA must have >=1 inbound neighbors (QLoRA), got: {nbrs_in}" + + nbrs_out = khive_session.verb("neighbors", {"node_id": lora_id, "direction": "out", + "namespace": ns}) + assert isinstance(nbrs_out, list), "neighbors must return a list" + assert len(nbrs_out) >= 1, f"LoRA must have >=1 outbound neighbors (paper), got: {nbrs_out}" + + # edge list + edges_from_qlora = khive_session.verb("list", {"kind": "edge", "source_id": qlora_id, + "namespace": ns}) + assert isinstance(edges_from_qlora, list), "list edges must return a list" + assert len(edges_from_qlora) >= 1, "QLoRA must have >=1 outbound edge" + + # update edge weight + updated_edge = khive_session.verb("update", { + "id": edge1_id, + "kind": "edge", + "weight": 0.95, + "namespace": ns, + }) + assert updated_edge is not None, "update edge returned None" + + # update entity description + patched = khive_session.verb("update", { + "id": lora_id, + "kind": "entity", + "description": "Low-Rank Adaptation of LLMs", + "namespace": ns, + }) + assert patched is not None, "update entity returned None" + + # create note + note = khive_session.verb("create", { + "kind": "note", + "note_kind": "observation", + "content": "LoRA reduces trainable parameters by 10000x", + "salience": 0.8, + "namespace": ns, + }) + assert note.get("kind") == "observation", f"note kind mismatch: {note}" + note_id = note["id"] + + # list notes + notes = khive_session.verb("list", {"kind": "note", "note_kind": "observation", + "namespace": ns}) + assert isinstance(notes, list), "list notes must return a list" + note_ids = [n["id"] for n in notes] + assert note_id in note_ids, "created observation note must appear in list" + + # search entities + search_hits = khive_session.verb("search", { + "kind": "entity", + "query": "LoRA parameter efficient", + "limit": 5, + "namespace": ns, + }) + assert isinstance(search_hits, list), f"search entities must return a list: {search_hits}" + + # search notes + note_hits = khive_session.verb("search", { + "kind": "note", + "query": "LoRA parameters", + "limit": 5, + "namespace": ns, + }) + assert isinstance(note_hits, list), f"search notes must return a list: {note_hits}" + + # annotated note (ADR-024 convenience shortcut) + ann_note = khive_session.verb("create", { + "kind": "note", + "note_kind": "insight", + "content": "LoRA is parameter-efficient", + "annotates": [lora_id], + "namespace": ns, + }) + assert ann_note is not None, "annotated note create must return a result" + ann_nbrs = khive_session.verb("neighbors", { + "node_id": lora_id, + "direction": "in", + "relations": ["annotates"], + "namespace": ns, + }) + assert isinstance(ann_nbrs, list), "annotates neighbors must return a list" + assert len(ann_nbrs) >= 1, f"LoRA must have >=1 annotates inbound neighbors: {ann_nbrs}" + + # GQL query + query_result = khive_session.verb("query", { + "query": "MATCH (a:concept)-[e:variant_of]->(b:concept) RETURN a, b LIMIT 10", + "namespace": ns, + }) + rows = query_result.get("rows", query_result) if isinstance(query_result, dict) else query_result + assert isinstance(rows, list), f"query must return list of rows: {query_result}" + assert len(rows) >= 1, f"Expected >=1 GQL rows: {rows}" + + # merge + dupe = khive_session.verb("create", { + "kind": "entity", + "entity_kind": "concept", + "name": "SmokeLoRADupe", + "namespace": ns, + }) + merge_summary = khive_session.verb("merge", { + "into_id": lora_id, + "from_id": dupe["id"], + "strategy": "prefer_into", + "namespace": ns, + }) + assert merge_summary.get("kept_id") == lora_id, ( + f"merge must return kept_id={lora_id}: {merge_summary}" + ) + + # delete entity + del_entity = khive_session.verb("delete", {"id": qlora_id, "kind": "entity", + "namespace": ns}) + assert del_entity.get("deleted") is True, f"delete entity must return deleted=True: {del_entity}" + + # delete edge + del_edge = khive_session.verb("delete", {"id": edge1_id, "kind": "edge", + "namespace": ns}) + assert del_edge.get("deleted") is True, f"delete edge must return deleted=True: {del_edge}" + + # delete note + del_note = khive_session.verb("delete", {"id": note_id, "kind": "note", + "namespace": ns}) + assert del_note.get("deleted") is True, f"delete note must return deleted=True: {del_note}" + + # traverse multi-hop + a = khive_session.verb("create", {"kind": "entity", "entity_kind": "concept", + "name": "TraverseA", "namespace": ns}) + b = khive_session.verb("create", {"kind": "entity", "entity_kind": "concept", + "name": "TraverseB", "namespace": ns}) + c = khive_session.verb("create", {"kind": "entity", "entity_kind": "concept", + "name": "TraverseC", "namespace": ns}) + khive_session.verb("link", {"source_id": a["id"], "target_id": b["id"], + "relation": "extends", "namespace": ns}) + khive_session.verb("link", {"source_id": b["id"], "target_id": c["id"], + "relation": "extends", "namespace": ns}) + paths = khive_session.verb("traverse", { + "roots": [a["id"]], + "max_depth": 2, + "include_roots": False, + "namespace": ns, + }) + assert isinstance(paths, list), f"traverse must return a list: {paths}" + all_node_ids = [n["id"] for p in paths for n in p.get("nodes", [])] + assert b["id"] in all_node_ids, f"B must be reachable from A at depth 1: {all_node_ids}" + assert c["id"] in all_node_ids, f"C must be reachable from A at depth 2: {all_node_ids}" + + # parallel batch + envelope = khive_session.request_batch([ + {"tool": "create", "args": {"kind": "entity", "entity_kind": "concept", + "name": "BulkA", "namespace": ns}}, + {"tool": "create", "args": {"kind": "entity", "entity_kind": "concept", + "name": "BulkB", "namespace": ns}}, + {"tool": "create", "args": {"kind": "entity", "entity_kind": "concept", + "name": "BulkC", "namespace": ns}}, + ]) + summary = envelope.get("summary", {}) + assert summary.get("total") == 3 and summary.get("failed") == 0, ( + f"parallel batch must have total=3, failed=0: {summary}" + ) + + +@pytest.mark.adr_027 +@pytest.mark.slow +def test_gtd_smoke( + khive_gtd_session: KhiveMcpSession, + temp_namespace: str, +) -> None: + """GTD pack smoke test: assign→next→tasks→transition→complete round-trip. + + ADR: ADR-027 + section: GTD pack verbs + + Ports gtd_smoke() from smoke_test.py into pytest. + """ + ns = temp_namespace + + # assign + assigned = khive_gtd_session.verb("assign", { + "title": "smoke-gtd task", + "status": "next", + "priority": "p0", + "namespace": ns, + }) + assert assigned.get("kind") == "task", f"assign must return kind=task: {assigned}" + assert assigned.get("status") == "next", f"assign status mismatch: {assigned}" + task_full_id = assigned.get("full_id") or assigned.get("id") + assert task_full_id, f"assign must return a task id: {assigned}" + + # next + ready = khive_gtd_session.verb("next", {"namespace": ns}) + assert isinstance(ready, list), f"next must return a list: {ready}" + assert any(t.get("full_id") == task_full_id for t in ready), ( + f"assigned task must appear in next(): {ready}" + ) + + # tasks + waiting_task = khive_gtd_session.verb("assign", { + "title": "waiting-task", + "status": "waiting", + "priority": "p1", + "namespace": ns, + }) + inbox_task = khive_gtd_session.verb("assign", { + "title": "inbox-task", + "status": "inbox", + "priority": "p2", + "namespace": ns, + }) + waiting_tasks = khive_gtd_session.verb("tasks", {"status": "waiting", "namespace": ns}) + assert isinstance(waiting_tasks, list), f"tasks must return a list: {waiting_tasks}" + waiting_ids = [t.get("full_id") for t in waiting_tasks] + assert waiting_task.get("full_id") in waiting_ids, ( + f"waiting task must appear in tasks(status=waiting): {waiting_ids}" + ) + assert inbox_task.get("full_id") not in waiting_ids, ( + f"inbox task must NOT appear in tasks(status=waiting): {waiting_ids}" + ) + + # transition + trans = khive_gtd_session.verb("transition", { + "id": inbox_task.get("full_id"), + "status": "next", + "note": "promoted from inbox", + "namespace": ns, + }) + assert trans.get("transitioned") is True, f"transition must set transitioned=True: {trans}" + assert trans.get("to") == "next", f"transition must report to=next: {trans}" + + # idempotent transition + trans_idem = khive_gtd_session.verb("transition", { + "id": inbox_task.get("full_id"), + "status": "next", + "namespace": ns, + }) + assert trans_idem.get("transitioned") is False, ( + f"idempotent transition must set transitioned=False: {trans_idem}" + ) + + # complete + done = khive_gtd_session.verb("complete", { + "id": task_full_id, + "result": "smoke-test pass", + "namespace": ns, + }) + assert done.get("to") == "done", f"complete must return to=done: {done}" + + +@pytest.mark.adr_027 +@pytest.mark.slow +def test_memory_smoke( + khive_memory_session: KhiveMcpSession, + temp_namespace: str, +) -> None: + """Memory pack smoke test: remember + recall round-trip. + + ADR: ADR-027 + section: Memory pack verbs + + Ports memory_smoke() from smoke_test.py into pytest. + """ + ns = temp_namespace + + # remember first memory + mem = khive_memory_session.verb("remember", { + "content": "khive uses SQLite with FTS5 and sqlite-vec for hybrid search", + "importance": 0.9, + "memory_type": "semantic", + "namespace": ns, + }) + assert mem is not None, "remember must return a result" + mem_id = mem.get("id") or mem.get("note_id") + assert mem_id, f"remember must return an id: {mem}" + + # remember second memory + mem2 = khive_memory_session.verb("remember", { + "content": "The runtime enforces namespace isolation for every ID-based operation", + "importance": 0.7, + "memory_type": "semantic", + "namespace": ns, + }) + assert mem2 is not None, "second remember must return a result" + + # recall + hits = khive_memory_session.verb("recall", { + "query": "SQLite hybrid search", + "limit": 5, + "namespace": ns, + }) + assert isinstance(hits, list), f"recall must return a list, got: {hits}" diff --git a/tests/khive-contract/tune/REPORT.md b/tests/khive-contract/tune/REPORT.md new file mode 100644 index 00000000..4eeb122d --- /dev/null +++ b/tests/khive-contract/tune/REPORT.md @@ -0,0 +1,75 @@ +# Param-Tuning Grid Search Report + +- **Date**: 2026-05-25 +- **Grid size**: 116 configs +- **Eval queries**: 20 +- **Total runtime**: 0.7s +- **Mode**: FTS-only (no_embed=True) + +## Winning Config (highest recall@10) + +| Metric | Value | +|--------|-------| +| recall@10 | 0.9333 | +| MRR | 0.9500 | +| mean latency | 0.3ms | +| config_index | 3 | + +Parameters: `rel=0.7 imp=0.2 tmp=0.1 cand=10 fuse=weighted(1.0/0.0) decay=hyperbolic hl=14.0` + +## Default vs Tuned Comparison + +| Metric | Default config | Tuned config | Delta | +|--------|---------------|-------------|-------| +| recall@10 | 0.9333 | 0.9333 | +0.0000 | +| MRR | 0.9250 | 0.9500 | +0.0250 | +| mean latency | 0.3ms | 0.3ms | -0.0ms | + +Default config: relevance=0.70 importance=0.20 temporal=0.10 candidate_multiplier=20 fuse=rrf(k=60) decay=exponential half_life=30.0 + +## Flat Optimization Landscape + +All 116 configs achieve **identical** recall@10 = 0.9333. MRR has exactly two values: +0.925 (all RRF + vector-only weighted configs, 58 total) and 0.950 (all other weighted +configs, 58 total). The split is determined entirely by fusion strategy — `relevance_weight`, +`importance_weight`, `temporal_weight`, `candidate_multiplier`, `decay_model`, and +`temporal_half_life_days` have **zero measurable effect** on either metric. + +**Root cause**: The synthetic corpus uses short exact-keyword queries against FTS5 (AND-logic). +Every relevant memory contains the query terms, so FTS5 trivially returns them regardless of +scoring parameters. A harder eval set (synonyms, cross-domain reasoning, partial matches) is +needed to discriminate non-fusion parameters. + +The three committed default changes (`half_life 30→14`, `decay exp→hyp`, `multiplier 20→10`) +are benign — they pass validation and lie within sensible ranges — but they are not empirically +distinguished from the old defaults by this grid search. + +## Top 10 by recall@10 + +| idx | recall@10 | mrr | latency | config | +|-----|-----------|-----|---------|--------| +| 3 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=10 fuse=weighted(1.0/0.0) decay=hyperbolic hl=14.0 | +| 4 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=10 fuse=weighted(0.75/0.25) decay=hyperbolic hl=30.0 | +| 5 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=10 fuse=weighted(0.5/0.5) decay=hyperbolic hl=60.0 | +| 6 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=10 fuse=weighted(0.25/0.75) decay=none hl=14.0 | +| 10 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=20 fuse=weighted(1.0/0.0) decay=exponential hl=30.0 | +| 11 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=20 fuse=weighted(0.75/0.25) decay=exponential hl=60.0 | +| 12 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=20 fuse=weighted(0.5/0.5) decay=hyperbolic hl=14.0 | +| 13 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=20 fuse=weighted(0.25/0.75) decay=hyperbolic hl=30.0 | +| 18 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=40 fuse=weighted(0.75/0.25) decay=exponential hl=14.0 | +| 19 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=40 fuse=weighted(0.5/0.5) decay=exponential hl=30.0 | + +## Top 10 by MRR + +| idx | recall@10 | mrr | latency | config | +|-----|-----------|-----|---------|--------| +| 3 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=10 fuse=weighted(1.0/0.0) decay=hyperbolic hl=14.0 | +| 4 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=10 fuse=weighted(0.75/0.25) decay=hyperbolic hl=30.0 | +| 5 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=10 fuse=weighted(0.5/0.5) decay=hyperbolic hl=60.0 | +| 6 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=10 fuse=weighted(0.25/0.75) decay=none hl=14.0 | +| 10 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=20 fuse=weighted(1.0/0.0) decay=exponential hl=30.0 | +| 11 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=20 fuse=weighted(0.75/0.25) decay=exponential hl=60.0 | +| 12 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=20 fuse=weighted(0.5/0.5) decay=hyperbolic hl=14.0 | +| 13 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=20 fuse=weighted(0.25/0.75) decay=hyperbolic hl=30.0 | +| 18 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=40 fuse=weighted(0.75/0.25) decay=exponential hl=14.0 | +| 19 | 0.9333 | 0.9500 | 0.3ms | rel=0.7 imp=0.2 tmp=0.1 cand=40 fuse=weighted(0.5/0.5) decay=exponential hl=30.0 | diff --git a/tests/khive-contract/tune/__init__.py b/tests/khive-contract/tune/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/khive-contract/tune/__main__.py b/tests/khive-contract/tune/__main__.py new file mode 100644 index 00000000..2cc20c92 --- /dev/null +++ b/tests/khive-contract/tune/__main__.py @@ -0,0 +1,3 @@ +from tune.grid_search import main + +main() diff --git a/tests/khive-contract/tune/grid_search.py b/tests/khive-contract/tune/grid_search.py new file mode 100644 index 00000000..b7e64a33 --- /dev/null +++ b/tests/khive-contract/tune/grid_search.py @@ -0,0 +1,517 @@ +"""Param-tuning grid search for khive recall configuration. + +Runs a FTS-only grid over scoring weights, candidate pool sizes, fusion +strategies, decay models, and temporal half-life parameters. One MCP session +is created and the corpus is loaded once; config is varied per recall() call. + +TODO: Add --with-embed flag for embedding-enabled grid over both + all-minilm-l6-v2 and paraphrase-multilingual-minilm-l12-v2 models. + Requires no_embed=False and KHIVE_ADDITIONAL_EMBEDDING_MODELS=paraphrase. +""" + +from __future__ import annotations + +import argparse +import json +import time +from datetime import date +from pathlib import Path +from typing import Any + +from khive_contract.client import KhiveMcpSession + +RANDOM_SEED = 42 + +_HERE = Path(__file__).parent +DEFAULT_CORPUS = _HERE.parent / "fixtures" / "memories_corpus.json" +DEFAULT_OUTPUT = _HERE + + +# --------------------------------------------------------------------------- +# Data loading +# --------------------------------------------------------------------------- + + +def load_corpus(path: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Load memories and eval_queries from a corpus JSON file.""" + data = json.loads(path.read_text()) + memories: list[dict[str, Any]] = data["memories"] + eval_queries: list[dict[str, Any]] = data["eval_queries"] + return memories, eval_queries + + +# --------------------------------------------------------------------------- +# Session setup +# --------------------------------------------------------------------------- + + +def setup_session( + memories: list[dict[str, Any]], db: str = ":memory:" +) -> tuple[KhiveMcpSession, dict[int, str]]: + """Open a KhiveMcpSession and load all corpus memories via remember(). + + The returned session is already entered (via __enter__). The caller MUST + call session.close() when done, or use a try/finally block. + + Returns: + (session, note_id_map) where note_id_map[corpus_index] = note_id string. + """ + session = KhiveMcpSession( + packs=("kg", "memory"), + db=db, + no_embed=True, + log="error", + ) + session.__enter__() + + note_id_map: dict[int, str] = {} + total = len(memories) + print(f"Loading {total} memories into session...") + t_load_start = time.perf_counter() + + for i, mem in enumerate(memories): + args: dict[str, Any] = { + "content": mem["content"], + "importance": mem["importance"], + "decay_factor": mem["decay_factor"], + "memory_type": mem["memory_type"], + } + if mem.get("tags"): + args["tags"] = mem["tags"] + + result = session.verb("remember", args) + note_id = result.get("note_id") or result.get("id") if result else None + if not note_id: + raise RuntimeError(f"remember() returned no note_id for memory {i}: {result!r}") + note_id_map[i] = str(note_id) + + if (i + 1) % 25 == 0: + elapsed = time.perf_counter() - t_load_start + print(f" Loaded {i + 1}/{total} memories ({elapsed:.1f}s)") + + elapsed = time.perf_counter() - t_load_start + print(f"Corpus loaded in {elapsed:.1f}s. Beginning grid search...") + return session, note_id_map + + +# --------------------------------------------------------------------------- +# Metric evaluation +# --------------------------------------------------------------------------- + + +def evaluate_config( + session: KhiveMcpSession, + config_dict: dict[str, Any], + eval_queries: list[dict[str, Any]], + note_id_map: dict[int, str], +) -> dict[str, float]: + """Evaluate one RecallConfig against all eval queries. + + Returns: + {"recall_at_10": float, "mrr": float, "mean_latency_ms": float} + """ + recalls: list[float] = [] + mrrs: list[float] = [] + latencies: list[float] = [] + + for eq in eval_queries: + query: str = eq["query"] + relevant_indices: list[int] = eq["relevant_indices"] + relevant_note_ids = {note_id_map[i] for i in relevant_indices if i in note_id_map} + + t0 = time.perf_counter() + try: + hits = session.verb( + "recall", + {"query": query, "limit": 10, "config": config_dict}, + ) + except Exception: + hits = [] + latency_ms = (time.perf_counter() - t0) * 1000.0 + latencies.append(latency_ms) + + retrieved_ids: list[str] = [] + if isinstance(hits, list): + for h in hits: + nid = h.get("note_id") or h.get("id") if isinstance(h, dict) else None + if nid: + retrieved_ids.append(str(nid)) + + # recall@10 + retrieved_set = set(retrieved_ids) + if relevant_note_ids: + r_at_10 = len(relevant_note_ids & retrieved_set) / len(relevant_note_ids) + else: + r_at_10 = 0.0 + recalls.append(r_at_10) + + # MRR — reciprocal rank of first relevant hit + mrr = 0.0 + for rank, nid in enumerate(retrieved_ids, 1): + if nid in relevant_note_ids: + mrr = 1.0 / rank + break + mrrs.append(mrr) + + n = len(eval_queries) + return { + "recall_at_10": sum(recalls) / n if n else 0.0, + "mrr": sum(mrrs) / n if n else 0.0, + "mean_latency_ms": sum(latencies) / n if n else 0.0, + } + + +# --------------------------------------------------------------------------- +# Grid generation +# --------------------------------------------------------------------------- + + +def generate_grid(quick: bool = False) -> list[dict[str, Any]]: + """Generate the FTS-only RecallConfig parameter grid. + + Full grid: 4 × 4 × 8 × 3 × 3 = 1152 configs + Quick grid: every 10th config (deterministic sort) ≈ 116 configs + + Weight triples are normalized so relevance+importance+temporal = 1.0. + Weighted fusion uses [text_weight, vector_weight] where alpha=vector_weight. + In FTS-only mode (no_embed=True) all vector results are empty, so + weighted configs with high vector alpha will score poorly — this is + expected and meaningful for the grid. + """ + weight_triples = [ + # (relevance_weight, importance_weight, temporal_weight) + (0.70, 0.20, 0.10), # default + (0.60, 0.30, 0.10), + (0.60, 0.20, 0.20), + (0.80, 0.10, 0.10), + ] + + candidate_pools = [ + # (candidate_multiplier, candidate_limit) + (10, None), + (20, None), # default + (40, None), + (20, 100), + ] + + # 3 RRF + 5 weighted = 8 fusion configs + fusion_configs: list[dict[str, Any]] = [ + {"rrf": {"k": 20}}, + {"rrf": {"k": 60}}, # default + {"rrf": {"k": 100}}, + {"weighted": {"weights": [1.0, 0.0]}}, # text-only + {"weighted": {"weights": [0.75, 0.25]}}, + {"weighted": {"weights": [0.5, 0.5]}}, + {"weighted": {"weights": [0.25, 0.75]}}, + {"weighted": {"weights": [0.0, 1.0]}}, # vector-only + ] + + decay_models = ["exponential", "hyperbolic", "none"] + half_lives = [14.0, 30.0, 60.0] + + configs: list[dict[str, Any]] = [] + for rw, iw, tw in weight_triples: + for cm, cl in candidate_pools: + for fuse in fusion_configs: + for decay in decay_models: + for hl in half_lives: + cfg: dict[str, Any] = { + "relevance_weight": rw, + "importance_weight": iw, + "temporal_weight": tw, + "candidate_multiplier": cm, + "fuse_strategy": fuse, + "decay_model": decay, + "temporal_half_life_days": hl, + "min_score": 0.0, + "min_salience": 0.0, + } + if cl is not None: + cfg["candidate_limit"] = cl + configs.append(cfg) + + if quick: + configs = configs[::10] + + return configs + + +# --------------------------------------------------------------------------- +# Grid execution +# --------------------------------------------------------------------------- + + +def run_grid( + session: KhiveMcpSession, + grid: list[dict[str, Any]], + eval_queries: list[dict[str, Any]], + note_id_map: dict[int, str], +) -> list[dict[str, Any]]: + """Run evaluate_config for every config in the grid. + + MCP is single-threaded stdio, so iteration is sequential. + Prints progress every 100 configs. + + Returns: + List of result dicts: {"config_index", "config", "recall_at_10", "mrr", "mean_latency_ms"} + """ + results: list[dict[str, Any]] = [] + total = len(grid) + + for i, config in enumerate(grid): + if i % 100 == 0: + print(f" [{i}/{total}] config {i}...") + metrics = evaluate_config(session, config, eval_queries, note_id_map) + results.append( + { + "config_index": i, + "config": config, + **metrics, + } + ) + + return results + + +# --------------------------------------------------------------------------- +# Result writing +# --------------------------------------------------------------------------- + + +def _fuse_to_toml(fuse: dict[str, Any] | str) -> str: + """Render a fuse_strategy value as a TOML inline table or string.""" + if isinstance(fuse, str): + return f'"{fuse}"' + if "rrf" in fuse: + k = fuse["rrf"]["k"] + return f"{{rrf = {{k = {k}}}}}" + if "weighted" in fuse: + weights = fuse["weighted"]["weights"] + return f"{{weighted = {{weights = [{weights[0]}, {weights[1]}]}}}}" + # fallback: JSON-encode as a TOML comment note + return f'"{json.dumps(fuse)}"' + + +def write_results( + results: list[dict[str, Any]], + output_dir: Path, + *, + t_total_seconds: float, + default_config_metrics: dict[str, float] | None = None, +) -> None: + """Write results.json, tuned-config.toml, and REPORT.md to output_dir.""" + output_dir.mkdir(parents=True, exist_ok=True) + t_total = t_total_seconds + today = date.today().isoformat() + + # --- results.json --- + (output_dir / "results.json").write_text(json.dumps(results, indent=2)) + print(f"Wrote {output_dir / 'results.json'} ({len(results)} configs)") + + # --- rank by recall@10 then MRR --- + sorted_by_recall = sorted( + results, key=lambda r: (r["recall_at_10"], r["mrr"]), reverse=True + ) + sorted_by_mrr = sorted( + results, key=lambda r: (r["mrr"], r["recall_at_10"]), reverse=True + ) + winner = sorted_by_recall[0] + cfg = winner["config"] + + # --- tuned-config.toml --- + fuse_toml = _fuse_to_toml(cfg["fuse_strategy"]) + decay_model_str = cfg["decay_model"] if isinstance(cfg["decay_model"], str) else json.dumps(cfg["decay_model"]) + cl_line = ( + f"candidate_limit = {cfg['candidate_limit']}" + if cfg.get("candidate_limit") is not None + else "# candidate_limit = null (use multiplier only)" + ) + toml_content = f"""\ +# Winning config from khive recall param-tuning grid search +# run_date = "{today}" +# recall_at_10 = {winner['recall_at_10']:.4f} +# mrr = {winner['mrr']:.4f} +# mean_latency_ms = {winner['mean_latency_ms']:.2f} + +[recall] +relevance_weight = {cfg['relevance_weight']} +importance_weight = {cfg['importance_weight']} +temporal_weight = {cfg['temporal_weight']} +temporal_half_life_days = {cfg['temporal_half_life_days']} +decay_model = "{decay_model_str}" +candidate_multiplier = {cfg['candidate_multiplier']} +{cl_line} +fuse_strategy = {fuse_toml} +min_score = {cfg['min_score']} +min_salience = {cfg['min_salience']} +""" + (output_dir / "tuned-config.toml").write_text(toml_content) + print(f"Wrote {output_dir / 'tuned-config.toml'}") + + # --- REPORT.md --- + top10_recall = sorted_by_recall[:10] + top10_mrr = sorted_by_mrr[:10] + + def _cfg_summary(r: dict[str, Any]) -> str: + c = r["config"] + fuse = c["fuse_strategy"] + if isinstance(fuse, dict) and "rrf" in fuse: + fuse_str = f"rrf(k={fuse['rrf']['k']})" + elif isinstance(fuse, dict) and "weighted" in fuse: + w = fuse["weighted"]["weights"] + fuse_str = f"weighted({w[0]}/{w[1]})" + else: + fuse_str = str(fuse) + decay_str = c["decay_model"] if isinstance(c["decay_model"], str) else json.dumps(c["decay_model"]) + return ( + f"rel={c['relevance_weight']} imp={c['importance_weight']} " + f"tmp={c['temporal_weight']} cand={c['candidate_multiplier']} " + f"fuse={fuse_str} decay={decay_str} hl={c['temporal_half_life_days']}" + ) + + def _row(r: dict[str, Any]) -> str: + return ( + f"| {r['config_index']:4d} | {r['recall_at_10']:.4f} | {r['mrr']:.4f} " + f"| {r['mean_latency_ms']:.1f}ms | {_cfg_summary(r)} |" + ) + + top10_recall_rows = "\n".join(_row(r) for r in top10_recall) + top10_mrr_rows = "\n".join(_row(r) for r in top10_mrr) + + default_section = "" + if default_config_metrics: + default_section = f""" +## Default vs Tuned Comparison + +| Metric | Default config | Tuned config | Delta | +|--------|---------------|-------------|-------| +| recall@10 | {default_config_metrics['recall_at_10']:.4f} | {winner['recall_at_10']:.4f} | {winner['recall_at_10'] - default_config_metrics['recall_at_10']:+.4f} | +| MRR | {default_config_metrics['mrr']:.4f} | {winner['mrr']:.4f} | {winner['mrr'] - default_config_metrics['mrr']:+.4f} | +| mean latency | {default_config_metrics['mean_latency_ms']:.1f}ms | {winner['mean_latency_ms']:.1f}ms | {winner['mean_latency_ms'] - default_config_metrics['mean_latency_ms']:+.1f}ms | + +Default config: relevance=0.70 importance=0.20 temporal=0.10 candidate_multiplier=20 fuse=rrf(k=60) decay=exponential half_life=30.0 +""" + + report = f"""\ +# Param-Tuning Grid Search Report + +- **Date**: {today} +- **Grid size**: {len(results)} configs +- **Eval queries**: 20 +- **Total runtime**: {t_total:.1f}s +- **Mode**: FTS-only (no_embed=True) + +## Winning Config (highest recall@10) + +| Metric | Value | +|--------|-------| +| recall@10 | {winner['recall_at_10']:.4f} | +| MRR | {winner['mrr']:.4f} | +| mean latency | {winner['mean_latency_ms']:.1f}ms | +| config_index | {winner['config_index']} | + +Parameters: `{_cfg_summary(winner)}` +{default_section} +## Top 10 by recall@10 + +| idx | recall@10 | mrr | latency | config | +|-----|-----------|-----|---------|--------| +{top10_recall_rows} + +## Top 10 by MRR + +| idx | recall@10 | mrr | latency | config | +|-----|-----------|-----|---------|--------| +{top10_mrr_rows} +""" + (output_dir / "REPORT.md").write_text(report) + print(f"Wrote {output_dir / 'REPORT.md'}") + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +_DEFAULT_CONFIG = { + "relevance_weight": 0.70, + "importance_weight": 0.20, + "temporal_weight": 0.10, + "candidate_multiplier": 20, + "fuse_strategy": {"rrf": {"k": 60}}, + "decay_model": "exponential", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0, +} + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Grid search for khive recall config parameters (FTS-only mode)." + ) + parser.add_argument( + "--quick", + action="store_true", + help="Sample every 10th config for a fast smoke test (~10x faster).", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=DEFAULT_OUTPUT, + help="Directory to write results.json, tuned-config.toml, REPORT.md.", + ) + parser.add_argument( + "--corpus", + type=Path, + default=DEFAULT_CORPUS, + help="Path to memories_corpus.json fixture.", + ) + args = parser.parse_args() + + corpus_path: Path = args.corpus + output_dir: Path = args.output_dir + + if not corpus_path.exists(): + raise FileNotFoundError(f"Corpus not found: {corpus_path}") + + print(f"Loading corpus from {corpus_path}") + memories, eval_queries = load_corpus(corpus_path) + print(f"Corpus: {len(memories)} memories, {len(eval_queries)} eval queries") + + grid = generate_grid(quick=args.quick) + print(f"Grid: {len(grid)} configs (quick={args.quick})") + + t_start = time.perf_counter() + session, note_id_map = setup_session(memories) + try: + # Evaluate default config for the comparison table + default_metrics = evaluate_config(session, _DEFAULT_CONFIG, eval_queries, note_id_map) + print( + f"Default config: recall@10={default_metrics['recall_at_10']:.4f} " + f"mrr={default_metrics['mrr']:.4f}" + ) + + results = run_grid(session, grid, eval_queries, note_id_map) + finally: + session.close() + + t_elapsed = time.perf_counter() - t_start + print(f"Grid search complete in {t_elapsed:.1f}s") + + write_results( + results, + output_dir, + t_total_seconds=t_elapsed, + default_config_metrics=default_metrics, + ) + + best = max(results, key=lambda r: (r["recall_at_10"], r["mrr"])) + print( + f"\nBest config: recall@10={best['recall_at_10']:.4f} mrr={best['mrr']:.4f} " + f"(index {best['config_index']})" + ) + print(f"Results written to {output_dir}") + + +if __name__ == "__main__": + main() diff --git a/tests/khive-contract/tune/results.json b/tests/khive-contract/tune/results.json new file mode 100644 index 00000000..d6df8206 --- /dev/null +++ b/tests/khive-contract/tune/results.json @@ -0,0 +1,2680 @@ +[ + { + "config_index": 0, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.28148540113761555 + }, + { + "config_index": 1, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "rrf": { + "k": 60 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2686937492399011 + }, + { + "config_index": 2, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.29486264975275844 + }, + { + "config_index": 3, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 1.0, + 0.0 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2862915989680914 + }, + { + "config_index": 4, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2918167483585421 + }, + { + "config_index": 5, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.5, + 0.5 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.28529789960884955 + }, + { + "config_index": 6, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2831625501130475 + }, + { + "config_index": 7, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.0, + 1.0 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2803040999424411 + }, + { + "config_index": 8, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "none", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2848207499482669 + }, + { + "config_index": 9, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.27973329997621477 + }, + { + "config_index": 10, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 1.0, + 0.0 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2801396494760411 + }, + { + "config_index": 11, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.28181665038573556 + }, + { + "config_index": 12, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.5, + 0.5 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.29417920086416416 + }, + { + "config_index": 13, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2827333999448456 + }, + { + "config_index": 14, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.0, + 1.0 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.28087909959140234 + }, + { + "config_index": 15, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "none", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.3020208008820191 + }, + { + "config_index": 16, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "rrf": { + "k": 60 + } + }, + "decay_model": "none", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.28055835064151324 + }, + { + "config_index": 17, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "none", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.28160629990452435 + }, + { + "config_index": 18, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.29320840003492776 + }, + { + "config_index": 19, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.5, + 0.5 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2866227991034975 + }, + { + "config_index": 20, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2897542504797457 + }, + { + "config_index": 21, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.0, + 1.0 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.29314370003703516 + }, + { + "config_index": 22, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2905041001213249 + }, + { + "config_index": 23, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 60 + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2886353995563695 + }, + { + "config_index": 24, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "none", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.28937284951098263 + }, + { + "config_index": 25, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 1.0, + 0.0 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2861790999304503 + }, + { + "config_index": 26, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.31045204887050204 + }, + { + "config_index": 27, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2888021495891735 + }, + { + "config_index": 28, + "config": { + "relevance_weight": 0.7, + "importance_weight": 0.2, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.0, + 1.0 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2846291503374232 + }, + { + "config_index": 29, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.27716039949154947 + }, + { + "config_index": 30, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "rrf": { + "k": 60 + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2801395508868154 + }, + { + "config_index": 31, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.27901260036742315 + }, + { + "config_index": 32, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 1.0, + 0.0 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.28072700115444604 + }, + { + "config_index": 33, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.28003539991914295 + }, + { + "config_index": 34, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.5, + 0.5 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2829062992532272 + }, + { + "config_index": 35, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2766604502539849 + }, + { + "config_index": 36, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2769689010165166 + }, + { + "config_index": 37, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 60 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.27942089982389007 + }, + { + "config_index": 38, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.27995829987048637 + }, + { + "config_index": 39, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 1.0, + 0.0 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.280987650694442 + }, + { + "config_index": 40, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2805270501994528 + }, + { + "config_index": 41, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.5, + 0.5 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2790415495837806 + }, + { + "config_index": 42, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2858790994650917 + }, + { + "config_index": 43, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.0, + 1.0 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.27572910039452836 + }, + { + "config_index": 44, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "none", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.28704375072265975 + }, + { + "config_index": 45, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.281006250588689 + }, + { + "config_index": 46, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 1.0, + 0.0 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2943519994005328 + }, + { + "config_index": 47, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.28374790090310853 + }, + { + "config_index": 48, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.5, + 0.5 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2794124502543127 + }, + { + "config_index": 49, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.28600204932445195 + }, + { + "config_index": 50, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.0, + 1.0 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2889624494855525 + }, + { + "config_index": 51, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "none", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2864793004846433 + }, + { + "config_index": 52, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 60 + } + }, + "decay_model": "none", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.27940414984186646 + }, + { + "config_index": 53, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "none", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2789604495774256 + }, + { + "config_index": 54, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2787499499390833 + }, + { + "config_index": 55, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.5, + 0.5 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2868397506972542 + }, + { + "config_index": 56, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.284945898965816 + }, + { + "config_index": 57, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.3, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.0, + 1.0 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2812874005030608 + }, + { + "config_index": 58, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 10, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.28095219931856263 + }, + { + "config_index": 59, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 10, + "fuse_strategy": { + "rrf": { + "k": 60 + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2758167509455234 + }, + { + "config_index": 60, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 10, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "none", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2824979506840464 + }, + { + "config_index": 61, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 1.0, + 0.0 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.282977097958792 + }, + { + "config_index": 62, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2763854499789886 + }, + { + "config_index": 63, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.27764179903897457 + }, + { + "config_index": 64, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.0, + 1.0 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2787354511383455 + }, + { + "config_index": 65, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2802791514113778 + }, + { + "config_index": 66, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 60 + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2792020015476737 + }, + { + "config_index": 67, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.27800425050372723 + }, + { + "config_index": 68, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 1.0, + 0.0 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2789042016956955 + }, + { + "config_index": 69, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2812332495523151 + }, + { + "config_index": 70, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.5, + 0.5 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2767645495623583 + }, + { + "config_index": 71, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.27748339998652227 + }, + { + "config_index": 72, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 40, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2761915504379431 + }, + { + "config_index": 73, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 40, + "fuse_strategy": { + "rrf": { + "k": 60 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2803582996421028 + }, + { + "config_index": 74, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 40, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.27802289987448603 + }, + { + "config_index": 75, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 1.0, + 0.0 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2767853995464975 + }, + { + "config_index": 76, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.28124175005359575 + }, + { + "config_index": 77, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.5, + 0.5 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2837229010765441 + }, + { + "config_index": 78, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2874540507036727 + }, + { + "config_index": 79, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.0, + 1.0 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2787061999697471 + }, + { + "config_index": 80, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "none", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2805125004670117 + }, + { + "config_index": 81, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2797540499159368 + }, + { + "config_index": 82, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 1.0, + 0.0 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2792020997731015 + }, + { + "config_index": 83, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2798353001708165 + }, + { + "config_index": 84, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.5, + 0.5 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2828772005159408 + }, + { + "config_index": 85, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.279381150539848 + }, + { + "config_index": 86, + "config": { + "relevance_weight": 0.6, + "importance_weight": 0.2, + "temporal_weight": 0.2, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.0, + 1.0 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.28124165000917856 + }, + { + "config_index": 87, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "none", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.27987510002276395 + }, + { + "config_index": 88, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "rrf": { + "k": 60 + } + }, + "decay_model": "none", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2800395010126522 + }, + { + "config_index": 89, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "none", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2810250996844843 + }, + { + "config_index": 90, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.28223335029906593 + }, + { + "config_index": 91, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.5, + 0.5 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.28449174933484755 + }, + { + "config_index": 92, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.28110419916629326 + }, + { + "config_index": 93, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 10, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.0, + 1.0 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.27862714960065205 + }, + { + "config_index": 94, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2819332996295998 + }, + { + "config_index": 95, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 60 + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.28068960054952186 + }, + { + "config_index": 96, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "none", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2806042510201223 + }, + { + "config_index": 97, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 1.0, + 0.0 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.27994805022899527 + }, + { + "config_index": 98, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2773583990347106 + }, + { + "config_index": 99, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.28961035059182905 + }, + { + "config_index": 100, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.0, + 1.0 + ] + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.28000009879178833 + }, + { + "config_index": 101, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.28202285029692575 + }, + { + "config_index": 102, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "rrf": { + "k": 60 + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.28163114984636195 + }, + { + "config_index": 103, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.28272290037421044 + }, + { + "config_index": 104, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 1.0, + 0.0 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.29119380087649915 + }, + { + "config_index": 105, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.28479799948399886 + }, + { + "config_index": 106, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.5, + 0.5 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.28276649973122403 + }, + { + "config_index": 107, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 40, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.28361035001580603 + }, + { + "config_index": 108, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 20 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.28614999901037663 + }, + { + "config_index": 109, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 60 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2842709007381927 + }, + { + "config_index": 110, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "rrf": { + "k": 100 + } + }, + "decay_model": "exponential", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.28580209873325657 + }, + { + "config_index": 111, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 1.0, + 0.0 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2826500996889081 + }, + { + "config_index": 112, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.75, + 0.25 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2826354011631338 + }, + { + "config_index": 113, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.5, + 0.5 + ] + } + }, + "decay_model": "hyperbolic", + "temporal_half_life_days": 60.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.2808332999848062 + }, + { + "config_index": 114, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.25, + 0.75 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 14.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.95, + "mean_latency_ms": 0.28887504995509516 + }, + { + "config_index": 115, + "config": { + "relevance_weight": 0.8, + "importance_weight": 0.1, + "temporal_weight": 0.1, + "candidate_multiplier": 20, + "fuse_strategy": { + "weighted": { + "weights": [ + 0.0, + 1.0 + ] + } + }, + "decay_model": "none", + "temporal_half_life_days": 30.0, + "min_score": 0.0, + "min_salience": 0.0, + "candidate_limit": 100 + }, + "recall_at_10": 0.9333333333333333, + "mrr": 0.925, + "mean_latency_ms": 0.2837999494659016 + } +] \ No newline at end of file diff --git a/tests/khive-contract/tune/tuned-config.toml b/tests/khive-contract/tune/tuned-config.toml new file mode 100644 index 00000000..23e84898 --- /dev/null +++ b/tests/khive-contract/tune/tuned-config.toml @@ -0,0 +1,17 @@ +# Winning config from khive recall param-tuning grid search +# run_date = "2026-05-25" +# recall_at_10 = 0.9333 +# mrr = 0.9500 +# mean_latency_ms = 0.29 + +[recall] +relevance_weight = 0.7 +importance_weight = 0.2 +temporal_weight = 0.1 +temporal_half_life_days = 14.0 +decay_model = "hyperbolic" +candidate_multiplier = 10 +# candidate_limit = null (use multiplier only) +fuse_strategy = {weighted = {weights = [1.0, 0.0]}} +min_score = 0.0 +min_salience = 0.0 diff --git a/tests/khive-contract/uv.lock b/tests/khive-contract/uv.lock new file mode 100644 index 00000000..d994f168 --- /dev/null +++ b/tests/khive-contract/uv.lock @@ -0,0 +1,294 @@ +version = 1 +revision = 2 +requires-python = ">=3.11" + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "idna" +version = "3.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "khive-contract" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "anyio" }, + { name = "jsonschema" }, + { name = "pytest" }, + { name = "pytest-benchmark" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4" }, + { name = "jsonschema", specifier = ">=4" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-benchmark", specifier = ">=4" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-benchmark" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]