From 021a67c59f125a5aaecbc1b37a7ac91387bd6d7f Mon Sep 17 00:00:00 2001 From: ajianaz Date: Sat, 29 Aug 2026 15:49:50 +0700 Subject: [PATCH] =?UTF-8?q?chore(release):=20v0.16.0=20=E2=80=94=20develop?= =?UTF-8?q?=20content=20into=20main=20(squash,=20conflicts=20resolved:=20t?= =?UTF-8?q?ake=20develop)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cora.yaml | 1 + .github/workflows/ci.yml | 51 ++ .github/workflows/cla-check.yml | 1 + .github/workflows/release.yml | 10 +- .gitignore | 4 + CHANGELOG.md | 19 + Cargo.lock | 7 + Cargo.toml | 2 +- benchmarks/longmemeval/.gitignore | 6 + benchmarks/longmemeval/RESULTS.md | 59 +++ benchmarks/longmemeval/check_volume.py | 27 + benchmarks/longmemeval/compare_fast50.py | 73 +++ benchmarks/longmemeval/fast_eval_ids.json | 100 ++++ .../longmemeval/lmeval_final_metrics.txt | 65 +++ benchmarks/longmemeval/lmeval_relaunch.log | 359 +++++++++++++ benchmarks/longmemeval/lmeval_watch.log | 104 ++++ .../longmemeval/lmeval_watch_state.json | 5 + benchmarks/longmemeval/make_fast_eval.py | 87 ++++ benchmarks/longmemeval/mmr.py | 105 ++++ benchmarks/longmemeval/modal_fanout.py | 279 ++++++++++ benchmarks/longmemeval/modal_fast50.py | 305 +++++++++++ benchmarks/longmemeval/preview_scores.py | 85 +++ benchmarks/longmemeval/pull_fast.py | 47 ++ benchmarks/longmemeval/pull_shards.py | 29 ++ benchmarks/longmemeval/run_eval.py | 189 +++++-- benchmarks/longmemeval/sim3way.py | 156 ++++++ benchmarks/longmemeval/sim3way_final.py | 97 ++++ benchmarks/longmemeval/temporal.py | 224 ++++++++ benchmarks/longmemeval/test_mmr.py | 73 +++ benchmarks/longmemeval/test_temporal.py | 164 ++++++ benchmarks/longmemeval/watch_and_pull.py | 173 +++++++ crates/uteke-cli/Cargo.toml | 9 +- crates/uteke-cli/src/cli.rs | 27 +- crates/uteke-cli/src/commands/bench.rs | 5 +- crates/uteke-cli/src/commands/maintenance.rs | 15 +- crates/uteke-cli/src/commands/mod.rs | 2 + crates/uteke-cli/src/commands/remember.rs | 27 +- crates/uteke-cli/src/commands/room.rs | 65 +++ crates/uteke-cli/src/commands/server.rs | 4 + crates/uteke-cli/src/config.rs | 21 +- crates/uteke-cli/src/main.rs | 57 +- crates/uteke-cli/src/output.rs | 7 +- crates/uteke-core/Cargo.toml | 18 +- crates/uteke-core/src/consolidate.rs | 185 +++++++ crates/uteke-core/src/consolidation_api.rs | 206 ++++++++ crates/uteke-core/src/consolidation_exec.rs | 362 +++++++++++++ crates/uteke-core/src/consolidation_plan.rs | 242 +++++++++ crates/uteke-core/src/edges.rs | 10 +- crates/uteke-core/src/embed/ort_init.rs | 19 +- crates/uteke-core/src/graph_rerank.rs | 2 + crates/uteke-core/src/import_export.rs | 2 + crates/uteke-core/src/lib.rs | 112 +++- crates/uteke-core/src/maintenance.rs | 128 ++++- crates/uteke-core/src/memory/bulk.rs | 8 +- crates/uteke-core/src/memory/crud.rs | 24 +- crates/uteke-core/src/memory/documents.rs | 21 + crates/uteke-core/src/memory/fts5.rs | 2 + crates/uteke-core/src/memory/rooms.rs | 8 + crates/uteke-core/src/memory/schema.rs | 72 +++ crates/uteke-core/src/memory/store.rs | 158 +++++- crates/uteke-core/src/memory/types.rs | 88 +++- crates/uteke-core/src/memory/vector.rs | 338 +++++++++--- crates/uteke-core/src/operations.rs | 485 ++++++++++++++++-- crates/uteke-core/src/orphans.rs | 2 + crates/uteke-core/src/provenance.rs | 226 ++++++++ crates/uteke-core/src/recall_cache.rs | 2 + crates/uteke-core/src/rooms.rs | 17 +- crates/uteke-core/src/rooms_segments.rs | 480 +++++++++++++++++ crates/uteke-core/src/salience_recency.rs | 2 + crates/uteke-core/src/structural_export.rs | 147 +++--- crates/uteke-core/src/timeline.rs | 8 +- crates/uteke-core/src/types.rs | 12 +- crates/uteke-mcp/Cargo.toml | 9 +- crates/uteke-mcp/src/lib.rs | 18 +- crates/uteke-server/Cargo.toml | 9 +- crates/uteke-server/src/api_registry.rs | 18 + crates/uteke-server/src/handlers.rs | 351 ++++++++++++- crates/uteke-server/src/main.rs | 6 + crates/uteke-server/src/types.rs | 27 +- crates/uteke-server/tests/cli_flags.rs | 49 ++ docs/api-reference.md | 24 +- docs/assets/longmemeval-comparison.jpg | Bin 0 -> 106363 bytes docs/benchmarks.md | 54 +- docs/cli-reference.md | 14 + docs/configuration.md | 6 +- scripts/release-notes-template.md | 10 +- 86 files changed, 6778 insertions(+), 348 deletions(-) create mode 100644 benchmarks/longmemeval/check_volume.py create mode 100644 benchmarks/longmemeval/compare_fast50.py create mode 100644 benchmarks/longmemeval/fast_eval_ids.json create mode 100644 benchmarks/longmemeval/lmeval_final_metrics.txt create mode 100644 benchmarks/longmemeval/lmeval_relaunch.log create mode 100644 benchmarks/longmemeval/lmeval_watch.log create mode 100644 benchmarks/longmemeval/lmeval_watch_state.json create mode 100644 benchmarks/longmemeval/make_fast_eval.py create mode 100644 benchmarks/longmemeval/mmr.py create mode 100644 benchmarks/longmemeval/modal_fanout.py create mode 100644 benchmarks/longmemeval/modal_fast50.py create mode 100644 benchmarks/longmemeval/preview_scores.py create mode 100644 benchmarks/longmemeval/pull_fast.py create mode 100644 benchmarks/longmemeval/pull_shards.py create mode 100644 benchmarks/longmemeval/sim3way.py create mode 100644 benchmarks/longmemeval/sim3way_final.py create mode 100644 benchmarks/longmemeval/temporal.py create mode 100644 benchmarks/longmemeval/test_mmr.py create mode 100644 benchmarks/longmemeval/test_temporal.py create mode 100644 benchmarks/longmemeval/watch_and_pull.py create mode 100644 crates/uteke-core/src/consolidation_api.rs create mode 100644 crates/uteke-core/src/consolidation_exec.rs create mode 100644 crates/uteke-core/src/consolidation_plan.rs create mode 100644 crates/uteke-core/src/provenance.rs create mode 100644 crates/uteke-core/src/rooms_segments.rs create mode 100644 crates/uteke-server/tests/cli_flags.rs create mode 100644 docs/assets/longmemeval-comparison.jpg diff --git a/.cora.yaml b/.cora.yaml index 4cd9d44a..39a6cba6 100644 --- a/.cora.yaml +++ b/.cora.yaml @@ -15,6 +15,7 @@ focus: # Custom rules / additional instructions for the reviewer rules: + - "Control-flow guardrail (cora-code#523): before claiming a code path is unreachable, a call is missing on a branch, or a branch skips a validation, you MUST verify the branch structure against the surrounding function body — not only the unified-diff context lines. Shared `match` arms, `.map()` producers feeding a single arm, and early-return patterns frequently make two call paths converge on the same code. If the diff context alone does not show the enclosing control flow, do not assert reachability claims — report only what is visible in the evidence." - "All SQL queries use parameterized/bound statements via rusqlite. CLI args are struct fields passed to uteke-core which uses param() bindings, never string interpolation. Do not flag SQL injection on CLI struct definitions." - "Fields, struct members, and CLI flags named `api_key` / `extract_api_key` (e.g. in cli.rs Import args, ExtractOpts, Extractor, ExtractionConfig) are identifiers and runtime-populated values resolved from env vars, config files, or CLI input. They are never hardcoded secrets. Do not flag 'hardcoded password or secret' on these declarations or assignments; this mirrors the existing `embed/openai.rs` api_key field." - "The `__init__.py.tmpl` template uses `subprocess.run(cmd)` where `cmd` is always constructed as `[self._bin] + args` with `self._bin` pointing to the uteke binary path and `args` being hardcoded string literals (e.g. `['recall', query, ...]`). This is a controlled subprocess invocation pattern, not command injection. Do not flag 'command injection' on subprocess calls in this file." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bed6f98f..e68042d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,40 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo check --workspace --all-targets + release-notes-check: + name: Release Notes Template + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Substituted names must match workflow asset names + run: | + # The release job archives "${{ matrix.artifact }}-${{ steps.version.outputs.VERSION }}" + # where VERSION keeps the tag's `v` (refs/tags/vX.Y.Z). The notes template + # substitutes __VER__ with that same value, so rendered names must be + # exactly `-v.` — no missing `v`, no double `v`. + set -euo pipefail + grep -oE 'uteke-[a-z0-9_-]+-__VER__\.(tar\.gz|zip)' scripts/release-notes-template.md \ + | sed 's/__VER__/v0.0.0/' | sort > rendered.txt + # Expected names mirror the release job's archive step: + # tar czf "${{ matrix.artifact }}-${{ steps.version.outputs.VERSION }}.${{ matrix.ext }}" + # where VERSION keeps the tag's `v` (refs/tags/vX.Y.Z). + printf '%s\n' \ + uteke-aarch64-apple-darwin-v0.0.0.tar.gz \ + uteke-aarch64-unknown-linux-gnu-v0.0.0.tar.gz \ + uteke-x86_64-pc-windows-msvc-v0.0.0.zip \ + uteke-x86_64-unknown-linux-gnu-legacy-v0.0.0.tar.gz \ + uteke-x86_64-unknown-linux-gnu-v0.0.0.tar.gz \ + | sort > expected.txt + if ! diff -u expected.txt rendered.txt; then + echo "::error::release-notes template renders asset names that do not match the release workflow artifact names (see diff above)" + exit 1 + fi + # Pinned-install line: __VER__ is substituted WITH the `v`-prefixed tag, + # so the template must carry the bare placeholder — UTEKE_VERSION=__VER__ + # renders as UTEKE_VERSION=vX.Y.Z (install.sh builds asset URLs from this). + grep -qE 'UTEKE_VERSION=__VER__ ' scripts/release-notes-template.md \ + || { echo "::error::pinned-install line must use UTEKE_VERSION=__VER__ (the v comes from the tag)"; exit 1; } + fmt: name: Format runs-on: ubuntu-latest @@ -60,6 +94,23 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo build --release --workspace + vecq-check: + name: Check (vecq backend) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + # Ensure the pure-Rust vecq vector backend (#1098) keeps compiling and + # passing its unit tests on every PR. + - run: cargo test -p uteke-core --no-default-features --features "onnx,vecq" --lib vector + # …and that it still builds into the shippable binaries (#1109). Testing + # only --lib let feature-unification break packaging silently. + - run: cargo build -p uteke-cli -p uteke-server -p uteke-mcp --no-default-features --features vecq + # #1131: the embedder-less profile (cloud/OpenAI embedders only, no + # onnx) must keep compiling — the embedding cache is backend-agnostic. + - run: cargo check -p uteke-core --no-default-features --features vecq + docs-check: name: API Docs Fresh runs-on: ubuntu-latest diff --git a/.github/workflows/cla-check.yml b/.github/workflows/cla-check.yml index f0fa96a6..98820d72 100644 --- a/.github/workflows/cla-check.yml +++ b/.github/workflows/cla-check.yml @@ -12,6 +12,7 @@ permissions: jobs: cla-check: runs-on: ubuntu-latest + if: "!contains(fromJSON('[\"app/dependabot\", \"app/renovate\", \"github-actions[bot]\"]'), github.event.pull_request.user.login)" steps: - name: Fetch & check CLA signature id: check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 508a67a4..bc2d164f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -325,13 +325,13 @@ jobs: run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - name: Download standard Linux x64 artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: uteke-x86_64-unknown-linux-gnu-${{ steps.version.outputs.VERSION }} path: standard-artifact - name: Download legacy ORT artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ort-legacy-linux-x64 path: ort-legacy @@ -375,7 +375,7 @@ jobs: run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - name: Download release artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: release-artifacts pattern: uteke-* @@ -431,7 +431,7 @@ jobs: # shell expansion: markdown backticks become command substitutions and # get EXECUTED (v0.14.3: installer curl|sh ran on the runner, then # uteke-serve blocked forever). Template file = zero expansion. - sed "s/__VER__/${VER#v}/g" scripts/release-notes-template.md + sed "s/__VER__/${VER}/g" scripts/release-notes-template.md } > release-notes.md fi @@ -480,7 +480,7 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Download Linux binaries from build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: pattern: uteke-*-${{ github.ref_name }} path: release-artifacts diff --git a/.gitignore b/.gitignore index c94a088e..3c61051a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ mutants*.out/ mutants*.out.old/ # VitePress build artifacts (keep config.ts, not lock files) + +# embedder cache artifact from local test runs +crates/uteke-core/embed_cache.db +embed_cache.db diff --git a/CHANGELOG.md b/CHANGELOG.md index b2187f89..3d19ec27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [0.16.0] — 2026-08-28 + +Minor release. One theme: retrieval quality that ships by default. + +The new `fusion` strategy — weighted Reciprocal Rank Fusion of the vector and hybrid rankings — is now the default everywhere: CLI, HTTP API, and MCP. Vector and hybrid fail on different questions; fusing both captures each side's wins. Zero config needed. + +### Added + +- **`fusion` recall strategy (#1123)** — runs vector and hybrid rankings and RRF-fuses them (k=60, weights tuned on LongMemEval fast50 actual x86 rankings). LongMemEval fast50: R@5 0.98 vs 0.9267 hybrid, R@10 1.0. Available on every surface: `--strategy fusion`, HTTP `strategy: "fusion"`, MCP `strategy: "fusion"`. + +### Changed + +- **Default recall strategy: `hybrid` → `fusion` (#1123)** — applies ONLY when no strategy is specified (CLI flag, HTTP field, MCP param, or `default_strategy` config). Existing configs with an explicit `default_strategy` are untouched. + +### Validated + +- **Full-release validation: pure-default 500Q LongMemEval run (2026-08-29)** — zero-config `--strategy default` on the complete validation set: **R@5 0.946 / R@10 0.977** on 470 non-abstention questions (**+9.2 pts** R@5 vs 0.15.0 hybrid baseline 0.854). Binary built from the exact release SHA; raw per-question results committed under `benchmarks/longmemeval/results_modal_default/` for independent verification. +- **Public benchmark page + comparison chart (#1141)** — `docs/benchmarks.md` now publishes the dual-metric view from the same run: recall_any@5 **98.2%** (the metric competitor benchmarks publish) alongside the stricter recall_all family (recall_all@10 95.4%, strict recall_all@5 88.0% with mathematical ceiling 99.4%, coverage@5 94.3%). No other system in the comparison publicly reports the strict family. + ## [0.15.0] — 2026-08-19 Minor release. The theme: memory you can trust across surfaces, and a store you can move. diff --git a/Cargo.lock b/Cargo.lock index 72972379..ed0034e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2811,6 +2811,7 @@ dependencies = [ "tracing", "usearch", "uuid", + "vecq-core", ] [[package]] @@ -2879,6 +2880,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vecq-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07959c480b6d1d1d0d80eb92fa322271d6c2f1ae69529d6b0fbefc6dd4c88693" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 90cf7470..fd6ddf59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.15.0" +version = "0.16.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/codecoradev/uteke" diff --git a/benchmarks/longmemeval/.gitignore b/benchmarks/longmemeval/.gitignore index 6f3d13ad..da0a8db6 100644 --- a/benchmarks/longmemeval/.gitignore +++ b/benchmarks/longmemeval/.gitignore @@ -9,3 +9,9 @@ results/*.log __pycache__/ *.pyc .venv/ + +# Fast-eval datasets are derived from the parent dataset (regenerate with make_fast_eval.py) +data/longmemeval_fast50.json +data/longmemeval_fast10.json +data/longmemeval_temporal15.json +data/longmemeval_multisession15.json diff --git a/benchmarks/longmemeval/RESULTS.md b/benchmarks/longmemeval/RESULTS.md index ffe54980..a5664877 100644 --- a/benchmarks/longmemeval/RESULTS.md +++ b/benchmarks/longmemeval/RESULTS.md @@ -21,6 +21,29 @@ **Improvement (Hybrid vs Vector): +12.6pp R@5** +### Fusion (weighted RRF of vector + hybrid rankings, #1123) — default since 0.16.0 + +| Questions | R@5 | R@10 | NDCG@5 | NDCG@10 | Embedding | Date | +|-----------|-----|------|--------|---------|-----------|------| +| 50 (mean) | 98.0% | 100.0% | 0.893 | 0.901 | EmbeddingGemma Q4 (ONNX local) | 2026-08-28 | +| 50 (43 evaluable*) | 97.7% | 100.0% | 0.893 | 0.901 | EmbeddingGemma Q4 (ONNX local) | 2026-08-28 | + +*print_metrics.py filters to 43 evaluable questions; mean over all 50 is the comparable figure. Run: `results_modal_vector_fusion17` (Modal x86, harness-level fusion). Remaining fails @5: 3 questions (92a0aa75, 60472f9c, a3838d2b) — all partial-recall, R@10 = 1.0. + +#### Zero-config validation (local ARM, 0.16.0 binary, no flags) + +| Check | Result | +|-------|--------| +| Parity: implicit default vs explicit `--strategy fusion` | **identical rankings** (top-10 equal) | +| Divergence: implicit default vs explicit `--strategy hybrid` | rankings differ (as expected) | +| fast10, `--strategy default` (flag omitted → core default) | **R@5 0.9667, R@10 1.0, NDCG@5 0.9643** | + +fast10 detail: 1 partial miss @5 (60472f9c multi-session, R@5 0.67 → R@10 1.0) — same known hard question as the Modal x86 fusion run. A fresh install with zero config gets benchmark-grade recall out of the box. + +**Why fusion works:** vector-only and hybrid-only fail on DISJOINT question sets (fast50: 7 questions vector wins, 5 hybrid wins, no overlap in failures). RRF fusing both rankings captures each side's wins. + +**Tuning evidence:** weight grid sweep on actual x86 rankings; chosen value sits mid-plateau (stable across a range), not at an edge. Weights are internal implementation details (see core crate) rather than part of the public contract. + --- ## 50Q Hybrid — Per Question Type Breakdown @@ -56,3 +79,39 @@ python run_eval.py --data data/longmemeval_s_cleaned.json --output results_vecto # Hybrid (50Q sample) python run_eval.py --data data/longmemeval_s_cleaned.json --output results_hybrid --limit 50 --strategy hybrid ``` + +### 3-way RRF (vec + hyb + fts5-only) — NO-GO (2026-08-29) + +Simulation over saved rankings; fts5-only rankings collected on Modal x86 +(500Q, ~4.5h, resume-safe via volume). Cross-check: sim fts5-only R@5=0.8211 +vs harness-reported 0.820 — parsing validated. + +- 2-way shipped fusion (tuned weights) R@5 = 0.9800 (fast50) +- best 3-way (2.0/1.0/0.1) R@5 = 0.9800 — delta +0.0000, **0 flipped questions** +- adding fts5 as third arm changes nothing at any grid weight tried +- fts5-only fails: temporal-reasoning (63) + multi-session (55) dominate — + same classes already covered (partially) by hybrid's own FTS5 component + +Conclusion: ranking-side optimization via more RRF arms is exhausted. +Remaining headroom is insert-side granularity (3 partial-recall fails) — +separate project, uncertain payoff. + +Artifacts: sim3way_final.py, preview_scores.py, results_modal_fts5_partial/ + +### Pure-default (zero-config fusion) 500Q — Modal x86_64, binary 0.16.0 from git bfbc296 (2026-08-29) + +Pre-release validation: `--strategy default` (no flag at all) against the full 500Q dataset. +Binary built in-image from exact SHA bfbc296 (PR #1137/#1138), image build prints `uteke 0.16.0`. + +| Question Type | Count | Recall@5 | Recall@10 | NDCG@5 | NDCG@10 | +|---|---|---|---|---|---| +| **Overall** | 470 | **0.946** | 0.977 | 0.897 | 0.911 | +| knowledge-update | 72 | 1.000 | 1.000 | 0.972 | 0.972 | +| multi-session | 121 | 0.906 | 0.976 | 0.880 | 0.913 | +| single-session-assistant | 56 | 0.982 | 1.000 | 0.959 | 0.965 | +| single-session-preference | 30 | 0.967 | 0.967 | 0.839 | 0.839 | +| single-session-user | 64 | 0.969 | 0.969 | 0.913 | 0.913 | +| temporal-reasoning | 127 | 0.920 | 0.962 | 0.849 | 0.867 | + +Context: v0.15.0 hybrid baseline on the same dataset: Overall R@5 = 0.854 / R@10 = 0.885 (2026-08-13). +Fusion default lifts full-500Q recall@5 by **+9.2 points** (0.854 → 0.946) with zero configuration. diff --git a/benchmarks/longmemeval/check_volume.py b/benchmarks/longmemeval/check_volume.py new file mode 100644 index 00000000..69d76da3 --- /dev/null +++ b/benchmarks/longmemeval/check_volume.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Quick check: shard mana saja yang sudah tersimpan di Modal Volume.""" +import modal + +vol = modal.Volume.from_name("uteke-longmemeval", create_if_missing=True) + +app = modal.App("check-lmeval-volume") + +@app.function(volumes={"/root/vol": vol}, timeout=120) +def check(strategy: str) -> list: + import pathlib + base = pathlib.Path("/root/vol") / strategy + out = [] + if base.exists(): + for f in sorted(base.glob("shard_*.jsonl")): + n = sum(1 for l in f.read_text().splitlines() if l.strip()) + out.append((f.name, n)) + return out + +@app.local_entrypoint() +def main(): + for strategy in ["hybrid", "vector"]: + res = check.remote(strategy) + total = sum(n for _, n in res) + print(f"{strategy}: {len(res)}/20 shard selesai, total {total} pertanyaan") + for name, n in res: + print(f" {name}: {n} Q") diff --git a/benchmarks/longmemeval/compare_fast50.py b/benchmarks/longmemeval/compare_fast50.py new file mode 100644 index 00000000..39f90a07 --- /dev/null +++ b/benchmarks/longmemeval/compare_fast50.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Compare baseline vs rerank fast-eval results (#1118). + +Usage: python3 compare_fast50.py +""" +import json +import sys +from pathlib import Path +from collections import defaultdict + + +def load(d): + rows = {} + p = Path(d) / "retrieval_results.jsonl" + for line in p.read_text().splitlines(): + if not line.strip(): + continue + r = json.loads(line) + m = (r.get("retrieval_results", {}) or {}).get("metrics") or {} + sm = (m.get("session") or {}) + rows[r["question_id"]] = { + "type": r["question_type"], + "r5": sm.get("recall_all@5"), + "r10": sm.get("recall_all@10"), + } + return rows + + +def agg(rows): + by_type = defaultdict(list) + for v in rows.values(): + if v["r5"] is not None: + by_type[v["type"]].append(v["r5"]) + out = {} + allv = [] + for t, vals in by_type.items(): + out[t] = sum(vals) / len(vals) + allv += vals + out["OVERALL"] = sum(allv) / len(allv) if allv else 0.0 + return out + + +def main(): + base = load(sys.argv[1]) + rerank = load(sys.argv[2]) + a, b = agg(base), agg(rerank) + + print(f"{'type':30s} {'n':>3s} {'baseline':>9s} {'rerank':>9s} {'delta':>8s}") + types = sorted(set(a) | set(b)) + for t in types: + d = b.get(t, 0) - a.get(t, 0) + n = sum(1 for v in rerank.values() if v["type"] == t and v["r5"] is not None) + flag = " <<<" if d >= 0.03 and t != "OVERALL" else "" + print(f"{t:30s} {n:3d} {a.get(t, 0):9.4f} {b.get(t, 0):9.4f} {d:+8.4f}{flag}") + + dd = b["OVERALL"] - a["OVERALL"] + print() + print(f"OVERALL: {a['OVERALL']:.4f} -> {b['OVERALL']:.4f} ({dd:+.4f})") + gate = dd >= 0.03 + print(f"GATE (+3pp): {'PASS ✅' if gate else 'FAIL ❌'}") + + # per-Q movers + up = sum(1 for q in base if q in rerank and rerank[q]["r5"] is not None + and base[q]["r5"] is not None and rerank[q]["r5"] > base[q]["r5"]) + down = sum(1 for q in base if q in rerank and rerank[q]["r5"] is not None + and base[q]["r5"] is not None and rerank[q]["r5"] < base[q]["r5"]) + print(f"Q improved: {up} Q regressed: {down}") + + return 0 if gate else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/longmemeval/fast_eval_ids.json b/benchmarks/longmemeval/fast_eval_ids.json new file mode 100644 index 00000000..79ed15fb --- /dev/null +++ b/benchmarks/longmemeval/fast_eval_ids.json @@ -0,0 +1,100 @@ +{ + "fast50": [ + "7527f7e2", + "75499fd8", + "8e9d538c", + "3d86fd0a", + "0862e8bf_abs", + "bc8a6e93_abs", + "29f2956b_abs", + "gpt4_a56e767c", + "b3c15d39", + "60472f9c", + "2b8f3739", + "c2ac3c61", + "eeda8a6d_abs", + "35a27287", + "57f827a0", + "75f70248", + "2311e44b", + "85fa3a3f", + "ef66a6e5", + "92a0aa75", + "bc149d6b", + "55241a1f", + "a08a253f", + "gpt4_f49edff3", + "gpt4_468eb063", + "6e984301", + "gpt4_8e165409", + "6e984302", + "gpt4_8279ba03", + "gpt4_b5700ca0", + "08f4fc43", + "gpt4_385a5000", + "a3838d2b", + "gpt4_b4a80587", + "gpt4_8c8961ae", + "982b5123_abs", + "5a4f22c0", + "6071bd76", + "8fb83627", + "b01defab", + "4b24c848", + "a2f3aa27", + "031748ae_abs", + "0ddfec37_abs", + "e9327a54", + "58470ed2", + "3e321797", + "e982271f", + "5809eb10", + "c8f1aeed" + ], + "fast10": [ + "7527f7e2", + "75499fd8", + "8e9d538c", + "3d86fd0a", + "0862e8bf_abs", + "bc8a6e93_abs", + "29f2956b_abs", + "gpt4_a56e767c", + "b3c15d39", + "60472f9c" + ], + "temporal15": [ + "gpt4_f49edff3", + "gpt4_468eb063", + "6e984301", + "gpt4_8e165409", + "6e984302", + "gpt4_8279ba03", + "gpt4_b5700ca0", + "08f4fc43", + "gpt4_385a5000", + "a3838d2b", + "gpt4_b4a80587", + "gpt4_8c8961ae", + "982b5123_abs", + "gpt4_7ddcf75f", + "gpt4_7a0daae1" + ], + "multisession15": [ + "gpt4_a56e767c", + "b3c15d39", + "60472f9c", + "2b8f3739", + "c2ac3c61", + "eeda8a6d_abs", + "2311e44b", + "85fa3a3f", + "ef66a6e5", + "92a0aa75", + "bc149d6b", + "55241a1f", + "a08a253f", + "81507db6", + "8979f9ec" + ] +} \ No newline at end of file diff --git a/benchmarks/longmemeval/lmeval_final_metrics.txt b/benchmarks/longmemeval/lmeval_final_metrics.txt new file mode 100644 index 00000000..6b575165 --- /dev/null +++ b/benchmarks/longmemeval/lmeval_final_metrics.txt @@ -0,0 +1,65 @@ +============================================================ +Session-Level Retrieval Metrics +============================================================ + +Overall (470 questions): + recall_all@5 = 0.9093 + ndcg_any@5 = 0.8749 + recall_all@10 = 0.9635 + ndcg_any@10 = 0.8972 + +knowledge-update (72 questions): + recall_all@5 = 0.9861 + ndcg_any@5 = 0.9694 + recall_all@10 = 0.9931 + ndcg_any@10 = 0.9719 + +multi-session (121 questions): + recall_all@5 = 0.8869 + ndcg_any@5 = 0.8580 + recall_all@10 = 0.9623 + ndcg_any@10 = 0.8920 + +single-session-assistant (56 questions): + recall_all@5 = 0.9464 + ndcg_any@5 = 0.8955 + recall_all@10 = 0.9643 + ndcg_any@10 = 0.9015 + +single-session-preference (30 questions): + recall_all@5 = 0.9000 + ndcg_any@5 = 0.7405 + recall_all@10 = 0.9333 + ndcg_any@10 = 0.7524 + +single-session-user (64 questions): + recall_all@5 = 0.9844 + ndcg_any@5 = 0.9619 + recall_all@10 = 1.0000 + ndcg_any@10 = 0.9675 + +temporal-reasoning (127 questions): + recall_all@5 = 0.8350 + ndcg_any@5 = 0.8162 + recall_all@10 = 0.9362 + ndcg_any@10 = 0.8566 + +============================================================ +Turn-Level Retrieval Metrics +============================================================ + +(Not measured — this harness indexes sessions, not individual turns.) + +============================================================ +Summary Table (for README) +============================================================ + +| Question Type | Count | Recall@5 | Recall@10 | NDCG@5 | NDCG@10 | +|---------------|-------|----------|-----------|--------|---------| +| **Overall** | 470 | 0.909 | 0.963 | 0.875 | 0.897 | +| knowledge-update | 72 | 0.986 | 0.993 | 0.969 | 0.972 | +| multi-session | 121 | 0.887 | 0.962 | 0.858 | 0.892 | +| single-session-assistant | 56 | 0.946 | 0.964 | 0.896 | 0.901 | +| single-session-preference | 30 | 0.900 | 0.933 | 0.741 | 0.752 | +| single-session-user | 64 | 0.984 | 1.000 | 0.962 | 0.967 | +| temporal-reasoning | 127 | 0.835 | 0.936 | 0.816 | 0.857 | diff --git a/benchmarks/longmemeval/lmeval_relaunch.log b/benchmarks/longmemeval/lmeval_relaunch.log new file mode 100644 index 00000000..360f9215 --- /dev/null +++ b/benchmarks/longmemeval/lmeval_relaunch.log @@ -0,0 +1,359 @@ +✓ Initialized. View run at +https://modal.com/apps/ajianaz/main/ap-B81Y5ELh4Z92idroPfgB5j +✓ Created objects. +├── 🔨 Created mount +│ /opt/data/github/codecoradev/uteke/benchmarks/longmemeval/modal_fanout.py +├── 🔨 Created mount +│ /opt/data/github/codecoradev/uteke/benchmarks/longmemeval/run_eval.py +├── 🔨 Created mount +│ /opt/data/github/codecoradev/uteke/benchmarks/longmemeval/data/longmemeval_s +│ _cleaned.json +├── 🔨 Created mount /opt/data/.codecora/uteke/models/embeddinggemma-q4 +├── 🔨 Created function run_shard. +└── 🔨 Created function list_volume. +Fan-out: strategy=hybrid shards=20 limit=ALL +Stopping app - user stopped from CLI. +Runner terminated. +Runner terminated. +Runner terminated. +Runner terminated. +Runner terminated. +Runner terminated. +Runner terminated. +Runner terminated. +Runner terminated. +Runner terminated. +Runner terminated. +Runner terminated. +Runner terminated. +Runner terminated. +Runner terminated. +Runner terminated. +Runner terminated. +╭───────────────────── Traceback (most recent call last) ──────────────────────╮ +│ /opt/data/github/codecoradev/uteke/benchmarks/longmemeval/modal_fanout.py:19 │ +│ 7 in main │ +│ │ +│ 196 │ merged, seen = [], set() │ +│ ❱ 197 │ for res in run_shard.map(inputs): │ +│ 198 │ │ n_eval = 0 │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_utils/async_utils.py:83 │ +│ 9 in __iter__ │ +│ │ +│ 838 │ │ │ with Runner() as runner: │ +│ ❱ 839 │ │ │ │ yield from run_async_gen(runner, self._async_iterable │ +│ 840 │ │ except NestedEventLoops: │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_utils/async_utils.py:91 │ +│ 7 in run_async_gen │ +│ │ +│ 916 │ │ │ else: │ +│ ❱ 917 │ │ │ │ next_yield = runner.run(gen.asend(next_send)) # type │ +│ 918 │ │ except KeyboardInterrupt as e: │ +│ │ +│ /usr/lib/python3.13/asyncio/base_events.py:725 in run_until_complete │ +│ │ +│ 724 │ │ │ +│ ❱ 725 │ │ return future.result() │ +│ 726 │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_utils/async_utils.py:74 │ +│ 9 in asend │ +│ │ +│ 748 │ │ self.iterated = True │ +│ ❱ 749 │ │ return await self.gen.asend(value) │ +│ 750 │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/parallel_map.py:1036 in │ +│ _map_async │ +│ │ +│ 1035 │ async_input_gen = async_zip(*[sync_or_async_iter(it) for it in in │ +│ ❱ 1036 │ async for output in _map_helper( │ +│ 1037 │ │ self, │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/parallel_map.py:1009 in │ +│ _map_helper │ +│ │ +│ 1008 │ ) as map_output_stream: │ +│ ❱ 1009 │ │ async for output in map_output_stream: │ +│ 1010 │ │ │ yield output │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_utils/async_utils.py:10 │ +│ 91 in async_merge │ +│ │ +│ 1090 │ │ │ │ │ assert_type(item, ExceptionWrapper) │ +│ ❱ 1091 │ │ │ │ │ raise item.value │ +│ 1092 │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_utils/async_utils.py:10 │ +│ 70 in producer │ +│ │ +│ 1069 │ │ │ async with aclosing(generator) as stream: │ +│ ❱ 1070 │ │ │ │ async for item in stream: │ +│ 1071 │ │ │ │ │ await queue.put(ValueWrapper(item)) │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_object.py:55 in wrapped │ +│ │ +│ 54 │ │ async with aclosing(method(self, *args, **kwargs)) as stream: │ +│ ❱ 55 │ │ │ async for item in stream: │ +│ 56 │ │ │ │ yield item │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_functions.py:1650 in │ +│ _map │ +│ │ +│ 1649 │ │ │ ) as stream: │ +│ ❱ 1650 │ │ │ │ async for item in stream: │ +│ 1651 │ │ │ │ │ yield item │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/parallel_map.py:965 in │ +│ _map_invocation_inputplane │ +│ │ +│ 964 │ ) as merged: │ +│ ❱ 965 │ │ async for maybe_output in merged: │ +│ 966 │ │ │ if maybe_output is not None: # ignore None sentinels │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_utils/async_utils.py:10 │ +│ 91 in async_merge │ +│ │ +│ 1090 │ │ │ │ │ assert_type(item, ExceptionWrapper) │ +│ ❱ 1091 │ │ │ │ │ raise item.value │ +│ 1092 │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_utils/async_utils.py:10 │ +│ 70 in producer │ +│ │ +│ 1069 │ │ │ async with aclosing(generator) as stream: │ +│ ❱ 1070 │ │ │ │ async for item in stream: │ +│ 1071 │ │ │ │ │ await queue.put(ValueWrapper(item)) │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/parallel_map.py:921 in │ +│ poll_outputs │ +│ │ +│ 920 │ │ ) as streamer: │ +│ ❱ 921 │ │ │ async for idx, output in streamer: │ +│ 922 │ │ │ │ if not order_outputs: │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_utils/async_utils.py:12 │ +│ 20 in async_map_ordered │ +│ │ +│ 1219 │ async with aclosing(async_map(async_zip(counter(), input_generato │ +│ ❱ 1220 │ │ async for output_idx, output_item in stream: │ +│ 1221 │ │ │ buffer[output_idx] = output_item │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_utils/async_utils.py:11 │ +│ 96 in async_map │ +│ │ +│ 1195 │ ) as stream: │ +│ ❱ 1196 │ │ async for item in stream: │ +│ 1197 │ │ │ yield item │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_utils/async_utils.py:10 │ +│ 91 in async_merge │ +│ │ +│ 1090 │ │ │ │ │ assert_type(item, ExceptionWrapper) │ +│ ❱ 1091 │ │ │ │ │ raise item.value │ +│ 1092 │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_utils/async_utils.py:10 │ +│ 70 in producer │ +│ │ +│ 1069 │ │ │ async with aclosing(generator) as stream: │ +│ ❱ 1070 │ │ │ │ async for item in stream: │ +│ 1071 │ │ │ │ │ await queue.put(ValueWrapper(item)) │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_utils/async_utils.py:11 │ +│ 85 in worker │ +│ │ +│ 1184 │ │ │ if isinstance(item, ValueWrapper): │ +│ ❱ 1185 │ │ │ │ res = await prevent_cancellation_abortion(async_mappe │ +│ 1186 │ │ │ │ yield res │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_utils/async_utils.py:11 │ +│ 50 in prevent_cancellation_abortion │ +│ │ +│ 1149 │ try: │ +│ ❱ 1150 │ │ return await asyncio.shield(t) │ +│ 1151 │ except asyncio.CancelledError: │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_utils/async_utils.py:12 │ +│ 09 in mapper_func_wrapper │ +│ │ +│ 1208 │ async def mapper_func_wrapper(tup: tuple[int, T]) -> tuple[int, V │ +│ ❱ 1209 │ │ return (tup[0], await async_mapper_func(tup[1])) │ +│ 1210 │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/parallel_map.py:910 in │ +│ fetch_output │ +│ │ +│ 909 │ │ │ else: │ +│ ❱ 910 │ │ │ │ raise e │ +│ 911 │ │ return (item.idx, output) │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/parallel_map.py:905 in │ +│ fetch_output │ +│ │ +│ 904 │ │ try: │ +│ ❱ 905 │ │ │ output = await _process_result(item.result, item.data_for │ +│ 906 │ │ except Exception as e: │ +│ │ +│ /opt/data/.local/lib/python3.13/site-packages/modal/_utils/function_utils.py │ +│ :550 in _process_result │ +│ │ +│ 549 │ │ │ +│ ❱ 550 │ │ raise RemoteError(result.exception) │ +│ 551 │ +╰──────────────────────────────────────────────────────────────────────────────╯ +RemoteError +✓ Initialized. View run at +https://modal.com/apps/ajianaz/main/ap-19sx5htEuCgLw7mifYtwhV +✓ Created objects. +├── 🔨 Created mount +│ /opt/data/github/codecoradev/uteke/benchmarks/longmemeval/modal_fanout.py +├── 🔨 Created mount +│ /opt/data/github/codecoradev/uteke/benchmarks/longmemeval/data/longmemeval_s +│ _cleaned.json +├── 🔨 Created mount +│ /opt/data/github/codecoradev/uteke/benchmarks/longmemeval/run_eval.py +├── 🔨 Created mount /opt/data/.codecora/uteke/models/embeddinggemma-q4 +├── 🔨 Created function run_shard. +└── 🔨 Created function list_volume. +Fan-out: strategy=hybrid shards=20 limit=ALL +✓ Initialized. View run at +https://modal.com/apps/ajianaz/main/ap-H2Wp8hhTGBEmLMBx9TXYzW +✓ Created objects. +├── 🔨 Created mount +│ /opt/data/github/codecoradev/uteke/benchmarks/longmemeval/modal_fanout.py +├── 🔨 Created mount +│ /opt/data/github/codecoradev/uteke/benchmarks/longmemeval/data/longmemeval_s +│ _cleaned.json +├── 🔨 Created mount +│ /opt/data/github/codecoradev/uteke/benchmarks/longmemeval/run_eval.py +├── 🔨 Created mount /opt/data/.codecora/uteke/models/embeddinggemma-q4 +├── 🔨 Created function run_shard. +└── 🔨 Created function list_volume. +Fan-out: strategy=hybrid shards=20 limit=ALL +Container terminated due to preemption. Your Function will be restarted with the same input. For more details, see https://modal.com/docs/guide/preemption +Container terminated due to preemption. Your Function will be restarted with the same input. For more details, see https://modal.com/docs/guide/preemption +Runner failed with exception: Worker disappeared, in-progress inputs will be re-scheduled. +Container terminated due to preemption. Your Function will be restarted with the same input. For more details, see https://modal.com/docs/guide/preemption +Container terminated due to preemption. Your Function will be restarted with the same input. For more details, see https://modal.com/docs/guide/preemption +Container terminated due to preemption. For more details, see https://modal.com/docs/guide/preemption +Runner terminated. + shard 0: 25/25 questions in 4190.6s + shard 1: 25/25 questions in 4059.8s + shard 2: 25/25 questions in 4094.0s +Container terminated due to preemption. Your Function will be restarted with the same input. For more details, see https://modal.com/docs/guide/preemption +Container terminated due to preemption. Your Function will be restarted with the same input. For more details, see https://modal.com/docs/guide/preemption +Container terminated due to preemption. Your Function will be restarted with the same input. For more details, see https://modal.com/docs/guide/preemption +Container terminated due to preemption. Your Function will be restarted with the same input. For more details, see https://modal.com/docs/guide/preemption +Container terminated due to preemption. Your Function will be restarted with the same input. For more details, see https://modal.com/docs/guide/preemption +Container terminated due to preemption. Your Function will be restarted with the same input. For more details, see https://modal.com/docs/guide/preemption + shard 3: 25/25 questions in 5802.8s + shard 4: 25/25 questions in 4099.4s + shard 5: 25/25 questions in 4246.5s + shard 6: 25/25 questions in 4175.9s + shard 7: 25/25 questions in 5948.5s + shard 8: 25/25 questions in 5910.1s + shard 9: 25/25 questions in 4138.0s + shard 10: 25/25 questions in 5969.8s + shard 11: 25/25 questions in 4112.3s + shard 12: 25/25 questions in 5010.1s + shard 13: 25/25 questions in 5690.3s + shard 14: 25/25 questions in 4276.1s + shard 15: 25/25 questions in 5263.5s +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous +WARNING: Logs may not be continuous + shard 16: 25/25 questions in 5084.4s + shard 17: 25/25 questions in 4178.2s + shard 18: 25/25 questions in 4103.4s + shard 19: 25/25 questions in 5038.7s + +Done: 500 questions in 15929s wall clock +Results: results_modal_hybrid/retrieval_results.jsonl + +Run: python print_metrics.py results_modal_hybrid/retrieval_results.jsonl +============================================================ +Session-Level Retrieval Metrics +============================================================ + +Overall (470 questions): + recall_all@5 = 0.9093 + ndcg_any@5 = 0.8749 + recall_all@10 = 0.9635 + ndcg_any@10 = 0.8972 + +knowledge-update (72 questions): + recall_all@5 = 0.9861 + ndcg_any@5 = 0.9694 + recall_all@10 = 0.9931 + ndcg_any@10 = 0.9719 + +multi-session (121 questions): + recall_all@5 = 0.8869 + ndcg_any@5 = 0.8580 + recall_all@10 = 0.9623 + ndcg_any@10 = 0.8920 + +single-session-assistant (56 questions): + recall_all@5 = 0.9464 + ndcg_any@5 = 0.8955 + recall_all@10 = 0.9643 + ndcg_any@10 = 0.9015 + +single-session-preference (30 questions): + recall_all@5 = 0.9000 + ndcg_any@5 = 0.7405 + recall_all@10 = 0.9333 + ndcg_any@10 = 0.7524 + +single-session-user (64 questions): + recall_all@5 = 0.9844 + ndcg_any@5 = 0.9619 + recall_all@10 = 1.0000 + ndcg_any@10 = 0.9675 + +temporal-reasoning (127 questions): + recall_all@5 = 0.8350 + ndcg_any@5 = 0.8162 + recall_all@10 = 0.9362 + ndcg_any@10 = 0.8566 + +============================================================ +Turn-Level Retrieval Metrics +============================================================ + +(Not measured — this harness indexes sessions, not individual turns.) + +============================================================ +Summary Table (for README) +============================================================ + +| Question Type | Count | Recall@5 | Recall@10 | NDCG@5 | NDCG@10 | +|---------------|-------|----------|-----------|--------|---------| +| **Overall** | 470 | 0.909 | 0.963 | 0.875 | 0.897 | +| knowledge-update | 72 | 0.986 | 0.993 | 0.969 | 0.972 | +| multi-session | 121 | 0.887 | 0.962 | 0.858 | 0.892 | +| single-session-assistant | 56 | 0.946 | 0.964 | 0.896 | 0.901 | +| single-session-preference | 30 | 0.900 | 0.933 | 0.741 | 0.752 | +| single-session-user | 64 | 0.984 | 1.000 | 0.962 | 0.967 | +| temporal-reasoning | 127 | 0.835 | 0.936 | 0.816 | 0.857 | +Stopping app - local entrypoint completed. +✓ App completed. View run at +https://modal.com/apps/ajianaz/main/ap-H2Wp8hhTGBEmLMBx9TXYzW diff --git a/benchmarks/longmemeval/lmeval_watch.log b/benchmarks/longmemeval/lmeval_watch.log new file mode 100644 index 00000000..9d04e3c2 --- /dev/null +++ b/benchmarks/longmemeval/lmeval_watch.log @@ -0,0 +1,104 @@ +[15:59:42] watcher start: strategy=hybrid shards=20 interval=600s +[15:59:53] pull: 0/20 shard, 0 Q merged → retrieval_results.jsonl +[15:59:55] app tidak aktif → relaunch modal_fanout (resume dari Volume) +[16:03:15] watcher start: strategy=hybrid shards=20 interval=600s +[16:03:24] pull: 0/20 shard, 0 Q merged → retrieval_results.jsonl +[16:03:26] app masih aktif +[16:13:35] pull: 0/20 shard, 0 Q merged → retrieval_results.jsonl +[16:13:37] app masih aktif +[16:22:04] watcher start: strategy=hybrid shards=20 interval=600s +[16:22:13] pull: 0/20 shard, 0 Q merged → retrieval_results.jsonl +[16:22:14] report: OK +[16:22:16] app masih aktif +[16:32:26] pull: 0/20 shard, 0 Q merged → retrieval_results.jsonl +[16:32:27] report: OK +[16:32:29] app masih aktif +[16:42:38] pull: 0/20 shard, 0 Q merged → retrieval_results.jsonl +[16:42:39] report: OK +[16:42:42] app tidak aktif → relaunch modal_fanout (resume dari Volume) +[16:52:52] pull: 0/20 shard, 0 Q merged → retrieval_results.jsonl +[16:52:53] report: OK +[16:52:55] app masih aktif +[17:00:10] watcher start: strategy=hybrid shards=20 interval=600s +[17:00:24] pull: 0/20 shard, 0 Q merged → retrieval_results.jsonl +[17:00:25] report: OK +[17:00:28] app tidak aktif → relaunch modal_fanout (resume dari Volume) +[17:10:37] pull: 0/20 shard, 0 Q merged → retrieval_results.jsonl +[17:10:38] report: OK +[17:10:40] app masih aktif +[17:20:49] pull: 0/20 shard, 0 Q merged → retrieval_results.jsonl +[17:20:50] report: OK +[17:20:52] app masih aktif +[17:31:02] pull: 0/20 shard, 0 Q merged → retrieval_results.jsonl +[17:31:03] report: OK +[17:31:05] app masih aktif +[17:41:14] pull: 0/20 shard, 0 Q merged → retrieval_results.jsonl +[17:41:14] report: OK +[17:41:17] app masih aktif +[17:51:27] pull: 0/20 shard, 0 Q merged → retrieval_results.jsonl +[17:51:28] report: OK +[17:51:31] app masih aktif +[18:01:39] pull: 0/20 shard, 0 Q merged → retrieval_results.jsonl +[18:01:40] report: OK +[18:01:42] app masih aktif +[18:11:52] pull: 5/20 shard, 125 Q merged → retrieval_results.jsonl +[18:11:53] report: OK +[18:11:55] app masih aktif +[18:22:06] pull: 5/20 shard, 125 Q merged → retrieval_results.jsonl +[18:22:07] report: OK +[18:22:09] app masih aktif +[18:32:19] pull: 7/20 shard, 175 Q merged → retrieval_results.jsonl +[18:32:19] report: OK +[18:32:21] app masih aktif +[18:42:32] pull: 12/20 shard, 300 Q merged → retrieval_results.jsonl +[18:42:33] report: OK +[18:42:36] app masih aktif +[18:52:46] pull: 13/20 shard, 325 Q merged → retrieval_results.jsonl +[18:52:46] report: OK +[18:52:49] app masih aktif +[19:02:59] pull: 13/20 shard, 325 Q merged → retrieval_results.jsonl +[19:03:00] report: OK +[19:03:02] app masih aktif +[19:13:11] pull: 15/20 shard, 375 Q merged → retrieval_results.jsonl +[19:13:12] report: OK +[19:13:14] app masih aktif +[19:23:25] pull: 15/20 shard, 375 Q merged → retrieval_results.jsonl +[19:23:26] report: OK +[19:23:28] app masih aktif +[19:33:38] pull: 16/20 shard, 400 Q merged → retrieval_results.jsonl +[19:33:39] report: OK +[19:33:41] app masih aktif +[19:43:52] pull: 16/20 shard, 400 Q merged → retrieval_results.jsonl +[19:43:53] report: OK +[19:43:56] app masih aktif +[19:54:07] pull: 18/20 shard, 450 Q merged → retrieval_results.jsonl +[19:54:08] report: OK +[19:54:11] app masih aktif +[20:04:20] pull: 18/20 shard, 450 Q merged → retrieval_results.jsonl +[20:04:21] report: OK +[20:04:25] app masih aktif +[20:14:36] pull: 18/20 shard, 450 Q merged → retrieval_results.jsonl +[20:14:37] report: OK +[20:14:39] app masih aktif +[20:24:50] pull: 19/20 shard, 475 Q merged → retrieval_results.jsonl +[20:24:51] report: OK +[20:24:53] app masih aktif +[20:35:06] pull: 19/20 shard, 475 Q merged → retrieval_results.jsonl +[20:35:07] report: OK +[20:35:10] app masih aktif +[20:45:29] pull: 19/20 shard, 475 Q merged → retrieval_results.jsonl +[20:45:34] report: OK +[20:45:40] app masih aktif +[20:55:54] pull: 19/20 shard, 475 Q merged → retrieval_results.jsonl +[20:55:55] report: OK +[20:56:00] app masih aktif +[21:06:14] pull: 19/20 shard, 475 Q merged → retrieval_results.jsonl +[21:06:15] report: OK +[21:06:19] app masih aktif +[21:16:34] pull: 19/20 shard, 475 Q merged → retrieval_results.jsonl +[21:16:35] report: OK +[21:16:39] app masih aktif +[21:26:53] pull: 20/20 shard, 500 Q merged → retrieval_results.jsonl +[21:26:55] report: OK +[21:26:55] SEMUA SHARD SELESAI — watcher berhenti +[21:26:55] metrik final tersimpan: lmeval_final_metrics.txt diff --git a/benchmarks/longmemeval/lmeval_watch_state.json b/benchmarks/longmemeval/lmeval_watch_state.json new file mode 100644 index 00000000..3ff2fa91 --- /dev/null +++ b/benchmarks/longmemeval/lmeval_watch_state.json @@ -0,0 +1,5 @@ +{ + "last_pull": "21:26:53", + "shards_done": 20, + "questions": 500 +} \ No newline at end of file diff --git a/benchmarks/longmemeval/make_fast_eval.py b/benchmarks/longmemeval/make_fast_eval.py new file mode 100644 index 00000000..be83b51a --- /dev/null +++ b/benchmarks/longmemeval/make_fast_eval.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Regenerate the fast-eval datasets from the parent LongMemEval-S dataset. + +The fast-eval JSON files (fast50 / temporal15 / multisession15 / fast10) are +derived subsets of ``data/longmemeval_s_cleaned.json`` — each question object +is byte-identical to its parent entry. Committing ~45MB of derived JSON bloats +the repository and triggers secret-scanning false positives (synthetic chat +text occasionally matches token-shaped patterns). Git therefore tracks only +this generator plus the per-file question_id lists; the JSON files themselves +are regenerated locally on demand: + + python3 make_fast_eval.py # regenerate all four files + python3 make_fast_eval.py --verify # check existing files match the lists + +The id lists live in fast_eval_ids.json. Selection was stratified per +question_type when first built (2026-08-27); the lists are the source of +truth, so regeneration is fully deterministic — no randomness. + +Requires the parent dataset. If missing, run download_data.sh first and fetch +longmemeval_s_cleaned.json manually (see the note in that script). +""" +import argparse +import json +import sys +from pathlib import Path + +HERE = Path(__file__).parent +DATA = HERE / "data" +PARENT = DATA / "longmemeval_s_cleaned.json" +IDS = HERE / "fast_eval_ids.json" + +# id-list file name -> output dataset file name +NAME_MAP = { + "fast50": "longmemeval_fast50.json", + "fast10": "longmemeval_fast10.json", + "temporal15": "longmemeval_temporal15.json", + "multisession15": "longmemeval_multisession15.json", +} + + +def load_parent(): + if not PARENT.exists(): + sys.exit(f"Parent dataset not found: {PARENT}\nRun download_data.sh and fetch longmemeval_s_cleaned.json first (see script notes).") + with open(PARENT) as f: + return json.load(f) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--verify", action="store_true", help="verify existing files instead of regenerating") + args = ap.parse_args() + + with open(IDS) as f: + id_lists = json.load(f) + parent = load_parent() + by_id = {q["question_id"]: q for q in parent} + + ok = True + for key, fname in NAME_MAP.items(): + ids = id_lists[key] + missing = [i for i in ids if i not in by_id] + if missing: + print(f"[FAIL] {fname}: {len(missing)} ids not in parent (first: {missing[0]})") + ok = False + continue + rebuilt = [by_id[i] for i in ids] + out_path = DATA / fname + if args.verify: + if not out_path.exists(): + print(f"[FAIL] {fname}: missing (run without --verify to regenerate)") + ok = False + continue + with open(out_path) as f: + cur = json.load(f) + match = json.dumps(cur) == json.dumps(rebuilt) + print(f"[{'OK' if match else 'FAIL'}] {fname}: {'identical' if match else 'DIFFERS from id-list rebuild'}") + ok &= match + else: + with open(out_path, "w") as f: + json.dump(rebuilt, f) + print(f"[OK] wrote {out_path} ({len(rebuilt)} questions)") + + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/longmemeval/mmr.py b/benchmarks/longmemeval/mmr.py new file mode 100644 index 00000000..69eac5ff --- /dev/null +++ b/benchmarks/longmemeval/mmr.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""MMR (Maximal Marginal Relevance) diversity for session ranking (#1120). + +Zero-model post-processing: reorders an already-ranked candidate list to +penalise redundancy among top results. Relevance is approximated from the +input rank order (RRF-style 1/(60+rank)); similarity between candidates is +a TF-IDF cosine over their raw texts, computed over the candidate pool +itself — no external model, no runtime dependencies. + +Usage: + from mmr import mmr_rerank + out = mmr_rerank(ids, texts, lam=0.7) # MMR active + out = mmr_rerank(ids, texts, lam=None) # no-op (flag off) + +Unit tests: test_mmr.py +""" +from __future__ import annotations + +import math +import re +from collections import Counter + +_RRF_K = 60 # same constant as the RRF fusion used elsewhere in the harness +_WORD = re.compile(r"[a-z0-9']+") + + +def _tokens(text: str) -> list: + return _WORD.findall(text.lower()) + + +def _tfidf_vectors(texts: list) -> list: + """TF-IDF vectors over the candidate pool. Pure python, pool-sized idf.""" + tokenized = [_tokens(t) for t in texts] + n_docs = len(tokenized) or 1 + df = Counter() + for toks in tokenized: + df.update(set(toks)) + idf = {w: math.log((n_docs + 1) / (c + 1)) + 1.0 for w, c in df.items()} + vecs = [] + for toks in tokenized: + tf = Counter(toks) + vec = {w: (1.0 + math.log(c)) * idf[w] for w, c in tf.items()} + norm = math.sqrt(sum(v * v for v in vec.values())) or 1.0 + vecs.append({w: v / norm for w, v in vec.items()}) + return vecs + + +def _cosine(a: dict, b: dict) -> float: + if len(b) < len(a): + a, b = b, a + return sum(v * b.get(w, 0.0) for w, v in a.items()) + + +def mmr_rerank(session_ids: list, session_texts: list, lam: float | None, + relevance: list | None = None) -> list: + """Greedy MMR reorder of session_ids. + + lam=None → no-op (returns input order; flag off). + lam=1.0 → pure relevance, reproduces input order. + lam<1.0 → trades relevance for diversity. + + session_texts[i] is the full text of session_ids[i]. Optional + `relevance` overrides the default rank-based scores (len must match). + """ + if lam is None or len(session_ids) < 2: + return list(session_ids) + if len(session_ids) != len(session_texts): + raise ValueError("session_ids/session_texts length mismatch") + + rel = relevance if relevance is not None else [ + 1.0 / (_RRF_K + i + 1) for i in range(len(session_ids)) + ] + # Normalise relevance to [0,1] so it is scale-comparable with the cosine + # similarity term. Raw RRF scores span ~0.016 with a spread of ~0.002 — + # without normalisation the diversity term (~0.1-1.0) dominates by ~50x + # and MMR degenerates into pure diversity (measured: R@5 1.0 -> 0.33 + # on the mini smoke dataset). + rmin, rmax = min(rel), max(rel) + if rmax > rmin: + rel = [(r - rmin) / (rmax - rmin) for r in rel] + else: + rel = [1.0] * len(rel) + vecs = _tfidf_vectors(session_texts) + + selected: list = [] + remaining = list(range(len(session_ids))) + # Track max similarity of each remaining candidate to the selected set. + max_sim = {i: 0.0 for i in remaining} + + while remaining: + best_i = remaining[0] + best_score = lam * rel[best_i] - (1.0 - lam) * max_sim[best_i] + for i in remaining[1:]: + score = lam * rel[i] - (1.0 - lam) * max_sim[i] + if score > best_score: + best_i, best_score = i, score + selected.append(best_i) + remaining.remove(best_i) + bv = vecs[best_i] + for j in remaining: + s = _cosine(bv, vecs[j]) + if s > max_sim[j]: + max_sim[j] = s + + return [session_ids[i] for i in selected] diff --git a/benchmarks/longmemeval/modal_fanout.py b/benchmarks/longmemeval/modal_fanout.py new file mode 100644 index 00000000..3aacb591 --- /dev/null +++ b/benchmarks/longmemeval/modal_fanout.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +""" +Modal fan-out runner for the LongMemEval benchmark (uteke). + +Runs the EXISTING harness (run_eval.py) unchanged, in parallel, on Modal CPU +containers. Same uteke binary (v0.15.0 GitHub release), same embedding model +(EmbeddingGemma 300M Q4 ONNX baked into the image), same dataset file → +results are directly comparable with local throttled runs. + +Why CPU and not GPU: the embedding model is a 300M Q4 ONNX model designed for +CPU inference. The historical bottleneck was (a) 2-core throttling + +sequential execution, not (b) compute. Fan-out over N containers divides +wall-clock by ~N at CPU pricing (<$1 for a full 500Q run). + +Usage: + # Sanity check: 10 questions, 2 shards + modal run modal_fanout.py --limit 10 --num-shards 2 + + # Full 500Q hybrid run (the publishable number) + modal run modal_fanout.py --strategy hybrid --num-shards 10 + + # Full 500Q vector run + modal run modal_fanout.py --strategy vector --num-shards 10 + +Results land in results_modal_[_q]/retrieval_results.jsonl +(merged from all shards), ready for: python print_metrics.py + +Notes: +- Each shard runs in its own container with its own temp store → no + cross-contamination between shards (harness wipes per question anyway). +- cpu=2 matches the local throttled run (taskset -c 0-1) for fair per-question + latency comparison. +- The embedding model is baked at /root/.codecora/uteke/models/ so no + first-run download happens inside containers. +""" + +import json +import os +import pathlib +import subprocess +import sys +import time + +import modal + +REPO_DIR = pathlib.Path(__file__).parent +UTEKE_VERSION = "v0.15.0" +# When set, the image builds uteke from this git ref instead of downloading +# the UTEKE_VERSION release. Used for pre-release validation (e.g. develop tip +# before tagging). The exact SHA must be used, not a branch name, so the image +# build is reproducible. +UTEKE_GIT_REF = os.environ.get("UTEKE_GIT_REF", "") +DATA_FILE = "longmemeval_s_cleaned.json" +# Local source of the embedding model (must contain onnx/ + tokenizer.json). +MODEL_SOURCE = pathlib.Path("/opt/data/.codecora/uteke/models/embeddinggemma-q4") + +app = modal.App("uteke-longmemeval") + +# Volume for durable shard outputs: if the local map() call dies (network, +# spend limit mid-run, Ctrl-C), completed shards are preserved and a rerun +# resumes instead of starting from zero. +vol = modal.Volume.from_name("uteke-longmemeval", create_if_missing=True) + +image = ( + # Ubuntu 24.04 base: glibc 2.39 — the uteke release binary needs it + # (debian slim/bookworm ships glibc 2.36 → too old, incl. the "legacy" build). + modal.Image.from_registry("ubuntu:24.04", add_python="3.11") + .apt_install("curl", "ca-certificates", "util-linux") +) +if UTEKE_GIT_REF: + # Pre-release validation: build uteke from the exact SHA (reproducible), + # but reuse the official ORT 1.24.4 x86_64 (SSE4.2) shared lib from the + # latest release tarball — ort is load-dynamic, so the lib is decoupled + # from the binary and versioned identically to official releases. + # build-essential: cargo needs a native linker (cc) on the base image. + image = image.apt_install("build-essential", "pkg-config").run_commands( + "curl -sL https://github.com/codecoradev/uteke/releases/download/" + f"{UTEKE_VERSION}/uteke-x86_64-unknown-linux-gnu-legacy-{UTEKE_VERSION}.tar.gz" + " | tar xz -C /tmp && " + "cp /tmp/libonnxruntime* /usr/local/bin/ && " + "curl -sL https://github.com/codecoradev/uteke/archive/" + UTEKE_GIT_REF + ".tar.gz | tar xz -C /tmp && " + "cd /tmp/uteke-*/ && " + "curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable && " + "$HOME/.cargo/bin/cargo build --release -p uteke-cli && " + "cp target/release/uteke /usr/local/bin/uteke && " + "chmod +x /usr/local/bin/uteke && " + "uteke --version" + ) +else: + # uteke release bundle: binary + libonnxruntime colocated (exe-dir lookup). + image = image.run_commands( + f"curl -sL https://github.com/codecoradev/uteke/releases/download/{UTEKE_VERSION}/" + f"uteke-x86_64-unknown-linux-gnu-legacy-{UTEKE_VERSION}.tar.gz | tar xz -C /tmp && " + "cp /tmp/uteke /usr/local/bin/uteke && cp /tmp/libonnxruntime* /usr/local/bin/ && " + "chmod +x /usr/local/bin/uteke && uteke --version" + ) +image = ( + image + .pip_install("tqdm>=4.65.0", "numpy>=1.24.0") + # Bake the embedding model so containers never download at runtime. + # (add_local_* must come last — Modal mounts them at container start.) + .add_local_dir(str(MODEL_SOURCE), "/root/.codecora/uteke/models/embeddinggemma-q4") + .add_local_file(str(REPO_DIR / "run_eval.py"), "/root/harness/run_eval.py") + # run_eval.py imports temporal/mmr experiment modules at import time — + # they must be baked into the image or every shard dies on ModuleNotFoundError. + .add_local_file(str(REPO_DIR / "temporal.py"), "/root/harness/temporal.py") + .add_local_file(str(REPO_DIR / "mmr.py"), "/root/harness/mmr.py") + .add_local_file(str(REPO_DIR / "data" / DATA_FILE), "/root/harness/data.json") +) + + +@app.function(image=image, timeout=14400, cpu=2, volumes={"/root/vol": vol}) +def run_shard(spec: dict) -> dict: + """Evaluate one strided slice of the dataset with the unmodified harness. + + spec: {"strategy": str, "shard_idx": int, "num_shards": int, "limit": int} + Writes shard output durably to the Modal Volume as soon as it finishes, + so progress survives local map() interruptions and reruns can resume. + """ + import json as _json + + strategy = spec["strategy"] + shard_idx = spec["shard_idx"] + num_shards = spec["num_shards"] + limit = spec["limit"] + + vol_path = pathlib.Path("/root/vol") / strategy / f"shard_{shard_idx:02d}.jsonl" + data = _json.loads(pathlib.Path("/root/harness/data.json").read_text()) + if limit and limit > 0: + data = data[:limit] + shard = data[shard_idx::num_shards] # strided → even question-type mix + expected = len(shard) + if not shard: + return {"shard_idx": shard_idx, "n": 0, "elapsed": 0.0, "jsonl": "", "stderr_tail": ""} + + # Volume cache: skip kalau shard SUDAH LENGKAP; resume kalau PARTIAL (dari timeout) + resume_args = [] + if vol_path.exists(): + prior = [l for l in vol_path.read_text().splitlines() if l.strip()] + if len(prior) >= expected: + return { + "shard_idx": shard_idx, + "n": len(prior), + "evaluated": len(prior), + "elapsed": 0.0, + "jsonl": "\n".join(prior), + "stderr_tail": "(resumed from volume)", + } + if prior: + # partial: seed output dir dari Volume agar run_eval --resume melanjutkan + out_seed = pathlib.Path(f"/tmp/out_{shard_idx}") + out_seed.mkdir(parents=True, exist_ok=True) + (out_seed / "retrieval_results.jsonl").write_text("\n".join(prior) + "\n") + resume_args = ["--resume"] + + pathlib.Path("/tmp/shard.json").write_text(_json.dumps(shard)) + out_dir = f"/tmp/out_{shard_idx}" + + t0 = time.time() + try: + proc = subprocess.run( + [ + sys.executable, "/root/harness/run_eval.py", + "--data", "/tmp/shard.json", + "--output", out_dir, + "--strategy", strategy, + "--namespace", "lmeval", + *resume_args, + ], + capture_output=True, text=True, timeout=14100, # 14400 - buffer commit + ) + except subprocess.TimeoutExpired as e: + # partial-save: run_eval menulis streaming ke out_dir; simpan apa adanya + partial = pathlib.Path(out_dir) / "retrieval_results.jsonl" + jsonl = partial.read_text() if partial.exists() else "" + vol_path.parent.mkdir(parents=True, exist_ok=True) + vol_path.write_text(jsonl) + vol.commit() + return { + "shard_idx": shard_idx, + "n": len([l for l in jsonl.splitlines() if l.strip()]), + "evaluated": len([l for l in jsonl.splitlines() if l.strip()]), + "elapsed": round(time.time() - t0, 1), + "jsonl": jsonl, + "stderr_tail": f"TIMEOUT partial-save: {len(jsonl.splitlines())}/{len(shard)} Q evaluated", + } + elapsed = time.time() - t0 + if proc.returncode != 0: + raise RuntimeError(f"shard {shard_idx} failed:\n{proc.stderr[-2000:]}") + + jsonl_path = pathlib.Path(out_dir) / "retrieval_results.jsonl" + jsonl = jsonl_path.read_text() if jsonl_path.exists() else "" + + # Persist this shard durably before returning — see vol comment above. + vol_path.parent.mkdir(parents=True, exist_ok=True) + vol_path.write_text(jsonl) + vol.commit() + + return { + "shard_idx": shard_idx, + "n": len(shard), + "evaluated": jsonl.count("\n"), + "elapsed": round(elapsed, 1), + "jsonl": jsonl, + "stderr_tail": proc.stderr[-500:], + } + + +@app.function(image=image, timeout=600, cpu=1, volumes={"/root/vol": vol}) +def list_volume(strategy: str) -> list: + """List completed shards for a strategy on the volume (progress check).""" + base = pathlib.Path("/root/vol") / strategy + out = [] + if base.exists(): + for f in sorted(base.glob("shard_*.jsonl")): + n = sum(1 for l in f.read_text().splitlines() if l.strip()) + out.append({"shard": f.name, "questions": n}) + return out + + +@app.local_entrypoint() +def main( + strategy: str = "hybrid", + num_shards: int = 10, + limit: int = 0, + outdir: str = "", +): + if not MODEL_SOURCE.exists(): + sys.exit(f"Model source not found: {MODEL_SOURCE}") + if not (REPO_DIR / "data" / DATA_FILE).exists(): + sys.exit(f"Dataset not found: {REPO_DIR / 'data' / DATA_FILE}") + + outdir = outdir or f"results_modal_{strategy}" + (f"_{limit}q" if limit else "") + out_path = pathlib.Path(outdir) / "retrieval_results.jsonl" + out_path.parent.mkdir(parents=True, exist_ok=True) + + # Progress check from the volume (if any shards completed before a prior + # interruption, they'll be resumed instead of recomputed). + try: + done = list_volume.remote(strategy) + if done: + print(f"Volume state: {len(done)} shard(s) already on volume:") + for d in done: + print(f" {d['shard']}: {d['questions']} questions") + except Exception as e: + print(f"(volume check skipped: {e})") + + print(f"Fan-out: strategy={strategy} shards={num_shards} limit={limit or 'ALL'}") + t0 = time.time() + + # Strided slicing means shard i covers data[i::num_shards]. + inputs = [ + {"strategy": strategy, "shard_idx": i, "num_shards": num_shards, "limit": limit} + for i in range(num_shards) + ] + merged, seen = [], set() + for res in run_shard.map(inputs): + n_eval = 0 + for line in res["jsonl"].splitlines(): + if not line.strip(): + continue + qid = json.loads(line).get("question_id") + if qid in seen: + continue # dedupe guard (retries) + seen.add(qid) + merged.append(line) + n_eval += 1 + print( + f" shard {res['shard_idx']:2d}: {n_eval}/{res['n']} questions " + f"in {res['elapsed']}s" + ) + + out_path.write_text("\n".join(merged) + ("\n" if merged else "")) + wall = time.time() - t0 + print(f"\nDone: {len(merged)} questions in {wall:.0f}s wall clock") + print(f"Results: {out_path}") + print(f"\nRun: python print_metrics.py {out_path}") + # Print metrics right away (numpy available locally). + subprocess.run([sys.executable, str(REPO_DIR / "print_metrics.py"), str(out_path)]) diff --git a/benchmarks/longmemeval/modal_fast50.py b/benchmarks/longmemeval/modal_fast50.py new file mode 100644 index 00000000..26230a1c --- /dev/null +++ b/benchmarks/longmemeval/modal_fast50.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +""" +Modal fan-out runner for the LongMemEval benchmark (uteke). + +Runs the EXISTING harness (run_eval.py) unchanged, in parallel, on Modal CPU +containers. Same uteke binary (v0.15.0 GitHub release), same embedding model +(EmbeddingGemma 300M Q4 ONNX baked into the image), same dataset file → +results are directly comparable with local throttled runs. + +Why CPU and not GPU: the embedding model is a 300M Q4 ONNX model designed for +CPU inference. The historical bottleneck was (a) 2-core throttling + +sequential execution, not (b) compute. Fan-out over N containers divides +wall-clock by ~N at CPU pricing (<$1 for a full 500Q run). + +Usage: + # Sanity check: 10 questions, 2 shards + modal run modal_fanout.py --limit 10 --num-shards 2 + + # Full 500Q hybrid run (the publishable number) + modal run modal_fanout.py --strategy hybrid --num-shards 10 + + # Full 500Q vector run + modal run modal_fanout.py --strategy vector --num-shards 10 + +Results land in results_modal_[_q]/retrieval_results.jsonl +(merged from all shards), ready for: python print_metrics.py + +Notes: +- Each shard runs in its own container with its own temp store → no + cross-contamination between shards (harness wipes per question anyway). +- cpu=2 matches the local throttled run (taskset -c 0-1) for fair per-question + latency comparison. +- The embedding model is baked at /root/.codecora/uteke/models/ so no + first-run download happens inside containers. +""" + +import json +import hashlib +import pathlib +import subprocess +import os +import sys +import time + +import modal + +REPO_DIR = pathlib.Path(__file__).parent +UTEKE_VERSION = "v0.15.0" +DATA_FILE = os.environ.get("LMEVAL_DATA", "longmemeval_fast50.json") +RERANK = False # removed (#1118 cancelled — single-model direction); kept as False for compat +RERANK_DEPTH = 20 +# Local source of the embedding model (must contain onnx/ + tokenizer.json). +MODEL_SOURCE = pathlib.Path("/opt/data/.codecora/uteke/models/embeddinggemma-q4") + +app = modal.App("uteke-lmeval-fast") + +# Volume for durable shard outputs: if the local map() call dies (network, +# spend limit mid-run, Ctrl-C), completed shards are preserved and a rerun +# resumes instead of starting from zero. +vol = modal.Volume.from_name("uteke-lmeval-fast", create_if_missing=True) + +image = ( + # Ubuntu 24.04 base: glibc 2.39 — the uteke release binary needs it + # (debian slim/bookworm ships glibc 2.36 → too old, incl. the "legacy" build). + modal.Image.from_registry("ubuntu:24.04", add_python="3.11") + .apt_install("curl", "ca-certificates", "util-linux") + # uteke release bundle: binary + libonnxruntime colocated (exe-dir lookup). + .run_commands( + f"curl -sL https://github.com/codecoradev/uteke/releases/download/{UTEKE_VERSION}/" + f"uteke-x86_64-unknown-linux-gnu-legacy-{UTEKE_VERSION}.tar.gz | tar xz -C /tmp && " + "cp /tmp/uteke /usr/local/bin/uteke && cp /tmp/libonnxruntime* /usr/local/bin/ && " + "chmod +x /usr/local/bin/uteke && uteke --version" + ) + .pip_install("tqdm>=4.65.0", "numpy>=1.24.0", "onnxruntime>=1.17.0", "tokenizers>=0.19.0") + # Bake the embedding model so containers never download at runtime. + # (add_local_* must come last — Modal mounts them at container start.) + .add_local_dir(str(MODEL_SOURCE), "/root/.codecora/uteke/models/embeddinggemma-q4") + .add_local_file(str(REPO_DIR / "run_eval.py"), "/root/harness/run_eval.py") + .add_local_file(str(REPO_DIR / "data" / DATA_FILE), "/root/harness/data.json") + .add_local_file(str(REPO_DIR / "temporal.py"), "/root/harness/temporal.py") + .add_local_file(str(REPO_DIR / "mmr.py"), "/root/harness/mmr.py") + .add_local_file(str(REPO_DIR / "compare_fast50.py"), "/root/harness/compare_fast50.py") +) + + +@app.function(image=image, timeout=14400, cpu=2, volumes={"/root/vol": vol}) +def run_shard(spec: dict) -> dict: + """Evaluate one strided slice of the dataset with the unmodified harness. + + spec: {"strategy": str, "shard_idx": int, "num_shards": int, "limit": int} + Writes shard output durably to the Modal Volume as soon as it finishes, + so progress survives local map() interruptions and reruns can resume. + """ + import json as _json + + strategy = spec["strategy"] + shard_idx = spec["shard_idx"] + num_shards = spec["num_shards"] + limit = spec["limit"] + temporal = bool(spec.get("temporal", False)) + mmr_lambda = spec.get("mmr_lambda") # None = OFF (default) + fusion = bool(spec.get("fusion", False)) + fusion_w = spec.get("fusion_weight") # primary weight, None = default + + # Variant-keyed volume path: baseline and temporal shards must never + # share cache entries, or resume would silently return stale results. + variant = f"{strategy}_temporal" if temporal else strategy + if mmr_lambda is not None: + variant += f"_mmr{mmr_lambda}" + if fusion: + variant += f"_fusion{fusion_w}" + # Dataset fingerprint: sha1 of the mounted dataset file. Cross-dataset + # stale hits are possible without it (fast50 shard satisfies a 15Q run's + # `len(prior) >= expected` check and is returned verbatim) — see #1129. + data_bytes = pathlib.Path("/root/harness/data.json").read_bytes() + data_tag = hashlib.sha1(data_bytes).hexdigest()[:8] + vol_path = pathlib.Path("/root/vol") / variant / data_tag / f"shard_{shard_idx:02d}.jsonl" + data = _json.loads(data_bytes) + if limit and limit > 0: + data = data[:limit] + shard = data[shard_idx::num_shards] # strided → even question-type mix + expected = len(shard) + if not shard: + return {"shard_idx": shard_idx, "n": 0, "elapsed": 0.0, "jsonl": "", "stderr_tail": ""} + + # Volume cache: skip kalau shard SUDAH LENGKAP; resume kalau PARTIAL (dari timeout) + resume_args = [] + if vol_path.exists(): + prior = [] + for l in vol_path.read_text().splitlines(): + if not l.strip(): + continue + try: + json.loads(l) + except json.JSONDecodeError: + # Truncated tail line from a killed container mid-write: drop + # it rather than abort the shard (#1130 review finding). + continue + prior.append(l) + prior_qids = {json.loads(l).get("question_id") for l in prior} + expected_qids = {e.get("question_id") for e in shard} + # Complete = same question set, not just same count (guard #1129 even + # if two datasets share a prefix and length by coincidence). + if len(prior) >= expected and prior_qids == expected_qids: + return { + "shard_idx": shard_idx, + "n": len(prior), + "evaluated": len(prior), + "elapsed": 0.0, + "jsonl": "\n".join(prior), + "stderr_tail": "(resumed from volume)", + } + if prior: + # partial: seed output dir dari Volume agar run_eval --resume melanjutkan + out_seed = pathlib.Path(f"/tmp/out_{shard_idx}") + out_seed.mkdir(parents=True, exist_ok=True) + (out_seed / "retrieval_results.jsonl").write_text("\n".join(prior) + "\n") + resume_args = ["--resume"] + + pathlib.Path("/tmp/shard.json").write_text(_json.dumps(shard)) + out_dir = f"/tmp/out_{shard_idx}" + + t0 = time.time() + try: + proc = subprocess.run( + [ + sys.executable, "/root/harness/run_eval.py", + "--data", "/tmp/shard.json", + "--output", out_dir, + "--strategy", strategy, + "--namespace", "lmeval", + *(["--temporal"] if temporal else []), + *(["--mmr-lambda", f"{mmr_lambda}"] if mmr_lambda is not None else []), + *(["--fusion", "--fusion-primary-weight", f"{fusion_w}"] if fusion and fusion_w is not None else (["--fusion"] if fusion else [])), + *resume_args, + ], + capture_output=True, text=True, timeout=14100, # 14400 - buffer commit + ) + except subprocess.TimeoutExpired as e: + # partial-save: run_eval menulis streaming ke out_dir; simpan apa adanya + partial = pathlib.Path(out_dir) / "retrieval_results.jsonl" + jsonl = partial.read_text() if partial.exists() else "" + vol_path.parent.mkdir(parents=True, exist_ok=True) + vol_path.write_text(jsonl) + vol.commit() + return { + "shard_idx": shard_idx, + "n": len([l for l in jsonl.splitlines() if l.strip()]), + "evaluated": len([l for l in jsonl.splitlines() if l.strip()]), + "elapsed": round(time.time() - t0, 1), + "jsonl": jsonl, + "stderr_tail": f"TIMEOUT partial-save: {len(jsonl.splitlines())}/{len(shard)} Q evaluated", + } + elapsed = time.time() - t0 + if proc.returncode != 0: + raise RuntimeError(f"shard {shard_idx} failed:\n{proc.stderr[-2000:]}") + + jsonl_path = pathlib.Path(out_dir) / "retrieval_results.jsonl" + jsonl = jsonl_path.read_text() if jsonl_path.exists() else "" + + # Persist this shard durably before returning — see vol comment above. + vol_path.parent.mkdir(parents=True, exist_ok=True) + vol_path.write_text(jsonl) + vol.commit() + + return { + "shard_idx": shard_idx, + "n": len(shard), + "evaluated": jsonl.count("\n"), + "elapsed": round(elapsed, 1), + "jsonl": jsonl, + "stderr_tail": proc.stderr[-500:], + } + + +@app.function(image=image, timeout=600, cpu=1, volumes={"/root/vol": vol}) +def list_volume(strategy: str) -> list: + """List completed shards for a strategy on the volume (progress check). + + Glob is dataset-tag-agnostic (shard_*.jsonl under any tag dir) so the + progress view still works after #1129 introduced per-dataset subdirs. + """ + base = pathlib.Path("/root/vol") / strategy + out = [] + if base.exists(): + for f in sorted(base.glob("*/shard_*.jsonl")): + n = sum(1 for l in f.read_text().splitlines() if l.strip()) + out.append({"shard": f"{f.parent.name}/{f.name}", "questions": n}) + return out + + +@app.local_entrypoint() +def main( + strategy: str = "hybrid", + num_shards: int = 2, + limit: int = 0, + outdir: str = "", + temporal: bool = False, + mmr_lambda: float = 0.0, + fusion: bool = False, + fusion_weight: float = 0.0, +) -> None: + if not MODEL_SOURCE.exists(): + sys.exit(f"Model source not found: {MODEL_SOURCE}") + if not (REPO_DIR / "data" / DATA_FILE).exists(): + sys.exit(f"Dataset not found: {REPO_DIR / 'data' / DATA_FILE}") + + lam: float | None = mmr_lambda if mmr_lambda > 0 else None # 0/omitted = OFF + fw: float | None = fusion_weight if fusion_weight > 0 else None # None = harness default + variant = f"{strategy}_temporal" if temporal else strategy + if lam is not None: + variant += f"_mmr{lam}" + if fusion: + variant += f"_fusion{fw}" + outdir = outdir or "results_modal_" + variant + (f"_{limit}q" if limit else "") + out_path = pathlib.Path(outdir) / "retrieval_results.jsonl" + out_path.parent.mkdir(parents=True, exist_ok=True) + + # Progress check from the volume (if any shards completed before a prior + # interruption, they'll be resumed instead of recomputed). + try: + variant = f"{strategy}_temporal" if temporal else strategy + if lam is not None: + variant += f"_mmr{lam}" + done = list_volume.remote(variant) + if done: + print(f"Volume state: {len(done)} shard(s) already on volume:") + for d in done: + print(f" {d['shard']}: {d['questions']} questions") + except Exception as e: + print(f"(volume check skipped: {e})") + + print(f"Fan-out: strategy={strategy} shards={num_shards} limit={limit or 'ALL'}") + t0 = time.time() + + # Strided slicing means shard i covers data[i::num_shards]. + inputs = [ + {"strategy": strategy, "shard_idx": i, "num_shards": num_shards, "limit": limit, + "temporal": temporal, "mmr_lambda": lam, "fusion": fusion, "fusion_weight": fw} + for i in range(num_shards) + ] + merged, seen = [], set() + for res in run_shard.map(inputs): + n_eval = 0 + for line in res["jsonl"].splitlines(): + if not line.strip(): + continue + qid = json.loads(line).get("question_id") + if qid in seen: + continue # dedupe guard (retries) + seen.add(qid) + merged.append(line) + n_eval += 1 + print( + f" shard {res['shard_idx']:2d}: {n_eval}/{res['n']} questions " + f"in {res['elapsed']}s" + ) + + out_path.write_text("\n".join(merged) + ("\n" if merged else "")) + wall = time.time() - t0 + print(f"\nDone: {len(merged)} questions in {wall:.0f}s wall clock") + print(f"Results: {out_path}") + print(f"\nRun: python print_metrics.py {out_path}") + # Print metrics right away (numpy available locally). + subprocess.run([sys.executable, str(REPO_DIR / "print_metrics.py"), str(out_path)]) diff --git a/benchmarks/longmemeval/preview_scores.py b/benchmarks/longmemeval/preview_scores.py new file mode 100644 index 00000000..b17ab606 --- /dev/null +++ b/benchmarks/longmemeval/preview_scores.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Partial preview: 2-way fusion scores from already-collected vec+hyb rankings. + +Also joins whatever fts5 shards are already on disk (results_modal_fts5/) for +an early 3-way peek, question-subset only. +""" +import glob +import json +import os +from collections import defaultdict + +BASE = os.path.dirname(os.path.abspath(__file__)) + + +def load(p): + return [json.loads(l) for l in open(p) if l.strip()] + + +def rrf(rankings, weights, k=60, topn=45): + s = defaultdict(float) + for ids, w in zip(rankings, weights): + for rank, sid in enumerate(ids[:topn]): + s[sid] += w / (k + rank + 1) + return [x for x, _ in sorted(s.items(), key=lambda t: -t[1])] + + +def r_at(ids, t, k): + return len(set(ids[:k]) & set(t)) / len(t) + + +def main(): + vec = {r['question_id']: r['retrieval_results']['retrieved_session_ids'] + for r in load(os.path.join(BASE, 'results_modal_vector', 'retrieval_results.jsonl'))} + hyb = {r['question_id']: r['retrieval_results']['retrieved_session_ids'] + for r in load(os.path.join(BASE, 'results_modal_hybrid_fresh_post1130', 'retrieval_results.jsonl'))} + + fts = {} + fts_files = sorted(glob.glob(os.path.join(BASE, 'results_modal_fts5_partial/shard_*.jsonl'))) + if not fts_files: + merged = os.path.join(BASE, 'results_modal_fts5', 'retrieval_results.jsonl') + if os.path.exists(merged): + fts_files = [merged] + for fp in fts_files: + for r in load(fp): + fts[r['question_id']] = r['retrieval_results']['retrieved_session_ids'] + + with open(os.path.join(BASE, 'data', 'longmemeval_fast50.json')) as f: + ds = json.load(f) + gt = {q['question_id']: q['answer_session_ids'] for q in ds} + + qids2 = [q for q in vec if q in hyb and gt.get(q)] + print(f'== 2-way preview (full fast50, n={len(qids2)}) ==') + rows = [] + for name, src in [('vector-only', 'v'), ('hybrid-only', 'h')]: + tot = sum(r_at(vec[q] if src == 'v' else hyb[q], gt[q], 5) for q in qids2) + rows.append((name, tot / len(qids2))) + for name, w in [('fusion 1.7/1.0 (shipped)', (1.7, 1.0)), ('fusion 1.0/1.0', (1.0, 1.0))]: + tot = sum(r_at(rrf([vec[q], hyb[q]], w), gt[q], 5) for q in qids2) + rows.append((name, tot / len(qids2))) + for name, v in rows: + print(f' {name:26s} R@5 = {v:.4f}') + + qids3 = [q for q in qids2 if q in fts] + if qids3: + print(f'\n== 3-way EARLY peek (subset with fts5, n={len(qids3)}/50) ==') + base2 = sum(r_at(rrf([vec[q], hyb[q]], (1.7, 1.0)), gt[q], 5) for q in qids3) + print(f' fusion 1.7/1.0 on subset R@5 = {base2 / len(qids3):.4f}') + fts_only = sum(r_at(fts[q], gt[q], 5) for q in qids3) + print(f' fts5-only on subset R@5 = {fts_only / len(qids3):.4f}') + best = (None, -1.0) + for wv in (1.4, 1.7, 2.0): + for wf in (0.2, 0.4, 0.6, 0.8, 1.0): + w = (wv, 1.0, wf) + tot = sum(r_at(rrf([vec[q], hyb[q], fts[q]], w), gt[q], 5) for q in qids3) + if tot > best[1]: + best = (w, tot) + print(f' best 3-way on subset: w={best[0]} R@5 = {best[1] / len(qids3):.4f} ' + f'(delta vs 2way {best[1] / len(qids3) - base2 / len(qids3):+.4f})') + print(' (ANGKA SUBSET — bisa berubah saat 50Q lengkap, jangan dipakai putus weight)') + else: + print('\n(fts5 rankings belum ada di disk — 3-way peek menyusul)') + + +if __name__ == '__main__': + main() diff --git a/benchmarks/longmemeval/pull_fast.py b/benchmarks/longmemeval/pull_fast.py new file mode 100644 index 00000000..5db23f4c --- /dev/null +++ b/benchmarks/longmemeval/pull_fast.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Pull fast-eval shards from Modal Volume uteke-lmeval-fast → local merged jsonl. + +Usage: modal run pull_fast.py --out results_fast50_baseline +Writes: /retrieval_results.jsonl (merged, dedup by question_id) +Prints: single summary line (shards, questions). +""" +import json +import pathlib +import modal + +vol = modal.Volume.from_name("uteke-lmeval-fast", create_if_missing=True) + +app = modal.App("uteke-lmeval-pull-fast") + + +@app.function(volumes={"/root/vol": vol}, timeout=300) +def pull(strategy: str) -> dict: + base = pathlib.Path("/root/vol") / strategy + out = {} + if base.exists(): + for f in sorted(base.glob("shard_*.jsonl")): + out[f.name] = f.read_text() + vol.commit() + return out + + +@app.local_entrypoint() +def main(out: str = "results_fast50_baseline", strategy: str = "hybrid"): + shards = pull.remote(strategy) + seen, rows = set(), [] + for fname in sorted(shards): + for line in shards[fname].splitlines(): + line = line.strip() + if not line: + continue + try: + qid = json.loads(line).get("question_id") + except json.JSONDecodeError: + continue + if qid and qid not in seen: + seen.add(qid) + rows.append(line) + p = pathlib.Path(out) / "retrieval_results.jsonl" + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("\n".join(rows) + ("\n" if rows else "")) + print(f"PULL_FAST: {len(shards)} shards, {len(rows)} Q → {p}") diff --git a/benchmarks/longmemeval/pull_shards.py b/benchmarks/longmemeval/pull_shards.py new file mode 100644 index 00000000..65d46262 --- /dev/null +++ b/benchmarks/longmemeval/pull_shards.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Pull completed LongMemEval shards from the Modal Volume. + +Prints a JSON dict {shard_file: content} on the last line (parsed by +watch_and_pull.py). +""" +import json + +import modal + +vol = modal.Volume.from_name("uteke-longmemeval", create_if_missing=True) + +app = modal.App("lmeval-pull") + + +@app.function(volumes={"/root/vol": vol}, timeout=120) +def pull(strategy: str) -> dict: + import pathlib + base = pathlib.Path("/root/vol") / strategy + out = {} + if base.exists(): + for f in sorted(base.glob("shard_*.jsonl")): + out[f.name] = f.read_text() + return out + + +@app.local_entrypoint() +def main(strategy: str = "hybrid"): + print(json.dumps(pull.remote(strategy))) diff --git a/benchmarks/longmemeval/run_eval.py b/benchmarks/longmemeval/run_eval.py index 120dd78b..3cf5f6a3 100644 --- a/benchmarks/longmemeval/run_eval.py +++ b/benchmarks/longmemeval/run_eval.py @@ -20,8 +20,15 @@ import time from pathlib import Path +# Temporal date-window boost (#1119) — harness-side, default OFF. +sys.path.insert(0, str(Path(__file__).parent)) +from temporal import boost_ranking, parse_question_date, parse_temporal + # Auto-configure embedding backend from EMBED_API_KEY/EMBED_API_BASE/EMBED_MODEL env vars. # Falls back to local ONNX if these are not set. +# MMR diversity rerank (#1120) — harness-side, default OFF. +sys.path.insert(0, str(Path(__file__).parent)) +from mmr import mmr_rerank if os.environ.get("EMBED_API_KEY") and os.environ.get("EMBED_API_BASE"): os.environ.setdefault("UTEKE_EMBEDDING_BACKEND", "openai") os.environ.setdefault("UTEKE_EMBEDDING_API_KEY", os.environ["EMBED_API_KEY"]) @@ -101,7 +108,14 @@ def run_uteke(args, store_path, subcommand, extra_args=None): repo_root = Path(__file__).resolve().parent.parent.parent uteke_bin = str(repo_root / "target" / "release" / "uteke") if not Path(uteke_bin).exists(): - uteke_bin = shutil.which("uteke") or "uteke" + # Prefer the known-good local install over ambiguous PATH hits + # (/opt/data/.cargo/bin/uteke is x86_64-dynamic and broken on this host) + for cand in ("/opt/data/.local/bin/uteke",): + if Path(cand).exists(): + uteke_bin = cand + break + else: + uteke_bin = shutil.which("uteke") or "uteke" # Linux-only: throttle CPU so benchmarks don't starve the server. if sys.platform == "linux": @@ -219,55 +233,136 @@ def recall_and_evaluate(args, store_path, entry, answer_sessions, inserted_sids, # from insert failures). evidence_session_ids = set(entry.get("answer_session_ids", [])) & inserted_sids - # Run recall — fetch top-50 for Recall@5/10/50 + # Run recall — fetch top-50 for Recall@5/10/50. + # --fusion mode (#1123): RRF-fuse two uteke rankings (vector + hybrid). + # Vector and hybrid fail on DISJOINT question sets (fast50 evidence: + # 7 questions vector-only wins, 5 hybrid wins) — RRF captures both. + # Weights vec×1.5 : hybrid×1 sit mid-plateau [1.3, 1.6] of R@5=0.97. + fusion_mode = getattr(args, "fusion", False) + # "default" sentinel: omit --strategy so the binary's zero-config + # default (0.16.0: fusion) is what gets validated (#1123). + strategy_args = [] if args.strategy == "default" else ["--strategy", args.strategy] recall_cmd = [ "recall", question, "--limit", "50", "--tags", "longmemeval", - "--strategy", args.strategy, + *strategy_args, "--min", "0.0", # Disable threshold — evaluate raw retrieval ranking (#995) ] + if fusion_mode: + # Base ranking from primary strategy, then fuse with the other. + # Build the complementary command by REPLACING the --strategy value + # (appending would duplicate the flag → clap error). + other = "vector" if args.strategy == "hybrid" else "hybrid" + alt_cmd = [ + "recall", question, + "--limit", "50", + "--tags", "longmemeval", + "--strategy", other, + "--min", "0.0", + ] + fusion_cmds = [recall_cmd, alt_cmd] + else: + fusion_cmds = [recall_cmd] try: - output = run_uteke(args, store_path, recall_cmd) + outputs = [run_uteke(args, store_path, cmd) for cmd in fusion_cmds] except RuntimeError as e: print(f" Warning: recall failed: {e}", file=sys.stderr) return None # Parse results try: - results = json.loads(output) + parsed = [json.loads(o) for o in outputs] + results = parsed if fusion_mode else (parsed[0] if isinstance(parsed[0], list) else [parsed[0]]) except json.JSONDecodeError: print(f" Warning: could not parse recall output", file=sys.stderr) return None - if not isinstance(results, list): - results = [results] - - # Extract session_ids via memory_id -> session_id mapping. - # Recall JSON in uteke 0.7+ returns memory_id, not metadata fields. - # We built mid_to_sid during insert to bridge this. - raw_session_ids = [] - for r in results: - mid = r.get("memory_id") or r.get("id") - if mid and mid in mid_to_sid: - raw_session_ids.append(mid_to_sid[mid]) - else: - # Fallback: try metadata (older uteke versions) - meta = r.get("metadata", {}) - sid = meta.get("session_id") - if sid: - raw_session_ids.append(sid) - # Deduplicate session_ids preserving rank order (first occurrence = highest rank). # When --chunk-sessions is enabled, multiple chunks from the same session # can appear in results. We keep only the first (best-ranked) occurrence # so that session-level Recall@k measures unique sessions, not chunks. - seen = set() - retrieved_session_ids = [] - for sid in raw_session_ids: - if sid not in seen: - seen.add(sid) - retrieved_session_ids.append(sid) + def _dedup_ranking(raw_sids): + seen = set() + out = [] + for sid in raw_sids: + if sid not in seen: + seen.add(sid) + out.append(sid) + return out + + if fusion_mode: + # results = [ranking_primary, ranking_secondary] (lists of recall dicts) + per_ranking_sids = [] + for res in results: + raw = [] + for r in res: + mid = r.get("memory_id") or r.get("id") + if mid and mid in mid_to_sid: + raw.append(mid_to_sid[mid]) + else: + meta = r.get("metadata", {}) + sid = meta.get("session_id") + if sid: + raw.append(sid) + per_ranking_sids.append(_dedup_ranking(raw)) + + # RRF fuse: primary (vector) ×1.7 + secondary (hybrid) ×1, k=60. + # Tuned on ACTUAL x86 Modal rankings: plateau [1.7, 1.9] → R@5=0.98. + scores = {} + primary_w = getattr(args, "fusion_primary_weight", 1.7) + k = 60 + for i, sid in enumerate(per_ranking_sids[0]): + scores[sid] = scores.get(sid, 0.0) + primary_w / (k + i + 1) + for i, sid in enumerate(per_ranking_sids[1]): + scores[sid] = scores.get(sid, 0.0) + 1.0 / (k + i + 1) + retrieved_session_ids = sorted(scores, key=lambda s: -scores[s]) + else: + raw_session_ids = [] + for r in results: + mid = r.get("memory_id") or r.get("id") + if mid and mid in mid_to_sid: + raw_session_ids.append(mid_to_sid[mid]) + else: + # Fallback: try metadata (older uteke versions) + meta = r.get("metadata", {}) + sid = meta.get("session_id") + if sid: + raw_session_ids.append(sid) + retrieved_session_ids = _dedup_ranking(raw_session_ids) + + # Temporal date-window boost (#1119): soft-boost sessions whose date falls + # inside the question's temporal window ("last month", "in February", + # "between X and Y", ...). No-op when the question has no temporal + # expression or when dates are missing. Default OFF (--temporal). + if getattr(args, "temporal", False) and retrieved_session_ids: + qdate = parse_question_date(entry.get("question_date", "")) + window = parse_temporal(question, qdate) + if window: + session_dates = {} + for sid, dstr in zip(entry.get("haystack_session_ids", []), + entry.get("haystack_dates", [])): + d = parse_question_date(dstr) + if d is not None: + session_dates[sid] = d + retrieved_session_ids = boost_ranking( + retrieved_session_ids, session_dates, window, + boost=getattr(args, "temporal_boost", 0.0022)) + + # MMR diversity rerank (#1120): penalise redundant sessions in the top-k. + # Zero-model post-processing (TF-IDF cosine over candidate texts) — + # default OFF (--mmr-lambda not passed → None → no-op). + if getattr(args, "mmr_lambda", None) is not None and retrieved_session_ids: + # haystack_sessions[i] (list of turn dicts) aligns with + # haystack_session_ids[i] by index — build sid -> text directly. + texts_by_sid = { + sid: session_to_text(hs) + for sid, hs in zip(entry.get("haystack_session_ids", []), + entry.get("haystack_sessions", [])) + } + candidate_texts = [texts_by_sid.get(sid, "") for sid in retrieved_session_ids] + retrieved_session_ids = mmr_rerank( + retrieved_session_ids, candidate_texts, lam=args.mmr_lambda) # --- Session-level metrics --- # Recall@k: fraction of evidence sessions in top-k @@ -297,6 +392,7 @@ def recall_and_evaluate(args, store_path, entry, answer_sessions, inserted_sids, return { "session": {**session_recall, **session_ndcg}, + "_retrieved_session_ids": retrieved_session_ids[:50], # Note: turn-level retrieval requires per-turn indexing, which this # harness does not do (sessions are inserted as single memories). # Turn-level metrics are omitted to avoid misleading copies of @@ -325,14 +421,35 @@ def main(): parser.add_argument("--reset-every", type=int, default=20, help="Wipe and recreate the store every N questions to prevent memory buildup (default: 20)") parser.add_argument("--strategy", default="vector", - choices=["vector", "fts5", "hybrid", "graph"], - help="Recall strategy (default: vector)") + choices=["vector", "fts5", "hybrid", "graph", "fusion", "default"], + help="Recall strategy (default: vector). 'fusion' runs " + "the IN-CORE weighted RRF (vector×1.7+hybrid×1) — " + "distinct from the harness-level --fusion flag (#1123). " + "'default' omits --strategy entirely so the binary's " + "own zero-config default applies (0.16.0: fusion)") + parser.add_argument("--fusion", action="store_true", + help="RRF-fuse two recall rankings: primary strategy ×1.7 + " + "complementary (vector↔hybrid) ×1, k=60 (#1123)") + parser.add_argument("--fusion-primary-weight", type=float, default=1.7, + help="RRF weight for the primary strategy ranking (default: 1.5)") parser.add_argument("--chunk-sessions", action="store_true", help="Chunk long sessions into smaller memories to reduce embedding dilution") parser.add_argument("--chunk-size", type=int, default=2000, help="Max chars per chunk when --chunk-sessions is enabled (default: 2000)") + parser.add_argument("--temporal", action="store_true", + help="Enable temporal date-window boost (#1119), default OFF") + parser.add_argument("--temporal-boost", type=float, default=0.0022, + help="Additive RRF boost for sessions inside the temporal window (default: 0.0022)") + parser.add_argument("--mmr-lambda", type=float, default=None, + help="Enable MMR diversity rerank (#1120) with this lambda " + "(e.g. 0.7). Default: off (None, no rerank).") args = parser.parse_args() + if args.temporal: + print(f"Temporal boost: enabled (boost={args.temporal_boost})") + if args.mmr_lambda is not None: + print(f"MMR diversity rerank: enabled (lambda={args.mmr_lambda})") + # Load data with open(args.data) as f: data = json.load(f) @@ -344,6 +461,9 @@ def main(): print(f" Questions: {len(data)}") print(f" Namespace: {args.namespace}") print(f" Strategy: {args.strategy}") + if getattr(args, "fusion", False): + other = "vector" if args.strategy == "hybrid" else "hybrid" + print(f" Fusion: enabled (RRF {args.strategy}×{args.fusion_primary_weight} + {other}×1, k=60)") if args.chunk_sessions: print(f" Chunking: enabled (max {args.chunk_size} chars/session)") else: @@ -398,7 +518,12 @@ def main(): result_entry = { "question_id": qid, "question_type": entry.get("question_type", "unknown"), - "retrieval_results": {"metrics": metrics}, + "retrieval_results": { + "metrics": metrics, + # Post-rerank session order — enables offline replay of + # temporal/MMR experiments without re-burning credits. + "retrieved_session_ids": metrics.pop("_retrieved_session_ids", []), + }, } fout.write(json.dumps(result_entry) + "\n") fout.flush() # Flush for resume safety diff --git a/benchmarks/longmemeval/sim3way.py b/benchmarks/longmemeval/sim3way.py new file mode 100644 index 00000000..bdafd29d --- /dev/null +++ b/benchmarks/longmemeval/sim3way.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""3-way RRF simulation: vector + hybrid + fts5-only rankings. + +Offline replay over saved rankings (fast50, Modal x86) — no uteke calls, no +embedding burns. Question: does adding fts5-only as a third RRF arm lift +R@5 over the shipped 2-way fusion (vec*1.7 + hyb*1, k=60)? + +Inputs (relative to this dir): + results_modal_vector/retrieval_results.jsonl — vector-only rankings + results_modal_hybrid_fresh_post1130/...jsonl — hybrid rankings + results_modal_fts5/retrieval_results.jsonl — fts5-only rankings + data/longmemeval_fast50.json — ground truth + +Usage: + python3 sim3way.py rankings # fail-overlap analysis (2-way) + python3 sim3way.py fusion # 3-way RRF grid search (weights, k) +""" +import json +import os +import sys +from collections import defaultdict + +BASE = os.path.dirname(os.path.abspath(__file__)) +K_DEFAULT = 60 + + +def load_jsonl(path): + with open(path) as f: + return [json.loads(line) for line in f if line.strip()] + + +def r_at_k(ids, truth, k): + if not truth: + return None + return len(set(ids[:k]) & truth) / len(truth) + + +def rrf_fuse(rankings, weights, k=K_DEFAULT, topn=45): + """Weighted RRF over multiple id lists. Mirrors rrf_fuse_weighted semantics.""" + scores = defaultdict(float) + for ids, w in zip(rankings, weights): + for rank, sid in enumerate(ids[:topn]): + scores[sid] += w / (k + rank + 1) + return [sid for sid, _ in sorted(scores.items(), key=lambda x: -x[1])] + + +def evaluate(fused, truth): + m = {} + m['recall_all@5'] = r_at_k(fused, truth, 5) + m['recall_all@10'] = r_at_k(fused, truth, 10) + return m + + +def load_all(): + vec = load_jsonl(os.path.join(BASE, 'results_modal_vector', 'retrieval_results.jsonl')) + hyb = load_jsonl(os.path.join(BASE, 'results_modal_hybrid_fresh_post1130', 'retrieval_results.jsonl')) + fts = load_jsonl(os.path.join(BASE, 'results_modal_fts5', 'retrieval_results.jsonl')) + ds = json.load(open(os.path.join(BASE, 'data', 'longmemeval_fast50.json'))) + if isinstance(ds, dict): + ds = [ds] + gt = {q['question_id']: set(q['answer_session_ids']) for q in ds} + + by_qid = defaultdict(dict) + for name, rows in (('vec', vec), ('hyb', hyb), ('fts', fts)): + for r in rows: + by_qid[r['question_id']][name] = r['retrieval_results']['retrieved_session_ids'] + + out = [] + for qid, d in by_qid.items(): + t = gt.get(qid) + if not t or len(d) < 3: + continue + out.append((qid, t, d['vec'], d['hyb'], d['fts'])) + return out + + +def mode_rankings(): + """Fail-overlap between the three base rankings.""" + data = load_all() + print(f'questions with all 3 rankings + truth: {len(data)}') + # simpler: recompute directly + res = {} + for name, idx in (('vec', 2), ('hyb', 3), ('fts', 4)): + fails = [] + total = 0.0 + for qid, t, *rankings in data: + r5 = r_at_k(rankings[idx - 2], t, 5) + assert r5 is not None + total += r5 + if r5 < 1.0: + fails.append((qid[:12], r5)) + res[name] = (total / len(data), fails) + print(f'{name}: mean R@5 {total / len(data):.4f}, fails {len(fails)}: {fails}') + + v = {q for q, _ in res["vec"][1]} + h = {q for q, _ in res["hyb"][1]} + f = {q for q, _ in res["fts"][1]} + print() + print(f'vec∩hyb fails: {len(v & h)} {sorted(v & h)}') + print(f'vec∩fts fails: {len(v & f)} {sorted(v & f)}') + print(f'hyb∩fts fails: {len(h & f)} {sorted(h & f)}') + print(f'all three fail: {len(v & h & f)} {sorted(v & h & f)}') + print(f'union of fails: {len(v | h | f)}') + + +def mode_fusion(): + data = load_all() + n = len(data) + print(f'questions: {n}') + + def run(weights, k=K_DEFAULT): + r5 = r10 = 0.0 + fails = [] + for qid, t, *rk in data: + fused = rrf_fuse(rk, weights, k) + m = evaluate(fused, t) + r5 += m['recall_all@5'] + r10 += m['recall_all@10'] + if m['recall_all@5'] < 1.0: + fails.append((qid[:12], m['recall_all@5'])) + return r5 / n, r10 / n, fails + + # Baseline: shipped 2-way (vec*1.7, hyb*1) + b = run((1.7, 1.0, 0.0)) + print(f'2-way shipped (1.7/1.0/0) R@5 {b[0]:.4f} R@10 {b[1]:.4f} fails {len(b[2])}: {b[2]}') + + # 3-way grid: fts weight 0.2–1.0, vec weight around 1.7 + print() + print('3-way grid (vec / hyb / fts):') + best = (None, -1) + for wv in (1.4, 1.7, 2.0): + for wf in (0.2, 0.4, 0.6, 0.8, 1.0): + w = (wv, 1.0, wf) + r5, r10, fails = run(w) + mark = '' + if r5 > best[1]: + best = (w, r5) + mark = ' <-- best so far' + print(f' {wv:.1f}/{1.0:.1f}/{wf:.1f} R@5 {r5:.4f} R@10 {r10:.4f} fails {len(fails)}{mark}') + print() + print(f'BEST: weights={best[0]} R@5={best[1]:.4f} (shipped 2-way: {b[0]:.4f}, delta {best[1]-b[0]:+.4f})') + + +def main(): + mode = sys.argv[1] if len(sys.argv) > 1 else 'rankings' + if mode == 'rankings': + mode_rankings() + elif mode == 'fusion': + mode_fusion() + else: + print(f'unknown mode: {mode}', file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/longmemeval/sim3way_final.py b/benchmarks/longmemeval/sim3way_final.py new file mode 100644 index 00000000..fdd0b304 --- /dev/null +++ b/benchmarks/longmemeval/sim3way_final.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Final 3-way RRF simulation on full 500Q (fts5) x fast50 (vec, hyb). + +Question: does adding fts5-only as a third RRF arm beat the shipped 2-way +fusion (vec*1.7 + hyb*1.0, k=60)? + +Data availability reality: + - fts5 rankings: 500 Q (just collected, Modal x86) + - vector + hybrid rankings: 50 Q (fast50, Modal x86) +=> 3-way grid runs on the 50-Q intersection; the fts5 arm at 500 Q is used + for the fts5-only baseline and failure analysis. +""" +import json +import os +from collections import defaultdict + +BASE = os.path.dirname(os.path.abspath(__file__)) + + +def load_jsonl(p): + with open(p) as f: + return [json.loads(l) for l in f if l.strip()] + + +def rrf(rankings, weights, k=60, topn=45): + s = defaultdict(float) + for ids, w in zip(rankings, weights): + for rank, sid in enumerate(ids[:topn]): + s[sid] += w / (k + rank + 1) + return [x for x, _ in sorted(s.items(), key=lambda t: -t[1])] + + +def r_at(ids, t, k): + return len(set(ids[:k]) & set(t)) / len(t) + + +def main(): + fts = {} + for s in range(10): + for r in load_jsonl(os.path.join(BASE, 'results_modal_fts5_partial', f'shard_{s:02d}.jsonl')): + fts[r['question_id']] = r['retrieval_results']['retrieved_session_ids'] + vec = {r['question_id']: r['retrieval_results']['retrieved_session_ids'] + for r in load_jsonl(os.path.join(BASE, 'results_modal_vector', 'retrieval_results.jsonl'))} + hyb = {r['question_id']: r['retrieval_results']['retrieved_session_ids'] + for r in load_jsonl(os.path.join(BASE, 'results_modal_hybrid_fresh_post1130', 'retrieval_results.jsonl'))} + + with open(os.path.join(BASE, 'data', 'longmemeval_s_cleaned.json')) as f: + ds = json.load(f) + gt = {q['question_id']: q['answer_session_ids'] for q in ds} + qtype = {q['question_id']: q.get('question_type', '?') for q in ds} + + # fts5-only baseline on full 500 + qids500 = [q for q in fts if gt.get(q)] + tot5 = sum(r_at(fts[q], gt[q], 5) for q in qids500) + tot10 = sum(r_at(fts[q], gt[q], 10) for q in qids500) + print(f'fts5-only 500Q: n={len(qids500)} R@5={tot5/len(qids500):.4f} R@10={tot10/len(qids500):.4f}') + + # 3-way grid on 50-Q intersection + qids = [q for q in vec if q in hyb and q in fts and gt.get(q)] + n = len(qids) + print(f'\n3-way grid on intersection n={n}') + base2 = {q: r_at(rrf([vec[q], hyb[q]], (1.7, 1.0)), gt[q], 5) for q in qids} + b2 = sum(base2.values()) / n + print(f' 2-way shipped (1.7,1.0) R@5 = {b2:.4f}') + + best = (None, -1.0) + for wv in (1.3, 1.4, 1.7, 2.0): + for wf in (0.1, 0.2, 0.4, 0.6, 1.0): + w = (wv, 1.0, wf) + tot = sum(r_at(rrf([vec[q], hyb[q], fts[q]], w), gt[q], 5) for q in qids) + if tot > best[1]: + best = (w, tot) + print(f' best 3-way w={best[0]} R@5 = {best[1]/n:.4f} (delta {best[1]/n - b2:+.4f})') + + # per-question delta at shipped 2-way vs best 3-way + wv, _, wf = best[0] + flips = [] + for q in qids: + d = r_at(rrf([vec[q], hyb[q], fts[q]], best[0]), gt[q], 5) - base2[q] + if abs(d) > 1e-9: + flips.append((q[:8], qtype.get(q, '?'), round(d, 2))) + print(f' flipped questions: {len(flips)}') + for f in flips: + print(' ', f) + + # failure overlap analysis on 500Q fts5 + fts_fail = {q for q in qids500 if r_at(fts[q], gt[q], 5) < 1.0} + print(f'\nfts5 fails R@5<1 @500Q: {len(fts_fail)}/{len(qids500)}') + by_type = defaultdict(int) + for q in fts_fail: + by_type[qtype.get(q, '?')] += 1 + for t, c in sorted(by_type.items(), key=lambda t: -t[1]): + print(f' {t}: {c}') + + +if __name__ == '__main__': + main() diff --git a/benchmarks/longmemeval/temporal.py b/benchmarks/longmemeval/temporal.py new file mode 100644 index 00000000..cc84a06a --- /dev/null +++ b/benchmarks/longmemeval/temporal.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Temporal expression parsing + date-window boost for LongMemEval (#1119). + +Harness-side only: no uteke/Rust changes. When a question contains a temporal +expression ("last month", "before January", "in February", "between X and Y"), +we compute a date window relative to the question date and softly boost +retrieved sessions whose session date falls inside that window, before +Recall@k is computed. Boost is a small additive constant on an RRF-style rank +score, so it can lift an in-window session from rank ~10 into the top-5 but +never completely overrides semantic ranking. + +Default OFF (--temporal flag in run_eval.py); when OFF or when no temporal +expression is parsed, output ordering is identical to baseline. +""" +import re +from datetime import date, timedelta + +MONTHS = { + "january": 1, "february": 2, "march": 3, "april": 4, "may": 5, "june": 6, + "july": 7, "august": 8, "september": 9, "october": 10, "november": 11, "december": 12, +} +MONTH_RE = "|".join(MONTHS.keys()) +UNITS = {"day": 1, "week": 7, "month": 30, "year": 365} + +# Question date format in LongMemEval: "2023/03/22 (Wed) 21:49" +QDATE_RE = re.compile(r"(\d{4})/(\d{2})/(\d{2})") + + +def _days_in_month(year, month): + if month == 12: + return 31 + return (date(year, month + 1, 1) - timedelta(days=1)).day + + +def _month_start_abs(month, year): + return date(year, month, 1) + + +def _month_end_abs(month, year): + return date(year, month, _days_in_month(year, month)) + + +def _resolve_year(month, year, q): + """Bare month names resolve within the 12 months preceding the question. + + LongMemEval haystacks span roughly the year before the question date, so + 'January' asked in 2023/03 means 2023-01, while 'May' means 2022-05. + """ + if year is not None: + return year + return q.year if month <= q.month else q.year - 1 + + +def parse_question_date(qdate_str): + """'2023/03/22 (Wed) 21:49' -> date(2023,3,22). None if unparseable.""" + m = QDATE_RE.search(qdate_str or "") + if not m: + return None + return date(int(m.group(1)), int(m.group(2)), int(m.group(3))) + + +# Each pattern: (compiled regex, name, resolver(match, q) -> (lo, hi)). +# lo/hi are datetime.date or None for open-ended bounds. Ordered most +# specific first. +_RX_BETWEEN = re.compile( + r"\bbetween\s+(" + MONTH_RE + r")(?:\s+(\d{4}))?\s+and\s+(" + MONTH_RE + r")(?:\s+(\d{4}))?\b", + re.I, +) +_RX_BEFORE = re.compile(r"\bbefore\s+(" + MONTH_RE + r")(?:\s+(\d{4}))?\b", re.I) +_RX_AFTER = re.compile(r"\b(?:after|since)\s+(" + MONTH_RE + r")(?:\s+(\d{4}))?\b", re.I) +_RX_IN_MONTH = re.compile(r"\bin\s+(" + MONTH_RE + r")(?:\s+(\d{4}))?\b", re.I) +_RX_LAST_N = re.compile(r"\b(?:last|past|previous)\s+(\d+)\s+(day|week|month|year)s?\b", re.I) +_RX_N_AGO = re.compile( + r"\b(\d+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)" + r"\s+(day|week|month)s?\s+ago\b", + re.I, +) +_RX_LAST_WEEK = re.compile(r"\blast\s+week\b", re.I) +_RX_LAST_MONTH = re.compile(r"\blast\s+month\b", re.I) +_RX_THIS_MONTH = re.compile(r"\bthis\s+month\b", re.I) +_RX_THIS_YEAR = re.compile(r"\bthis\s+year\b", re.I) + + +def _res_between(m, q): + m1, y1 = MONTHS[m.group(1).lower()], m.group(2) + m2, y2 = MONTHS[m.group(3).lower()], m.group(4) + lo = _month_start_abs(m1, _resolve_year(m1, int(y1) if y1 else None, q)) + hi_year = _resolve_year(m2, int(y2) if y2 else None, q) + hi = _month_end_abs(m2, hi_year) + if hi < lo: + # Range crossing the question date (e.g. "between February and April" + # asked in March): bare-month resolution can put hi a year before lo. + # The range is stated in forward order, so bump hi into the next year. + hi = _month_end_abs(m2, hi_year + 1) + return (lo, hi) + + +def _res_before(m, q): + mm, yy = MONTHS[m.group(1).lower()], m.group(2) + hi = _month_end_abs(mm, _resolve_year(mm, int(yy) if yy else None, q)) + return (None, hi) + + +def _res_after(m, q): + mm, yy = MONTHS[m.group(1).lower()], m.group(2) + lo = _month_start_abs(mm, _resolve_year(mm, int(yy) if yy else None, q)) + return (lo, None) + + +def _res_in_month(m, q): + mm, yy = MONTHS[m.group(1).lower()], m.group(2) + y = _resolve_year(mm, int(yy) if yy else None, q) + return (_month_start_abs(mm, y), _month_end_abs(mm, y)) + + +def _res_last_n(m, q): + n, unit = int(m.group(1)), m.group(2).lower() + return (q - timedelta(days=n * UNITS[unit]), q) + + +def _res_last_month(m, q): + first_this = _month_start_abs(q.month, q.year) + last_prev = first_this - timedelta(days=1) # final day of previous month + return (_month_start_abs(last_prev.month, last_prev.year), last_prev) + + +_WORDS = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, + "seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12} + + +def _res_n_ago(m, q): + # "10 days ago" / "four weeks ago": the referenced event sits ~N units + # before the question date. Window = [q - 1.5*span, q - 0.5*span] so the + # nominal day lands mid-window with generous slack for fuzzy memories. + g = m.group(1).lower() + n = int(g) if g.isdigit() else _WORDS[g] + span = n * UNITS[m.group(2).lower()] + return (q - timedelta(days=int(1.5 * span)), q - timedelta(days=span // 2)) + + +def _res_last_week(m, q): + # Previous calendar week (Mon-Sun) relative to the question date. + monday_this = q - timedelta(days=q.weekday()) + return (monday_this - timedelta(days=7), monday_this - timedelta(days=1)) + + +def _res_this_month(m, q): + return (_month_start_abs(q.month, q.year), q) + + +def _res_this_year(m, q): + return (date(q.year, 1, 1), q) + + +_PATTERNS = [ + (_RX_BETWEEN, "between", _res_between), + (_RX_BEFORE, "before", _res_before), + (_RX_AFTER, "after/since", _res_after), + (_RX_IN_MONTH, "in-month", _res_in_month), + (_RX_N_AGO, "n-ago", _res_n_ago), + (_RX_LAST_N, "last-N", _res_last_n), + (_RX_LAST_WEEK, "last-week", _res_last_week), + (_RX_LAST_MONTH, "last-month", _res_last_month), + (_RX_THIS_MONTH, "this-month", _res_this_month), + (_RX_THIS_YEAR, "this-year", _res_this_year), +] + + +def parse_temporal(question, qdate): + """Parse the first matching temporal expression from a question. + + Returns (lo, hi, kind) — lo/hi are datetime.date or None (open bound) — + or None when the question carries no recognized temporal expression. + """ + if not question or qdate is None: + return None + for rx, name, fn in _PATTERNS: + m = rx.search(question) + if m: + lo, hi = fn(m, qdate) + return (lo, hi, name) + return None + + +def in_window(d, window): + """True if date d is within (lo, hi) inclusive; open ends are unbounded.""" + if not window: + return False + lo, hi, _ = window + if lo and d < lo: + return False + if hi and d > hi: + return False + return True + + +RRF_K = 60 # standard RRF constant + + +def boost_ranking(session_ids, session_dates, window, boost=0.0022, rrf_k=RRF_K): + """Re-rank deduped session ids with an additive temporal boost. + + session_ids: recall rank order (index 0 = rank 1) + session_dates: dict session_id -> datetime.date (metadata 'date'); missing ok + window: output of parse_temporal(), or None -> returned unchanged + + Rank r gets RRF score 1/(k+r); an in-window session gets +`boost`. + Calibration goal (recall@5): an in-window session ranked 6..15 must pass + an out-of-window session holding the rank-5 slot — 1/(k+15)+boost > + 1/(k+5) holds for boost=0.0022, k=60. Reordering *within* the top-5 is + possible and harmless: recall@k membership only changes at the k/k+1 + boundary. Ties keep recall order (stable). + """ + if not window or not session_ids: + return session_ids + scored = [] + for rank, sid in enumerate(session_ids, start=1): + s = 1.0 / (rrf_k + rank) + d = session_dates.get(sid) + if d is not None and in_window(d, window): + s += boost + scored.append((s, -rank, sid)) # -rank: stable tie-break toward recall order + scored.sort(key=lambda t: (t[0], t[1]), reverse=True) + return [sid for _, _, sid in scored] diff --git a/benchmarks/longmemeval/test_mmr.py b/benchmarks/longmemeval/test_mmr.py new file mode 100644 index 00000000..f59b5804 --- /dev/null +++ b/benchmarks/longmemeval/test_mmr.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Unit tests for mmr.py (#1120). Run: python3 test_mmr.py""" +import sys + +from mmr import mmr_rerank, _tfidf_vectors, _cosine + +PASS = 0 +FAIL = 0 + + +def check(name, cond, detail=""): + global PASS, FAIL + if cond: + PASS += 1 + print(f" ok {name}") + else: + FAIL += 1 + print(f" FAIL {name} {detail}") + + +# --- no-op semantics (flag off) --- +ids = ["s1", "s2", "s3"] +texts = ["alpha beta", "gamma delta", "epsilon zeta"] +check("lam=None returns input unchanged", mmr_rerank(ids, texts, None) == ids) +check("lam=1.0 pure relevance keeps order", mmr_rerank(ids, texts, 1.0) == ids) +check("single item passthrough", mmr_rerank(["s1"], ["text"], 0.5) == ["s1"]) +check("empty list passthrough", mmr_rerank([], [], 0.5) == []) + +# --- diversity behavior --- +# Pool: A and A2 near-duplicates, B distinct. Rank order: A, A2, B. +dup = ["cat sat mat", "cat sat mat cat", "dogs bark loudly"] +out = mmr_rerank(["A", "A2", "B"], dup, lam=0.5) +check("near-duplicate demoted below distinct", out[0] == "A" and out[1] == "B", + f"got {out}") + +# With lam=1.0 the duplicate stays at rank 2 (pure relevance). +out = mmr_rerank(["A", "A2", "B"], dup, lam=1.0) +check("lam=1.0 keeps duplicate at rank 2", out[:2] == ["A", "A2"], f"got {out}") + +# --- relevance override respected --- +# Force B's relevance to dominate: even at lam=0.9 B must come first. +rel = [0.01, 0.01, 0.99] +out = mmr_rerank(["A", "A2", "B"], dup, lam=0.9, relevance=rel) +check("relevance override dominates", out[0] == "B", f"got {out}") + +# --- similarity helpers --- +v = _tfidf_vectors(["same same same", "same same same"]) +check("identical texts cosine=1.0", abs(_cosine(v[0], v[1]) - 1.0) < 1e-9, + f"got {_cosine(v[0], v[1])}") +v2 = _tfidf_vectors(["cat dog", "bird fish"]) +check("disjoint texts cosine=0.0", _cosine(v2[0], v2[1]) == 0.0) + +# --- length mismatch raises --- +try: + mmr_rerank(["A", "B"], ["only one text"], 0.5) + check("length mismatch raises", False) +except ValueError: + check("length mismatch raises", True) + +# --- deterministic --- +r1 = mmr_rerank(ids, texts, 0.7) +r2 = mmr_rerank(ids, texts, 0.7) +check("deterministic output", r1 == r2) + +# --- pool sizes: sanity on a bigger pool --- +big_ids = [f"s{i}" for i in range(30)] +big_texts = [f"doc {i} topic {i % 5}" for i in range(30)] +big_out = mmr_rerank(big_ids, big_texts, 0.6) +check("big pool: permutation valid", sorted(big_out) == sorted(big_ids)) +check("big pool: top item is rank-1", big_out[0] == "s0") + +print(f"\n{PASS} passed, {FAIL} failure(s)") +sys.exit(1 if FAIL else 0) diff --git a/benchmarks/longmemeval/test_temporal.py b/benchmarks/longmemeval/test_temporal.py new file mode 100644 index 00000000..beeab1e2 --- /dev/null +++ b/benchmarks/longmemeval/test_temporal.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Unit tests for temporal.py (#1119). + +Run: python3 -m pytest test_temporal.py -v (or python3 test_temporal.py) +""" +import sys +from datetime import date +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from temporal import (parse_temporal, parse_question_date, in_window, + boost_ranking, RRF_K) + + +# ---------- parse_question_date ---------- + +def test_qdate_standard(): + assert parse_question_date("2023/03/22 (Wed) 21:49") == date(2023, 3, 22) + +def test_qdate_none(): + assert parse_question_date(None) is None + assert parse_question_date("") is None + assert parse_question_date("no date here") is None + + +# ---------- parse_temporal: resolver coverage ---------- + +Q = date(2023, 3, 22) + +def test_between(): + w = parse_temporal("What did I do between February and April?", Q) + assert w[2] == "between" + assert w[0] == date(2023, 2, 1) + assert w[1] == date(2023, 4, 30) + +def test_before(): + w = parse_temporal("What did I buy before January?", Q) + assert w[2] == "before" + assert w[0] is None and w[1] == date(2023, 1, 31) + +def test_after(): + w = parse_temporal("What changed after March?", Q) + assert w[2] == "after/since" + assert w[0] == date(2023, 3, 1) and w[1] is None + +def test_since_with_year(): + w = parse_temporal("since May 2022", Q) + assert w[0] == date(2022, 5, 1) and w[1] is None + +def test_in_month(): + w = parse_temporal("What did I eat in February?", Q) + assert w[2] == "in-month" + assert w[0] == date(2023, 2, 1) and w[1] == date(2023, 2, 28) + +def test_in_month_wrap_year(): + # 'May' asked in March 2023 -> 2022-05 (haystack year precedes question) + w = parse_temporal("in May", Q) + assert w[0] == date(2022, 5, 1) + +def test_last_n_months(): + w = parse_temporal("What did I cook in the last 3 months?", Q) + assert w[2] == "last-N" + assert w[0] == date(2022, 12, 22) and w[1] == Q + +def test_last_month(): + w = parse_temporal("last month", Q) + assert w[2] == "last-month" + assert w[0] == date(2023, 2, 1) and w[1] == date(2023, 2, 28) + +def test_last_month_january_edge(): + w = parse_temporal("last month", date(2023, 1, 15)) + assert w[0] == date(2022, 12, 1) and w[1] == date(2022, 12, 31) + +def test_this_month(): + w = parse_temporal("this month", Q) + assert w[0] == date(2023, 3, 1) + assert w[1] == Q # window extends through question date itself + +def test_this_year(): + w = parse_temporal("this year", Q) + assert w[0] == date(2023, 1, 1) and w[1] == Q + +def test_no_temporal(): + assert parse_temporal("What is my favorite sushi?", Q) is None + # Month name used as a proper noun must NOT parse (strict patterns + # require a preposition, e.g. "in January", not "January Jones"). + assert parse_temporal("January Jones is an actress", Q) is None + assert parse_temporal("", Q) is None + assert parse_temporal("last month", None) is None + + +# ---------- in_window ---------- + +def test_in_window_basic(): + w = (date(2023, 2, 1), date(2023, 2, 28), "t") + assert in_window(date(2023, 2, 15), w) + assert not in_window(date(2023, 3, 1), w) + +def test_in_window_open_bounds(): + w = (None, date(2023, 1, 31), "t") + assert in_window(date(2020, 6, 1), w) + assert not in_window(date(2023, 2, 1), w) + + +# ---------- boost_ranking ---------- + +def test_boost_lifts_in_window_session(): + # session at rank 9 (RRF 1/69 = 0.01449) with boost 0.002 -> 0.01649 + # rank 5 (1/65 = 0.01538) without boost stays below it? 0.01538 < 0.01649 yes + sids = [f"s{i}" for i in range(1, 16)] + dates = {f"s{i}": date(2023, 2, 10) if i == 9 else date(2022, 6, 1) for i in range(1, 16)} + w = (date(2023, 2, 1), date(3, 1, 1) and date(2023, 2, 28), "t") + out = boost_ranking(sids, dates, w) + assert out[0] == "s9", "in-window session at rank 9 should lift to top-5 (and here to #1 vs out-of-window ranks 2-4)" + +def test_boost_no_window_is_noop(): + sids = [f"s{i}" for i in range(1, 10)] + out = boost_ranking(sids, {}, None) + assert out == sids + +def test_boost_without_dates_is_noop(): + sids = [f"s{i}" for i in range(1, 10)] + w = (date(2023, 2, 1), date(2023, 2, 28), "t") + out = boost_ranking(sids, {}, w) + assert out == sids + +def test_boost_preserves_order_within_window(): + # two in-window sessions keep their recall order relative to each other + sids = [f"s{i}" for i in range(1, 12)] + dates = {sid: date(2023, 2, 5) for sid in sids} + w = (date(2023, 2, 1), date(2023, 2, 28), "t") + out = boost_ranking(sids, dates, w) + assert out == sids + +def test_boost_stable_semantic_top_ranked_not_demoted(): + # rank-1 session, in-window, must stay #1 even if many others boosted + sids = [f"s{i}" for i in range(1, 26)] + dates = {sid: date(2023, 2, 5) for sid in sids} + w = (date(2023, 2, 1), date(2023, 2, 28), "t") + out = boost_ranking(sids, dates, w) + assert out[0] == "s1" + +def test_rank_math(): + # Boundary property for recall@5: an in-window rank-15 must beat an + # out-of-window rank-5 (membership crossing), while a rank-1 out-of-window + # still beats an in-window rank-15 (head of ranking stays semantic). + r15_boosted = 1 / (RRF_K + 15) + 0.0022 + r5 = 1 / (RRF_K + 5) + r1 = 1 / (RRF_K + 1) + assert r15_boosted > r5, "in-window rank-15 must cross the rank-5 boundary" + assert r1 > r15_boosted, "rank-1 must stay above a boosted rank-15" + + +if __name__ == "__main__": + fails = 0 + for name, fn in [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)]: + try: + fn() + print(f"PASS {name}") + except AssertionError as e: + fails += 1 + print(f"FAIL {name}: {e}") + print(f"\n{fails} failure(s)") + sys.exit(1 if fails else 0) diff --git a/benchmarks/longmemeval/watch_and_pull.py b/benchmarks/longmemeval/watch_and_pull.py new file mode 100644 index 00000000..4019930e --- /dev/null +++ b/benchmarks/longmemeval/watch_and_pull.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Watch & pull LongMemEval shards from Modal Volume every 10 minutes. + +- Merges completed shards into results_modal_/retrieval_results.jsonl + (partial analysis possible anytime) +- Auto-relaunches `modal run modal_fanout.py` if the Modal run died + (shards on the Volume are resumed, not recomputed) +- Writes progress to lmeval_watch_state.json; log to lmeval_watch.log + +Run: nohup-style background via Hermes terminal(background=true). +Stop: kill the process (state persists on Volume + local jsonl). +""" +import json +import pathlib +import subprocess +import time + +BASE = pathlib.Path("/opt/data/github/codecoradev/uteke/benchmarks/longmemeval") +STRATEGY = "hybrid" +NUM_SHARDS = 20 +INTERVAL = 600 # 10 menit +STATE = BASE / "lmeval_watch_state.json" +LOG = BASE / "lmeval_watch.log" +RESULTS = BASE / f"results_modal_{STRATEGY}" / "retrieval_results.jsonl" + + +def log(msg): + line = f"[{time.strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with open(LOG, "a") as f: + f.write(line + "\n") + + +def pull_shards(): + """Run pull_shards.py, parse JSON from last line.""" + r = subprocess.run( + ["modal", "run", "pull_shards.py", "--strategy", STRATEGY], + capture_output=True, text=True, cwd=BASE, timeout=300, + ) + if r.returncode != 0: + log(f"pull failed: {(r.stderr or r.stdout)[-200:]}") + return {} + for line in reversed((r.stdout + "\n" + r.stderr).splitlines()): + line = line.strip() + if line.startswith("{") and "shard_" in line: + try: + return json.loads(line) + except json.JSONDecodeError: + continue + return {} + + +def merge(shards: dict): + seen, rows = set(), [] + for fname in sorted(shards): + for line in shards[fname].splitlines(): + line = line.strip() + if not line: + continue + try: + qid = json.loads(line).get("question_id") + except json.JSONDecodeError: + continue + if qid and qid not in seen: + seen.add(qid) + rows.append(line) + RESULTS.parent.mkdir(parents=True, exist_ok=True) + RESULTS.write_text("\n".join(rows) + ("\n" if rows else "")) + return len(rows) + + +def app_running(): + """True kalau ada app uteke-longmemeval state=ephemeral DAN punya tasks > 0. + Pakai --json biar tidak kena format tabel terpotong.""" + import json as _json + r = subprocess.run(["modal", "app", "list", "--json"], capture_output=True, text=True, timeout=60) + try: + apps = _json.loads(r.stdout) + except _json.JSONDecodeError: + return True # gagal parse → asumsi jalan (jangan salah relaunch) + for a in apps: + desc = str(a.get("description", "")) + if "uteke-longmemeval" in desc and a.get("state") == "ephemeral": + return True + return False + + +def relaunch(): + log("app tidak aktif → relaunch modal_fanout (resume dari Volume)") + subprocess.Popen( + ["modal", "run", "modal_fanout.py", "--strategy", STRATEGY, + "--num-shards", str(NUM_SHARDS)], + cwd=BASE, + stdout=open(BASE / "lmeval_relaunch.log", "a"), + stderr=subprocess.STDOUT, + start_new_session=True, # lepas dari process group watcher — kill watcher ≠ kill run + ) + + +def report(n_shards: int, n_q: int, metrics_stdout: str): + """Progress report ke Discord thread benchmark (via discord_notify pattern).""" + import sys + sys.path.insert(0, "/opt/data/scripts") + try: + from discord_notify import send_embed + except ImportError: + log("report: discord_notify tidak importable — skip") + return + + recall5 = ndcg5 = recall10 = "?" + for line in metrics_stdout.splitlines(): + if "recall_all@5" in line: + recall5 = line.split("=")[1].strip() + elif "ndcg_any@5" in line: + ndcg5 = line.split("=")[1].strip() + elif "recall_all@10" in line: + recall10 = line.split("=")[1].strip() + + pct = n_q * 100 // 500 + desc = ( + f"Shards selesai: **{n_shards}/20** · Q merged: **{n_q}/500** ({pct}%)\n" + f"recall_all@5 = **{recall5}** · ndcg_any@5 = **{ndcg5}** · recall_all@10 = {recall10}" + ) + r = send_embed("1541981760668176484", { # thread benchmark ini + "title": f"📊 Progress {time.strftime('%H:%M')} — LongMemEval 500Q hybrid", + "description": desc, + "color": 0x3498DB, + }, bot="cmo") + log(f"report: {'OK' if r.get('success') else r.get('error')}") + + +def main(): + log(f"watcher start: strategy={STRATEGY} shards={NUM_SHARDS} interval={INTERVAL}s") + while True: + try: + shards = pull_shards() + n_shards = len(shards) + n_q = merge(shards) + state = {"last_pull": time.strftime("%H:%M:%S"), + "shards_done": n_shards, "questions": n_q} + json.dump(state, open(STATE, "w"), indent=1) + log(f"pull: {n_shards}/{NUM_SHARDS} shard, {n_q} Q merged → {RESULTS.name}") + + # hitung partial metrics + report ke Discord + pm = subprocess.run( + ["python3", "print_metrics.py", str(RESULTS)], + capture_output=True, text=True, cwd=BASE, timeout=120, + ) + report(n_shards, n_q, pm.stdout) + + if n_shards >= NUM_SHARDS: + if n_q >= 480: + # 480/500 = toleransi beberapa Q gagal evaluasi (metrics None) + log("SEMUA SHARD SELESAI — watcher berhenti") + # final: simpan metrik lengkap (pm sudah dihitung di atas) + open(BASE / "lmeval_final_metrics.txt", "w").write(pm.stdout) + log("metrik final tersimpan: lmeval_final_metrics.txt") + return + else: + log(f"20 file shard ada tapi baru {n_q}/500 Q — ada shard partial; " + f"tunggu relaunch resume (kode 14400s) menuntaskan sisa Q") + + if not app_running(): + relaunch() + else: + log("app masih aktif") + except Exception as e: + log(f"error: {type(e).__name__}: {e}") + time.sleep(INTERVAL) + + +if __name__ == "__main__": + main() diff --git a/crates/uteke-cli/Cargo.toml b/crates/uteke-cli/Cargo.toml index baf9e472..c95cab3a 100644 --- a/crates/uteke-cli/Cargo.toml +++ b/crates/uteke-cli/Cargo.toml @@ -13,8 +13,15 @@ categories = ["command-line-utilities", "development-tools"] name = "uteke" path = "src/main.rs" +[features] +default = ["usearch"] +# Vector index backend forwarded to uteke-core. Exactly one must be enabled; +# default is usearch (HNSW C++ FFI). vecq = pure-Rust 4-bit quantization (#1098). +usearch = ["uteke-core/usearch"] +vecq = ["uteke-core/vecq"] + [dependencies] -uteke-core = { path = "../uteke-core", version = "0.15.0", features = ["onnx"] } +uteke-core = { path = "../uteke-core", version = "0.16.0", default-features = false, features = ["onnx"] } clap = { version = "4", features = ["derive"] } serde_json = "1" tracing = "0.1" diff --git a/crates/uteke-cli/src/cli.rs b/crates/uteke-cli/src/cli.rs index 95aceef2..ef1db9a3 100644 --- a/crates/uteke-cli/src/cli.rs +++ b/crates/uteke-cli/src/cli.rs @@ -63,6 +63,10 @@ pub enum Commands { /// Entity identifier for structured metadata #[arg(long)] entity: Option, + /// Who authored the memory: human or agent (#1084). Default: agent + /// (matches the schema default, #1083). + #[arg(long)] + author_type: Option, /// Category classification #[arg(long)] category: Option, @@ -104,9 +108,10 @@ pub enum Commands { /// Use strict threshold from config (min_score_strict) #[arg(long)] strict: bool, - /// Recall strategy: vector, fts5, hybrid, or graph (graph = hybrid + + /// Recall strategy: vector, fts5, hybrid, graph, or fusion (fusion = + /// weighted RRF of vector×1.7 + hybrid×1, #1123; graph = hybrid + /// graph-signal reranking, #378). Defaults to config's - /// `[recall].default_strategy` (hybrid). + /// `[recall].default_strategy` (fusion). #[arg(long)] strategy: Option, /// Enable salience boost (how much each result matters) (#352). @@ -205,7 +210,8 @@ pub enum Commands { /// Delete ALL memories in namespace (requires --confirm) #[arg(long)] all: bool, - /// Confirm destructive operations + /// Confirm destructive operations (the non-interactive equivalent of + /// the y/N prompt; scripts and cron jobs must pass this) #[arg(long)] confirm: bool, }, @@ -776,6 +782,21 @@ pub enum RoomCommands { /// Document slug doc_slug: String, }, + /// Consolidate a room's memories into fewer, denser records (#1088). + /// + /// Dry-run by default: shows the batching plan and estimated LLM calls + /// without contacting any API. Pass --apply to execute (requires + /// extraction LLM config in uteke.toml or UTEKE_* env vars). + Consolidate { + /// Room ID + room_id: String, + /// Execute the plan (LLM calls + store writes). Omit for dry-run. + #[arg(long)] + apply: bool, + /// Max LLM requests for this run (default 10, failed calls count too) + #[arg(long, default_value_t = 10)] + max_calls: usize, + }, } /// Subcommands for memory aging operations. diff --git a/crates/uteke-cli/src/commands/bench.rs b/crates/uteke-cli/src/commands/bench.rs index 81f7df8a..b17fa169 100644 --- a/crates/uteke-cli/src/commands/bench.rs +++ b/crates/uteke-cli/src/commands/bench.rs @@ -230,7 +230,10 @@ fn bench_single_count(count: usize) -> Result { let _ = uteke.shutdown(); let db_path = dir.join("uteke.db"); - let index_path = dir.join("uteke_index.usearch"); + let index_path = dir.join(format!( + "uteke_index.{}", + uteke_core::memory::vector::INDEX_EXT + )); let keys_path = dir.join("uteke_index.keys"); let db_size_kb = file_size_kb(&db_path); diff --git a/crates/uteke-cli/src/commands/maintenance.rs b/crates/uteke-cli/src/commands/maintenance.rs index f96fa5b0..26e955d9 100644 --- a/crates/uteke-cli/src/commands/maintenance.rs +++ b/crates/uteke-cli/src/commands/maintenance.rs @@ -39,14 +39,13 @@ pub(crate) fn run_repair( if rebuild { tracing::info!("Running repair --rebuild (deleting index files first)"); - // Determine index path: cli --store override or config default. - let store_dir = cli - .store - .as_deref() - .map(|s| s.to_string()) - .unwrap_or_else(|| crate::Config::expand_tilde(&config.store.path)); - - let index_path = std::path::PathBuf::from(&store_dir).join("uteke_index.usearch"); + // Same resolution order as main (#1105): --store > UTEKE_HOME > config. + let store_dir = crate::resolve_store_path(cli, config); + + let index_path = std::path::PathBuf::from(&store_dir).join(format!( + "uteke_index.{}", + uteke_core::memory::vector::INDEX_EXT + )); let keys_path = std::path::PathBuf::from(&store_dir).join("uteke_index.keys"); // Delete both index files so the store opens cleanly. diff --git a/crates/uteke-cli/src/commands/mod.rs b/crates/uteke-cli/src/commands/mod.rs index 03a09477..3ccdc902 100644 --- a/crates/uteke-cli/src/commands/mod.rs +++ b/crates/uteke-cli/src/commands/mod.rs @@ -44,6 +44,7 @@ pub(crate) fn run_command(cli: &Cli, uteke: &mut Uteke, config: &Config) -> Resu detect_contradiction, entity, category, + author_type, meta, room, author, @@ -59,6 +60,7 @@ pub(crate) fn run_command(cli: &Cli, uteke: &mut Uteke, config: &Config) -> Resu *detect_contradiction, entity.as_deref(), category.as_deref(), + author_type.as_deref(), meta, room.as_deref(), author.as_deref(), diff --git a/crates/uteke-cli/src/commands/remember.rs b/crates/uteke-cli/src/commands/remember.rs index ebe0457e..67f2e365 100644 --- a/crates/uteke-cli/src/commands/remember.rs +++ b/crates/uteke-cli/src/commands/remember.rs @@ -58,6 +58,7 @@ pub(crate) fn run( detect_contradiction: bool, entity: Option<&str>, category: Option<&str>, + author_type: Option<&str>, meta: &[String], room: Option<&str>, author: Option<&str>, @@ -66,6 +67,14 @@ pub(crate) fn run( ) -> Result<(), String> { tracing::debug!("Remembering: {content} (type: {type}, contradiction: {detect_contradiction})"); + // #1106: validate BEFORE any write so an invalid value fails loudly + // without leaving a stored row behind (the post-store set would have + // been too late — the row already exists with the schema default). + if let Some(at) = author_type { + uteke_core::memory::types::validate_author_type(at) + .map_err(|e| format!("Invalid --author-type: {e}"))?; + } + // Read from stdin when content argument is "-" (#620). // Standard Unix convention: pipe content via echo "text" | uteke remember - --tags "test" let content = if content == "-" { @@ -130,9 +139,16 @@ pub(crate) fn run( .map_err(|e| format!("Failed to store memory: {e}"))?; stored_id = id.clone(); tracing::info!("Memory stored with ID: {id}"); + // #1084/#1106: persist the (already-validated) author_type. + if let Some(at) = author_type { + if let Err(e) = uteke.set_author_type(&id, at) { + return Err(format!("Failed to set author_type: {e}")); + } + } if cli.json { let mut obj = serde_json::json!({ "id": id, + "author_type": author_type.unwrap_or("agent"), "contradiction": { "detected": contradiction.contradicted, "deprecated_id": contradiction.deprecated_id, @@ -185,8 +201,17 @@ pub(crate) fn run( }; stored_id = id.clone(); tracing::info!("Memory stored with ID: {id}"); + // #1084/#1106: persist the (already-validated) author_type. + if let Some(at) = author_type { + if let Err(e) = uteke.set_author_type(&id, at) { + return Err(format!("Failed to set author_type: {e}")); + } + } if cli.json { - let mut obj = serde_json::json!({"id": id}); + let mut obj = serde_json::json!({ + "id": id, + "author_type": author_type.unwrap_or("agent"), + }); // Reconstruct metadata for JSON output (already consumed by remember) if entity.is_some() || category.is_some() || !meta.is_empty() { let mut m = serde_json::Map::new(); diff --git a/crates/uteke-cli/src/commands/room.rs b/crates/uteke-cli/src/commands/room.rs index 0ed46788..16d3cec2 100644 --- a/crates/uteke-cli/src/commands/room.rs +++ b/crates/uteke-cli/src/commands/room.rs @@ -330,5 +330,70 @@ pub(crate) fn run( } Ok(()) } + RoomCommands::Consolidate { + room_id, + apply, + max_calls, + } => { + let dry = uteke_core::consolidation_api::plan_room(uteke, room_id) + .map_err(|e| format!("Failed to plan consolidation: {e}"))?; + + if !apply { + if cli.json { + println!("{}", serde_json::to_string_pretty(&dry).unwrap()); + } else { + println!("Consolidation plan for room '{room_id}' (dry-run, no LLM calls):"); + println!(" Memories: {}", dry.plan.total_memories); + println!(" Batches: {}", dry.plan.batches.len()); + println!( + " Estimated LLM calls: {}", + dry.plan.batches.len().min(*max_calls) + ); + if dry.plan.batches.len() > *max_calls { + println!( + " ⚠ {} batches exceed --max-calls {max_calls}; run with higher --max-calls or in parts.", + dry.plan.batches.len() - max_calls + ); + } + println!(" Pass --apply to execute."); + } + return Ok(()); + } + + // Apply: requires extraction LLM setup (shared with import --extract). + let ext = &config.extraction; + if ext.api_key.is_empty() { + return Err( + "Consolidation --apply needs LLM config: set [extraction] in uteke.toml \ + or UTEKE_EXTRACTION_API_KEY (same setup as `import --extract`)." + .to_string(), + ); + } + let result = + uteke_core::consolidation_api::consolidate_room(uteke, room_id, ext, *max_calls) + .map_err(|e| format!("Consolidation failed: {e}"))?; + + if cli.json { + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + } else { + println!("Consolidation of room '{room_id}' complete:"); + println!(" Batches processed: {}", result.batches_processed); + println!(" Records written: {}", result.records_written); + println!(" Sources deprecated: {}", result.sources_deprecated); + if result.budget_skipped > 0 { + println!(" Budget-skipped: {}", result.budget_skipped); + } + if result.rejected_by_policy > 0 { + println!(" Rejected (policy): {}", result.rejected_by_policy); + } + if !result.batch_errors.is_empty() { + println!(" ⚠ Batch errors (isolated, run continued):"); + for e in &result.batch_errors { + println!(" - {e}"); + } + } + } + Ok(()) + } } } diff --git a/crates/uteke-cli/src/commands/server.rs b/crates/uteke-cli/src/commands/server.rs index 6dd099b6..377afcb6 100644 --- a/crates/uteke-cli/src/commands/server.rs +++ b/crates/uteke-cli/src/commands/server.rs @@ -48,6 +48,7 @@ pub(crate) fn run_via_server(cli: &Cli, server_url: &str) -> Result<(), String> detect_contradiction, entity, category, + author_type, meta, room, author, @@ -59,6 +60,9 @@ pub(crate) fn run_via_server(cli: &Cli, server_url: &str) -> Result<(), String> "tags": tags, "namespace": ns }); + if let Some(at) = author_type { + body["author_type"] = serde_json::json!(at); + } if !r#type.is_empty() { body["type"] = serde_json::json!(r#type); } diff --git a/crates/uteke-cli/src/config.rs b/crates/uteke-cli/src/config.rs index 15eccd6c..2c92eaac 100644 --- a/crates/uteke-cli/src/config.rs +++ b/crates/uteke-cli/src/config.rs @@ -251,9 +251,10 @@ impl Default for RecallConfig { Self { min_score: 0.3, min_score_strict: 0.5, - // Hybrid (RRF: vector + FTS5) is the default strategy. - // Benchmark: R@5 98.0% vs vector-only 85.4% on LongMemEval-S. - default_strategy: "hybrid".to_string(), + // Fusion (weighted RRF: vector×1.7 + hybrid×1) is the default + // strategy since 0.16.0 (#1123). Benchmark: fast50 R@5 0.98 vs + // 0.9267 hybrid. Explicit config still overrides. + default_strategy: "fusion".to_string(), graph_density_weight: 0.1, graph_authority_weight: 0.1, graph_rerank_enabled: true, @@ -834,11 +835,14 @@ impl Config { // Graph-augmented reranking overrides (#378) if let Ok(v) = std::env::var("UTEKE_RECALL_STRATEGY") { - if matches!(v.as_str(), "vector" | "fts5" | "hybrid" | "graph") { + if matches!( + v.as_str(), + "vector" | "fts5" | "hybrid" | "graph" | "fusion" + ) { self.recall.default_strategy = v; } else { tracing::warn!( - "Invalid UTEKE_RECALL_STRATEGY='{v}', ignoring (expected vector|fts5|hybrid|graph)" + "Invalid UTEKE_RECALL_STRATEGY='{v}', ignoring (expected vector|fts5|hybrid|graph|fusion)" ); } } @@ -1012,7 +1016,7 @@ impl Config { [recall] # min_score = 0.3 # min_score_strict = 0.5 -# default_strategy = "hybrid" # vector | fts5 | hybrid | graph +# default_strategy = "fusion" # vector | fts5 | hybrid | graph | fusion # graph_density_weight = 0.1 # graph_authority_weight = 0.1 # graph_rerank_enabled = true @@ -1475,8 +1479,9 @@ namespace = "agent1" let cfg = RecallConfig::default(); assert!((cfg.min_score - 0.3).abs() < f64::EPSILON); assert!((cfg.min_score_strict - 0.5).abs() < f64::EPSILON); - // Hybrid (RRF: vector + FTS5) is the default strategy. - assert_eq!(cfg.default_strategy, "hybrid"); + // Fusion (vector×1.7 + hybrid×1 weighted RRF) is the default + // strategy since 0.16.0 (#1123). + assert_eq!(cfg.default_strategy, "fusion"); } #[test] diff --git a/crates/uteke-cli/src/main.rs b/crates/uteke-cli/src/main.rs index dbcd0d4e..a2862f7c 100644 --- a/crates/uteke-cli/src/main.rs +++ b/crates/uteke-cli/src/main.rs @@ -19,6 +19,23 @@ use uteke_core::Uteke; /// Global flag set by SIGINT handler. static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false); +/// Store resolution order (#1105): `--store` flag > `UTEKE_HOME` env > +/// `uteke.toml [store].path` > built-in default. `UTEKE_HOME` previously only +/// affected models/config/logs while the DB silently stayed on the default +/// (often production) path — an operator trap that could read/write the wrong +/// database from an "isolated-looking" invocation. +pub(crate) fn resolve_store_path(cli: &Cli, config: &Config) -> String { + if let Some(s) = cli.store.as_deref() { + return s.to_string(); + } + if let Ok(home) = std::env::var("UTEKE_HOME") { + if !home.is_empty() { + return home; + } + } + Config::expand_tilde(&config.store.path) +} + fn main() { let cli = Cli::parse(); @@ -130,11 +147,7 @@ fn main() { tracing::debug!("No server detected, using local store"); } - let store_path = cli - .store - .as_deref() - .map(|s| s.to_string()) - .unwrap_or_else(|| Config::expand_tilde(&config.store.path)); + let store_path = resolve_store_path(&cli, &config); tracing::debug!("Opening store at: {store_path}"); @@ -236,3 +249,37 @@ pub(crate) fn resolve_namespace(cli: &Cli, config: &Config) -> String { } "default".to_string() } + +#[cfg(test)] +mod resolve_store_tests { + use super::*; + + /// All four precedence cases mutate the process-global UTEKE_HOME, so + /// they run sequentially inside ONE #[test] — parallel #[test]s racing + /// on the same env var flake nondeterministically (same lesson as the + /// ort_init tests). + #[test] + fn store_resolution_precedence() { + let cfg = Config::default(); + let expanded_default = Config::expand_tilde(&cfg.store.path); + + // 1) --store flag beats everything + // SAFETY: single-threaded test section; no other test touches this var. + unsafe { std::env::set_var("UTEKE_HOME", "/tmp/env-home") }; + let mut cli = Cli::try_parse_from(["uteke", "stats"]).unwrap(); + cli.store = Some("/tmp/flag-store".to_string()); + assert_eq!(resolve_store_path(&cli, &cfg), "/tmp/flag-store"); + + // 2) UTEKE_HOME beats config when flag absent + cli.store = None; + assert_eq!(resolve_store_path(&cli, &cfg), "/tmp/env-home"); + + // 3) empty env falls back to config + unsafe { std::env::set_var("UTEKE_HOME", "") }; + assert_eq!(resolve_store_path(&cli, &cfg), expanded_default); + + // 4) config default when no flag, no env + unsafe { std::env::remove_var("UTEKE_HOME") }; + assert_eq!(resolve_store_path(&cli, &cfg), expanded_default); + } +} diff --git a/crates/uteke-cli/src/output.rs b/crates/uteke-cli/src/output.rs index 19afe101..0379a803 100644 --- a/crates/uteke-cli/src/output.rs +++ b/crates/uteke-cli/src/output.rs @@ -225,7 +225,10 @@ pub(crate) fn print_verify_human(report: &uteke_core::VerifyReport) { println!("Verify Report"); println!("─────────────"); println!(" SQLite DB: {} memories", report.db_count); - println!(" usearch index: {} vectors", report.index_count); + if report.chunk_count > 0 { + println!(" Doc chunks: {} chunks", report.chunk_count); + } + println!(" vector index: {} vectors", report.index_count); if report.consistent { println!(" ✓ Consistent"); } else { @@ -240,7 +243,7 @@ pub(crate) fn print_repair_human(report: &uteke_core::RepairReport) { println!(" SQLite DB: {} memories", report.db_count); println!(" Index before: {} vectors", report.index_before); println!(" Index after: {} vectors", report.index_after); - if report.index_after == report.db_count { + if report.index_after == report.db_count + report.chunk_count { println!(" ✓ Index rebuilt successfully"); } else { println!(" ⚠ Index count still differs from DB"); diff --git a/crates/uteke-core/Cargo.toml b/crates/uteke-core/Cargo.toml index ffea082c..c5075e3f 100644 --- a/crates/uteke-core/Cargo.toml +++ b/crates/uteke-core/Cargo.toml @@ -11,14 +11,22 @@ categories = ["science", "data-structures"] readme = "../../README.md" [features] -default = ["onnx"] -onnx = ["dep:ort", "dep:ndarray", "dep:tokenizers", "dep:sha2"] +default = ["onnx", "usearch"] +onnx = ["dep:ort", "dep:ndarray", "dep:tokenizers"] +# Default vector search backend (HNSW, C++ FFI). +usearch = ["dep:usearch"] +# Alternative pure-Rust vector search backend (training-free 4-bit quantization). +# Mutually exclusive with `usearch` — enabling both is a compile error +# (see src/memory/vector.rs). Build with: +# cargo build -p uteke-core --no-default-features --features "onnx,vecq" +vecq = ["dep:vecq-core"] [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { version = "0.40", features = ["bundled"] } -usearch = "2" +usearch = { version = "2", optional = true } +vecq-core = { version = "0.1", optional = true } uuid = { version = "1", features = ["v4", "v7"] } chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] } thiserror = "2" @@ -32,7 +40,9 @@ fs2 = "0.4" ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "api-18"], optional = true } ndarray = { version = "0.17", optional = true } tokenizers = { version = "0.23", optional = true } -sha2 = { version = "0.11", optional = true } +# Non-optional: the embedding cache (all backends, incl. OpenAI/Ollama) +# hashes keys with SHA256 — needed in every feature profile (#1131). +sha2 = { version = "0.11" } [dev-dependencies] tempfile = "3" diff --git a/crates/uteke-core/src/consolidate.rs b/crates/uteke-core/src/consolidate.rs index e2a8a0f8..c9c65ab2 100644 --- a/crates/uteke-core/src/consolidate.rs +++ b/crates/uteke-core/src/consolidate.rs @@ -330,6 +330,80 @@ impl crate::Uteke { kept_ids, }) } + + /// Consolidate a single caller-chosen pair (#1076): keep `id_keep` + /// untouched, deprecate (or hard-delete) `id_remove`. + /// + /// Unlike [`Uteke::consolidate`], the keep-newer rule is NOT applied — + /// the caller explicitly picks the survivor. Reuses the same + /// soft-delete + reason + index cleanup path as bulk consolidate. + pub fn consolidate_pair( + &self, + id_keep: &str, + id_remove: &str, + hard: bool, + ) -> Result { + if id_keep == id_remove { + return Err(Error::validation("id_keep and id_remove must differ")); + } + // Both memories must exist (and the keeper must not already be + // deprecated) — otherwise the pair choice is stale. + let keeper = self + .store + .get_by_id(id_keep) + .map_err(|e| Error::db("consolidate_pair lookup keeper", e))? + .ok_or_else(|| Error::validation(format!("memory {id_keep} not found")))?; + if keeper.deprecated { + return Err(Error::validation(format!( + "memory {id_keep} is deprecated and cannot be kept" + ))); + } + let loser = self + .store + .get_by_id(id_remove) + .map_err(|e| Error::db("consolidate_pair lookup loser", e))? + .ok_or_else(|| Error::validation(format!("memory {id_remove} not found")))?; + + let namespace = loser.namespace.clone(); + let dream_soft = self.lifecycle_config.dream_dedup_soft_delete; + if hard || !dream_soft { + self.store + .delete(id_remove) + .map_err(|e| Error::db("consolidate_pair delete", e))?; + } else { + let reason = format!("manual pair dedup: superseded by {id_keep}"); + self.store + .deprecate_with_reason(id_remove, &reason) + .map_err(|e| Error::db("consolidate_pair soft-delete", e))?; + } + // SQLite first (source of truth), then vector index. + let mut index = self + .index + .write() + .map_err(|_| Error::lock("index write lock during consolidate_pair"))?; + if !index.remove(id_remove) { + tracing::warn!( + "Vector index entry not found during consolidate_pair for id={id_remove}" + ); + } + if let Err(e) = index.save() { + tracing::warn!( + "Failed to persist vector index after consolidate_pair: {e}. \ + Orphan entries will be cleaned up by verify/repair." + ); + } + // Invalidate recall cache — the removed memory affects search results. + if namespace.is_empty() { + self.recall_cache.clear(); + } else { + self.recall_cache.invalidate_namespace(&namespace); + } + Ok(crate::memory::types::PairConsolidationResult { + kept: id_keep.to_string(), + removed: id_remove.to_string(), + hard: hard || !dream_soft, + }) + } } /// Compute cosine similarity between two vectors. @@ -445,6 +519,117 @@ mod tests { /// Verify that metadata JSON values round-trip through serde correctly. /// This guards against type mismatches when metadata is passed through /// remember_with_contradiction → remember_precomputed. + /// #1076: per-pair consolidation keeps the caller-chosen survivor + /// and deprecates the loser (soft path, dream_dedup_soft_delete on). + #[test] + fn test_consolidate_pair_soft_delete() { + let dir = tempfile::tempdir().unwrap(); + let uteke = crate::Uteke::open(dir.path().join("t.db")).unwrap(); + + let older = crate::memory::types::Memory { + id: "older-1076".to_string(), + content: "older entry with richer content".to_string(), + created_at: chrono::Utc::now() - chrono::Duration::hours(3), + ..probe_base() + }; + let newer = crate::memory::types::Memory { + id: "newer-1076".to_string(), + content: "newer truncated re-save".to_string(), + created_at: chrono::Utc::now(), + ..probe_base() + }; + uteke.store().insert(&older).unwrap(); + uteke.store().insert(&newer).unwrap(); + + // Caller keeps the OLDER (richer) one — overrides keep-newer. + let res = uteke + .consolidate_pair("older-1076", "newer-1076", false) + .unwrap(); + assert_eq!(res.kept, "older-1076"); + assert_eq!(res.removed, "newer-1076"); + assert!(!res.hard); + + let kept = uteke.store().get_by_id("older-1076").unwrap().unwrap(); + assert!(!kept.deprecated); + let removed = uteke.store().get_by_id("newer-1076").unwrap().unwrap(); + assert!(removed.deprecated); + + assert!(removed.deprecated_at.is_some()); + } + + /// #1076: hard=true bypasses soft-delete and deletes the row outright. + #[test] + fn test_consolidate_pair_hard_delete() { + let dir = tempfile::tempdir().unwrap(); + let uteke = crate::Uteke::open(dir.path().join("t.db")).unwrap(); + + let a = crate::memory::types::Memory { + id: "keep-a".to_string(), + ..probe_base() + }; + let b = crate::memory::types::Memory { + id: "drop-b".to_string(), + ..probe_base() + }; + uteke.store().insert(&a).unwrap(); + uteke.store().insert(&b).unwrap(); + + let res = uteke.consolidate_pair("keep-a", "drop-b", true).unwrap(); + assert!(res.hard); + assert!(uteke.store().get_by_id("drop-b").unwrap().is_none()); + assert!(uteke.store().get_by_id("keep-a").unwrap().is_some()); + } + + /// #1076: validation guards — same id, missing memory, deprecated keeper. + #[test] + fn test_consolidate_pair_validation() { + let dir = tempfile::tempdir().unwrap(); + let uteke = crate::Uteke::open(dir.path().join("t.db")).unwrap(); + + let a = crate::memory::types::Memory { + id: "v-a".to_string(), + ..probe_base() + }; + uteke.store().insert(&a).unwrap(); + + // same id + assert!(uteke.consolidate_pair("v-a", "v-a", false).is_err()); + // missing loser + assert!(uteke.consolidate_pair("v-a", "nope", false).is_err()); + // missing keeper + assert!(uteke.consolidate_pair("nope", "v-a", false).is_err()); + // deprecated keeper rejected + uteke.store().deprecate("v-a").unwrap(); + assert!(uteke.consolidate_pair("v-a", "nope", false).is_err()); + } + + fn probe_base() -> crate::memory::types::Memory { + crate::memory::types::Memory { + content: "probe".to_string(), + embedding: vec![], + tags: vec![], + metadata: serde_json::json!({}), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + namespace: crate::memory::types::DEFAULT_NAMESPACE.to_string(), + access_count: 0, + last_accessed: None, + deprecated: false, + deprecated_at: None, + valid_from: None, + valid_until: None, + memory_type: "fact".to_string(), + importance: 0.5, + pinned: false, + content_type: "text".to_string(), + slug: None, + source: None, + source_type: "direct".to_string(), + author_type: "agent".to_string(), + id: String::new(), + } + } + #[test] fn test_metadata_value_serialization() { use serde_json::Value; diff --git a/crates/uteke-core/src/consolidation_api.rs b/crates/uteke-core/src/consolidation_api.rs new file mode 100644 index 00000000..dab33fb7 --- /dev/null +++ b/crates/uteke-core/src/consolidation_api.rs @@ -0,0 +1,206 @@ +//! High-level consolidation API wiring the executor to a real `Uteke` store. +//! +//! Implements `ConsolidationStore` for `Uteke` (SQLite + lazy embedder) and +//! exposes a single entry point [`consolidate_room`] used by the CLI and the +//! HTTP server. The executor itself stays store-agnostic and testable with +//! a mock store (see `consolidation_exec`). +//! +//! All operations are opt-in: nothing in the dream pipeline calls this +//! automatically. Rooms are consolidated only when explicitly requested +//! via CLI (`uteke consolidate`) or HTTP (`POST /room/{id}/consolidate`). + +use crate::Error; +use crate::Uteke; +use crate::consolidation_exec::{self, ConsolidationExecution, ConsolidationStore}; +use crate::consolidation_plan::{self, ConsolidationPlan}; +use crate::extraction::ExtractionConfig; +use crate::memory::types::Memory; + +/// System author tag for consolidated records. +const CONSOLIDATOR_AUTHOR: &str = "uteke-consolidator"; + +impl ConsolidationStore for Uteke { + fn room_memories(&self, room_id: &str) -> Result, Error> { + // author=None → cross-namespace; limit=0 → all; active only (#784). + self.store().recall_room(room_id, None, 0) + } + + fn insert_memory(&self, memory: &mut Memory) -> Result<(), Error> { + let room_id = memory + .metadata + .get("room_id") + .and_then(|v| v.as_str()) + .map(str::to_string); + + // Persist (transactional insert, mirrors crud flow). + self.store().insert(memory)?; + + // Insert into the vector index so recall finds it; roll back the row + // if indexing fails so we never leave an un-searchable memory behind. + if !memory.embedding.is_empty() { + let key = memory.id.clone(); + let embedding = memory.embedding.clone(); + if let Err(e) = self.add_to_index(&key, &embedding) { + let _ = self.store().delete(&memory.id); + return Err(e); + } + } + + // Keep the record linked to its room so recall_room returns it; + // roll back the row + index entry on failure (no room links to a + // memory the room cannot see). + if let Some(room_id) = room_id { + if let Err(e) = self.store().link_memory_to_room( + &room_id, + &memory.id, + CONSOLIDATOR_AUTHOR, + "consolidated", + ) { + self.remove_from_index(&memory.id); + let _ = self.store().delete(&memory.id); + return Err(e); + } + } + Ok(()) + } + + fn deprecate_memory(&self, id: &str, reason: &str) -> Result<(), Error> { + self.store().deprecate_with_reason(id, reason) + } + + fn embed_content(&self, content: &str) -> Result, Error> { + self.embed_text(content) + } +} + +/// Outcome of a dry-run (plan only, no LLM calls, no writes). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ConsolidationDryRun { + pub plan: ConsolidationPlan, +} + +/// Plan consolidation for a room without any side effects. +/// +/// Costs nothing: no LLM call, no store write. The returned plan carries +/// per-batch payloads and cost estimates the caller can inspect first. +pub fn plan_room(uteke: &Uteke, room_id: &str) -> Result { + let plan = uteke.room_consolidation_plan( + room_id, + consolidation_plan::DEFAULT_THRESHOLD, + consolidation_plan::DEFAULT_MAX_SIZE, + consolidation_plan::DEFAULT_MIN_SIZE, + )?; + Ok(ConsolidationDryRun { plan }) +} + +/// Execute consolidation for a room: plan → LLM batches → provenance gate → +/// write consolidated records and deprecate sources. +/// +/// `max_llm_calls` caps the number of LLM requests for this run (failed +/// calls count against the budget too). Uses the shared extraction endpoint +/// setup — same model/key/base URL as import extraction ("1 setup, many uses"). +pub fn consolidate_room( + uteke: &Uteke, + room_id: &str, + config: &ExtractionConfig, + max_llm_calls: usize, +) -> Result { + let dry = plan_room(uteke, room_id)?; + consolidation_exec::execute_plan(uteke, room_id, &dry.plan, config, max_llm_calls) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dry_run_makes_no_changes() { + let uteke = Uteke::open(":memory:").unwrap(); + let dry = plan_room(&uteke, "r1").expect("plan empty room"); + assert!(dry.plan.batches.is_empty()); + assert_eq!(dry.plan.total_memories, 0); + } + + #[test] + fn trait_impl_reads_room() { + // Trait dispatch through &Uteke — catches signature drift between + // the trait and the store API at compile time. + fn takes_store(_s: &S) {} + let uteke = Uteke::open(":memory:").unwrap(); + takes_store(&uteke); + let mems = ConsolidationStore::room_memories(&uteke, "nope").unwrap(); + assert!(mems.is_empty()); + } + + fn mem(id: &str, content: &str) -> crate::memory::types::Memory { + use crate::memory::types::Memory; + use chrono::Utc; + Memory { + id: id.to_string(), + content: content.to_string(), + embedding: vec![0.1; 768], + tags: vec!["test".into()], + metadata: serde_json::json!({}), + created_at: Utc::now(), + updated_at: Utc::now(), + namespace: crate::memory::types::DEFAULT_NAMESPACE.to_string(), + access_count: 0, + last_accessed: None, + deprecated: false, + deprecated_at: None, + valid_from: None, + valid_until: None, + memory_type: "fact".to_string(), + importance: 0.5, + pinned: false, + content_type: "text".to_string(), + slug: None, + source: None, + source_type: "unknown".to_string(), + author_type: "agent".to_string(), + } + } + + #[test] + fn plan_room_batches_real_memories() { + let uteke = Uteke::open(":memory:").unwrap(); + uteke.store.create_room("r1", None, "default").unwrap(); + for i in 0..6 { + let id = format!("m{i}"); + uteke + .store + .insert(&mem(&id, &format!("Fact {i} about caching"))) + .unwrap(); + uteke + .store + .link_memory_to_room("r1", &id, "tester", "member") + .unwrap(); + } + let dry = plan_room(&uteke, "r1").unwrap(); + assert_eq!(dry.plan.total_memories, 6); + assert!( + !dry.plan.batches.is_empty(), + "6 similar memories should form at least one batch" + ); + } + + #[test] + fn deprecate_and_insert_via_trait() { + // Exercises the two write paths the executor relies on. + let uteke = Uteke::open(":memory:").unwrap(); + uteke.store.create_room("r1", None, "default").unwrap(); + let mut m = mem("m1", "caching fact"); + ConsolidationStore::insert_memory(&uteke, &mut m).unwrap(); + assert!(!m.id.is_empty()); + uteke + .store + .link_memory_to_room("r1", &m.id, "tester", "member") + .unwrap(); + ConsolidationStore::deprecate_memory(&uteke, &m.id, "consolidated").unwrap(); + let after = ConsolidationStore::room_memories(&uteke, "r1").unwrap(); + assert!( + after.is_empty(), + "deprecated memory must not appear in room recall" + ); + } +} diff --git a/crates/uteke-core/src/consolidation_exec.rs b/crates/uteke-core/src/consolidation_exec.rs new file mode 100644 index 00000000..b48b436b --- /dev/null +++ b/crates/uteke-core/src/consolidation_exec.rs @@ -0,0 +1,362 @@ +//! LLM executor for segment-level room consolidation (#1088, final phase). +//! +//! Reuses the extraction endpoint setup (`ExtractionConfig` — same model, +//! key, and base URL as the import/extraction pipeline) so one setup serves +//! both. The consolidation prompt differs: it *merges* a batch of same-topic +//! memories into one dense summary, preserving hedging markers. +//! +//! Safety (per #1089): +//! - Every generated record passes [`crate::provenance`] validation before +//! it is written: non-amplification (never above the weakest source tier) +//! and hedging guard (never upgrade confidence). +//! - Source memories are soft-deprecated (`deprecated=true`), never deleted. +//! - Budget-capped: at most `max_llm_calls` LLM requests per run. + +use crate::consolidation_plan::ConsolidationPlan; +use crate::error::Error; +use crate::extraction::ExtractionConfig; +use crate::memory::types::Memory; +use crate::provenance::ProvenancePolicy; +use serde::{Deserialize, Serialize}; + +/// Per-request timeout for consolidation LLM calls. +const LLM_TIMEOUT_SECS: u64 = 120; + +const CONSOLIDATE_SYSTEM_PROMPT: &str = "\ +You consolidate a batch of related memory records from one discussion into a \ +single dense record. Rules:\n\ +- Merge duplicates and closely related statements into one concise record.\n\ +- Preserve uncertainty: if ANY source says maybe/mungkin/perhaps/sepertinya \ +etc., the output MUST keep that hedge word. Never turn a hedge into a fact.\n\ +- Keep names, dates, numbers, decisions verbatim where possible.\n\ +- Do not invent information not present in the sources.\n\ +- Output ONLY a JSON array with exactly one object: \ +[{\"content\": \"...\", \"type\": \"fact|decision|preference|procedure|context\"}]\n\ +- The content must be in the same language as the sources."; + +/// Result of one executor run. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsolidationExecution { + /// Batches processed (LLM calls made). + pub batches_processed: usize, + /// Records written to the store. + pub records_written: usize, + /// Outputs rejected by the provenance policy (#1089). + pub rejected_by_policy: usize, + /// Source memories soft-deprecated. + pub sources_deprecated: usize, + /// LLM calls skipped because the budget was exhausted. + pub budget_skipped: usize, + /// Batches that failed (network/HTTP/parse error) and were skipped so + /// the rest of the run could proceed. Their sources are left untouched. + pub batch_errors: Vec, +} + +/// Execute a consolidation plan with the shared extraction LLM setup. +/// +/// `uteke` provides store access (`recall_room`, `insert`, `deprecate`). +/// The plan is produced by `room_consolidation_plan` (segment-based +/// batching). Each batch is sent as one LLM call; the returned record is +/// validated against the provenance policy, embedded, written, and its +/// sources are soft-deprecated with a reason pointing at the new record. +pub fn execute_plan( + uteke: &U, + room_id: &str, + plan: &ConsolidationPlan, + config: &ExtractionConfig, + max_llm_calls: usize, +) -> Result { + // No default timeout on reqwest clients — set one so a slow endpoint + // cannot hang the consolidation run indefinitely (cf. update_check). + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(LLM_TIMEOUT_SECS)) + .build() + .map_err(|e| Error::Generic(format!("http client build failed: {e}")))?; + let endpoint = consolidation_endpoint(config); + let mut result = ConsolidationExecution { + batches_processed: 0, + records_written: 0, + rejected_by_policy: 0, + sources_deprecated: 0, + budget_skipped: 0, + batch_errors: Vec::new(), + }; + + // Fetch room memories once for provenance checks. + let memories: Vec = uteke.room_memories(room_id)?; + let by_id = memories + .iter() + .map(|m| (m.id.clone(), m.clone())) + .collect::>(); + + // Budget counts LLM requests made, not successful ones: a failed call + // still consumed an API request against the run's quota. + let mut llm_calls_made: usize = 0; + for batch in &plan.batches { + if llm_calls_made >= max_llm_calls { + result.budget_skipped += 1; + continue; + } + let sources: Vec = batch + .memory_ids + .iter() + .filter_map(|id| by_id.get(id).cloned()) + .collect(); + if sources.len() < 2 { + // Nothing to consolidate. + continue; + } + let payload = batch.payload.clone(); + llm_calls_made += 1; + let output_text = match call_llm(&client, &endpoint, config, &payload) { + Ok(t) => t, + Err(e) => { + // A failed batch must not abort the run: earlier batches + // have already been written, and remaining ones are still + // worth processing. Record and continue (sources of this + // batch stay untouched). + tracing::warn!("consolidation batch failed: {e}"); + result.batch_errors.push(e.to_string()); + continue; + } + }; + result.batches_processed += 1; + + let parsed = parse_output(&output_text); + let Some(fact) = parsed else { + result.rejected_by_policy += 1; + continue; + }; + + // Build the candidate record at the agent/derived tier (the floor + // for consolidated output) — provenance policy then validates. + let mut record = Memory { + id: uuid::Uuid::new_v4().to_string(), + content: fact.content, + embedding: vec![], + tags: vec![format!("room:{room_id}"), "consolidated".to_string()], + metadata: serde_json::json!({ + "consolidated_from": batch.memory_ids, + "room": room_id, + }), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + namespace: sources + .first() + .map(|s| s.namespace.clone()) + .unwrap_or_else(|| "default".to_string()), + access_count: 0, + last_accessed: None, + deprecated: false, + deprecated_at: None, + valid_from: None, + valid_until: None, + memory_type: sanitize_memory_type(fact.fact_type.as_deref()), + importance: sources.iter().map(|s| s.importance).fold(0.0_f64, f64::max), + pinned: false, + content_type: "text".to_string(), + slug: None, + source: Some(format!("room:{room_id}#consolidation")), + source_type: "derived".to_string(), + author_type: "agent".to_string(), + }; + + let source_refs: Vec<&Memory> = sources.iter().collect(); + if ProvenancePolicy::validate_output(&source_refs, &record).is_err() { + result.rejected_by_policy += 1; + continue; + } + + // Embed before write so the record is vector-searchable. Embedding + // failure aborts only this batch (sources stay untouched). + record.embedding = match uteke.embed_content(&record.content) { + Ok(v) => v, + Err(e) => { + tracing::warn!("consolidation embedding failed: {e}"); + result.batch_errors.push(format!("embedding: {e}")); + continue; + } + }; + + // Store failures after this point also abort only this batch. The + // write/deprecate pair is not transactional across the two calls, + // so a deprecate failure after a successful insert leaves the new + // record active alongside its sources — recorded here rather than + // silently killing the run (duplicate content is recoverable by a + // later dedup pass; lost batches are not). + if let Err(e) = uteke.insert_memory(&mut record) { + tracing::warn!("consolidation write failed: {e}"); + result.batch_errors.push(format!("store write: {e}")); + continue; + } + result.records_written += 1; + for id in &batch.memory_ids { + let reason = format!( + "consolidated into {} (room {room_id} segment batching)", + record.id + ); + if let Err(e) = uteke.deprecate_memory(id, &reason) { + tracing::warn!("consolidation deprecate failed for {id}: {e}"); + result.batch_errors.push(format!("deprecate {id}: {e}")); + } else { + result.sources_deprecated += 1; + } + } + } + Ok(result) +} + +/// Store operations the executor needs (implemented by `Uteke`; mocked in tests). +pub trait ConsolidationStore { + fn room_memories(&self, room_id: &str) -> Result, Error>; + fn insert_memory(&self, memory: &mut Memory) -> Result<(), Error>; + fn deprecate_memory(&self, id: &str, reason: &str) -> Result<(), Error>; + /// Compute the embedding vector for a record before it is written, so + /// consolidated records stay visible to vector/semantic recall. + fn embed_content(&self, content: &str) -> Result, Error>; +} + +/// Whitelist for LLM-provided memory types — anything else falls back to +/// "fact" so arbitrary LLM output can never set an unknown memory_type. +fn sanitize_memory_type(raw: Option<&str>) -> String { + const ALLOWED: [&str; 5] = ["fact", "decision", "preference", "procedure", "context"]; + match raw { + Some(t) if ALLOWED.contains(&t) => t.to_string(), + _ => "fact".to_string(), + } +} + +fn consolidation_endpoint(config: &ExtractionConfig) -> String { + let base = config.base_url.trim_end_matches('/'); + let path = if config.endpoint_path.starts_with('/') { + config.endpoint_path.clone() + } else if config.endpoint_path.is_empty() { + "/chat/completions".to_string() + } else { + format!("/{}", config.endpoint_path) + }; + format!("{base}{path}") +} + +fn call_llm( + client: &reqwest::blocking::Client, + endpoint: &str, + config: &ExtractionConfig, + payload: &str, +) -> Result { + let body = serde_json::json!({ + "model": config.model, + "messages": [ + { "role": "system", "content": CONSOLIDATE_SYSTEM_PROMPT }, + { "role": "user", "content": format!("Sources:\n{payload}") }, + ], + "temperature": 0.0, + }); + let resp = client + .post(endpoint) + .bearer_auth(&config.api_key) + .json(&body) + .send() + .map_err(|e| Error::generic(format!("Consolidation request failed: {e}")))?; + let status = resp.status(); + if !status.is_success() { + let detail = resp.text().unwrap_or_default(); + return Err(Error::generic(format!( + "Consolidation endpoint returned HTTP {status}: {detail}" + ))); + } + #[derive(serde::Deserialize)] + struct ChatResponse { + choices: Vec, + } + #[derive(serde::Deserialize)] + struct Choice { + message: Message, + } + #[derive(serde::Deserialize)] + struct Message { + content: String, + } + let parsed: ChatResponse = resp + .json() + .map_err(|e| Error::generic(format!("Failed to parse consolidation response: {e}")))?; + parsed + .choices + .into_iter() + .next() + .map(|c| c.message.content) + .ok_or_else(|| Error::generic("Consolidation response had no choices")) +} + +struct ParsedFact { + content: String, + fact_type: Option, +} + +/// Parse `[{\"content\": \"...\", \"type\": \"...\"}]` — tolerate markdown fences. +fn parse_output(text: &str) -> Option { + let trimmed = text.trim().trim_matches('`'); + let start = trimmed.find('[')?; + let end = trimmed.rfind(']')?; + let json = &trimmed[start..=end]; + #[derive(serde::Deserialize)] + struct RawFact { + content: String, + #[serde(rename = "type")] + fact_type: Option, + } + let facts: Vec = serde_json::from_str(json).ok()?; + let f = facts.into_iter().next()?; + if f.content.trim().is_empty() { + return None; + } + Some(ParsedFact { + content: f.content, + fact_type: f.fact_type, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_markdown_fenced_output() { + let parsed = + parse_output("```json\n[{\"content\": \"Uses bun\", \"type\": \"fact\"}]\n```") + .unwrap(); + assert_eq!(parsed.content, "Uses bun"); + assert_eq!(parsed.fact_type.as_deref(), Some("fact")); + } + + #[test] + fn parses_plain_array() { + let parsed = parse_output("[{\"content\": \"Rilis mungkin besok\"}]").unwrap(); + assert_eq!(parsed.content, "Rilis mungkin besok"); + assert!(parsed.fact_type.is_none()); + } + + #[test] + fn rejects_empty_content() { + assert!(parse_output("[{\"content\": \" \"}]").is_none()); + assert!(parse_output("no json here").is_none()); + } + + #[test] + fn endpoint_joins_base_and_path() { + let mut cfg = ExtractionConfig { + base_url: "https://api.example.com/v1".into(), + endpoint_path: "/chat/completions".into(), + ..Default::default() + }; + assert_eq!( + consolidation_endpoint(&cfg), + "https://api.example.com/v1/chat/completions" + ); + cfg.endpoint_path = String::new(); + assert_eq!( + consolidation_endpoint(&cfg), + "https://api.example.com/v1/chat/completions" + ); + } +} diff --git a/crates/uteke-core/src/consolidation_plan.rs b/crates/uteke-core/src/consolidation_plan.rs new file mode 100644 index 00000000..90e193fd --- /dev/null +++ b/crates/uteke-core/src/consolidation_plan.rs @@ -0,0 +1,242 @@ +//! Segment-level consolidation planning (#1088, phase 3 — measure-only). +//! +//! Before wiring LLM calls into room consolidation, this module produces a +//! *batching plan*: which memories belong to which LLM call, and what the +//! call/token cost would be compared to naive per-memory consolidation. +//! Zero LLM calls are made here — the plan is the evaluation artifact. + +use crate::error::Error; +use crate::memory::types::Memory; +use crate::rooms_segments::SegmentationResult; +use serde::{Deserialize, Serialize}; + +/// Default similarity threshold for segmentation (validated on production +/// room data — see #1090: stable across 0.40–0.55 in 3 of 4 rooms). +pub const DEFAULT_THRESHOLD: f32 = 0.45; +/// Default max segment size (memories per LLM batch). +pub const DEFAULT_MAX_SIZE: usize = 12; +/// Default min segment size. +pub const DEFAULT_MIN_SIZE: usize = 3; + +/// Rough token estimate: ~4 chars per token for English/mixed content. +const CHARS_PER_TOKEN: f64 = 4.0; + +/// A planned LLM batch: one segment's memories, joined into one prompt. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlannedBatch { + pub segment_index: usize, + pub memory_ids: Vec, + /// Joined content that would be sent to the LLM (the actual payload). + pub payload: String, + /// Estimated prompt tokens for this batch. + pub est_tokens: u64, +} + +/// Cost comparison between the two consolidation granularities. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct CostEstimate { + /// LLM calls if every memory is consolidated on its own (naive). + pub naive_calls: u64, + /// Estimated tokens for the naive approach. + pub naive_tokens: u64, + /// LLM calls when batching per segment. + pub segment_calls: u64, + /// Estimated tokens for segment batching (payload tokens only). + pub segment_tokens: u64, + /// Relative call reduction, 0.0–1.0 (naive → segment). + pub call_reduction: f32, + /// Relative token reduction, 0.0–1.0. + pub token_reduction: f32, +} + +/// The full consolidation plan for one room. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsolidationPlan { + pub room_id: String, + pub total_memories: usize, + pub batches: Vec, + pub cost: CostEstimate, + pub threshold: f32, +} + +impl crate::Uteke { + /// Plan segment-level LLM consolidation for a room (#1088 phase 3). + /// + /// Measure-only: builds the batching plan and cost estimate without + /// calling any LLM. Use this to evaluate the LycheeMemory-style + /// granularity tradeoff on real rooms before enabling execution. + pub fn room_consolidation_plan( + &self, + room_id: &str, + threshold: f32, + max_size: usize, + min_size: usize, + ) -> Result { + let memories = self.store.recall_room(room_id, None, 0)?; + self.consolidation_plan_from(room_id, &memories, threshold, max_size, min_size) + } + + /// Plan over caller-supplied memories (avoids a second query). + pub fn consolidation_plan_from( + &self, + room_id: &str, + memories: &[Memory], + threshold: f32, + max_size: usize, + min_size: usize, + ) -> Result { + let segmentation: SegmentationResult = + self.room_segments_inner(room_id, memories, threshold, max_size, min_size)?; + + let id_to_memory: std::collections::HashMap<&str, &Memory> = + memories.iter().map(|m| (m.id.as_str(), m)).collect(); + + let mut batches = Vec::with_capacity(segmentation.segments.len()); + let mut segment_tokens: u64 = 0; + for seg in &segmentation.segments { + let contents: Vec<&str> = seg + .memory_ids + .iter() + .filter_map(|id| id_to_memory.get(id.as_str()).map(|m| m.content.as_str())) + .collect(); + // Memories without embeddings are skipped by segmentation; the + // filter_map above keeps batch contents aligned to the segment. + if contents.is_empty() { + continue; + } + let payload = contents.join("\n---\n"); + let est = (payload.chars().count() as f64 / CHARS_PER_TOKEN).ceil() as u64; + segment_tokens += est; + batches.push(PlannedBatch { + segment_index: seg.index, + memory_ids: seg.memory_ids.clone(), + payload, + est_tokens: est, + }); + } + + let naive_calls = memories.len() as u64; + let naive_tokens: u64 = memories + .iter() + .map(|m| ((m.content.chars().count() as f64) / CHARS_PER_TOKEN).ceil() as u64) + .sum(); + let segment_calls = batches.len() as u64; + + let call_reduction = if naive_calls > 0 { + 1.0 - (segment_calls as f32 / naive_calls as f32) + } else { + 0.0 + }; + let token_reduction = if naive_tokens > 0 { + 1.0 - (segment_tokens as f32 / naive_tokens as f32) + } else { + 0.0 + }; + + Ok(ConsolidationPlan { + room_id: room_id.to_string(), + total_memories: memories.len(), + batches, + cost: CostEstimate { + naive_calls, + naive_tokens, + segment_calls, + segment_tokens, + call_reduction, + token_reduction, + }, + threshold, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_memory(id: &str, content: &str, embedding: Vec) -> crate::Memory { + crate::Memory { + id: id.to_string(), + content: content.to_string(), + embedding, + tags: vec![], + metadata: serde_json::Value::Null, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + namespace: "default".to_string(), + access_count: 0, + last_accessed: None, + deprecated: false, + deprecated_at: None, + valid_from: None, + valid_until: None, + memory_type: "fact".to_string(), + importance: 0.5, + pinned: false, + content_type: "text".to_string(), + slug: None, + source: None, + source_type: String::new(), + author_type: String::new(), + } + } + + #[test] + fn plan_buckets_memories_and_estimates_savings() { + let uteke = crate::Uteke::open(":memory:").unwrap(); + // Two orthogonal topics of 3 memories each -> 2 segments. + let a = vec![1.0, 0.0]; + let b = vec![0.0, 1.0]; + let mems: Vec = (0..3) + .map(|i| make_memory(&format!("a{i}"), "alpha topic content", a.clone())) + .chain((0..3).map(|i| make_memory(&format!("b{i}"), "beta topic content", b.clone()))) + .collect(); + + let plan = uteke + .consolidation_plan_from( + "r", + &mems, + DEFAULT_THRESHOLD, + DEFAULT_MAX_SIZE, + DEFAULT_MIN_SIZE, + ) + .unwrap(); + + assert_eq!(plan.total_memories, 6); + assert_eq!(plan.batches.len(), 2, "batches: {:?}", plan.batches.len()); + assert_eq!(plan.cost.naive_calls, 6); + assert_eq!(plan.cost.segment_calls, 2); + assert!((plan.cost.call_reduction - (1.0 - 2.0 / 6.0)).abs() < 1e-6); + // Batching joins with separators, so payload tokens can slightly + // exceed the naive sum — the real win is CALL reduction (above). + assert!(plan.cost.token_reduction >= -0.5); + for b in &plan.batches { + assert!(b.payload.contains("---")); + assert_eq!(b.memory_ids.len(), 3); + } + } + + #[test] + fn empty_room_plans_zero_batches() { + let uteke = crate::Uteke::open(":memory:").unwrap(); + let plan = uteke + .consolidation_plan_from("r", &[], DEFAULT_THRESHOLD, 12, 3) + .unwrap(); + assert_eq!(plan.total_memories, 0); + assert!(plan.batches.is_empty()); + assert_eq!(plan.cost.call_reduction, 0.0); + } + + #[test] + fn unembedded_memories_yield_empty_plan() { + let uteke = crate::Uteke::open(":memory:").unwrap(); + let mems = vec![make_memory("x1", "no embedding here", vec![])]; + let plan = uteke + .consolidation_plan_from("r", &mems, DEFAULT_THRESHOLD, 12, 3) + .unwrap(); + assert_eq!(plan.total_memories, 1); + // Without embeddings, adjacent cosine is 0 → one min_size-merged batch. + assert_eq!(plan.batches.len(), 1); + assert_eq!(plan.batches[0].memory_ids, vec!["x1".to_string()]); + } +} diff --git a/crates/uteke-core/src/edges.rs b/crates/uteke-core/src/edges.rs index 5797086a..ad708627 100644 --- a/crates/uteke-core/src/edges.rs +++ b/crates/uteke-core/src/edges.rs @@ -995,6 +995,7 @@ mod tests { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: "fact".to_string(), @@ -1004,6 +1005,7 @@ mod tests { slug: None, source: None, source_type: "user".to_string(), + author_type: "agent".to_string(), } } @@ -1353,7 +1355,11 @@ mod tests { fn migration_dispatcher_reaches_v8() { let store = Store::open(":memory:").unwrap(); let v = store.schema_version().unwrap(); - assert_eq!(v, 15, "fresh store must reach CURRENT_SCHEMA_VERSION=15"); + assert_eq!( + v, + crate::memory::store::CURRENT_SCHEMA_VERSION, + "fresh store must reach CURRENT_SCHEMA_VERSION" + ); // memory_edges table must exist and be queryable after migration. let n = store.count_memory_edges().unwrap(); @@ -2025,7 +2031,7 @@ impl crate::Uteke { ) .map_err(|e| Error::db("insert supersedes edge", e))?; tx.execute( - "UPDATE memories SET deprecated = 1, valid_until = ?1, deprecate_reason = ?2, updated_at = ?1 WHERE id = ?3 AND deprecated = 0", + "UPDATE memories SET deprecated = 1, valid_until = ?1, deprecate_reason = ?2, updated_at = ?1, deprecated_at = ?1 WHERE id = ?3 AND deprecated = 0", params![now, reason_text, old.id], ) .map_err(|e| Error::db("deprecate superseded memory", e))?; diff --git a/crates/uteke-core/src/embed/ort_init.rs b/crates/uteke-core/src/embed/ort_init.rs index ad5b024e..1801a8b7 100644 --- a/crates/uteke-core/src/embed/ort_init.rs +++ b/crates/uteke-core/src/embed/ort_init.rs @@ -348,13 +348,18 @@ mod tests { let _avx2 = has_avx2(); } + /// env-var tests share the process-global ORT_LIB_PATH, so they run + /// sequentially INSIDE one #[test] — parallel #[test]s mutating the same + /// env var race each other (valid path overwritten by the invalid one + /// mid-resolve), which flaked CI nondeterministically. #[test] - fn resolve_ort_lib_prefers_env_var() { - // Create a temp file to use as fake ORT lib + fn resolve_ort_lib_env_var_override() { + // 1) Valid path → Ok, and the resolved path equals the override let tmp = std::env::temp_dir().join("test_ort_lib_resolve.so"); std::fs::write(&tmp, b"fake").unwrap(); - // SAFETY: set_var/remove_var in test code is safe — no concurrent access. + // SAFETY: single-threaded test section; no other test touches this var + // while we hold it (see note above on why these cases are merged). unsafe { std::env::set_var("ORT_LIB_PATH", &tmp); } @@ -362,17 +367,13 @@ mod tests { unsafe { std::env::remove_var("ORT_LIB_PATH"); } - - // Cleanup std::fs::remove_file(&tmp).ok(); assert!(result.is_ok()); assert_eq!(result.unwrap().lib_path, tmp); - } - #[test] - fn resolve_ort_lib_errors_on_missing_env_var_path() { - // SAFETY: set_var/remove_var in test code is safe — no concurrent access. + // 2) Nonexistent path → Err mentioning the missing file + // SAFETY: same single-threaded section. unsafe { std::env::set_var("ORT_LIB_PATH", "/nonexistent/path/libonnxruntime.so"); } diff --git a/crates/uteke-core/src/graph_rerank.rs b/crates/uteke-core/src/graph_rerank.rs index 8cc39c67..ebc81c9d 100644 --- a/crates/uteke-core/src/graph_rerank.rs +++ b/crates/uteke-core/src/graph_rerank.rs @@ -290,6 +290,7 @@ mod tests { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: String::from("fact"), @@ -299,6 +300,7 @@ mod tests { slug: None, source: None, source_type: "user".to_string(), + author_type: "agent".to_string(), }, score, } diff --git a/crates/uteke-core/src/import_export.rs b/crates/uteke-core/src/import_export.rs index bb97c443..2406b802 100644 --- a/crates/uteke-core/src/import_export.rs +++ b/crates/uteke-core/src/import_export.rs @@ -119,6 +119,7 @@ impl crate::Uteke { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: Some(entry.created_at), valid_until: None, memory_type: "fact".to_string(), @@ -128,6 +129,7 @@ impl crate::Uteke { slug: None, source: Some(format!("import:{}", entry.source.unwrap_or_default())), source_type: "import".to_string(), + author_type: "agent".to_string(), }; // Write-ahead: vector index first (can be rolled back), then SQLite. diff --git a/crates/uteke-core/src/lib.rs b/crates/uteke-core/src/lib.rs index 4ba46386..c3d9509c 100644 --- a/crates/uteke-core/src/lib.rs +++ b/crates/uteke-core/src/lib.rs @@ -11,6 +11,9 @@ pub mod chunker; mod consolidate; +pub mod consolidation_api; +pub mod consolidation_exec; +pub mod consolidation_plan; pub mod dream; mod edges; mod embed; @@ -26,8 +29,10 @@ pub mod memory; pub mod offline_extraction; mod operations; mod orphans; +pub mod provenance; mod recall_cache; mod rooms; +pub mod rooms_segments; pub mod salience_recency; pub mod structural_export; mod timeline; @@ -589,6 +594,47 @@ impl Uteke { &self.lifecycle_config } + /// Embed an arbitrary text with the configured backend (lazy init). + /// Used by the consolidation executor to embed consolidated records + /// before writing them so they stay vector-searchable. + pub fn embed_text(&self, text: &str) -> Result, Error> { + self.ensure_embedder()?; + self.embedder + .lock() + .map_err(|_| Error::lock("embedder lock during embed_text"))? + .as_ref() + .expect("embedder ensured above") + .embed(text) + } + + /// Add an already-persisted memory's embedding to the vector index. + /// Used by the consolidation write path so new records are searchable. + pub(crate) fn add_to_index(&self, id: &str, embedding: &[f32]) -> Result<(), Error> { + let mut index = self + .index + .write() + .map_err(|_| Error::lock("index write lock during consolidation insert"))?; + index.insert(id, embedding)?; + if let Err(e) = index.save() { + tracing::warn!("failed to persist vector index after insert id={id}: {e}"); + } + Ok(()) + } + + /// Remove an entry from the vector index (compensating action for a + /// failed consolidation insert). Persistence failure is logged, not + /// fatal — the caller is already unwinding an error. + pub(crate) fn remove_from_index(&self, id: &str) { + let Ok(mut index) = self.index.write() else { + return; + }; + if index.remove(id) { + if let Err(e) = index.save() { + tracing::warn!("failed to persist vector index after remove id={id}: {e}"); + } + } + } + /// Open or create a Uteke memory store. /// /// `path` can be a directory path (will create `uteke.db` inside) @@ -667,10 +713,24 @@ impl Uteke { embedding_settings: EmbeddingSettings, graph_rerank_config: graph_rerank::GraphRerankConfig, ) -> Result { - // Determine index path: same directory as SQLite DB - let index_path = store.path().map(|p| { - let dir = p.parent().unwrap_or(Path::new(".")); - dir.join("uteke_index.usearch") + // Determine index path: same directory as SQLite DB. + // `:memory:` databases get an EPHEMERAL in-memory index instead: + // persisting to `./uteke_index.usearch` in the CWD would make every + // concurrent in-memory instance (e.g. parallel #[test]s) share and + // corrupt one index file (sidecar keys vs usearch keys race → + // "Duplicate keys not allowed" flakes). + let index_path = store.path().and_then(|p| { + // rusqlite reports "" (empty) for in-memory DBs, not ":memory:" + if matches!(p.to_str(), Some(":memory:") | Some("")) { + None + } else { + let dir = p.parent().unwrap_or(Path::new(".")); + // Backend-specific extension (#1112): a vecq build reads/writes + // `uteke_index.vecq`, a usearch build `uteke_index.usearch`. + // Cross-backend opens no longer parse (and re-save over) the + // other format's file. + Some(dir.join(format!("uteke_index.{}", crate::memory::vector::INDEX_EXT))) + } }); // Use dims from the provided embedder if available. @@ -749,9 +809,15 @@ impl Uteke { // Resolve embedding cache path: same directory as the main DB. // `embed_cache.db` avoids ONNX model load for repeated queries (#896). - let embed_cache_path = store.path().map(|p| { - let dir = p.parent().unwrap_or(Path::new(".")); - dir.join("embed_cache.db") + let embed_cache_path = store.path().and_then(|p| { + // In-memory DBs are ephemeral — no cache file beside them + // (prevents a stray ./embed_cache.db in the CWD, see #1101). + if matches!(p.to_str(), Some(":memory:") | Some("")) { + None + } else { + let dir = p.parent().unwrap_or(Path::new(".")); + Some(dir.join("embed_cache.db")) + } }); Ok(Self { @@ -1141,6 +1207,12 @@ impl Uteke { self.store.set_source(id, source, source_type) } + /// Set author type on a memory (#1083): "human" | "agent". + /// Invalid values return a Validation error. + pub fn set_author_type(&self, id: &str, author_type: &str) -> Result { + self.store.set_author_type(id, author_type) + } + /// Recalculate importance scores for all memories. pub fn recompute_importance(&self) -> Result { let updated = self.store.recompute_importance()?; @@ -2336,6 +2408,26 @@ fn resolve_db_path(db_path: &Path) -> Result { #[cfg(test)] mod tests { + #[ignore = "requires ONNX embedder — index file extension follows active backend"] + #[test] + fn index_file_uses_backend_extension() { + // #1112: the index filename must follow the active backend, so + // vecq builds write `uteke_index.vecq` and never clobber a + // usearch-written `uteke_index.usearch` (and vice versa). + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("uteke.db"); + let uteke = crate::Uteke::open(&db).unwrap(); + uteke.remember("backend ext test", &[], None, None).unwrap(); + uteke.shutdown().unwrap(); + let expected = dir + .path() + .join(format!("uteke_index.{}", crate::memory::vector::INDEX_EXT)); + assert!( + expected.exists(), + "expected index file {expected:?} to exist after shutdown" + ); + } + use super::*; use serial_test::serial; @@ -2354,6 +2446,7 @@ mod tests { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: "fact".to_string(), @@ -2363,6 +2456,7 @@ mod tests { slug: None, source: None, source_type: "user".to_string(), + author_type: "agent".to_string(), }; let json = serde_json::to_string(&m).unwrap(); @@ -2402,6 +2496,7 @@ mod tests { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: "fact".to_string(), @@ -2411,6 +2506,7 @@ mod tests { slug: None, source: None, source_type: "user".to_string(), + author_type: "agent".to_string(), }; let sr = SearchResult { @@ -3167,6 +3263,7 @@ mod tests { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: "fact".to_string(), @@ -3176,6 +3273,7 @@ mod tests { slug: None, source: None, source_type: "user".to_string(), + author_type: "agent".to_string(), }; uteke.store.insert(&m1).unwrap(); diff --git a/crates/uteke-core/src/maintenance.rs b/crates/uteke-core/src/maintenance.rs index 493c61af..ccd8b3da 100644 --- a/crates/uteke-core/src/maintenance.rs +++ b/crates/uteke-core/src/maintenance.rs @@ -28,32 +28,40 @@ impl crate::Uteke { detail: format!("{} memories, {}", db_count, format_bytes(db_size)), }); - // 2. usearch index + // 2. vector index (backend-aware label, #1112) let index = self .index .read() .map_err(|_| Error::lock("index read lock during doctor"))?; let index_count = index.len(); checks.push(DoctorCheck { - name: "usearch index".to_string(), + name: format!("{} index", crate::memory::vector::INDEX_EXT), status: DoctorStatus::Ok, detail: format!("{} vectors", index_count), }); // 3. Index consistency - if db_count == index_count { + // The vector index holds memories AND document chunk vectors + // ("chunk:" keys) — compare index.len() against both counts so + // stores with documents don't report a false MISMATCH (#1111). + let chunk_count = self.store.load_all_chunk_embeddings()?.len(); + let expected = db_count + chunk_count; + if expected == index_count { checks.push(DoctorCheck { name: "Index consistency".to_string(), status: DoctorStatus::Ok, - detail: format!("DB={} Index={}", db_count, index_count), + detail: format!( + "DB={} (+{} chunks) Index={}", + db_count, chunk_count, index_count + ), }); } else { checks.push(DoctorCheck { name: "Index consistency".to_string(), status: DoctorStatus::Error, detail: format!( - "MISMATCH: DB={} Index={} — run `uteke repair`", - db_count, index_count + "MISMATCH: DB={} (+{} chunks) Index={} — run `uteke repair`", + db_count, chunk_count, index_count ), }); } @@ -93,15 +101,19 @@ impl crate::Uteke { /// Verify DB and index consistency. Returns mismatch count. pub fn verify(&self) -> Result { let db_count = self.store.count(None)?; + // Chunk vectors share the index ("chunk:" keys) — include them on + // the DB side so stores with documents don't report false MISMATCH (#1111). + let chunk_count = self.store.load_all_chunk_embeddings()?.len(); let index = self .index .read() .map_err(|_| Error::lock("index read lock during verify"))?; let index_count = index.len(); - let consistent = db_count == index_count; + let consistent = db_count + chunk_count == index_count; Ok(VerifyReport { db_count, + chunk_count, index_count, consistent, }) @@ -123,12 +135,32 @@ impl crate::Uteke { // Load all from SQLite and rebuild index (NULL embeddings filtered in load_all) let all_memories = self.store.load_all(None)?; - let items: Vec<(String, Vec)> = all_memories + let mut items: Vec<(String, Vec)> = all_memories .iter() .filter(|m| !m.embedding.is_empty()) .map(|m| (m.id.clone(), m.embedding.clone())) .collect(); + // Document chunk vectors live in the index under "chunk:" keys. + // load_all returns memories only — without this the rebuild silently + // evicts every chunk entry (#1110). Chunk embeddings are persisted in + // document_chunks, so no re-embedding is needed. + let chunk_count = { + let chunks = self.store.load_all_chunk_embeddings()?; + let n = chunks.len(); + items.extend(chunks.into_iter().map(|(id, emb)| { + let key = format!("chunk:{id}"); + (key, emb) + })); + n + }; + if chunk_count > 0 { + tracing::info!( + chunks = chunk_count, + "including document chunks in index rebuild" + ); + } + { let mut index = self .index @@ -144,6 +176,7 @@ impl crate::Uteke { db_count: before_db, index_before: before_index, index_after: items.len(), + chunk_count, }) } @@ -632,4 +665,83 @@ mod tests { let restored: CleanupResult = serde_json::from_str(&json).unwrap(); assert_eq!(restored.deleted, 5); } + + #[ignore = "requires ONNX embedder — verify/doctor count chunk vectors alongside memories"] + #[test] + fn test_verify_counts_doc_chunks() { + // Regression test for #1111: verify()/doctor() must count document + // chunk vectors on the DB side, otherwise any store with documents + // reports a memory-only count vs index (which holds memories AND + // chunk vectors) and flags a false MISMATCH. + let dir = tempfile::tempdir().unwrap(); + let uteke = crate::Uteke::open(dir.path().join("uteke.db")).unwrap(); + uteke + .doc_upsert( + "verify-chunks-doc", + "Verify Chunks Doc", + "A document about distributed systems and consensus algorithms like raft.", + &[], + None, + ) + .unwrap(); + + let report = uteke.verify().unwrap(); + assert_eq!(report.db_count, 0, "no memories in this store"); + assert!(report.chunk_count >= 1, "doc chunks must be counted"); + assert_eq!( + report.index_count, + report.db_count + report.chunk_count, + "index holds memories + chunk vectors" + ); + assert!( + report.consistent, + "no false MISMATCH with documents present" + ); + } + + #[ignore = "requires ONNX embedder — validates repair() keeps doc chunk vectors indexed"] + #[test] + fn test_repair_preserves_doc_chunk_vectors() { + // Regression test for #1110: repair() rebuilt the index from + // load_all() (memories only), silently evicting every "chunk:" + // vector. After repair, semantic doc search must still find chunks. + let dir = tempfile::tempdir().unwrap(); + let uteke = crate::Uteke::open(dir.path().join("uteke.db")).unwrap(); + let doc_id = uteke + .doc_upsert( + "repair-chunks-doc", + "Repair Chunks Doc", + "Chapter one introduces the architecture. Chapter two covers vector quantization theory in depth with mathematical foundations.", + &[], + None, + ) + .unwrap(); + + // Sanity: semantic search finds the doc before repair. + let before = uteke + .doc_search("quantization theory mathematics", 5, "semantic") + .unwrap(); + assert!( + !before.is_empty(), + "semantic doc search should find the doc pre-repair" + ); + + let report = uteke.repair().unwrap(); + // Index must include memories (0 here) + doc chunks (>= 1). + assert!( + report.index_after >= 1, + "rebuild must include document chunk vectors, got index_after={}", + report.index_after + ); + + // After repair, the chunk vector must still be searchable. + let after = uteke + .doc_search("quantization theory mathematics", 5, "semantic") + .unwrap(); + assert!( + !after.is_empty(), + "semantic doc search must still find doc {} after repair", + doc_id + ); + } } diff --git a/crates/uteke-core/src/memory/bulk.rs b/crates/uteke-core/src/memory/bulk.rs index 765f5a44..c15143d5 100644 --- a/crates/uteke-core/src/memory/bulk.rs +++ b/crates/uteke-core/src/memory/bulk.rs @@ -155,7 +155,7 @@ impl super::Store { let now = chrono::Utc::now().to_rfc3339(); self.conn .execute( - "UPDATE memories SET deprecated = 1, valid_until = ?1, updated_at = ?1 WHERE id = ?2", + "UPDATE memories SET deprecated = 1, valid_until = ?1, updated_at = ?1, deprecated_at = ?1 WHERE id = ?2", params![now, id], ) .map_err(|e| Error::db("database operation", e))?; @@ -171,7 +171,7 @@ impl super::Store { let rows = self .conn .execute( - "UPDATE memories SET deprecated = 1, valid_until = ?1, deprecate_reason = ?2, updated_at = ?1 WHERE id = ?3 AND deprecated = 0", + "UPDATE memories SET deprecated = 1, valid_until = ?1, deprecate_reason = ?2, updated_at = ?1, deprecated_at = ?1 WHERE id = ?3 AND deprecated = 0", params![now, reason, id], ) .map_err(|e| Error::db("database operation", e))?; @@ -208,7 +208,7 @@ impl super::Store { .collect::>() .join(","); let sql = format!( - "UPDATE memories SET deprecated = 1, valid_until = ?1, deprecate_reason = ?2, updated_at = ?1 WHERE id IN ({placeholders}) AND deprecated = 0" + "UPDATE memories SET deprecated = 1, valid_until = ?1, deprecate_reason = ?2, updated_at = ?1, deprecated_at = ?1 WHERE id IN ({placeholders}) AND deprecated = 0" ); let mut params_vec: Vec> = vec![Box::new(now), Box::new(reason.to_string())]; @@ -233,7 +233,7 @@ impl super::Store { let rows = self .conn .execute( - "UPDATE memories SET deprecated = 0, valid_until = NULL, deprecate_reason = NULL, updated_at = ?1 WHERE id = ?2 AND deprecated = 1", + "UPDATE memories SET deprecated = 0, valid_until = NULL, deprecate_reason = NULL, deprecated_at = NULL, updated_at = ?1 WHERE id = ?2 AND deprecated = 1", params![now, id], ) .map_err(|e| Error::db("database operation", e))?; diff --git a/crates/uteke-core/src/memory/crud.rs b/crates/uteke-core/src/memory/crud.rs index 3302eb56..06a1d5e1 100644 --- a/crates/uteke-core/src/memory/crud.rs +++ b/crates/uteke-core/src/memory/crud.rs @@ -102,8 +102,8 @@ impl super::Store { ) -> Result<(), Error> { self.conn .execute( - "INSERT INTO memories (id, content, embedding, tags, metadata, created_at, updated_at, namespace, access_count, last_accessed, deprecated, valid_from, valid_until, memory_type, importance, pinned, content_type, slug, source, source_type) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", + "INSERT INTO memories (id, content, embedding, tags, metadata, created_at, updated_at, namespace, access_count, last_accessed, deprecated, valid_from, valid_until, memory_type, importance, pinned, content_type, slug, source, source_type, author_type, deprecated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)", params![ memory.id, memory.content, @@ -125,6 +125,8 @@ impl super::Store { memory.slug, memory.source, memory.source_type, + memory.author_type, + memory.deprecated_at.map(|t| t.to_rfc3339()), ], ) .map_err(|e| Error::db("Failed to insert memory", e))?; @@ -159,7 +161,7 @@ impl super::Store { pub fn get_by_id(&self, id: &str) -> Result, Error> { let mut stmt = self .conn - .prepare("SELECT id, content, embedding, tags, metadata, created_at, updated_at, namespace, access_count, last_accessed, deprecated, valid_from, valid_until, memory_type, importance, pinned, content_type, slug FROM memories WHERE id = ?1") + .prepare("SELECT id, content, embedding, tags, metadata, created_at, updated_at, namespace, access_count, last_accessed, deprecated, valid_from, valid_until, memory_type, importance, pinned, content_type, slug, source, source_type, author_type, deprecated_at FROM memories WHERE id = ?1") .map_err(|e| Error::db("Failed to prepare statement for get_by_id", e))?; let result = stmt @@ -186,7 +188,7 @@ impl super::Store { for chunk in ids.chunks(CHUNK_SIZE) { let placeholders = chunk.iter().map(|_| "?").collect::>().join(","); let sql = format!( - "SELECT id, content, embedding, tags, metadata, created_at, updated_at, namespace, access_count, last_accessed, deprecated, valid_from, valid_until, memory_type, importance, pinned, content_type, slug FROM memories WHERE id IN ({placeholders})" + "SELECT id, content, embedding, tags, metadata, created_at, updated_at, namespace, access_count, last_accessed, deprecated, valid_from, valid_until, memory_type, importance, pinned, content_type, slug, source, source_type, author_type, deprecated_at FROM memories WHERE id IN ({placeholders})" ); let mut stmt = self .conn @@ -248,7 +250,7 @@ impl super::Store { let ns = namespace.unwrap_or(crate::memory::types::DEFAULT_NAMESPACE); let mut stmt = self .conn - .prepare("SELECT id, content, embedding, tags, metadata, created_at, updated_at, namespace, access_count, last_accessed, deprecated, valid_from, valid_until, memory_type, importance, pinned, content_type, slug FROM memories WHERE id = ?1 AND namespace = ?2") + .prepare("SELECT id, content, embedding, tags, metadata, created_at, updated_at, namespace, access_count, last_accessed, deprecated, valid_from, valid_until, memory_type, importance, pinned, content_type, slug, source, source_type, author_type, deprecated_at FROM memories WHERE id = ?1 AND namespace = ?2") .map_err(|e| Error::db("Failed to prepare statement for get_by_id_in_namespace", e))?; let result = stmt @@ -593,10 +595,10 @@ impl super::Store { // recall and must not re-enter the vector index on repair/verify (#1047). let sql = match namespace { Some(_) => { - "SELECT id, content, embedding, tags, metadata, created_at, updated_at, namespace, access_count, last_accessed, deprecated, valid_from, valid_until, memory_type, importance, pinned, content_type, slug FROM memories WHERE namespace = ?1 AND embedding IS NOT NULL AND deprecated = 0 ORDER BY created_at" + "SELECT id, content, embedding, tags, metadata, created_at, updated_at, namespace, access_count, last_accessed, deprecated, valid_from, valid_until, memory_type, importance, pinned, content_type, slug, source, source_type, author_type, deprecated_at FROM memories WHERE namespace = ?1 AND embedding IS NOT NULL AND deprecated = 0 ORDER BY created_at" } None => { - "SELECT id, content, embedding, tags, metadata, created_at, updated_at, namespace, access_count, last_accessed, deprecated, valid_from, valid_until, memory_type, importance, pinned, content_type, slug FROM memories WHERE embedding IS NOT NULL AND deprecated = 0 ORDER BY created_at" + "SELECT id, content, embedding, tags, metadata, created_at, updated_at, namespace, access_count, last_accessed, deprecated, valid_from, valid_until, memory_type, importance, pinned, content_type, slug, source, source_type, author_type, deprecated_at FROM memories WHERE embedding IS NOT NULL AND deprecated = 0 ORDER BY created_at" } }; @@ -960,6 +962,7 @@ mod content_type_tests { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: "fact".to_string(), @@ -969,6 +972,7 @@ mod content_type_tests { slug: None, source: None, source_type: "user".to_string(), + author_type: "agent".to_string(), }; store.insert(&memory).unwrap(); @@ -992,6 +996,7 @@ mod content_type_tests { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: "fact".to_string(), @@ -1001,6 +1006,7 @@ mod content_type_tests { slug: None, source: None, source_type: "user".to_string(), + author_type: "agent".to_string(), }; store.insert(&memory).unwrap(); @@ -1015,7 +1021,7 @@ mod content_type_tests { let store = super::super::store::Store::open(":memory:").unwrap(); assert!(store.column_exists("content_type")); let version = store.schema_version().unwrap(); - assert_eq!(version, 15); // v15 = room_documents junction (#689); v14 = FTS5 memory_type column (#662); v13 = global docs no namespace (#614); v12 = hierarchical docs (#438); v11 = document engine (#406); v10 = source columns (#348); v9 = timeline (#347); v8 = edges + slug; v7 = graph + assert_eq!(version, crate::memory::store::CURRENT_SCHEMA_VERSION); // v16 = author_type (#1083); v15 = room_documents junction (#689); v14 = FTS5 memory_type column (#662); v13 = global docs no namespace (#614); v12 = hierarchical docs (#438); v11 = document engine (#406); v10 = source columns (#348); v9 = timeline (#347); v8 = edges + slug; v7 = graph } #[test] @@ -1039,6 +1045,7 @@ mod content_type_tests { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: "fact".to_string(), @@ -1048,6 +1055,7 @@ mod content_type_tests { slug: None, source: None, source_type: "user".to_string(), + author_type: "agent".to_string(), }; store.insert(&m1).unwrap(); diff --git a/crates/uteke-core/src/memory/documents.rs b/crates/uteke-core/src/memory/documents.rs index 014b5303..da6ebd76 100644 --- a/crates/uteke-core/src/memory/documents.rs +++ b/crates/uteke-core/src/memory/documents.rs @@ -858,6 +858,27 @@ impl super::Store { Ok(rows.filter_map(|r| r.ok()).collect()) } + /// Load embeddings for ALL document chunks (id, embedding). + /// + /// Used by `repair()` to rebuild the vector index including document + /// chunk vectors — `load_all` returns memories only, so without this the + /// rebuild silently evicts every `chunk:` entry (#1110). + pub fn load_all_chunk_embeddings(&self) -> Result)>, Error> { + let mut stmt = self + .conn + .prepare("SELECT id, embedding FROM document_chunks WHERE embedding IS NOT NULL AND length(embedding) > 0") + .map_err(|e| Error::db("prepare load all chunk embeddings", e))?; + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, Vec>(1)?)) + }) + .map_err(|e| Error::db("load all chunk embeddings query", e))?; + Ok(rows + .filter_map(|r| r.ok()) + .map(|(id, blob)| (id, super::store::deserialize_embedding(&blob))) + .collect()) + } + /// Get document chunks by their IDs (for semantic search result enrichment). /// /// Returns chunk data needed for search result display. diff --git a/crates/uteke-core/src/memory/fts5.rs b/crates/uteke-core/src/memory/fts5.rs index 1a3ed95c..2aeef80c 100644 --- a/crates/uteke-core/src/memory/fts5.rs +++ b/crates/uteke-core/src/memory/fts5.rs @@ -250,6 +250,7 @@ mod tests { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: "fact".to_string(), @@ -259,6 +260,7 @@ mod tests { slug: None, source: None, source_type: "user".to_string(), + author_type: "agent".to_string(), } } diff --git a/crates/uteke-core/src/memory/rooms.rs b/crates/uteke-core/src/memory/rooms.rs index a300a809..a5d5adb6 100644 --- a/crates/uteke-core/src/memory/rooms.rs +++ b/crates/uteke-core/src/memory/rooms.rs @@ -41,6 +41,10 @@ pub struct RoomSummary { /// Document slugs linked to this room via the room_documents junction table. #[serde(default, skip_serializing_if = "Option::is_none")] pub referenced_documents: Option>, + /// Semantic segments (embedding-distance boundaries, #1088). + /// Computed on demand; absent when the room has no embeddings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub segments: Option>, } /// Time range of memories in a room. @@ -490,6 +494,7 @@ impl super::Store { recent_decisions: vec![], pinned_highlights: vec![], referenced_documents: None, + segments: None, })); } @@ -734,6 +739,7 @@ impl super::Store { recent_decisions, pinned_highlights: pinned, referenced_documents: None, + segments: None, })) } @@ -1041,6 +1047,7 @@ mod tests { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: "fact".to_string(), @@ -1050,6 +1057,7 @@ mod tests { slug: None, source: None, source_type: "user".to_string(), + author_type: "agent".to_string(), } } diff --git a/crates/uteke-core/src/memory/schema.rs b/crates/uteke-core/src/memory/schema.rs index bdacfc5e..0166933e 100644 --- a/crates/uteke-core/src/memory/schema.rs +++ b/crates/uteke-core/src/memory/schema.rs @@ -414,6 +414,10 @@ impl super::Store { 14 => self.migrate_v13_to_v14()?, // v15: room_documents junction table (#689) 15 => self.migrate_v14_to_v15()?, + // v16: author_type column (human|agent) (#1083) + 16 => self.migrate_v15_to_v16()?, + // v17: deprecated_at column — time-travel deprecation predicate (#1086) + 17 => self.migrate_v16_to_v17()?, _ => { // No-op for future versions. } @@ -1057,4 +1061,72 @@ impl super::Store { tracing::info!("Migration v14 to v15 complete: room_documents table created"); Ok(()) } + + /// v16: Add author_type column (human|agent) to memories (#1083). + /// + /// Distinguishes memories authored by a human (direct user capture) from + /// memories authored by an agent (inference, consolidation, import). + /// Default 'agent' matches the dominant pre-v16 write path. Validation of + /// the human|agent domain happens at the application layer (SQLite CHECK + /// on ALTER TABLE ADD COLUMN is unsupported for existing rows). + /// + /// Index lives here (not in SCHEMA_INDEXES) per the #492 convention: + /// indexes on migration-added columns belong in the migration itself so + /// fresh and upgraded DBs converge identically. + fn migrate_v15_to_v16(&self) -> Result<(), Error> { + tracing::info!("Applying schema migration v15 to v16: author_type column"); + + if !self.column_exists("author_type") { + self.conn + .execute_batch( + "ALTER TABLE memories ADD COLUMN author_type TEXT NOT NULL DEFAULT 'agent';", + ) + .map_err(|e| Error::db("schema migration v15 to v16", e))?; + } + self.conn + .execute_batch( + "CREATE INDEX IF NOT EXISTS idx_memories_author_type ON memories(author_type);", + ) + .map_err(|e| Error::db("create idx_memories_author_type", e))?; + + tracing::info!("Migration v15 to v16 complete: author_type column added"); + Ok(()) + } + + /// Schema v17 (#1086): add `deprecated_at` so time-travel recall can + /// distinguish "deprecated before the point-in-time" from "deprecated + /// after it". + /// + /// - Adds the column (no default; NULL = unknown). + /// - Backfills legacy deprecated rows with `updated_at` as the best + /// available estimate of when the deprecation happened. + /// - Active rows keep NULL. + fn migrate_v16_to_v17(&self) -> Result<(), Error> { + tracing::info!("Applying schema migration v16 to v17: deprecated_at column (#1086)"); + + if !self.column_exists("deprecated_at") { + self.conn + .execute_batch("ALTER TABLE memories ADD COLUMN deprecated_at TEXT;") + .map_err(|e| Error::db("schema migration v16 to v17", e))?; + } + // Backfill: legacy deprecated rows have no timestamp; `updated_at` is + // the closest estimate (deprecate paths always bump updated_at). + let backfilled = self + .conn + .execute( + "UPDATE memories SET deprecated_at = updated_at \ + WHERE deprecated = 1 AND deprecated_at IS NULL", + [], + ) + .map_err(|e| Error::db("backfill deprecated_at", e))?; + if backfilled > 0 { + tracing::info!( + count = backfilled, + "Backfilled deprecated_at from updated_at" + ); + } + + tracing::info!("Migration v16 to v17 complete: deprecated_at column added"); + Ok(()) + } } diff --git a/crates/uteke-core/src/memory/store.rs b/crates/uteke-core/src/memory/store.rs index 0a0bf4c4..ade512cb 100644 --- a/crates/uteke-core/src/memory/store.rs +++ b/crates/uteke-core/src/memory/store.rs @@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS memories ( last_accessed TEXT, deprecated INTEGER NOT NULL DEFAULT 0, deprecate_reason TEXT, + deprecated_at TEXT, valid_from TEXT, valid_until TEXT, memory_type TEXT NOT NULL DEFAULT 'fact', @@ -29,7 +30,8 @@ CREATE TABLE IF NOT EXISTS memories ( content_type TEXT NOT NULL DEFAULT 'text', slug TEXT, source TEXT, - source_type TEXT NOT NULL DEFAULT 'user' + source_type TEXT NOT NULL DEFAULT 'user', + author_type TEXT NOT NULL DEFAULT 'agent' ); CREATE INDEX IF NOT EXISTS idx_memories_tags ON memories(tags); CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at); @@ -174,7 +176,7 @@ pub(super) const SCHEMA_INDEXES: &[&str] = &[ ]; /// Current schema version. Increment when adding migrations. -pub(super) const CURRENT_SCHEMA_VERSION: i32 = 15; +pub(crate) const CURRENT_SCHEMA_VERSION: i32 = 17; /// Persistent SQLite store for memories. pub struct Store { @@ -270,6 +272,20 @@ impl Store { Ok(rows > 0) } + /// Set author type on a memory (#1083): "human" or "agent". + /// Returns false if the memory does not exist. + pub fn set_author_type(&self, id: &str, author_type: &str) -> Result { + crate::memory::types::validate_author_type(author_type)?; + let rows = self + .conn + .execute( + "UPDATE memories SET author_type = ?1 WHERE id = ?2", + rusqlite::params![author_type, id], + ) + .map_err(|e| Error::db("set author_type", e))?; + Ok(rows > 0) + } + /// Recalculate importance for all memories. /// importance = 0.3*access_score + 0.3*recency_score + 0.2*connectivity + 0.2*is_pinned pub fn recompute_importance(&self) -> Result { @@ -435,6 +451,8 @@ pub(crate) fn row_to_memory(row: &rusqlite::Row<'_>) -> Result = row.get(21).ok().flatten(); + let deprecated_at = deprecated_at_str.as_deref().and_then(parse_datetime_opt); let valid_from_str: Option = row.get(11).ok().flatten(); let valid_from = valid_from_str.as_deref().and_then(parse_datetime_opt); let valid_until_str: Option = row.get(12).ok().flatten(); @@ -446,6 +464,7 @@ pub(crate) fn row_to_memory(row: &rusqlite::Row<'_>) -> Result = row.get(17).ok().flatten(); let source: Option = row.get(18).ok().flatten(); let source_type: String = row.get(19).unwrap_or_else(|_| "unknown".to_string()); + let author_type: String = row.get(20).unwrap_or_else(|_| "agent".to_string()); Ok(Memory { id, @@ -459,6 +478,7 @@ pub(crate) fn row_to_memory(row: &rusqlite::Row<'_>) -> Result) -> Result>, /// When this fact became valid (temporal metadata). #[serde(default)] pub valid_from: Option>, @@ -66,6 +71,16 @@ pub struct Memory { skip_serializing_if = "is_default_source_type" )] pub source_type: String, + /// Author type: "human" or "agent" (#1083). + /// + /// Distinguishes memories captured directly from a human (user capture, + /// import) from memories generated by an agent (inference, consolidation). + /// Default "agent" matches the dominant write path. + #[serde( + default = "default_author_type", + skip_serializing_if = "is_default_author_type" + )] + pub author_type: String, } fn default_namespace() -> String { @@ -82,6 +97,30 @@ fn is_default_source_type(s: &str) -> bool { s == "user" } +/// Default author type for memories (#1083). +pub fn default_author_type() -> String { + "agent".to_string() +} + +/// Serde predicate: skip if author_type equals the default. +fn is_default_author_type(s: &str) -> bool { + s == "agent" +} + +/// Validate author_type against the allowed domain (#1083). +/// +/// Returns `Ok(())` for "human" | "agent", otherwise a [`crate::Error::Validation`]. +/// Validation lives in the application layer — SQLite cannot add a CHECK +/// constraint to an existing table via ALTER TABLE ADD COLUMN. +pub fn validate_author_type(author_type: &str) -> Result<(), crate::Error> { + match author_type { + "human" | "agent" => Ok(()), + other => Err(crate::Error::Validation(format!( + "Invalid author_type '{other}'. Valid values: human, agent" + ))), + } +} + fn default_importance() -> f64 { 0.5 } @@ -677,6 +716,18 @@ pub struct ConsolidationResult { pub kept_ids: Vec, } +/// Result of a per-pair consolidation (#1076): the caller chose which +/// duplicate survives instead of the keep-newer rule. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PairConsolidationResult { + /// ID of the surviving memory (untouched). + pub kept: String, + /// ID of the deprecated/deleted memory. + pub removed: String, + /// True when the loser was hard-deleted rather than deprecated. + pub hard: bool, +} + /// A pair of similar memories (potential duplicate). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SimilarPair { @@ -697,7 +748,6 @@ pub struct SimilarPair { #[serde(rename_all = "kebab-case")] pub enum RecallStrategy { /// Hybrid: vector + FTS5 merged via Reciprocal Rank Fusion. - #[default] Hybrid, /// Vector similarity only (original behavior). Vector, @@ -706,6 +756,14 @@ pub enum RecallStrategy { /// Graph-augmented: hybrid RRF + graph-signal reranking (#378). /// Well-connected memories get a subtle log-scaled score boost. Graph, + /// Fusion (#1123): vector ranking (×1.7) RRF-fused with hybrid ranking + /// (×1). Vector and hybrid fail on disjoint question sets; fusing both + /// captures each other's wins. LongMemEval fast50 evidence: R@5 + /// 0.9267 (hybrid) → 0.98 (fusion), R@10 1.0. + /// + /// DEFAULT since 0.16.0: the zero-config recall strategy. + #[default] + Fusion, } impl RecallStrategy { @@ -716,6 +774,7 @@ impl RecallStrategy { "fts5" => Some(Self::Fts5), "hybrid" => Some(Self::Hybrid), "graph" => Some(Self::Graph), + "fusion" => Some(Self::Fusion), _ => None, } } @@ -726,6 +785,7 @@ impl RecallStrategy { Self::Fts5 => "fts5", Self::Hybrid => "hybrid", Self::Graph => "graph", + Self::Fusion => "fusion", } } } @@ -734,6 +794,32 @@ impl RecallStrategy { mod tests { use super::*; + #[test] + fn recall_strategy_fusion_roundtrip() { + assert_eq!( + RecallStrategy::from_str_opt("fusion"), + Some(RecallStrategy::Fusion) + ); + assert_eq!(RecallStrategy::Fusion.as_str(), "fusion"); + // Exact-match parsing like every other variant (uppercase rejected). + assert_eq!(RecallStrategy::from_str_opt("FUSION"), None); + assert_eq!(RecallStrategy::from_str_opt("fusio"), None); + } + + #[test] + fn recall_strategy_default_is_fusion() { + // #1123: Fusion is the zero-config default since 0.16.0. + assert_eq!(RecallStrategy::default(), RecallStrategy::Fusion); + } + + #[test] + fn recall_strategy_serde_kebab() { + let s = serde_json::to_string(&RecallStrategy::Fusion).unwrap(); + assert_eq!(s, "\"fusion\""); + let back: RecallStrategy = serde_json::from_str(&s).unwrap(); + assert_eq!(back, RecallStrategy::Fusion); + } + #[test] fn memory_type_roundtrip() { for t in [ diff --git a/crates/uteke-core/src/memory/vector.rs b/crates/uteke-core/src/memory/vector.rs index eba7e1e9..11170345 100644 --- a/crates/uteke-core/src/memory/vector.rs +++ b/crates/uteke-core/src/memory/vector.rs @@ -1,7 +1,17 @@ -//! Persistent vector index using usearch (HNSW with disk persistence). +//! Persistent vector index with pluggable backends. +//! +//! Two mutually exclusive backends, selected at compile time via cargo features: +//! +//! - `usearch` (default): HNSW with disk persistence via C++ FFI. +//! - `vecq`: training-free 4-bit vector quantization, pure Rust, no C++ +//! toolchain (for mobile/FFI builds, #1098). +//! +//! The public API (`new`, `load_or_create`, `insert`, `remove`, `search`, +//! `build`, `save`, `len`, `dims`, ...) is identical for both backends — +//! callers never branch on the backend. //! //! Cross-process safety (#543): Each VectorIndex acquires an exclusive file -//! lock (via fs2) on the .usearch file during construction. The lock is held +//! lock (via fs2) on the index file during construction. The lock is held //! until the VectorIndex is dropped, serializing concurrent CLI invocations //! that share the same on-disk index. In-process thread safety uses //! RwLock in lib.rs. @@ -11,44 +21,73 @@ //! `fread`, `mmap`) which has Windows-specific issues (MAX_PATH, file lock //! conflicts, AV interference). Save serializes to memory then atomic-writes //! via Rust std::fs; load reads via Rust std::fs then deserializes from buffer. +//! +//! vecq format note: vecq has no incremental delete — removed rows are +//! tombstoned (tracked via the `dead` bitmap) and filtered out of search +//! results. Tombstones are derived from the key-mapping sidecar on load, so +//! no extra on-disk state is needed. + +#[cfg(all(feature = "usearch", feature = "vecq"))] +compile_error!( + "features `usearch` and `vecq` are mutually exclusive vector index backends; \ + enable exactly one (default build uses `usearch`)" +); +#[cfg(not(any(feature = "usearch", feature = "vecq")))] +compile_error!("uteke-core requires a vector index backend; enable `usearch` (default) or `vecq`"); use crate::Error; use fs2::FileExt; use std::collections::HashMap; use std::fs::File; use std::path::{Path, PathBuf}; + +#[cfg(feature = "usearch")] use usearch::{Index, IndexOptions, MetricKind, ScalarKind}; +#[cfg(feature = "vecq")] +use vecq_core::VecqIndex; -/// Extension for usearch index files. -const USEARCH_EXT: &str = "usearch"; +/// Extension for the on-disk index file. +#[cfg(feature = "usearch")] +pub const INDEX_EXT: &str = "usearch"; +/// Extension for the on-disk index file (vecq backend, #1098). +#[cfg(feature = "vecq")] +pub const INDEX_EXT: &str = "vecq"; /// Default dimensions for EmbeddingGemma Q4 (768d). const DEFAULT_DIMS: usize = 768; -/// Persistent vector index backed by usearch. +/// Deterministic vecq seed. vecq results are bit-identical across platforms +/// for a given seed + file; pinning the seed keeps rebuilds reproducible. +#[cfg(feature = "vecq")] +const VECQ_SEED: u64 = 0x7574_656b; // "utek" + +/// Persistent vector index. /// /// - **Startup**: loads from disk (~5ms), no rebuild needed /// - **Insert**: incremental, no rebuild /// - **Delete**: incremental, no rebuild /// - **Save**: persists to disk after mutations /// -/// **Cross-process safety (#543):** An exclusive file lock on the `.usearch` +/// **Cross-process safety (#543):** An exclusive file lock on the index /// file serializes concurrent access from separate CLI processes. The lock is /// held for the lifetime of the VectorIndex. In-process thread safety uses /// `RwLock` in `Uteke`. pub struct VectorIndex { + #[cfg(feature = "usearch")] index: Index, + #[cfg(feature = "vecq")] + index: VecqIndex, /// Maps integer key (u64) → memory UUID string. key_to_id: HashMap, /// Maps memory UUID → integer key. id_to_key: HashMap, /// Next available integer key. next_key: u64, - /// Path to the usearch index file. + /// Path to the index file. path: Option, /// Whether the index has unsaved changes. dirty: bool, - /// Cross-process file lock on the .usearch file (#543). + /// Cross-process file lock on the index file (#543). /// Held until the VectorIndex is dropped. _lock_file: Option, } @@ -69,9 +108,9 @@ impl VectorIndex { } /// Load index from disk, or create empty if file doesn't exist. - /// `path` is the path to the `.usearch` file. + /// `path` is the path to the index file. /// - /// Acquires an **exclusive file lock** on the `.usearch` file to prevent + /// Acquires an **exclusive file lock** on the index file to prevent /// cross-process race conditions (e.g., `xargs -P5 uteke remember`). /// The lock is held until this `VectorIndex` is dropped (#543). /// @@ -112,16 +151,21 @@ impl VectorIndex { use std::io::{Read, Seek, SeekFrom}; file.seek(SeekFrom::Start(0)) - .map_err(|e| Error::embed("seek usearch file", e))?; + .map_err(|e| Error::embed("seek index file", e))?; let mut buffer = Vec::new(); file.read_to_end(&mut buffer) - .map_err(|e| Error::embed("read usearch file from locked handle", e))?; + .map_err(|e| Error::embed("read index file from locked handle", e))?; + #[cfg(feature = "usearch")] let index = Index::restore_from_buffer(&buffer) .map_err(|e| Error::embed("load vector index", e))?; + #[cfg(feature = "usearch")] + let _ = index.size(); - let _size = index.size(); + #[cfg(feature = "vecq")] + let index = VecqIndex::from_bytes(&buffer) + .map_err(|e| Error::embed("load vector index (vecq)", e))?; // Rebuild key mappings from the sidecar file let mut key_to_id = HashMap::new(); @@ -165,19 +209,19 @@ impl VectorIndex { /// Load an existing index from disk. /// /// Uses buffer-based deserialization (#684): reads the file into memory via - /// Rust's `std::fs::read()`, then deserializes via `restore_from_buffer()`. - /// This bypasses usearch's C++ `fopen("rb")` + `mmap()` which causes - /// "Permission denied" errors on Windows (#684). + /// Rust's `std::fs::read()`, then deserializes via the backend's + /// from-buffer API. This bypasses usearch's C++ `fopen("rb")` + `mmap()` + /// which causes "Permission denied" errors on Windows (#684). pub fn load(path: &Path) -> Result { - let mut file = File::open(path).map_err(|e| Error::embed("open usearch file", e))?; + let mut file = File::open(path).map_err(|e| Error::embed("open index file", e))?; Self::load_from_file(&mut file, path) } /// Save index and key mappings to disk. /// - /// Uses buffer-based serialization (#647): serializes the usearch index - /// into an in-memory buffer via `save_to_buffer`, then writes the buffer - /// to disk using Rust's `std::fs` with atomic write (temp file + rename). + /// Uses buffer-based serialization (#647): serializes the index into an + /// in-memory buffer, then writes the buffer to disk using Rust's + /// `std::fs` with atomic write (temp file + rename). /// /// This bypasses usearch's C++ `fopen("wb")` file I/O which has known /// issues on Windows: @@ -186,22 +230,27 @@ impl VectorIndex { /// - Windows Defender can intercept `fwrite` calls /// /// The in-memory buffer approach is safe because: - /// - The index data is already fully in RAM (usearch always loads fully) - /// - Buffer size = `serialized_length()`, same data as file-based save + /// - The index data is already fully in RAM (both backends load fully) /// - Atomic write prevents corruption on crash pub fn save(&mut self) -> Result<(), Error> { if let Some(ref path) = self.path { // Serialize index to in-memory buffer, bypassing C++ file I/O (#647) - let buf_len = self.index.serialized_length(); - let mut buffer = vec![0u8; buf_len]; - self.index - .save_to_buffer(&mut buffer) - .map_err(|e| Error::embed("save vector index to buffer", e))?; + #[cfg(feature = "usearch")] + let buffer: Vec = { + let buf_len = self.index.serialized_length(); + let mut buffer = vec![0u8; buf_len]; + self.index + .save_to_buffer(&mut buffer) + .map_err(|e| Error::embed("save vector index to buffer", e))?; + buffer + }; + #[cfg(feature = "vecq")] + let buffer: Vec = self.index.to_bytes(); // Write buffer to disk via atomic write (temp file + rename) - let tmp_path = path.with_extension(format!("{USEARCH_EXT}.tmp")); + let tmp_path = path.with_extension(format!("{INDEX_EXT}.tmp")); std::fs::write(&tmp_path, &buffer) - .map_err(|e| Error::embed("write temp usearch index", e))?; + .map_err(|e| Error::embed("write temp index file", e))?; // On Windows, `std::fs::rename` fails with `ERROR_ACCESS_DENIED` if // the destination file is locked by `LockFileEx` via fs2 (#926). @@ -226,7 +275,7 @@ impl VectorIndex { delay *= 2; } Err(e) => { - return Err(Error::embed("rename temp to final usearch index", e)); + return Err(Error::embed("rename temp to final index file", e)); } } } @@ -234,7 +283,7 @@ impl VectorIndex { #[cfg(not(windows))] { std::fs::rename(&tmp_path, path) - .map_err(|e| Error::embed("rename temp to final usearch index", e))?; + .map_err(|e| Error::embed("rename temp to final index file", e))?; } // On Windows, reopen the file after rename to refresh the lock @@ -248,11 +297,11 @@ impl VectorIndex { .open(path) .map_err(|e| { Error::embed_msg(format!( - "Failed to reopen usearch file after save rename: {e}" + "Failed to reopen index file after save rename: {e}" )) })?; fs2::FileExt::try_lock_exclusive(&new_file) - .map_err(|e| Error::embed("re-lock usearch file after save", e))?; + .map_err(|e| Error::embed("re-lock index file after save", e))?; *lock_file = new_file; } } @@ -279,17 +328,11 @@ impl VectorIndex { } else { items[0].1.len() }; - self.index = match Self::create_index(dims) { - Ok(idx) => idx, - Err(e) => { - return Err(e); - } - }; + self.index = Self::create_index(dims)?; self.key_to_id.clear(); self.id_to_key.clear(); self.next_key = 0; - // Pre-reserve capacity for bulk insert if !items.is_empty() { // Validate all items have consistent dimensions for (id, emb) in items { @@ -300,6 +343,7 @@ impl VectorIndex { ))); } } + #[cfg(feature = "usearch")] if let Err(e) = self.index.reserve(items.len()) { tracing::error!("Failed to reserve usearch capacity: {e}"); } @@ -313,39 +357,84 @@ impl VectorIndex { /// Insert a single item into the index. /// If the ID already exists, removes the old entry first to prevent duplicates. - /// Returns error if the underlying usearch operation fails. + /// Returns error if the underlying index operation fails. pub fn insert(&mut self, id: &str, embedding: &[f32]) -> Result<(), Error> { + // Validate dimensions up front, before any map mutation, so error + // paths leave the key maps consistent with the physical index. + #[cfg(feature = "vecq")] + if embedding.len() != self.index.dim() { + return Err(Error::validation(format!( + "embedding dimension mismatch: got {}, expected {}", + embedding.len(), + self.index.dim() + ))); + } + // Guard: remove old entry if ID already exists (prevents duplicate + stale slot) if let Some(old_key) = self.id_to_key.get(id) { let old_key = *old_key; self.key_to_id.remove(&old_key); + #[cfg(feature = "usearch")] self.index.remove(old_key).map_err(|e| { Error::embed_msg(format!( "Failed to remove old entry for duplicate ID {id}: {e}" )) })?; + // vecq has no incremental delete — the dead row is filtered out of + // search via the key map (key_to_id no longer contains old_key). } - let key = self.next_key; - self.next_key = self.next_key.saturating_add(1); + #[cfg(feature = "vecq")] + { + // vecq rows are append-only: the new entry lands at physical row + // `index.len()`, and search maps rows back via key_to_id — so the + // key MUST equal that row exactly (no gaps). + // + // Crash window: save() writes the index file and the `.keys` + // sidecar as two separate atomic writes. If the process dies after + // the sidecar but before the index, the reloaded sidecar can hold + // keys ≥ index.len() ("phantom" keys pointing at rows that were + // never written). Overwriting such a phantom key here is correct: + // its row never existed, so nothing live can be shadowed. + let key = self.index.len() as u64; + if let Some(phantom_id) = self.key_to_id.remove(&key) { + tracing::warn!( + "overwriting phantom key {key} (id '{phantom_id}' had no row in the vecq index — likely a crash between sidecar and index writes)" + ); + self.id_to_key.remove(&phantom_id); + } + self.next_key = key.saturating_add(1); - self.key_to_id.insert(key, id.to_string()); - self.id_to_key.insert(id.to_string(), key); + self.key_to_id.insert(key, id.to_string()); + self.id_to_key.insert(id.to_string(), key); + } + #[cfg(feature = "usearch")] + { + let key = self.next_key; + self.next_key = self.next_key.saturating_add(1); + + self.key_to_id.insert(key, id.to_string()); + self.id_to_key.insert(id.to_string(), key); + + // Auto-reserve if at capacity using geometric growth to amortize reallocation cost. + // Growth strategy: max(current * 2, current + 4096, 1024). + // Doubling amortizes to O(1) per insertion; +4096 floor avoids tiny allocs at small scale. + if self.index.size() >= self.index.capacity() { + let current = self.index.capacity(); + let new_cap = (current * 2).max(current + 4096).max(1024); + self.index.reserve(new_cap).map_err(|e| { + Error::embed_msg(format!("Failed to reserve usearch capacity: {e}")) + })?; + } - // Auto-reserve if at capacity using geometric growth to amortize reallocation cost. - // Growth strategy: max(current * 2, current + 4096, 1024). - // Doubling amortizes to O(1) per insertion; +4096 floor avoids tiny allocs at small scale. - if self.index.size() >= self.index.capacity() { - let current = self.index.capacity(); - let new_cap = (current * 2).max(current + 4096).max(1024); - self.index.reserve(new_cap).map_err(|e| { - Error::embed_msg(format!("Failed to reserve usearch capacity: {e}")) + self.index.add(key, embedding).map_err(|e| { + Error::embed_msg(format!("Failed to insert into usearch index: {e}")) })?; } - - self.index - .add(key, embedding) - .map_err(|e| Error::embed_msg(format!("Failed to insert into usearch index: {e}")))?; + // vecq assigns rows sequentially; row == key by construction + // (key was derived from `index.len()` above; dims validated up front). + #[cfg(feature = "vecq")] + self.index.add(embedding); self.dirty = true; Ok(()) @@ -355,9 +444,12 @@ impl VectorIndex { pub fn remove(&mut self, id: &str) -> bool { if let Some(key) = self.id_to_key.remove(id) { self.key_to_id.remove(&key); + #[cfg(feature = "usearch")] if let Err(e) = self.index.remove(key) { tracing::error!("Failed to remove from usearch index: {e}"); } + // vecq: tombstone is implicit — the key vanishes from the map, so + // search results referencing that row are filtered out below. self.dirty = true; true } else { @@ -367,15 +459,16 @@ impl VectorIndex { /// Search for the k nearest neighbors of the query vector. /// Returns (memory_id, distance_f32) pairs, sorted by distance ascending. - /// Search for k nearest neighbors. /// Note: `ef` parameter is accepted for API compatibility but not passed to /// usearch v2.25.3 (Rust bindings don't expose `ef` in `search()`). pub fn search(&self, query: &[f32], k: usize, _ef: usize) -> Vec<(String, f32)> { - if self.index.size() == 0 { + if self.is_empty() { return Vec::new(); } let count = k.max(1); + + #[cfg(feature = "usearch")] let results = match self.index.search(query, count) { Ok(r) => r, Err(e) => { @@ -383,19 +476,57 @@ impl VectorIndex { return Vec::new(); } }; - - results + #[cfg(feature = "usearch")] + return results .keys .iter() .zip(results.distances.iter()) .filter_map(|(key, dist)| self.key_to_id.get(key).map(|id| (id.clone(), *dist))) - .collect() + .collect(); + + // vecq backend (#1098): brute-force top-k over quantized codes, + // returns (row, cosine similarity). Rows not present in key_to_id are + // tombstoned and filtered out. Convert similarity → cosine distance + // (1 - sim) so downstream scoring matches the usearch backend. + #[cfg(feature = "vecq")] + { + // Tombstoned rows (physically present in the index but absent + // from the key map) are filtered out below — over-fetch by the + // exact dead-row count so we still return up to `k` live results. + let dead = self.index.len().saturating_sub(self.key_to_id.len()); + let mut results: Vec<(String, f32)> = self + .index + .search(query, count + dead) + .into_iter() + .filter_map(|(row, sim)| { + self.key_to_id + .get(&(row as u64)) + .map(|id| (id.clone(), 1.0 - sim)) + }) + .collect(); + // Tombstones may shrink results below k; over-fetch above mitigates + // this. Cap at k and keep ascending-distance order. + results.truncate(count); + results + } } - /// Number of items in the index. + /// Number of live (non-tombstoned) items in the index. #[allow(dead_code)] pub fn len(&self) -> usize { - self.index.size() + #[cfg(feature = "usearch")] + return self.key_to_id.len().max(self.index.size()); + #[cfg(feature = "vecq")] + return self.key_to_id.len(); + } + + /// Capacity of the underlying index (diagnostics). + #[allow(dead_code)] + pub fn capacity(&self) -> usize { + #[cfg(feature = "usearch")] + return self.index.capacity(); + #[cfg(feature = "vecq")] + return self.index.len(); } /// Capacity of the underlying usearch index (diagnostics). @@ -409,13 +540,16 @@ impl VectorIndex { /// Used by backend dispatch to detect dim mismatch when the user swaps /// embedding backends on an existing store (#337). pub fn dims(&self) -> usize { - self.index.dimensions() + #[cfg(feature = "usearch")] + return self.index.dimensions(); + #[cfg(feature = "vecq")] + return self.index.dim(); } /// Check if the index is empty. #[allow(dead_code)] pub fn is_empty(&self) -> bool { - self.index.size() == 0 + self.len() == 0 } /// Whether the index has unsaved changes. @@ -423,6 +557,7 @@ impl VectorIndex { self.dirty } + #[cfg(feature = "usearch")] fn create_index(dims: usize) -> Result { let options = IndexOptions { dimensions: dims, @@ -437,6 +572,11 @@ impl VectorIndex { )) }) } + + #[cfg(feature = "vecq")] + fn create_index(dims: usize) -> Result { + Ok(VecqIndex::new(dims, VECQ_SEED)) + } } impl Default for VectorIndex { @@ -446,14 +586,14 @@ impl Default for VectorIndex { } /// Convert cosine distance (0..2) to cosine similarity (0..1). -/// usearch with MetricKind::Cos returns cosine *distance* (1 - similarity). +/// Both backends return cosine *distance* (1 - similarity) from `search()`. pub fn cosine_distance_to_similarity(distance: f32) -> f32 { - // usearch cosine distance = 1 - cosine_similarity + // cosine distance = 1 - cosine_similarity let sim = 1.0 - distance; sim.clamp(0.0, 1.0) } -/// Acquire an exclusive file lock on the usearch index file (#543). +/// Acquire an exclusive file lock on the index file (#543). /// /// Retry loop with timeout: tries `try_lock_exclusive` every 200ms up to 30s, /// then fails with a helpful diagnostic. This prevents indefinite blocking @@ -463,11 +603,11 @@ fn acquire_file_lock(path: &Path) -> Result { .read(true) .write(true) .open(path) - .map_err(|e| Error::embed_msg(format!("Failed to open usearch file for locking: {e}")))?; + .map_err(|e| Error::embed_msg(format!("Failed to open index file for locking: {e}")))?; // Fast path: try non-blocking lock first. if file.try_lock_exclusive().is_ok() { - tracing::debug!("usearch file lock acquired: {}", path.display()); + tracing::debug!("index file lock acquired: {}", path.display()); return Ok(file); } @@ -477,7 +617,7 @@ fn acquire_file_lock(path: &Path) -> Result { // LockFileEx(LOCKFILE_EXCLUSIVE_LOCK) also blocks forever — the retry loop // with try_lock_exclusive gives us control over the timeout. tracing::debug!( - "usearch file lock busy on {}, retrying with timeout...", + "index file lock busy on {}, retrying with timeout...", path.display() ); @@ -492,7 +632,7 @@ fn acquire_file_lock(path: &Path) -> Result { if file.try_lock_exclusive().is_ok() { tracing::debug!( - "usearch file lock acquired (after {:?}): {}", + "index file lock acquired (after {:?}): {}", waited, path.display() ); @@ -509,7 +649,7 @@ fn acquire_file_lock(path: &Path) -> Result { } tracing::trace!( - "usearch file lock still busy after {:?}: {}", + "index file lock still busy after {:?}: {}", waited, path.display() ); @@ -594,7 +734,7 @@ mod tests { #[test] fn test_save_and_load() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("test.usearch"); + let path = dir.path().join(format!("test.{INDEX_EXT}")); // Create and insert let mut idx = VectorIndex::new(64).unwrap(); @@ -618,7 +758,7 @@ mod tests { // Verify on-disk files are non-empty (#647 regression) assert!( path.metadata().unwrap().len() > 0, - ".usearch file must not be 0 bytes" + "index file must not be 0 bytes" ); let keys_path = path.with_extension("keys"); assert!( @@ -640,7 +780,7 @@ mod tests { fn test_save_buffer_produces_valid_index() { // Round-trip test: save via buffer → load via buffer (#647, #684) let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("roundtrip.usearch"); + let path = dir.path().join(format!("roundtrip.{INDEX_EXT}")); let mut idx = VectorIndex::new(32).unwrap(); idx.path = Some(path.clone()); @@ -653,14 +793,26 @@ mod tests { idx.insert("round-1", &v).unwrap(); idx.save().unwrap(); - // Verify the saved file can be loaded by usearch's restore_from_buffer + // Verify the saved file is loadable by the backend's buffer API let buffer = std::fs::read(&path).unwrap(); - let raw_index = usearch::Index::restore_from_buffer(&buffer); - assert!( - raw_index.is_ok(), - "Buffer-saved index must be loadable by usearch restore_from_buffer" - ); - assert_eq!(raw_index.unwrap().size(), 1); + #[cfg(feature = "usearch")] + { + let raw_index = usearch::Index::restore_from_buffer(&buffer); + assert!( + raw_index.is_ok(), + "Buffer-saved index must be loadable by usearch restore_from_buffer" + ); + assert_eq!(raw_index.unwrap().size(), 1); + } + #[cfg(feature = "vecq")] + { + let raw_index = VecqIndex::from_bytes(&buffer); + assert!( + raw_index.is_ok(), + "Buffer-saved index must be loadable by VecqIndex::from_bytes" + ); + assert_eq!(raw_index.unwrap().len(), 1); + } } #[test] @@ -682,4 +834,22 @@ mod tests { assert!(!results.is_empty()); assert_eq!(results[0].0, "item-0"); } + + #[cfg(feature = "vecq")] + #[test] + fn test_vecq_tombstones_filter_removed_rows() { + // Removed IDs must never appear in search results (vecq has no + // incremental delete — rows are tombstoned via the key map, #1098). + let mut idx = VectorIndex::new(64).unwrap(); + let v1 = make_vec(64, 0); + let v2 = make_vec(64, 1); + + idx.insert("alive", &v1).unwrap(); + idx.insert("dead", &v2).unwrap(); + assert!(idx.remove("dead")); + + let results = idx.search(&v2, 2, 50); + assert!(results.iter().all(|(id, _)| id != "dead")); + assert!(results.iter().any(|(id, _)| id == "alive")); + } } diff --git a/crates/uteke-core/src/operations.rs b/crates/uteke-core/src/operations.rs index bc935f6e..eec39985 100644 --- a/crates/uteke-core/src/operations.rs +++ b/crates/uteke-core/src/operations.rs @@ -292,6 +292,7 @@ impl crate::Uteke { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: Some(now), valid_until: None, memory_type: memory_type.to_string(), @@ -301,6 +302,7 @@ impl crate::Uteke { slug: None, source: None, source_type: "user".to_string(), + author_type: "agent".to_string(), }; // Acquire index write lock BEFORE any writes so lock failures are detected early. @@ -384,6 +386,32 @@ impl crate::Uteke { min_score: f32, entity_filter: Option<&str>, category_filter: Option<&str>, + ) -> Result, Error> { + self.recall_inner( + query, + limit, + tags_filter, + namespace, + min_score, + entity_filter, + category_filter, + false, + ) + } + + /// Same as `recall` but optionally keeps deprecated memories so temporal + /// filters (#1086) can decide their fate based on `deprecated_at`. + #[allow(clippy::too_many_arguments)] + pub(crate) fn recall_inner( + &self, + query: &str, + limit: usize, + tags_filter: Option<&[&str]>, + namespace: Option<&str>, + min_score: f32, + entity_filter: Option<&str>, + category_filter: Option<&str>, + include_deprecated: bool, ) -> Result, Error> { // Embed query outside any lock — CPU-intensive (~50ms), no shared state needed. // Only the embedder Mutex is held here, allowing concurrent index reads. @@ -474,8 +502,9 @@ impl crate::Uteke { } } - // Filter deprecated memories (#748) - if memory.deprecated { + // Filter deprecated memories (#748) — unless the caller + // needs them for point-in-time filtering (#1086). + if memory.deprecated && !include_deprecated { continue; } @@ -573,18 +602,65 @@ impl crate::Uteke { // min_score is passed as 0.0 to the underlying paths: thresholding // happens AFTER boosts (on both cache-miss and cache-hit reads) so // cold and warm calls filter identical boosted score sets. - let results = match strategy { + let results = self.compute_recall( + strategy, + query, + boost_window, + tags_filter, + namespace, + min_score, + )?; + + // Cache results for future queries (without min_score filtering, + // so cached results are reusable for any threshold). The cached set + // is the boost_window candidate set — truncated to `limit` on every + // read after boost re-application (#1037). + // + // Post-process in place (identical to the cache-hit read path): + // boost → sort → truncate → min_score. No put-then-get round-trip — + // a cache eviction or lock failure between put and get must never + // turn a successful computation into an empty result (cora finding). + let mut results = results; + results.truncate(boost_window); + let raw = results.clone(); + self.recall_cache + .put(query, cache_ns, limit, tags_filter, strategy, raw); + + self.apply_salience_recency_boosts(&mut results, limit); + if min_score > 0.0 { + results.retain(|r| r.score >= min_score); + } + results.truncate(limit); + Ok(results) + } + + /// Raw strategy computation below the cache layer. Shared by the + /// dispatch path (cache-miss) and by Fusion, which runs two + /// sub-strategies and fuses their rankings (#1123). + /// + /// Callers must NOT cache inside this method — the dispatcher owns the + /// cache put and applies salience/recency boosts exactly once. + fn compute_recall( + &self, + strategy: RecallStrategy, + query: &str, + boost_window: usize, + tags_filter: Option<&[&str]>, + namespace: Option<&str>, + min_score: f32, + ) -> Result, Error> { + match strategy { RecallStrategy::Vector => { - self.recall(query, boost_window, tags_filter, namespace, 0.0, None, None)? + self.recall(query, boost_window, tags_filter, namespace, 0.0, None, None) } RecallStrategy::Fts5 => { - self.recall_fts5_only(query, boost_window, tags_filter, namespace, 0.0)? + self.recall_fts5_only(query, boost_window, tags_filter, namespace, 0.0) } // Hybrid (RRF): min_score is passed but not used for filtering. // RRF scores are rank-based, not cosine similarity. Applying a // cosine threshold to RRF scores would incorrectly filter results. RecallStrategy::Hybrid => { - self.recall_rrf(query, boost_window, tags_filter, namespace, min_score)? + self.recall_rrf(query, boost_window, tags_filter, namespace, min_score) } // Graph (#378): hybrid RRF, then fuse graph-signal boosts. // The boost is additive + log-scaled, so isolated memories are @@ -597,34 +673,46 @@ impl crate::Uteke { let ids: Vec = rrf.iter().map(|r| r.memory.id.clone()).collect(); let signals = crate::graph_rerank::compute_graph_signals(&self.store.conn, &ids)?; - crate::graph_rerank::rerank_with_graph(rrf, &signals, &self.graph_rerank_config) + Ok(crate::graph_rerank::rerank_with_graph( + rrf, + &signals, + &self.graph_rerank_config, + )) } else { - rrf + Ok(rrf) } } - }; - - // Cache results for future queries (without min_score filtering, - // so cached results are reusable for any threshold). The cached set - // is the boost_window candidate set — truncated to `limit` on every - // read after boost re-application (#1037). - // - // Post-process in place (identical to the cache-hit read path): - // boost → sort → truncate → min_score. No put-then-get round-trip — - // a cache eviction or lock failure between put and get must never - // turn a successful computation into an empty result (cora finding). - let mut results = results; - results.truncate(boost_window); - let raw = results.clone(); - self.recall_cache - .put(query, cache_ns, limit, tags_filter, strategy, raw); - - self.apply_salience_recency_boosts(&mut results, limit); - if min_score > 0.0 { - results.retain(|r| r.score >= min_score); + // Fusion (#1123): run vector and hybrid rankings at boost_window + // depth, then weighted-RRF fuse them. Vector and hybrid fail on + // disjoint question sets; the fused ranking captures both sides' + // wins (fast50 R@5 0.9267 hybrid → 0.98 fusion). Sub-calls bypass + // the cache/boost layer — THIS dispatcher applies boosts once and + // caches the fused set, mirroring harness semantics. + RecallStrategy::Fusion => { + let vec_res = self.compute_recall( + RecallStrategy::Vector, + query, + boost_window, + tags_filter, + namespace, + 0.0, + )?; + let hyb_res = self.compute_recall( + RecallStrategy::Hybrid, + query, + boost_window, + tags_filter, + namespace, + min_score, + )?; + Ok(rrf_fuse_weighted( + vec_res, + hyb_res, + FUSION_W_VECTOR, + FUSION_W_HYBRID, + )) + } } - results.truncate(limit); - Ok(results) } /// Apply salience/recency boosts in place, then re-sort and truncate. @@ -1496,7 +1584,7 @@ impl crate::Uteke { loop { let fetch_limit = (limit * multiplier).max(50); - let candidates = self.recall( + let candidates = self.recall_inner( query, fetch_limit, tags_filter, @@ -1504,34 +1592,13 @@ impl crate::Uteke { min_score, entity_filter, category_filter, + true, )?; let candidates_len = candidates.len(); let mut results: Vec = candidates .into_iter() - .filter(|r| { - // Memory must have existed at this time - if r.memory.created_at > point_in_time { - return false; - } - // Memory must not have been invalidated before this time - if let Some(valid_until) = r.memory.valid_until { - if valid_until <= point_in_time { - return false; - } - } - // Memory should not be deprecated - if r.memory.deprecated { - return false; - } - // valid_from should be before point_in_time (if set) - if let Some(valid_from) = r.memory.valid_from { - if valid_from > point_in_time { - return false; - } - } - true - }) + .filter(|r| memory_existed_at(&r.memory, point_in_time)) .collect(); // Stop if we have enough results or exhausted retry budget. @@ -1946,10 +2013,276 @@ mod recall_cache_parity_tests { } } +/// Fusion weights benchmark-tuned on LongMemEval fast50 (#1123): +/// plateau [1.7, 1.9] → R@5 0.98; 1.7 chosen mid-plateau. +/// k=60 matches the k used by recall_rrf. +const FUSION_W_VECTOR: f64 = 1.7; +const FUSION_W_HYBRID: f64 = 1.0; +const FUSION_RRF_K: f64 = 60.0; + +/// Weighted RRF fuse of two complete SearchResult rankings (#1123). +/// +/// Deduplicates by memory id (first occurrence keeps the Memory payload), +/// sorts by fused score descending, and rewrites each result's score to the +/// fused RRF score. No truncation — the caller truncates to its window. +fn rrf_fuse_weighted( + primary: Vec, + secondary: Vec, + w_primary: f64, + w_secondary: f64, +) -> Vec { + use std::collections::HashMap; + let mut scores: HashMap = HashMap::new(); + for (rank, r) in primary.iter().enumerate() { + *scores.entry(r.memory.id.clone()).or_default() += + w_primary / (FUSION_RRF_K + rank as f64 + 1.0); + } + for (rank, r) in secondary.iter().enumerate() { + *scores.entry(r.memory.id.clone()).or_default() += + w_secondary / (FUSION_RRF_K + rank as f64 + 1.0); + } + + // Chain both rankings, dedup by id (first occurrence wins the payload), + // then stable-sort by fused score descending. + let mut seen = std::collections::HashSet::new(); + let mut out: Vec = primary + .into_iter() + .chain(secondary) + .filter(|r| seen.insert(r.memory.id.clone())) + .collect(); + out.sort_by(|a, b| { + let sb = scores.get(&b.memory.id).copied().unwrap_or(0.0); + let sa = scores.get(&a.memory.id).copied().unwrap_or(0.0); + sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal) + }); + for r in out.iter_mut() { + r.score = scores.get(&r.memory.id).copied().unwrap_or(0.0) as f32; + } + out +} + +#[cfg(test)] +mod rrf_fuse_weighted_tests { + use super::rrf_fuse_weighted; + use crate::memory::types::{Memory, SearchResult}; + + fn sr(id: &str, score: f32) -> SearchResult { + let now = chrono::Utc::now(); + let memory = Memory { + id: id.to_string(), + content: format!("content {id}"), + embedding: vec![0.1_f32; 4], + tags: vec![], + metadata: serde_json::Value::Null, + created_at: now, + updated_at: now, + namespace: "test".to_string(), + access_count: 0, + last_accessed: None, + deprecated: false, + deprecated_at: None, + valid_from: Some(now), + valid_until: None, + memory_type: "note".to_string(), + importance: 0.5, + pinned: false, + content_type: "text".to_string(), + slug: None, + source: None, + source_type: "user".to_string(), + author_type: "agent".to_string(), + }; + SearchResult { memory, score } + } + + #[test] + fn overlap_wins_and_ids_dedup() { + // 'both' appears in BOTH rankings; 'gold' only in secondary. + let primary = vec![sr("a", 0.9), sr("b", 0.8), sr("both", 0.7)]; + let secondary = vec![sr("gold", 0.95), sr("both", 0.85), sr("c", 0.6)]; + let fused = rrf_fuse_weighted(primary, secondary, 1.7, 1.0); + // 'both' ranks 3rd primary + 2nd secondary → highest fused score. + assert_eq!(fused[0].memory.id, "both"); + // No duplicate ids survive. + let n = fused.len(); + let uniq = fused + .iter() + .map(|r| r.memory.id.as_str()) + .collect::>() + .len(); + assert_eq!(n, uniq); + // Scores are fused RRF scores (small positive), not the originals. + assert!(fused[0].score > 0.0 && fused[0].score < 0.1); + } + + #[test] + fn primary_weight_orders_disagreeing_rankings() { + let primary = vec![sr("x", 0.9), sr("y", 0.8)]; + let secondary = vec![sr("y", 0.95), sr("x", 0.85)]; + // x: 1st primary + 2nd secondary; y: mirrored. Higher primary weight + // must lift x; higher secondary weight must lift y. + let f1 = rrf_fuse_weighted(primary.clone(), secondary.clone(), 2.0, 1.0); + assert_eq!(f1[0].memory.id, "x"); + let f2 = rrf_fuse_weighted(primary, secondary, 0.5, 2.0); + assert_eq!(f2[0].memory.id, "y"); + } + + #[test] + fn empty_inputs_yield_empty() { + let fused = rrf_fuse_weighted(vec![], vec![], 1.7, 1.0); + assert!(fused.is_empty()); + let fused = rrf_fuse_weighted(vec![sr("only", 0.9)], vec![], 1.7, 1.0); + assert_eq!(fused.len(), 1); + assert_eq!(fused[0].memory.id, "only"); + } +} + +#[cfg(test)] +mod fusion_strategy_tests { + use crate::Uteke; + use crate::memory::types::RecallStrategy; + + /// #1123: Fusion strategy end-to-end — seeds a store, runs recall with + /// the explicit Fusion strategy, and asserts the on-topic memory surfaces + /// in the top results. Also asserts cold/warm parity through the cache. + #[test] + #[ignore = "requires ONNX embedder (model download) in CI"] + fn fusion_recall_finds_on_topic_memory() { + let dir = std::env::temp_dir().join(format!("fusion-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let uteke = Uteke::open(dir.join("t.db").to_str().unwrap()).unwrap(); + + let base = vec![0.1_f32; 768]; + // On-topic memory: near-parallel to the probe direction. + let mut on_topic = base.clone(); + for v in on_topic.iter_mut().take(384) { + *v = 0.9; + } + // Off-topic memories: anti-parallel. + let mut off = base.clone(); + for v in off.iter_mut().take(384) { + *v = -0.9; + } + + uteke + .remember_precomputed( + "quarterly revenue projections for the board meeting", + &["finance"], + None, + Some("fusion-ns"), + "fact", + "text", + &on_topic, + ) + .unwrap(); + for i in 0..4 { + uteke + .remember_precomputed( + &format!("unrelated filler note {i} about gardening"), + &["misc"], + None, + Some("fusion-ns"), + "note", + "text", + &off, + ) + .unwrap(); + } + + let ns = Some("fusion-ns"); + let cold = uteke + .recall_hybrid( + "board meeting revenue projections", + 3, + None, + ns, + RecallStrategy::Fusion, + 0.0, + ) + .unwrap(); + assert!(!cold.is_empty(), "fusion must return results"); + assert_eq!( + cold[0].memory.content, "quarterly revenue projections for the board meeting", + "on-topic memory must rank first under fusion" + ); + assert!(cold.len() <= 3); + + // Cold/warm parity through the recall cache (#1037 invariant). + let warm = uteke + .recall_hybrid( + "board meeting revenue projections", + 3, + None, + ns, + RecallStrategy::Fusion, + 0.0, + ) + .unwrap(); + let cold_ids: Vec<_> = cold.iter().map(|r| r.memory.id.clone()).collect(); + let warm_ids: Vec<_> = warm.iter().map(|r| r.memory.id.clone()).collect(); + assert_eq!(cold_ids, warm_ids, "cold and warm fusion must match"); + + drop(uteke); + std::fs::remove_dir_all(&dir).ok(); + } + + /// Fusion must not silently degrade: an explicit Fusion call on an empty + /// store returns empty results (both sub-rankings empty), NOT an error + /// and NOT a fallback ranking. + #[test] + #[ignore = "requires ONNX embedder (model download) in CI"] + fn fusion_recall_empty_store_returns_empty() { + let dir = std::env::temp_dir().join(format!("fusion-e-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let uteke = Uteke::open(dir.join("t.db").to_str().unwrap()).unwrap(); + let res = uteke + .recall_hybrid( + "anything", + 5, + None, + Some("empty-ns"), + RecallStrategy::Fusion, + 0.0, + ) + .unwrap(); + assert!(res.is_empty()); + drop(uteke); + std::fs::remove_dir_all(&dir).ok(); + } +} + #[cfg(test)] mod dedup_tests { + use super::memory_existed_at; use crate::Uteke; + fn probe_memory(created: chrono::DateTime) -> crate::memory::types::Memory { + crate::memory::types::Memory { + id: "probe-1086".to_string(), + content: "temporal deprecation probe alpha".to_string(), + embedding: vec![], + tags: vec![], + metadata: serde_json::json!({}), + created_at: created, + updated_at: created, + namespace: crate::memory::types::DEFAULT_NAMESPACE.to_string(), + access_count: 0, + last_accessed: None, + deprecated: false, + deprecated_at: None, + valid_from: Some(created), + valid_until: None, + memory_type: "fact".to_string(), + importance: 0.5, + pinned: false, + content_type: "text".to_string(), + slug: None, + source: None, + source_type: "direct".to_string(), + author_type: "agent".to_string(), + } + } + #[test] #[ignore = "requires ONNX embedder (model download) in CI"] fn test_dedup_blocks_exact_duplicate() { @@ -2024,4 +2357,48 @@ mod dedup_tests { assert_eq!(obj.get("entity").unwrap(), "test-app"); assert_eq!(obj.get("category").unwrap(), "integration"); } + + /// #1086: a memory deprecated AFTER the point-in-time must still appear in + /// time-travel recall; one deprecated BEFORE it must not. + #[test] + fn test_recall_at_time_deprecated_after_pit() { + let past = chrono::Utc::now() - chrono::Duration::hours(2); + let now = chrono::Utc::now(); + + // Deprecated after `past` -> existed at `past`. + let mut m = probe_memory(past); + m.deprecated = true; + m.deprecated_at = Some(now); + assert!(memory_existed_at(&m, past)); + assert!(!memory_existed_at(&m, now)); + } +} + +/// Temporal predicate shared by `recall_at_time` (core) and +/// `memory_exists_at` (server): did this memory exist at `pit`? +/// Deprecated-before-pit excludes; deprecated-after-pit includes (#1086). +pub fn memory_existed_at( + m: &crate::memory::types::Memory, + pit: chrono::DateTime, +) -> bool { + if m.created_at > pit { + return false; + } + if let Some(valid_until) = m.valid_until { + if valid_until <= pit { + return false; + } + } + if m.deprecated { + match m.deprecated_at { + Some(dep_at) if dep_at <= pit => return false, + _ => {} + } + } + if let Some(valid_from) = m.valid_from { + if valid_from > pit { + return false; + } + } + true } diff --git a/crates/uteke-core/src/orphans.rs b/crates/uteke-core/src/orphans.rs index 14cb8687..decc7f75 100644 --- a/crates/uteke-core/src/orphans.rs +++ b/crates/uteke-core/src/orphans.rs @@ -176,6 +176,7 @@ mod tests { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: "fact".to_string(), @@ -185,6 +186,7 @@ mod tests { slug: None, source: None, source_type: "user".to_string(), + author_type: "agent".to_string(), } } diff --git a/crates/uteke-core/src/provenance.rs b/crates/uteke-core/src/provenance.rs new file mode 100644 index 00000000..0fcb1335 --- /dev/null +++ b/crates/uteke-core/src/provenance.rs @@ -0,0 +1,226 @@ +//! Provenance trust boundaries for consolidated memories (#1089). +//! +//! Research backdrop: memory rewriting during consolidation can launder +//! untrusted observations into apparent user facts ("provenance laundering", +//! arXiv:2607.29167), upgrade hedged remarks into confident assertions +//! ("manufactured confidence", arXiv:2606.29279), and collapse authority at +//! the consolidation boundary (arXiv:2608.01679). +//! +//! This module defines the *policy* layer: trust tiers per source/author, +//! a non-amplification rule (a consolidated record never carries more +//! authority than its weakest source), and a hedging guard that rejects +//! confidence-upgrading rewrites. The LLM consolidation executor (follow-up +//! to the measure-only planner) must consult these rules before writing. + +use crate::error::Error; +use crate::memory::types::Memory; +use serde::{Deserialize, Serialize}; + +/// Trust tier of a memory's provenance. Higher = more authoritative. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TrustTier { + /// Direct human statement (author_type=human, source_type=user). + Human = 3, + /// Agent-derived knowledge (author_type=agent, source_type=user/system). + Agent = 2, + /// Attested external observation (url, file, import). + External = 1, + /// Unknown or "derived" provenance — the floor. + Untrusted = 0, +} + +impl TrustTier { + /// Classify a memory by its provenance fields. + /// + /// Non-amplification starts here: a memory recorded from an external + /// observation never classifies above [`TrustTier::External`], no matter + /// what its content claims. + pub fn of(memory: &Memory) -> Self { + match (memory.author_type.as_str(), memory.source_type.as_str()) { + ("human", "user") => TrustTier::Human, + ("agent", "user") | ("agent", "system") => TrustTier::Agent, + (_, "url") | (_, "file") | (_, "import") => TrustTier::External, + (_, "derived") | (_, "unknown") => TrustTier::Untrusted, + // Anything else (future combos) is treated conservatively. + _ => TrustTier::Untrusted, + } + } +} + +/// Hedging markers whose *presence* in a source must survive consolidation. +/// A consolidated record that drops them while keeping the claim is a +/// confidence upgrade (manufactured confidence). +const HEDGE_MARKERS: &[&str] = &[ + "maybe", + "perhaps", + "possibly", + "probably", + "seems", + "appears", + "not sure", + "unsure", + "might", + "could be", + "unclear", + "possibly", + "mungkin", + "sepertinya", + "kayaknya", + "kurang yakin", + "belum pasti", +]; + +/// Provenance policy checks for consolidation. +#[derive(Debug, Clone, Copy, Default)] +pub struct ProvenancePolicy; + +impl ProvenancePolicy { + /// Non-amplification: the tier a consolidated record derived from + /// `sources` is allowed to claim — always the weakest source tier. + pub fn allowed_tier(sources: &[&Memory]) -> TrustTier { + sources + .iter() + .map(|m| TrustTier::of(m)) + .min() + .unwrap_or(TrustTier::Untrusted) + } + + /// Detect a confidence upgrade: source memories hedge, but the + /// consolidated output does not. + /// + /// Returns true when ANY source contains a hedge marker and NONE of the + /// markers survive in `consolidated`. This is a coarse lexical check — + /// the executor should regenerate (or downgrade) rather than store. + pub fn confidence_upgraded(sources: &[&Memory], consolidated: &str) -> bool { + let consolidated_lower = consolidated.to_lowercase(); + let any_hedged = sources + .iter() + .any(|m| contains_hedge(&m.content.to_lowercase())); + let hedging_preserved = HEDGE_MARKERS.iter().any(|h| consolidated_lower.contains(h)); + any_hedged && !hedging_preserved + } + + /// Validate a proposed consolidated record before it is written. + /// + /// Enforces, per #1089: + /// 1. Provenance laundering — external/untrusted sources may not be + /// rewritten as human/user facts (`author_type=human, source_type=user`). + /// 2. Manufactured confidence — hedged sources must stay hedged. + pub fn validate_output(sources: &[&Memory], output: &Memory) -> Result<(), Error> { + let allowed = Self::allowed_tier(sources); + let claimed = TrustTier::of(output); + if claimed > allowed { + return Err(Error::Validation(format!( + "provenance non-amplification violated: consolidated record claims \ + tier {claimed:?} but weakest source tier is {allowed:?}" + ))); + } + if Self::confidence_upgraded(sources, &output.content) { + return Err(Error::Validation( + "manufactured confidence: hedged source consolidated into an \ + unhedged assertion — preserve uncertainty markers" + .into(), + )); + } + Ok(()) + } +} + +fn contains_hedge(lower: &str) -> bool { + HEDGE_MARKERS.iter().any(|h| lower.contains(h)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mem(author: &str, source_type: &str, content: &str) -> crate::Memory { + crate::Memory { + id: uuid_v4(), + content: content.to_string(), + embedding: vec![], + tags: vec![], + metadata: serde_json::Value::Null, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + namespace: "default".to_string(), + access_count: 0, + last_accessed: None, + deprecated: false, + deprecated_at: None, + valid_from: None, + valid_until: None, + memory_type: "fact".to_string(), + importance: 0.5, + pinned: false, + content_type: "text".to_string(), + slug: None, + source: None, + source_type: source_type.to_string(), + author_type: author.to_string(), + } + } + + fn uuid_v4() -> String { + // Deterministic-enough unique id for tests. + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + format!("test-{}", N.fetch_add(1, Ordering::SeqCst)) + } + + #[test] + fn tier_classification() { + assert_eq!(TrustTier::of(&mem("human", "user", "x")), TrustTier::Human); + assert_eq!(TrustTier::of(&mem("agent", "user", "x")), TrustTier::Agent); + assert_eq!( + TrustTier::of(&mem("agent", "url", "x")), + TrustTier::External + ); + assert_eq!( + TrustTier::of(&mem("agent", "derived", "x")), + TrustTier::Untrusted + ); + } + + #[test] + fn non_amplification_takes_weakest() { + let strong = mem("human", "user", "user said"); + let weak = mem("agent", "url", "from a blog"); + assert_eq!( + ProvenancePolicy::allowed_tier(&[&strong, &weak]), + TrustTier::External + ); + } + + #[test] + fn laundering_rejected() { + let src = mem("agent", "url", "blog claims revenue grew"); + let mut out = mem("human", "user", "our revenue grew"); + assert!(ProvenancePolicy::validate_output(&[&src], &out).is_err()); + + // Same claim kept at external tier is fine. + out.author_type = "agent".into(); + out.source_type = "url".into(); + assert!(ProvenancePolicy::validate_output(&[&src], &out).is_ok()); + } + + #[test] + fn confidence_upgrade_rejected() { + let src = mem("human", "user", "mungkin rilis besok"); + let out = mem("human", "user", "rilis besok"); + let err = ProvenancePolicy::validate_output(&[&src], &out).unwrap_err(); + assert!(err.to_string().contains("confidence")); + + // Hedge preserved -> valid. + let ok_out = mem("human", "user", "user said rilis mungkin besok"); + assert!(ProvenancePolicy::validate_output(&[&src], &ok_out).is_ok()); + } + + #[test] + fn unhedged_sources_consolidate_freely() { + let src = mem("human", "user", "uses bun"); + let out = mem("human", "user", "project uses bun"); + assert!(ProvenancePolicy::validate_output(&[&src], &out).is_ok()); + } +} diff --git a/crates/uteke-core/src/recall_cache.rs b/crates/uteke-core/src/recall_cache.rs index b501db23..a3fb7369 100644 --- a/crates/uteke-core/src/recall_cache.rs +++ b/crates/uteke-core/src/recall_cache.rs @@ -217,6 +217,7 @@ mod tests { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: "fact".to_string(), @@ -226,6 +227,7 @@ mod tests { slug: None, source: None, source_type: "user".to_string(), + author_type: "agent".to_string(), }, score: 0.95, }]; diff --git a/crates/uteke-core/src/rooms.rs b/crates/uteke-core/src/rooms.rs index 5e2b0547..b30d9cb6 100644 --- a/crates/uteke-core/src/rooms.rs +++ b/crates/uteke-core/src/rooms.rs @@ -191,8 +191,23 @@ impl crate::Uteke { } /// Generate a summary of room discussion (topic clustering, no LLM needed). + /// Enriched with embedding-distance semantic segments (#1088) when + /// embeddings exist — these are the batching units for future + /// segment-level LLM consolidation. pub fn room_summary(&self, room_id: &str) -> Result, Error> { - self.store.room_summary(room_id) + let mut summary = match self.store.room_summary(room_id)? { + Some(s) => s, + None => return Ok(None), + }; + if summary.total_memories > 0 && summary.segments.is_none() { + // Segment only when embeddings are present; otherwise keep None. + let memories = self.store.recall_room(room_id, None, 0)?; + if memories.iter().any(|m| !m.embedding.is_empty()) { + let segmentation = self.room_segments_inner(room_id, &memories, 0.45, 12, 3)?; + summary.segments = Some(segmentation.segments); + } + } + Ok(Some(summary)) } /// Get room summary with referenced documents populated. diff --git a/crates/uteke-core/src/rooms_segments.rs b/crates/uteke-core/src/rooms_segments.rs new file mode 100644 index 00000000..5af287ee --- /dev/null +++ b/crates/uteke-core/src/rooms_segments.rs @@ -0,0 +1,480 @@ +//! Room semantic segmentation (PoC for #1088, inspired by LycheeMemory V2, +//! arXiv:2608.12990). +//! +//! Groups a room's memories (chronologically ordered) into coherent semantic +//! segments by measuring embedding similarity between adjacent memories. +//! A segment boundary is placed when adjacent similarity drops below a +//! threshold, or when a maximum segment size is reached. +//! +//! This is intentionally LLM-free: it only uses the embeddings that already +//! exist on every memory. The resulting segments are the batching unit for +//! future segment-level LLM consolidation — one LLM call per segment instead +//! of one per memory. + +use crate::error::Error; +use crate::memory::types::Memory; +use serde::{Deserialize, Serialize}; + +/// Default adjacent-similarity threshold below which a boundary is placed. +/// +/// Embedding cosine similarity of adjacent same-topic memories is typically +/// well above 0.5 for the built-in 768-d model; unrelated memories land far +/// lower. 0.45 is a conservative default — tune per deployment. +pub const DEFAULT_BOUNDARY_THRESHOLD: f32 = 0.45; + +/// Default maximum memories per segment. Mirrors the segment-batching idea: +/// large enough to amortize LLM calls, small enough to stay coherent. +pub const DEFAULT_MAX_SEGMENT_SIZE: usize = 12; + +/// Default minimum memories per segment; shorter runs are merged into the +/// previous segment to avoid tiny, low-value consolidation batches. +pub const DEFAULT_MIN_SEGMENT_SIZE: usize = 3; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryWithSim { + pub memory: Memory, + /// Cosine similarity to the previous memory in the room (None for the + /// first memory — it always starts a segment). + pub sim_to_prev: Option, + /// Index of the segment this memory belongs to. + pub segment: usize, + /// Whether this memory starts a new segment. + pub starts_segment: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Segment { + pub index: usize, + pub memory_ids: Vec, + /// Index range [start, end) into the chronological memory list. + pub range: (usize, usize), + /// Mean adjacent similarity inside the segment (coherence signal). + pub mean_sim: f32, + /// Boundary reason for the segment that follows this one, if any. + pub boundary_reason: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BoundaryReason { + /// Adjacent similarity dropped below the threshold. + Semantic, + /// Segment reached the maximum size. + MaxSize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SegmentationResult { + pub room_id: String, + pub total_memories: usize, + pub segments: Vec, + /// Per-memory assignment with adjacent similarities, chronological order. + pub memories: Vec, + pub threshold: f32, +} + +/// Cosine similarity for equal-length f32 vectors. +fn cosine(a: &[f32], b: &[f32]) -> f32 { + if a.len() != b.len() || a.is_empty() { + return 0.0; + } + let mut dot = 0.0f32; + let mut na = 0.0f32; + let mut nb = 0.0f32; + for i in 0..a.len() { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + if na == 0.0 || nb == 0.0 { + return 0.0; + } + dot / (na.sqrt() * nb.sqrt()) +} + +impl crate::Uteke { + /// Segment a room's memories by semantic boundaries (LLM-free, PoC #1088). + /// + /// Memories are ordered chronologically; a new segment starts when the + /// cosine similarity to the previous memory drops below `threshold`, or + /// when the current segment reaches `max_size`. Runs shorter than + /// `min_size` are merged into the previous segment. + pub fn room_segments( + &self, + room_id: &str, + threshold: f32, + max_size: usize, + min_size: usize, + ) -> Result { + // Bulk-fetch room memories in one query, chronological order. + let memories = self.store.recall_room(room_id, None, 0)?; + self.room_segments_inner(room_id, &memories, threshold, max_size, min_size) + } + + /// Segmentation over caller-supplied memories (already fetched — avoids + /// a second query when the caller already holds the room's memories). + pub fn room_segments_inner( + &self, + room_id: &str, + memories: &[Memory], + threshold: f32, + max_size: usize, + min_size: usize, + ) -> Result { + let mut memories: Vec = memories.to_vec(); + if memories.is_empty() { + return Ok(SegmentationResult { + room_id: room_id.to_string(), + total_memories: 0, + segments: vec![], + memories: vec![], + threshold, + }); + } + + memories.sort_by_key(|a| a.created_at); + + // Adjacent similarities. + let mut sims: Vec> = Vec::with_capacity(memories.len()); + sims.push(None); + for w in memories.windows(2) { + let s = cosine(&w[0].embedding, &w[1].embedding); + sims.push(Some(s)); + } + + // First pass: boundaries. + let mut assignments: Vec<(usize, bool, BoundaryReason)> = + Vec::with_capacity(memories.len()); + assignments.push((0, true, BoundaryReason::Semantic)); // first memory starts segment 0 + let mut cur = 0usize; + let mut cur_len = 1usize; + for sim in sims.iter().skip(1) { + let sim = sim.unwrap_or(0.0); + let semantic_break = sim < threshold; + let size_break = cur_len >= max_size; + if semantic_break || size_break { + cur += 1; + cur_len = 1; + assignments.push(( + cur, + true, + if semantic_break { + BoundaryReason::Semantic + } else { + BoundaryReason::MaxSize + }, + )); + } else { + cur_len += 1; + assignments.push((cur, false, BoundaryReason::Semantic)); + } + } + + // Second pass: merge runs shorter than min_size into the previous + // segment (keeps at most the first segment unmerged). + let mut merged: Vec = assignments.iter().map(|a| a.0).collect(); + let mut i = 1usize; + while i < merged.len() { + let start = i; + while i < merged.len() && merged[i] == merged[start] { + i += 1; + } + let len = i - start; + if len < min_size && merged[start] > 0 { + // Merge into the *actual* preceding segment (which may itself + // have been merged earlier in this pass) — not the raw index. + let prev = merged[start - 1]; + for m in merged.iter_mut().skip(start).take(len) { + *m = prev; + } + // Re-scan from the start of the merged segment in case the + // merge made the previous segment exceed max_size; we accept + // this overshoot deliberately (coherence beats hard caps). + } + } + // Compact segment indices. + let mut remap: std::collections::HashMap = std::collections::HashMap::new(); + let mut next = 0usize; + for m in merged.iter_mut() { + let e = remap.entry(*m).or_insert_with(|| { + let v = next; + next += 1; + v + }); + *m = *e; + } + + // Build per-memory view. + let mut view: Vec = Vec::with_capacity(memories.len()); + for (idx, mem) in memories.into_iter().enumerate() { + let seg = merged[idx]; + let starts = idx == 0 || merged[idx - 1] != seg; + view.push(MemoryWithSim { + memory: mem, + sim_to_prev: sims[idx], + segment: seg, + starts_segment: starts, + }); + } + + // Build segments with coherence stats and boundary reasons. + let n_segments = next; + let mut segments: Vec = (0..n_segments) + .map(|i| Segment { + index: i, + memory_ids: vec![], + range: (usize::MAX, 0), + mean_sim: 0.0, + boundary_reason: None, + }) + .collect(); + for (idx, m) in view.iter().enumerate() { + let s = &mut segments[m.segment]; + if s.memory_ids.is_empty() { + s.range.0 = idx; + } + s.range.1 = idx + 1; + s.memory_ids.push(m.memory.id.clone()); + if let Some(sim) = m.sim_to_prev { + // Only count within-segment adjacency for coherence. + if idx > 0 && view[idx - 1].segment == m.segment { + s.mean_sim += sim; + } + } + } + for s in segments.iter_mut() { + let n = s.memory_ids.len(); + let inner = n.saturating_sub(1); + if inner > 0 { + s.mean_sim /= inner as f32; + } else { + s.mean_sim = 1.0; + } + } + // Boundary reason of the NEXT segment lives on the boundary memory. + for idx in 1..view.len() { + if view[idx].starts_segment { + // Find original assignment reason (pre-merge index may differ; + // reason only meaningful for semantic/max-size boundaries). + let reason = sims[idx] + .map(|sim| { + if sim < threshold { + BoundaryReason::Semantic + } else { + BoundaryReason::MaxSize + } + }) + .unwrap_or(BoundaryReason::Semantic); + segments[view[idx].segment].boundary_reason = Some(reason); + } + } + + Ok(SegmentationResult { + room_id: room_id.to_string(), + total_memories: view.len(), + segments, + memories: view, + threshold, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(seed: f32) -> Vec { + // Simple 4-d embeddings: same seed → identical vector. + vec![seed, seed * 0.5, 1.0 - seed, seed + 0.1] + } + + #[test] + fn cosine_basics() { + assert!((cosine(&v(0.2), &v(0.2)) - 1.0).abs() < 1e-5); + assert!(cosine(&v(0.0), &v(1.0)) < 0.5); + assert_eq!(cosine(&[], &[]), 0.0); + } + + fn make_memory(id: &str, content: &str, embedding: Vec) -> crate::Memory { + crate::Memory { + id: id.to_string(), + content: content.to_string(), + embedding, + tags: vec![], + metadata: serde_json::Value::Null, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + namespace: "default".to_string(), + access_count: 0, + last_accessed: None, + deprecated: false, + deprecated_at: None, + valid_from: None, + valid_until: None, + memory_type: "fact".to_string(), + importance: 0.5, + pinned: false, + content_type: "text".to_string(), + slug: None, + source: None, + source_type: String::new(), + author_type: String::new(), + } + } + + #[test] + fn segments_two_topic_clusters() { + let uteke = crate::Uteke::open(":memory:").unwrap(); + uteke.store.create_room("r1", None, "default").unwrap(); + + // Cluster A (similar embeddings), then cluster B (different). + let a = v(0.1); + let mut b = v(0.9); + // ensure b differs strongly from a + b[2] = 0.05; + let ids = ["m1", "m2", "m3", "m4", "m5", "m6"]; + let embs = [ + a.clone(), + a.clone(), + a.clone(), + b.clone(), + b.clone(), + b.clone(), + ]; + for (id, e) in ids.iter().zip(embs.iter()) { + let m = make_memory(id, id, e.clone()); + uteke.store.insert(&m).unwrap(); + uteke + .store + .link_memory_to_room("r1", id, "tester", "participant") + .unwrap(); + } + + let res = uteke.room_segments("r1", 0.45, 12, 2).unwrap(); + assert_eq!(res.total_memories, 6); + assert_eq!( + res.segments.len(), + 2, + "expected 2 segments: {:?}", + res.segments + .iter() + .map(|s| s.memory_ids.len()) + .collect::>() + ); + assert_eq!(res.segments[0].memory_ids, vec!["m1", "m2", "m3"]); + assert_eq!(res.segments[1].memory_ids, vec!["m4", "m5", "m6"]); + assert!(res.segments[1].boundary_reason == Some(BoundaryReason::Semantic)); + // Within-cluster coherence should be high. + assert!(res.segments[0].mean_sim > 0.99); + } + + #[test] + fn max_size_split_single_topic() { + let uteke = crate::Uteke::open(":memory:").unwrap(); + uteke.store.create_room("r2", None, "default").unwrap(); + let e = v(0.3); + for i in 0..7 { + let id = format!("s{i}"); + let m = make_memory(&id, &id, e.clone()); + uteke.store.insert(&m).unwrap(); + uteke + .store + .link_memory_to_room("r2", &id, "tester", "participant") + .unwrap(); + } + // Same topic everywhere, but max_size=3 forces splits; the 1-item + // tail run is below min_size=2 and merges into the previous segment. + let res = uteke.room_segments("r2", 0.45, 3, 2).unwrap(); + assert_eq!(res.total_memories, 7); + assert_eq!(res.segments.len(), 2); + assert_eq!(res.segments[0].memory_ids.len(), 3); + assert_eq!(res.segments[1].memory_ids.len(), 4); + // The merge boundary was a max-size split, not semantic. + assert!(res.segments[1].boundary_reason == Some(BoundaryReason::MaxSize)); + } + + #[test] + fn empty_room_returns_empty() { + let uteke = crate::Uteke::open(":memory:").unwrap(); + let res = uteke.room_segments("nope", 0.45, 12, 3).unwrap(); + assert_eq!(res.total_memories, 0); + assert!(res.segments.is_empty()); + } + + #[test] + fn chains_of_short_runs_merge_progressively() { + // Regression: merging a short run must target the *actual* preceding + // segment, even when that segment was itself merged earlier in the + // pass (previously `merged[start] - 1` produced orphan indices). + // Layout: A(2) B(1) C(1) D(2) — adjacent B/C are dissimilar from + // their neighbours, so first pass yields 4 segments of sizes 2,1,1,2. + // With min_size=3, both B and C must merge into A (3 segments -> 2). + let uteke = crate::Uteke::open(":memory:").unwrap(); + uteke.store.create_room("r3", None, "default").unwrap(); + // Orthogonal unit vectors -> pairwise cosine ≈ 0 (all < threshold). + let a = vec![1.0, 0.0, 0.0, 0.0]; + let b = vec![0.0, 1.0, 0.0, 0.0]; + let c = vec![0.0, 0.0, 1.0, 0.0]; + let d = vec![0.0, 0.0, 0.0, 1.0]; + let embs = [a.clone(), a.clone(), b, c, d.clone(), d.clone()]; + for (i, e) in embs.into_iter().enumerate() { + let id = format!("m{i}"); + let m = make_memory(&id, &id, e); + uteke.store.insert(&m).unwrap(); + uteke + .store + .link_memory_to_room("r3", &id, "tester", "participant") + .unwrap(); + } + let res = uteke.room_segments("r3", 0.45, 12, 3).unwrap(); + let sizes: Vec = res.segments.iter().map(|s| s.memory_ids.len()).collect(); + // B, C, D runs all merge progressively into the leading A run: [6]. + assert_eq!(sizes, vec![6], "sizes: {sizes:?}"); + // No tail segment below min_size may survive. + assert!( + sizes.iter().skip(1).all(|&s| s >= 3), + "tail segments below min_size: {sizes:?}" + ); + } + + #[test] + fn room_summary_includes_segments_when_embeddings_exist() { + let uteke = crate::Uteke::open(":memory:").unwrap(); + uteke.store.create_room("r4", None, "default").unwrap(); + let a = vec![1.0, 0.0, 0.0, 0.0]; + let b = vec![0.0, 1.0, 0.0, 0.0]; + let embs = [ + a.clone(), + a.clone(), + a.clone(), + b.clone(), + b.clone(), + b.clone(), + ]; + for (i, e) in embs.into_iter().enumerate() { + let id = format!("m{i}"); + let m = make_memory(&id, &id, e); + uteke.store.insert(&m).unwrap(); + uteke + .store + .link_memory_to_room("r4", &id, "tester", "participant") + .unwrap(); + } + let summary = uteke.room_summary("r4").unwrap().expect("summary exists"); + let segs = summary.segments.expect("segments present"); + assert_eq!(segs.len(), 2); + assert_eq!(segs[0].memory_ids.len(), 3); + assert_eq!(segs[1].memory_ids.len(), 3); + + // No embeddings -> segments stay None. + uteke.store.create_room("r5", None, "default").unwrap(); + let m = make_memory("plain", "plain", vec![]); + uteke.store.insert(&m).unwrap(); + uteke + .store + .link_memory_to_room("r5", "plain", "tester", "participant") + .unwrap(); + let s5 = uteke.room_summary("r5").unwrap().expect("summary exists"); + assert!(s5.segments.is_none()); + } +} diff --git a/crates/uteke-core/src/salience_recency.rs b/crates/uteke-core/src/salience_recency.rs index efc8bcd6..4a92e34b 100644 --- a/crates/uteke-core/src/salience_recency.rs +++ b/crates/uteke-core/src/salience_recency.rs @@ -154,6 +154,7 @@ mod tests { access_count: access, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: type_.to_string(), @@ -163,6 +164,7 @@ mod tests { slug: None, source: None, source_type: "user".to_string(), + author_type: "agent".to_string(), } } diff --git a/crates/uteke-core/src/structural_export.rs b/crates/uteke-core/src/structural_export.rs index 8c429d20..8adf1267 100644 --- a/crates/uteke-core/src/structural_export.rs +++ b/crates/uteke-core/src/structural_export.rs @@ -148,7 +148,7 @@ impl crate::Uteke { ), ( "room_document", - "SELECT room_id, doc_slug FROM room_documents", + "SELECT room_id, doc_slug, added_at FROM room_documents", ), ( "graph_node", @@ -322,6 +322,9 @@ impl crate::Uteke { let mut pending_chunks: Vec = Vec::new(); let mut pending_nodes: Vec = Vec::new(); let mut pending_graph_edges: Vec = Vec::new(); + // room_documents has no FK on doc_slug, but restore it after documents + // (pass 2) for clarity of dependency ordering (#1078). + let mut pending_room_documents: Vec = Vec::new(); let mut rest: Vec = Vec::new(); for line in input.lines().filter(|l| !l.trim().is_empty()) { @@ -338,6 +341,7 @@ impl crate::Uteke { "document_chunk" => pending_chunks.push(v), "graph_node" => pending_nodes.push(v), "graph_edge" => pending_graph_edges.push(v), + "room_document" => pending_room_documents.push(v), _ => rest.push(v), } } @@ -419,16 +423,6 @@ impl crate::Uteke { .map_err(|e| Error::db_msg(format!("import room_memory: {e}")))?; *counts.entry("room_memories".into()).or_default() += n; } - "room_document" => { - let r = &v["row"]; - let n = conn - .execute( - "INSERT OR IGNORE INTO room_documents (room_id, doc_slug) VALUES (?1,?2)", - rusqlite::params![r[0].as_str().unwrap_or(""), r[1].as_str().unwrap_or("")], - ) - .map_err(|e| Error::db_msg(format!("import room_document: {e}")))?; - *counts.entry("room_documents".into()).or_default() += n; - } "memory_edge" => { let r = &v["row"]; let n = conn @@ -460,56 +454,6 @@ impl crate::Uteke { .map_err(|e| Error::db_msg(format!("import timeline_event: {e}")))?; *counts.entry("timeline_events".into()).or_default() += n; } - "document" => { - let tags = serde_json::to_string(&v["tags"]).unwrap_or_else(|_| "[]".into()); - let metadata = - serde_json::to_string(&v["metadata"]).unwrap_or_else(|_| "{}".into()); - let n = conn - .execute( - "INSERT OR IGNORE INTO documents (id, slug, title, content, namespace, author, tags, metadata, version, content_type, created_at, updated_at, parent_id, depth, sort_order, has_children) \ - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16)", - rusqlite::params![ - v["id"].as_str().unwrap_or(""), - v["slug"].as_str().unwrap_or(""), - v["title"].as_str().unwrap_or(""), - v["content"].as_str().unwrap_or(""), - v["namespace"].as_str(), - v["author"].as_str(), - tags, - metadata, - v["version"].as_i64().unwrap_or(1), - v["content_type"].as_str().unwrap_or("markdown"), - v["created_at"].as_str().unwrap_or(""), - v["updated_at"].as_str().unwrap_or(""), - v["parent_id"].as_str(), - v["depth"].as_i64().unwrap_or(0), - v["sort_order"].as_i64().unwrap_or(0), - v["has_children"].as_bool().unwrap_or(false), - ], - ) - .map_err(|e| Error::db_msg(format!("import document: {e}")))?; - *counts.entry("documents".into()).or_default() += n; - } - "document_chunk" => { - let tags = serde_json::to_string(&v["tags"]).unwrap_or_else(|_| "[]".into()); - let n = conn - .execute( - "INSERT OR IGNORE INTO document_chunks (id, document_id, chunk_index, heading, content, char_start, char_end, tags) \ - VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", - rusqlite::params![ - v["id"].as_str().unwrap_or(""), - v["document_id"].as_str().unwrap_or(""), - v["chunk_index"].as_i64().unwrap_or(0), - v["heading"].as_str().unwrap_or(""), - v["content"].as_str().unwrap_or(""), - v["char_start"].as_i64().unwrap_or(0), - v["char_end"].as_i64().unwrap_or(0), - tags, - ], - ) - .map_err(|e| Error::db_msg(format!("import document_chunk: {e}")))?; - *counts.entry("document_chunks".into()).or_default() += n; - } _ => { // Unknown tag — ignore for forward compatibility (newer // export format version writing sections this build does @@ -614,6 +558,20 @@ impl crate::Uteke { *counts.entry("graph_edges".into()).or_default() += n; } + for v in &pending_room_documents { + let r = &v["row"]; + let n = conn + .execute( + "INSERT OR IGNORE INTO room_documents (room_id, doc_slug, added_at) VALUES (?1,?2,?3)", + rusqlite::params![ + r[0].as_str().unwrap_or(""), + r[1].as_str().unwrap_or(""), + r[2].as_str().unwrap_or(""), + ], + ) + .map_err(|e| Error::db_msg(format!("import room_document: {e}")))?; + *counts.entry("room_documents".into()).or_default() += n; + } // Sentinel-embedded rows must NOT enter the vector index (dimension // mismatch); load_all filters NULL embeddings but these are non-NULL. // Repair with --reembed fixes them; flag the count in the result. @@ -670,7 +628,7 @@ mod tests { ).unwrap(); conn.execute( "INSERT INTO memories (id, content, embedding, tags, metadata, created_at, updated_at, namespace, access_count, deprecated, memory_type, importance, pinned, content_type, source_type) \ - VALUES ('m2','second memory',NULL,'[]','{}',?1,?1,'sx-ns',0,0,'fact',0.5,0,'text','user')", + VALUES ('m2','second memory',NULL,'[]','{}',?1,?1,'sx-ns',0,0,'fact',0.5,1,'text','user')", [now], ).unwrap(); conn.execute( @@ -687,6 +645,30 @@ mod tests { VALUES ('c1','d1',0,'# Title','# Title\ncontent body',0,20,'[]',?1)", [now], ).unwrap(); + // Seed every remaining exported section so the round-trip exercises + // each match arm end-to-end (#1078: a section missing from the test + // fixture leaves delete-arm mutants uncatchable). + conn.execute( + "INSERT INTO room_documents (room_id, doc_slug, added_at) VALUES ('r1','rt-doc',?1)", + [now], + ) + .unwrap(); + conn.execute( + "INSERT INTO graph_nodes (id, label, entity_type, properties_json, created_at) VALUES ('gn1','n1','concept','{}',?1)", + [now], + ).unwrap(); + conn.execute( + "INSERT INTO graph_nodes (id, label, entity_type, properties_json, created_at) VALUES ('gn2','n2','concept','{}',?1)", + [now], + ).unwrap(); + conn.execute( + "INSERT INTO graph_edges (id, source_id, target_id, relation, weight, created_at) VALUES ('ge1','gn1','gn2','relates',0.5,?1)", + [now], + ).unwrap(); + conn.execute( + "INSERT INTO timeline_events (memory_id, event_type, event_data, created_at) VALUES ('m1','created','{}',?1)", + [now], + ).unwrap(); let exported = src.export_full().unwrap(); let first_line = exported.lines().next().unwrap(); @@ -694,6 +676,23 @@ mod tests { assert!(manifest["uteke_export"].is_object(), "manifest first line"); assert_eq!(manifest["uteke_export"]["format_version"], 1); assert_eq!(manifest["uteke_export"]["sections"]["memories"], 2); + for (section, expected) in [ + ("memories", 2), + ("rooms", 1), + ("room_memories", 1), + ("room_documents", 1), + ("memory_edges", 1), + ("documents", 1), + ("document_chunks", 1), + ("graph_nodes", 2), + ("graph_edges", 1), + ("timeline_events", 1), + ] { + assert_eq!( + manifest["uteke_export"]["sections"][section], expected, + "manifest count for {section}" + ); + } // Import into a fresh store. let dst = Uteke::open(dir_b.join("t.db").to_str().unwrap()).unwrap(); @@ -704,6 +703,23 @@ mod tests { assert_eq!(result["imported"]["memory_edges"], 1); assert_eq!(result["imported"]["documents"], 1); assert_eq!(result["imported"]["document_chunks"], 1); + for (section, expected) in [ + ("memories", 2), + ("rooms", 1), + ("room_memories", 1), + ("room_documents", 1), + ("memory_edges", 1), + ("documents", 1), + ("document_chunks", 1), + ("graph_nodes", 2), + ("graph_edges", 1), + ("timeline_events", 1), + ] { + assert_eq!( + result["imported"][section], expected, + "imported count for {section}" + ); + } // ids intact: room links at restored memory ids. let dconn = dst.graph_store(); @@ -715,6 +731,13 @@ mod tests { ) .unwrap(); assert_eq!(linked, "m1", "room points at restored memory id"); + // pinned flag must survive the round-trip verbatim + let pinned: i64 = dconn + .query_row("SELECT pinned FROM memories WHERE id='m2'", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(pinned, 1, "pinned flag round-trips"); let doc: String = dconn .query_row("SELECT slug FROM documents WHERE id='d1'", [], |r| r.get(0)) .unwrap(); diff --git a/crates/uteke-core/src/timeline.rs b/crates/uteke-core/src/timeline.rs index 3c924342..d36ad948 100644 --- a/crates/uteke-core/src/timeline.rs +++ b/crates/uteke-core/src/timeline.rs @@ -209,6 +209,7 @@ mod tests { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: "fact".to_string(), @@ -218,6 +219,7 @@ mod tests { slug: None, source: None, source_type: "user".to_string(), + author_type: "agent".to_string(), } } @@ -296,7 +298,11 @@ mod tests { fn migration_dispatcher_reaches_v9() { let store = Store::open(":memory:").unwrap(); let v = store.schema_version().unwrap(); - assert_eq!(v, 15, "fresh store must reach CURRENT_SCHEMA_VERSION=15"); + assert_eq!( + v, + crate::memory::store::CURRENT_SCHEMA_VERSION, + "fresh store must reach CURRENT_SCHEMA_VERSION" + ); // timeline_events table must exist. let m = mem(); store.insert(&m).unwrap(); diff --git a/crates/uteke-core/src/types.rs b/crates/uteke-core/src/types.rs index 64b02153..4e6411f7 100644 --- a/crates/uteke-core/src/types.rs +++ b/crates/uteke-core/src/types.rs @@ -33,9 +33,12 @@ pub struct DoctorReport { pub struct VerifyReport { /// Count of memories in SQLite. pub db_count: usize, + /// Count of document chunks in SQLite (chunk vectors share the index). + #[serde(default)] + pub chunk_count: usize, /// Count of vectors in usearch index. pub index_count: usize, - /// Whether they match. + /// Whether they match (db_count + chunk_count == index_count). pub consistent: bool, } @@ -48,6 +51,10 @@ pub struct RepairReport { pub index_before: usize, /// Index count after repair. pub index_after: usize, + /// Document chunks included in the rebuilt index (#1110). + /// Defaults to 0 when deserializing reports from older binaries. + #[serde(default)] + pub chunk_count: usize, } /// Result of `uteke repair --reembed`. @@ -92,6 +99,7 @@ mod tests { fn test_verify_report_serialization() { let report = VerifyReport { db_count: 10, + chunk_count: 0, index_count: 10, consistent: true, }; @@ -106,9 +114,11 @@ mod tests { db_count: 10, index_before: 5, index_after: 10, + chunk_count: 2, }; let json = serde_json::to_string(&report).unwrap(); let restored: RepairReport = serde_json::from_str(&json).unwrap(); assert_eq!(restored.index_after, 10); + assert_eq!(restored.chunk_count, 2); } } diff --git a/crates/uteke-mcp/Cargo.toml b/crates/uteke-mcp/Cargo.toml index 27f797d8..31a5b187 100644 --- a/crates/uteke-mcp/Cargo.toml +++ b/crates/uteke-mcp/Cargo.toml @@ -20,8 +20,15 @@ path = "src/main.rs" name = "uteke_mcp" path = "src/lib.rs" +[features] +default = ["usearch"] +# Vector index backend forwarded to uteke-core. Exactly one must be enabled; +# default is usearch (HNSW C++ FFI). vecq = pure-Rust 4-bit quantization (#1098). +usearch = ["uteke-core/usearch"] +vecq = ["uteke-core/vecq"] + [dependencies] -uteke-core = { path = "../uteke-core", version = "0.15.0", features = ["onnx"] } +uteke-core = { path = "../uteke-core", version = "0.16.0", default-features = false, features = ["onnx"] } serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "6" diff --git a/crates/uteke-mcp/src/lib.rs b/crates/uteke-mcp/src/lib.rs index 624eeb82..6d0de145 100644 --- a/crates/uteke-mcp/src/lib.rs +++ b/crates/uteke-mcp/src/lib.rs @@ -275,7 +275,7 @@ fn tool_recall() -> Value { "tags": { "type": "array", "items": { "type": "string" }, "description": "Filter by tags (optional)" }, "min_score": { "type": "number", "description": "Minimum similarity score 0..1 (default: 0.0)" }, "type": { "type": "string", "enum": ["all", "memory", "doc"], "description": "Search type: 'all' (default, unified), 'memory', or 'doc'" }, - "strategy": { "type": "string", "enum": ["hybrid", "vector", "fts5", "graph"], "description": "Recall strategy: 'hybrid' (default, vector+FTS5 via RRF), 'vector' (similarity only), 'fts5' (keyword only), or 'graph' (hybrid + graph-signal reranking)", "default": "hybrid" } + "strategy": { "type": "string", "enum": ["fusion", "hybrid", "vector", "fts5", "graph"], "description": "Recall strategy: 'fusion' (default since 0.16.0, weighted RRF of vector×1.7 + hybrid×1, #1123), 'hybrid' (vector+FTS5 via RRF), 'vector' (similarity only), 'fts5' (keyword only), or 'graph' (hybrid + graph-signal reranking)", "default": "fusion" } }, "required": ["query"] } @@ -885,19 +885,19 @@ fn exec_recall(uteke: &Uteke, args: &Value) -> Result { } }; - // Parse optional recall strategy (#1035): default hybrid, matching the - // CLI and HTTP defaults. Unknown values are a loud error (JSON-RPC -32603), - // never a silent fallback. + // Parse optional recall strategy (#1035): default fusion since 0.16.0 + // (#1123), matching the CLI and HTTP defaults. Unknown values are a loud + // error (JSON-RPC -32603), never a silent fallback. let strategy = match args["strategy"].as_str() { Some(name) => match uteke_core::RecallStrategy::from_str_opt(name) { Some(s) => s, None => { return Err(format!( - "Invalid strategy: '{name}'. Use 'vector', 'fts5', 'hybrid', or 'graph'." + "Invalid strategy: '{name}'. Use 'vector', 'fts5', 'hybrid', 'graph', or 'fusion'." )); } }, - None => uteke_core::RecallStrategy::Hybrid, + None => uteke_core::RecallStrategy::Fusion, }; // Use unified search when type is specified or default (all). @@ -2298,6 +2298,7 @@ mod id_resolution_tests { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: "fact".to_string(), @@ -2307,6 +2308,8 @@ mod id_resolution_tests { slug: None, source: None, source_type: "user".to_string(), + + author_type: "agent".to_string(), }; uteke.store().insert(&m).unwrap(); id @@ -2438,6 +2441,7 @@ mod supersession_mcp_tests { access_count: 0, last_accessed: None, deprecated: false, + deprecated_at: None, valid_from: None, valid_until: None, memory_type: "decision".to_string(), @@ -2447,6 +2451,8 @@ mod supersession_mcp_tests { slug: None, source: None, source_type: "user".to_string(), + + author_type: "agent".to_string(), }; uteke.store().insert(&m).unwrap(); id diff --git a/crates/uteke-server/Cargo.toml b/crates/uteke-server/Cargo.toml index bba5c5b7..044d5bb6 100644 --- a/crates/uteke-server/Cargo.toml +++ b/crates/uteke-server/Cargo.toml @@ -10,7 +10,12 @@ keywords = ["server", "memory", "ai", "http", "embedding"] categories = ["web-programming::http-server", "development-tools"] [features] +default = ["usearch"] docgen = ["dep:schemars"] +# Vector index backend forwarded to uteke-core. Exactly one must be enabled; +# default is usearch (HNSW, C++ FFI). vecq = pure-Rust 4-bit quantization (#1098). +usearch = ["uteke-core/usearch", "uteke-mcp/usearch"] +vecq = ["uteke-core/vecq", "uteke-mcp/vecq"] [lib] name = "uteke_server" @@ -21,8 +26,8 @@ name = "uteke-serve" path = "src/main.rs" [dependencies] -uteke-core = { path = "../uteke-core", version = "0.15.0", features = ["onnx"] } -uteke-mcp = { path = "../uteke-mcp", version = "0.15.0" } +uteke-core = { path = "../uteke-core", version = "0.16.0", default-features = false, features = ["onnx"] } +uteke-mcp = { path = "../uteke-mcp", version = "0.16.0", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = { version = "1", optional = true } diff --git a/crates/uteke-server/src/api_registry.rs b/crates/uteke-server/src/api_registry.rs index 309e34ec..2439cd98 100644 --- a/crates/uteke-server/src/api_registry.rs +++ b/crates/uteke-server/src/api_registry.rs @@ -284,6 +284,15 @@ pub const ENDPOINTS: &[Endpoint] = &[ excludes_deprecated: false, issues: &[], }, + Endpoint { + method: "POST", + path: "/room/consolidate", + description: "Plan or execute segment-level LLM consolidation of room memories (#1088). Dry-run by default; `apply: true` executes with a hard budget cap. Write op — blocked for read-only tokens.", + request_type: Some("serde_json::Value"), + response_type: None, + excludes_deprecated: false, + issues: &["#1088"], + }, Endpoint { method: "POST", path: "/room/stats", @@ -515,6 +524,15 @@ pub const ENDPOINTS: &[Endpoint] = &[ excludes_deprecated: false, issues: &[], }, + Endpoint { + method: "POST", + path: "/consolidate/pair", + description: "Consolidate a single caller-chosen duplicate pair: keep id_keep, deprecate (or hard-delete) id_remove.", + request_type: Some("ConsolidatePairRequest"), + response_type: None, + excludes_deprecated: false, + issues: &["1076"], + }, Endpoint { method: "POST", path: "/aging", diff --git a/crates/uteke-server/src/handlers.rs b/crates/uteke-server/src/handlers.rs index b9bfe7d3..df7050a9 100644 --- a/crates/uteke-server/src/handlers.rs +++ b/crates/uteke-server/src/handlers.rs @@ -12,6 +12,7 @@ use tiny_http::{Header, Method, Request, Response, StatusCode}; use tracing::{error, warn}; use uteke_core::Uteke; +use uteke_core::memory::types::validate_author_type; use crate::context::{self, ApiRole, AuthResult, ReqCtx}; use crate::types::*; @@ -181,6 +182,15 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< Some(serde_json::Value::Object(meta)) }; + // Validate author_type BEFORE any write (#1083, cora finding): + // invalid values must reject the whole request — inserting then + // failing would leave a persisted memory the client believes failed. + if let Some(at) = req_data.author_type.as_deref() { + if let Err(e) = validate_author_type(at) { + return ctx.error_response_for(req, 400, e.to_string()); + } + } + let result = if req_data.detect_contradiction { uteke .remember_with_contradiction( @@ -211,7 +221,24 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< error!("Failed to set source for {id}: {e}"); } } - ctx.ok_response_for(req, &serde_json::json!({"id": id})) + // Set author type after storage (#1083). Validate before + // writing so invalid values fail loudly (400, not silent). + if let Some(at) = req_data.author_type.as_deref() { + if let Err(e) = uteke.set_author_type(&id, at) { + return ctx.error_response_for(req, 400, e.to_string()); + } + } + // Echo author_type so clients can confirm what was + // recorded (#1106) — data was already stored correctly, + // the response just omitted the field. Default mirrors + // the schema default (author_type DEFAULT 'agent', #1083). + ctx.ok_response_for( + req, + &serde_json::json!({ + "id": id, + "author_type": req_data.author_type.clone().unwrap_or_else(|| "agent".to_string()), + }), + ) } Err(e) => { error!("Internal error: {e}"); @@ -333,12 +360,14 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< req, 400, format!( - "Invalid strategy: '{name}'. Use 'vector', 'fts5', 'hybrid', or 'graph'." + "Invalid strategy: '{name}'. Use 'vector', 'fts5', 'hybrid', 'graph', or 'fusion'." ), ); } }, - None => uteke_core::RecallStrategy::Hybrid, + // Implicit default since 0.16.0 (#1123): fusion. + // Matches the core enum default and CLI default_strategy. + None => uteke_core::RecallStrategy::Fusion, }; // Unified search path (#531): when search_type is specified, @@ -977,6 +1006,69 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< } } + // ── Room Consolidation (#1088) ─────────────────────────────────── + // Dry-run by default; `apply: true` executes with a hard budget cap. + // Blocked for read-only tokens (not in read_only_post_paths). + (Method::Post, "/room/consolidate") => { + #[derive(Deserialize)] + struct RoomConsolidateRequest { + room_id: String, + /// Execute the plan (LLM calls + writes). Default false = dry-run. + #[serde(default)] + apply: bool, + /// Max LLM requests for this run. Default 10, hard cap 100. + #[serde(default = "default_max_calls")] + max_calls: usize, + } + fn default_max_calls() -> usize { + 10 + } + match read_body::(req.as_reader()) { + Ok(req_data) => { + let max_calls = req_data.max_calls.min(100); + let result = if req_data.apply { + let ext = resolve_extraction_config(ctx, None, None, None); + if ext.api_key.is_empty() { + return ctx.error_response_for( + req, + 503, + "Consolidation apply requires server-side extraction LLM config", + ); + } + match uteke_core::consolidation_api::consolidate_room( + &uteke, + &req_data.room_id, + &ext, + max_calls, + ) { + Ok(exec) => serde_json::to_value(&exec).unwrap_or_default(), + Err(e) => { + error!("Internal error: {e}"); + return ctx.error_response_for(req, 500, "Internal server error"); + } + } + } else { + match uteke_core::consolidation_api::plan_room(&uteke, &req_data.room_id) { + Ok(dry) => serde_json::json!({ + "room_id": req_data.room_id, + "dry_run": true, + "total_memories": dry.plan.total_memories, + "batches": dry.plan.batches.len(), + "estimated_llm_calls": dry.plan.batches.len().min(max_calls), + "plan": dry.plan, + }), + Err(e) => { + error!("Internal error: {e}"); + return ctx.error_response_for(req, 500, "Internal server error"); + } + } + }; + ctx.ok_response_for(req, &result) + } + Err(e) => ctx.error_response_for(req, 400, e), + } + } + // ── DEPRECATED: POST /room/document → /room/summary-document (#735) (Method::Post, "/room/document") => { warn!( @@ -1244,15 +1336,50 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< // ── Room Recall (semantic with optional fallback to chronological) ── (Method::Post, "/room/recall") => match read_body::(req.as_reader()) { Ok(req_data) => { + // Time-travel: parse & validate `at` before any query so an + // invalid timestamp fails loudly (400) instead of being + // silently ignored (#1082). + let point_in_time = match req_data.at.as_deref() { + Some(at_str) => match chrono::DateTime::parse_from_rfc3339(at_str) { + Ok(dt) => Some(dt.with_timezone(&chrono::Utc)), + Err(_) => { + return ctx.error_response_for( + req, + 400, + format!( + "Invalid 'at' timestamp: {at_str}. Use RFC3339 format (e.g. 2026-06-01T12:00:00Z)" + ), + ); + } + }, + None => None, + }; let query = req_data.query.as_deref().unwrap_or("").trim(); if query.is_empty() { // No query provided — fall back to chronological recall (#785) + // Over-fetch when time-traveling: the SQL LIMIT applies + // before the temporal post-filter, so fetch extra rows + // and truncate after filtering (cora MAJOR on #1085). + let fetch_limit = if point_in_time.is_some() && req_data.limit > 0 { + req_data.limit.saturating_mul(3).max(req_data.limit + 10) + } else { + req_data.limit + }; match uteke.recall_room( &req_data.room_id, req_data.author.as_deref(), - req_data.limit, + fetch_limit, ) { - Ok(memories) => ctx.ok_response_for(req, &memories), + Ok(memories) => { + let mut memories = match point_in_time { + Some(pit) => filter_room_memories_at_time(memories, pit), + None => memories, + }; + if point_in_time.is_some() && req_data.limit > 0 { + memories.truncate(req_data.limit); + } + ctx.ok_response_for(req, &memories) + } Err(e) => { error!("Internal error: {e}"); ctx.error_response_for(req, 500, "Internal server error") @@ -1273,7 +1400,20 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< req_data.author.as_deref(), min_score, ) { - Ok(results) => ctx.ok_response_for(req, &results), + Ok(results) => { + let results = match point_in_time { + Some(pit) => { + let mut kept: Vec<_> = results + .into_iter() + .filter(|sr| memory_exists_at(&sr.memory, pit)) + .collect(); + kept.truncate(req_data.limit.max(1)); + kept + } + None => results, + }; + ctx.ok_response_for(req, &results) + } Err(e) => { error!("Internal error: {e}"); ctx.error_response_for(req, 500, "Internal server error") @@ -2129,6 +2269,31 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< Err(e) => ctx.error_response_for(req, 400, e), }, + // Per-pair dedup control (#1076): caller picks the survivor. + (Method::Post, "/consolidate/pair") => { + match read_body::(req.as_reader()) { + Ok(req_data) => { + let result = uteke.consolidate_pair( + &req_data.id_keep, + &req_data.id_remove, + req_data.hard, + ); + match result { + Ok(r) => ctx.ok_response_for(req, &r), + Err(e) => { + let status = if matches!(e, uteke_core::Error::Validation(_)) { + 400 + } else { + 500 + }; + ctx.error_response_for(req, status, e.to_string()) + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } + } + // ── Aging (maintenance) ───────────────────────────────────────── (Method::Post, "/aging") => match read_body::(req.as_reader()) { Ok(req_data) => { @@ -2240,3 +2405,177 @@ fn resolve_extraction_config( max_facts: req_max_facts.unwrap_or(base.max_facts), } } + +/// Point-in-time predicate for room time-travel (#1082). +/// Mirrors the core `recall_at_time` temporal rules: memory must have been +/// created at or before `pit`, not yet invalidated (valid_until), not +/// deprecated, and valid_from must not be in the future relative to `pit`. +/// +/// Known limitation (#1086): the schema stores no deprecation timestamp, +/// so a memory deprecated *after* `pit` is also excluded here — time-travel +/// treats `deprecated` as "never existed". Same rule as core `recall_at_time`. +fn memory_exists_at( + memory: &uteke_core::memory::types::Memory, + pit: chrono::DateTime, +) -> bool { + if memory.created_at > pit { + return false; + } + if let Some(valid_until) = memory.valid_until { + if valid_until <= pit { + return false; + } + } + if memory.deprecated { + // Deprecated before the point-in-time → did not exist then; + // deprecated after it → existed (#1086). Matches core semantics: + // NULL deprecated_at is treated as "deprecated after the pit" + // (the v17 migration backfills a timestamp onto every deprecated + // row, so NULL + deprecated should not occur in practice). + let gone_by_then = match memory.deprecated_at { + Some(dep_at) => dep_at <= pit, + None => false, + }; + if gone_by_then { + return false; + } + } + if let Some(valid_from) = memory.valid_from { + if valid_from > pit { + return false; + } + } + true +} + +/// Apply the time-travel predicate to chronological room recall results +/// (no-query path), preserving chronological order (#1082). +fn filter_room_memories_at_time( + memories: Vec, + pit: chrono::DateTime, +) -> Vec { + memories + .into_iter() + .filter(|m| memory_exists_at(m, pit)) + .collect() +} + +#[cfg(test)] +mod room_recall_at_tests { + use super::*; + use chrono::Utc; + + fn mem(created_offset_secs: i64) -> uteke_core::memory::types::Memory { + uteke_core::memory::types::Memory { + id: uuid::Uuid::new_v4().to_string(), + content: format!("m{created_offset_secs}"), + embedding: Vec::new(), + tags: Vec::new(), + metadata: serde_json::Value::Object(serde_json::Map::new()), + created_at: Utc::now() + chrono::Duration::seconds(created_offset_secs), + updated_at: Utc::now(), + namespace: "default".into(), + access_count: 0, + last_accessed: None, + deprecated: false, + deprecated_at: None, + valid_from: None, + valid_until: None, + memory_type: "fact".into(), + importance: 0.5, + pinned: false, + content_type: "text".into(), + slug: None, + source: None, + source_type: "user".into(), + author_type: "agent".into(), + } + } + + #[test] + fn at_time_excludes_future_and_invalidated() { + let now = Utc::now(); + let old = mem(-3600); // created an hour ago — existed at `now` + let future = mem(3600); // created an hour from now — must be excluded + let mut invalidated = mem(-7200); + invalidated.valid_until = Some(now - chrono::Duration::seconds(60)); // expired before `now` + let mut not_yet_valid = mem(-7200); + not_yet_valid.valid_from = Some(now + chrono::Duration::seconds(60)); + + assert!(memory_exists_at(&old, now)); + assert!(!memory_exists_at(&future, now)); + assert!(!memory_exists_at(&invalidated, now)); + assert!(!memory_exists_at(¬_yet_valid, now)); + } + + #[test] + fn at_time_excludes_deprecated() { + let now = Utc::now(); + let mut dep = mem(-60); + dep.deprecated = true; + dep.deprecated_at = Some(now - chrono::Duration::seconds(30)); // deprecated before pit + assert!(!memory_exists_at(&dep, now)); + } + + #[test] + fn at_time_includes_deprecated_after_pit() { + // #1086: deprecated AFTER the pit — the memory existed then. + let now = Utc::now(); + let mut dep = mem(-60); + dep.deprecated = true; + dep.deprecated_at = Some(now + chrono::Duration::seconds(30)); // deprecated after pit + assert!(memory_exists_at(&dep, now)); + } + + #[test] + fn at_time_boundary_inclusive_created_at() { + let exact = mem(0); + // created_at == pit: memory existed at that instant (inclusive, matches core recall_at_time rule `> pit → reject`) + assert!(memory_exists_at(&exact, exact.created_at)); + } + + #[test] + fn filter_room_memories_preserves_order() { + let now = Utc::now(); + let older = mem(-100); + let newer = mem(-50); + let future = mem(100); + let out = filter_room_memories_at_time(vec![older, newer, future], now); + assert_eq!(out.len(), 2); + assert_eq!(out[0].content, "m-100"); + assert_eq!(out[1].content, "m-50"); + } + + #[test] + fn room_recall_request_deserializes_at() { + // Regression for #1082: `at` must be a recognized field, not silently dropped. + let req: RoomRecallRequest = + serde_json::from_str(r#"{"room_id":"r1","limit":3,"at":"2020-01-01T00:00:00Z"}"#) + .expect("at field must deserialize"); + assert_eq!(req.at.as_deref(), Some("2020-01-01T00:00:00Z")); + assert_eq!(req.room_id, "r1"); + // Omitted → None (backwards compatible) + let req: RoomRecallRequest = + serde_json::from_str(r#"{"room_id":"r1"}"#).expect("no-at body must parse"); + assert!(req.at.is_none()); + } + + /// #1076: ConsolidatePairRequest deserialization — happy path + defaults. + #[test] + fn consolidate_pair_request_parses() { + let req: ConsolidatePairRequest = + serde_json::from_str(r#"{"id_keep":"abc","id_remove":"def","hard":true}"#) + .expect("pair body must parse"); + assert_eq!(req.id_keep, "abc"); + assert_eq!(req.id_remove, "def"); + assert!(req.hard); + + // hard defaults to false + let req: ConsolidatePairRequest = + serde_json::from_str(r#"{"id_keep":"abc","id_remove":"def"}"#).unwrap(); + assert!(!req.hard); + + // missing id_remove → 400 at deserialize + assert!(serde_json::from_str::(r#"{"id_keep":"abc"}"#).is_err()); + } +} diff --git a/crates/uteke-server/src/main.rs b/crates/uteke-server/src/main.rs index 4a460700..56053b28 100644 --- a/crates/uteke-server/src/main.rs +++ b/crates/uteke-server/src/main.rs @@ -85,6 +85,11 @@ fn main() { std::process::exit(1); } } + "--version" | "-V" => { + // Match the main CLI's format: `uteke v0.15.0` (see #1044) + println!("uteke-serve {}", env!("CARGO_PKG_VERSION")); + std::process::exit(0); + } "--help" | "-h" => { println!("uteke-serve — persistent warm memory server"); println!(); @@ -96,6 +101,7 @@ fn main() { println!(" --auth-token Bearer token for API auth"); println!(" --cors-origin Allowed CORS origin (repeatable)"); println!(" --read-only-token Read-only API token (GET endpoints only) (#409)"); + println!(" -V, --version Show version"); println!(" -h, --help Show this help"); println!(); println!("Config: reads [server] section from uteke.toml"); diff --git a/crates/uteke-server/src/types.rs b/crates/uteke-server/src/types.rs index 491db8fc..45032552 100644 --- a/crates/uteke-server/src/types.rs +++ b/crates/uteke-server/src/types.rs @@ -206,6 +206,9 @@ pub struct RememberRequest { /// Source type (defaults to "user"). #[serde(default)] pub source_type: Option, + /// Author type: "human" | "agent" (#1083). Defaults to "agent" when omitted. + #[serde(default)] + pub author_type: Option, } #[cfg_attr(feature = "docgen", derive(schemars::JsonSchema))] @@ -242,9 +245,10 @@ pub struct RecallRequest { /// `linked_memory_ids` on document results. #[serde(default)] pub enrich: bool, - /// Recall strategy: "hybrid" (default), "vector", "fts5", or "graph" (#900, #1034). + /// Recall strategy: "fusion" (default since 0.16.0), "vector", "fts5", + /// "hybrid", or "graph" (#900, #1034, #1123). /// When absent, the server falls back to `[recall] default_strategy` from - /// uteke.toml, then to "hybrid" — matching the CLI default. + /// uteke.toml, then to "fusion" — matching the CLI default. /// Invalid values return HTTP 400. #[serde(default)] pub strategy: Option, @@ -337,6 +341,10 @@ pub struct RoomRecallRequest { pub author: Option, #[serde(default)] pub min_score: Option, + /// Time-travel: recall room state as of this RFC3339 timestamp (#1082). + /// Memories created after `at` (or invalidated before it) are excluded. + #[serde(default)] + pub at: Option, } pub fn default_limit_search() -> usize { @@ -432,7 +440,8 @@ pub struct RecallFileSection { /// Strict mode threshold (higher, for critical queries). pub min_score_strict: Option, /// Default recall strategy when a request omits `strategy` (#1034). - /// One of: vector | fts5 | hybrid | graph. Server-side default: hybrid. + /// One of: vector | fts5 | hybrid | graph | fusion. Server-side default: + /// fusion (#1123). pub default_strategy: Option, } @@ -628,6 +637,18 @@ fn default_consolidate_threshold() -> f32 { 0.9 } +#[cfg_attr(feature = "docgen", derive(schemars::JsonSchema))] +#[derive(Deserialize)] +pub struct ConsolidatePairRequest { + /// Memory to keep untouched. + pub id_keep: String, + /// Memory to deprecate (or hard-delete when `hard` is true). + pub id_remove: String, + /// Hard-delete instead of soft-delete (deprecate). Default false. + #[serde(default)] + pub hard: bool, +} + /// Deserialize an `f32` from either a number or a JSON string. /// Some MCP layers (e.g. Hermes) serialize all parameters as strings, /// so accepting `"0.95"` in addition to `0.95` avoids 400 errors diff --git a/crates/uteke-server/tests/cli_flags.rs b/crates/uteke-server/tests/cli_flags.rs new file mode 100644 index 00000000..cf20671d --- /dev/null +++ b/crates/uteke-server/tests/cli_flags.rs @@ -0,0 +1,49 @@ +//! CLI flag tests for the `uteke-serve` binary (#1044). +//! +//! Uses Cargo's `CARGO_BIN_EXE_uteke-serve` env var, which points at the +//! built binary regardless of profile — no path assumptions. + +use std::process::Command; + +#[test] +fn version_flag_prints_version_and_exits_zero() { + let out = Command::new(env!("CARGO_BIN_EXE_uteke-serve")) + .arg("--version") + .output() + .expect("failed to spawn uteke-serve"); + assert!(out.status.success(), "--version must exit 0"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + stdout.trim(), + format!("uteke-serve {}", env!("CARGO_PKG_VERSION")), + "--version output must be `uteke-serve `" + ); +} + +#[test] +fn short_version_flag_matches_long() { + let out = Command::new(env!("CARGO_BIN_EXE_uteke-serve")) + .arg("-V") + .output() + .expect("failed to spawn uteke-serve"); + assert!(out.status.success(), "-V must exit 0"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + stdout.trim(), + format!("uteke-serve {}", env!("CARGO_PKG_VERSION")) + ); +} + +#[test] +fn unknown_flag_still_rejected_with_usage_hint() { + let out = Command::new(env!("CARGO_BIN_EXE_uteke-serve")) + .arg("--definitely-not-a-flag") + .output() + .expect("failed to spawn uteke-serve"); + assert!(!out.status.success(), "unknown flag must exit non-zero"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("Unknown argument") && stderr.contains("--help"), + "stderr should point at --help, got: {stderr}" + ); +} diff --git a/docs/api-reference.md b/docs/api-reference.md index d0b5b062..f97095e2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -140,6 +140,14 @@ Merge similar/duplicate memories automatically. **Request body**: [`ConsolidateRequest`](#consolidaterequest) +#### 🟡 `POST` `/consolidate/pair` + +Consolidate a single caller-chosen duplicate pair: keep id_keep, deprecate (or hard-delete) id_remove. + +**Request body**: [`ConsolidatePairRequest`](#consolidatepairrequest) + +*Related: `1076`* + #### 🟡 `POST` `/aging` Run aging cleanup — deprioritize or remove old/stale memories. @@ -335,6 +343,14 @@ Get room summary focused on document-type memories. List all rooms. +#### 🟡 `POST` `/room/consolidate` + +Plan or execute segment-level LLM consolidation of room memories (#1088). Dry-run by default; `apply: true` executes with a hard budget cap. Write op — blocked for read-only tokens. + +**Request body**: JSON object (see handler source for fields) + +*Related: `#1088`* + #### 🟡 `POST` `/room/stats` Get memory count for a room. Includes deprecated memories (known discrepancy vs /room/summary). @@ -640,9 +656,10 @@ Default: 0.0 (no filtering). Use `strict=true` for 0.5 default (#995). | | `namespace` | any | No | | | `query` | `string` | Yes | | | `search_type` | any | No | Search type filter: "all" (default, unified), "memory", or "doc" (#531). | -| `strategy` | any | No | Recall strategy: "hybrid" (default), "vector", "fts5", or "graph" (#900, #1034). +| `strategy` | any | No | Recall strategy: "fusion" (default since 0.16.0), "vector", "fts5", +"hybrid", or "graph" (#900, #1034, #1123). When absent, the server falls back to `[recall] default_strategy` from -uteke.toml, then to "hybrid" — matching the CLI default. +uteke.toml, then to "fusion" — matching the CLI default. Invalid values return HTTP 400. | | `strict` | `boolean` | No | Use strict threshold (defaults to 0.5 if min_score not set). | | `tags` | ``string``[] | No | | @@ -652,6 +669,7 @@ Invalid values return HTTP 400. | | Field | Type | Required | Description | |-------|------|----------|-------------| +| `author_type` | any | No | Author type: "human" | "agent" (#1083). Defaults to "agent" when omitted. | | `category` | any | No | Category — stored as metadata key "category". | | `content` | `string` | Yes | | | `detect_contradiction` | `boolean` | No | | @@ -671,6 +689,8 @@ Accepts an object (e.g. {"project": "uteke"}). | | Field | Type | Required | Description | |-------|------|----------|-------------| +| `at` | any | No | Time-travel: recall room state as of this RFC3339 timestamp (#1082). +Memories created after `at` (or invalidated before it) are excluded. | | `author` | any | No | | | `limit` | `integer` | No | | | `min_score` | any | No | | diff --git a/docs/assets/longmemeval-comparison.jpg b/docs/assets/longmemeval-comparison.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ee0b8cec390d1e47cdcbe44444cce69cbf9d2dfc GIT binary patch literal 106363 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 ^4!!byCBlL$9{#PEYv`b{va5$|Ldk> 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 literal 0 HcmV?d00001 diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 255de32c..6af66a29 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -28,11 +28,15 @@ The killer stat: recall latency barely changes as the store grows. HNSW search is O(log N), so even at 10K memories, the vector index adds <1ms. The ~40ms floor is dominated by ONNX embedding inference, not search. -The full pipeline: +The full pipeline (fusion strategy, default since 0.16.0): 1. Query → ONNX embedding generation -2. HNSW vector search -3. FTS5 full-text search -4. Reciprocal Rank Fusion (k=60) +2. HNSW vector search → vector ranking +3. FTS5 full-text search + RRF (k=60) → hybrid ranking +4. Weighted RRF fusion of the two rankings (#1123, tuned weights) + +Retrieval quality (LongMemEval fast50, session-level): fusion R@5 0.98 +vs hybrid 0.9267 vs vector-only 0.854. Vector and hybrid fail on +disjoint question sets — fusing captures both sides' wins. No network round-trip. No API call. Everything in-process. @@ -70,6 +74,48 @@ uteke bench --counts 100,1000 --store /tmp/bench --json See [LongMemEval retrieval harness](https://github.com/codecoradev/uteke/tree/develop/benchmarks/longmemeval) for accuracy evaluation against standard benchmarks. +## LongMemEval-S — Retrieval Accuracy (500 questions) + +Full validation run of uteke v0.16.0 default strategy (fusion, zero-config) on +LongMemEval-S: 500 questions, session-level retrieval, ~115 haystack sessions per +question (2,415 unique sessions), EmbeddingGemma Q4 CPU-only, deterministic — no +LLM anywhere in the retrieval path. + +![uteke vs published systems on LongMemEval-S](assets/longmemeval-comparison.jpg) + +### Headline numbers + +| Metric | Value | What it means | +|---|---|---| +| **recall_any@5** | **98.2%** | At least one gold session in top-5 — the metric competitor benchmarks publish | +| recall_any@10 | 98.8% | | +| recall_all@10 | 95.4% | **Strict:** every gold session must appear in top-10 | +| strict recall_all@5 | 88.0% | Every gold session in top-5 (mathematical ceiling 99.4% — 3 questions have 6 gold sessions) | +| coverage@5 | 94.3% | Partial credit per question (the harness's default aggregate) | + +Gold-session distribution across the 500 questions: 1 gold ×176, 2 ×250, 3 ×41, +4 ×19, 5 ×11, 6 ×3. 43% of questions are multi-session — which is why we report +the strict family at all. + +### Why two metric families + +`recall_any@K` passes a question when *at least one* gold session is retrieved. +It is the de-facto industry metric — and the one every competitor number in the +chart above uses. But a question whose answer needs evidence from 3 sessions is +only truly solved when **all 3** are retrieved. `recall_all@K` measures exactly +that. It is harder, bounded below recall_any, and to our knowledge no other +system in the comparison publishes it. We report both, from the same run, with +the same data. + +### Honesty notes + +- The comparison chart mixes evaluation setups: uteke numbers come from our own + harness on `longmemeval-s` (cleaned set); competitor numbers are from their + published benchmark documents (accessed Aug 2026) and differ in embedding + models and pipeline details. +- The FTS5-only bar is an ablation of our own system, not a competitor. +- Raw per-question results for the uteke run: `benchmarks/longmemeval/results_modal_default/` in this repo. + ## Environment | Component | Details | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 5b63824b..6c9a8f89 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -394,8 +394,22 @@ uteke room list-documents "project-kickoff" # List rooms that reference a document (#859, positional slug) uteke room list-rooms "architecture-spec" + +# Consolidate a room's memories (#1088) — dry-run by default, no LLM calls +uteke room consolidate "project-kickoff" + +# Execute consolidation (LLM calls + writes; budget-capped) +uteke room consolidate "project-kickoff" --apply --max-calls 20 ``` +### Room consolidation notes (#1088) + +- **Dry-run default**: shows memory count, batches, and estimated LLM calls — zero API calls. +- **`--apply`** requires LLM config (same `[extraction]` setup as `import --extract`). +- **`--max-calls`** (default 10) is a hard budget: failed LLM calls count too, batches beyond the cap are skipped. +- Source memories are **soft-deprecated** (never deleted), each carrying a `consolidated into ` reason. +- New consolidated records pass a provenance policy gate (hedging language, trust tier) before being written. + ## uteke bench Run performance benchmarks with synthetic data: diff --git a/docs/configuration.md b/docs/configuration.md index 31eaccee..76a6b685 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -202,7 +202,7 @@ min_score_strict = 0.5 # hybrid — vector + FTS5 fused via Reciprocal Rank Fusion (default) # graph — hybrid + graph-signal reranking (#378): well-connected memories # get a subtle log-scaled score boost -default_strategy = "hybrid" +default_strategy = "fusion" # Graph-augmented reranking weights (only affect the `graph` strategy). # Boosts are additive + log-scaled, so 0.1 is subtle and saturates quickly. @@ -215,7 +215,7 @@ graph_rerank_enabled = true # master switch; false → graph acts like hybrid |---------|---------|-------------| | `min_score` | 0.3 | Minimum similarity score (0.0-1.0). **CLI only.** | | `min_score_strict` | 0.5 | Strict-mode threshold (used with `--strict`). **CLI only.** | -| `default_strategy` | `hybrid` | Default recall strategy (`vector\|fts5\|hybrid\|graph`) | +| `default_strategy` | `fusion` | Default recall strategy (`vector\|fts5\|hybrid\|graph\|fusion`). Since 0.16.0 `fusion` (weighted RRF: vector×1.7 + hybrid×1, #1123) is the default — LongMemEval fast50 R@5 0.98 vs 0.9267 hybrid | | `graph_density_weight` | 0.1 | Edge-density boost weight (graph strategy only) | | `graph_authority_weight` | 0.1 | Incoming-edge authority boost weight (graph strategy only) | | `graph_rerank_enabled` | true | Master switch for graph reranking | @@ -269,7 +269,7 @@ Resolution order (highest priority first): | `UTEKE_SERVER_PORT` | `[server] port` | `8767` | Server port | | `UTEKE_RECALL_MIN_SCORE` | `[recall] min_score` | `0.3` | Default similarity threshold | | `UTEKE_RECALL_MIN_SCORE_STRICT` | `[recall] min_score_strict` | `0.5` | Strict threshold | -| `UTEKE_RECALL_STRATEGY` | `[recall] default_strategy` | `hybrid` | Default recall strategy (`vector\|fts5\|hybrid\|graph`) | +| `UTEKE_RECALL_STRATEGY` | `[recall] default_strategy` | `fusion` | Default recall strategy (`vector\|fts5\|hybrid\|graph\|fusion`) | | `UTEKE_GRAPH_DENSITY_WEIGHT` | `[recall] graph_density_weight` | `0.1` | Edge-density boost weight | | `UTEKE_GRAPH_AUTHORITY_WEIGHT` | `[recall] graph_authority_weight` | `0.1` | Incoming-edge authority boost weight | | `UTEKE_GRAPH_RERANK_ENABLED` | `[recall] graph_rerank_enabled` | `true` | Master switch for graph reranking | diff --git a/scripts/release-notes-template.md b/scripts/release-notes-template.md index ee69bfaf..642277db 100644 --- a/scripts/release-notes-template.md +++ b/scripts/release-notes-template.md @@ -11,11 +11,11 @@ Each archive contains three binaries + ONNX Runtime shared library: | Platform | File | |----------|------| -| Linux x86_64 (AVX2) | `uteke-x86_64-unknown-linux-gnu-v__VER__.tar.gz` | -| Linux x86_64 (Legacy, SSE4.2) | `uteke-x86_64-unknown-linux-gnu-legacy-v__VER__.tar.gz` | -| Linux ARM64 | `uteke-aarch64-unknown-linux-gnu-v__VER__.tar.gz` | -| macOS Apple Silicon | `uteke-aarch64-apple-darwin-v__VER__.tar.gz` | -| Windows x86_64 | `uteke-x86_64-pc-windows-msvc-v__VER__.zip` | +| Linux x86_64 (AVX2) | `uteke-x86_64-unknown-linux-gnu-__VER__.tar.gz` | +| Linux x86_64 (Legacy, SSE4.2) | `uteke-x86_64-unknown-linux-gnu-legacy-__VER__.tar.gz` | +| Linux ARM64 | `uteke-aarch64-unknown-linux-gnu-__VER__.tar.gz` | +| macOS Apple Silicon | `uteke-aarch64-apple-darwin-__VER__.tar.gz` | +| Windows x86_64 | `uteke-x86_64-pc-windows-msvc-__VER__.zip` | > **Legacy Bundle (Linux only)** — Includes a SSE4.2-only ORT sidecar (`ort-legacy/`) for CPUs without AVX2 (Intel Celeron J4125/N4020). Use this bundle to avoid SIGILL crashes on older hardware.