mnemo-engine 0.5.2__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/PKG-INFO +27 -7
  2. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/README.md +26 -6
  3. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/Cargo.lock +1 -1
  4. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/Cargo.toml +1 -1
  5. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/README.md +66 -2
  6. mnemo_engine-0.6.0/mnemo/examples/ann_defaults_v0_6_0.rs +315 -0
  7. mnemo_engine-0.6.0/mnemo/examples/ann_diagnose.rs +254 -0
  8. mnemo_engine-0.6.0/mnemo/examples/ann_recall_grid_v2.rs +326 -0
  9. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/bin/mnemo.rs +6 -4
  10. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/crypto.rs +7 -3
  11. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/embed_cmd.rs +37 -0
  12. mnemo_engine-0.6.0/mnemo/src/index.rs +783 -0
  13. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/pager.rs +0 -10
  14. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/store.rs +724 -130
  15. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/wal.rs +123 -0
  16. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/tests/cli_smoke.rs +87 -0
  17. mnemo_engine-0.6.0/mnemo/tests/dur1_torn_header.rs +402 -0
  18. mnemo_engine-0.6.0/mnemo/tests/fix1_correctness_sweep.rs +386 -0
  19. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/tests/proptest_store.rs +88 -0
  20. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo-python/Cargo.lock +2 -2
  21. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo-python/Cargo.toml +1 -1
  22. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo-python/README.md +26 -6
  23. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo-python/src/lib.rs +349 -143
  24. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo-python/test_mnemo.py +229 -9
  25. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/pyproject.toml +1 -1
  26. mnemo_engine-0.5.2/mnemo/src/index.rs +0 -449
  27. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/.gitignore +0 -0
  28. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/benches/cache.rs +0 -0
  29. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/benches/corpus.rs +0 -0
  30. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/benches/embed_overhead.rs +0 -0
  31. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/benches/open.rs +0 -0
  32. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/benches/recall.rs +0 -0
  33. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/benches/write.rs +0 -0
  34. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/deny.toml +0 -0
  35. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/examples/ann_recall_grid.rs +0 -0
  36. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/examples/embedder_server.py +0 -0
  37. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/examples/file_size_breakdown.rs +0 -0
  38. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/examples/quickstart.rs +0 -0
  39. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/examples/session.rs +0 -0
  40. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/cache.rs +0 -0
  41. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/embed.rs +0 -0
  42. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/error.rs +0 -0
  43. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/format.rs +0 -0
  44. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/lib.rs +0 -0
  45. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/mcp.rs +0 -0
  46. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/memory.rs +0 -0
  47. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/result_cache.rs +0 -0
  48. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/session.rs +0 -0
  49. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/tests/integration.rs +0 -0
  50. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/tests/proptest_store.proptest-regressions +0 -0
  51. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/tests/sec1_nonce_uniqueness.rs +0 -0
  52. {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo-python/.gitignore +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mnemo-engine
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: Apache Software License
@@ -58,9 +58,28 @@ The scaffold tells the next agent what to do: replace it with one that
58
58
  records your real embedder and conventions. See the [main README](../mnemo/README.md#self-describing-databases)
59
59
  for the full pattern.
60
60
 
61
- ## Build & install
61
+ ## Install
62
62
 
63
- The bindings build with [maturin](https://www.maturin.rs):
63
+ The published wheel is the normal install path — nothing to build:
64
+
65
+ ```bash
66
+ pip install mnemo-engine
67
+ ```
68
+
69
+ Prebuilt wheels ship for CPython 3.8+ on macOS x86_64 + arm64, Linux
70
+ x86_64 + aarch64 (manylinux), and Windows x86_64. If none fits (e.g. a
71
+ BSD, aarch64 musl, or any other platform mnemo doesn't ship a wheel
72
+ for), pip falls back to the sdist and compiles from source — that path
73
+ needs a working Rust toolchain (`rustup`) and will take a few minutes.
74
+
75
+ > ARM users building from source (sdist, or `cargo install mnemo-engine`
76
+ > directly): set `RUSTFLAGS="--cfg aes_armv8 --cfg polyval_armv8"` before
77
+ > install to enable hardware AES. The prebuilt wheels have this baked
78
+ > in; your sdist build doesn't see our repo's `.cargo/config.toml`.
79
+ > Verify afterwards with `mnemo.build_info()['aes_backend']`, which
80
+ > should read `"armv8 hardware"` (not `"armv8 software"`).
81
+
82
+ ### From source (contributors)
64
83
 
65
84
  ```bash
66
85
  pip install maturin
@@ -138,7 +157,7 @@ its own agent.
138
157
  | Method | Purpose |
139
158
  |---|---|
140
159
  | `remember(content, memory_type, vector, *, agent_id, importance, session_id, ttl_secs, shared, metadata)` | Store a memory; returns its id |
141
- | `recall(query, top_k=10, memory_types=None, agent_id=None, track_access=True)` | Multi-signal ranked retrieval. `track_access=False` skips access-stat updates (fully read-only recall) |
160
+ | `recall(query, top_k=10, memory_types=None, agent_id=None, track_access=True, n_probe=None, n_rerank=None)` | Multi-signal ranked retrieval. `track_access=False` skips access-stat updates (fully read-only recall). `n_probe` / `n_rerank` (PR ANN-1): per-query ANN tuning — `None` uses the index's build-time defaults (ANN-1 auto-formulas: `(partitions+1)/2` and `500`) |
142
161
  | `search(query, top_k=10)` | Exact nearest-neighbour search |
143
162
  | `get(id)` / `delete(id)` | Fetch / soft-delete by id |
144
163
  | `about()` | Self-describing onboarding briefing — memories tagged `metadata.area="onboarding"`, manifest first |
@@ -163,17 +182,18 @@ its own agent.
163
182
  | `rekey(new_passphrase, fast=False)` | Change the passphrase. Re-derives the KEK and re-wraps the DEK; encrypted pages are not rewritten (O(1) in db size). `fast=True` uses the cheap-Argon2 test params — omit for production |
164
183
  | `set_embedder(fn, *, name, dimensions, normalize=True)` | Register a Python callable `embed(texts: list[str]) -> list[list[float]]` as this handle's text embedder (Phase 3). Dimension-checks against the database's width; identity-checks against (and on first use upserts) the scaffold manifest's `embedder_name` / `embedder_dimensions` / `embedder_normalize` keys, so swapping embedders between opens fails loudly |
165
184
  | `remember_text(content, memory_type, **kwargs)` | Embed `content` via the registered embedder and `remember` the result. Accepts the same per-memory kwargs as `remember` (`agent_id`, `importance`, `session_id`, `ttl_secs`, `shared`, `metadata`) |
166
- | `recall_text(query, top_k=10, memory_types=None, agent_id=None, track_access=True)` | Embed `query` via the registered embedder and run `recall`. Same return shape as `recall` |
185
+ | `recall_text(query, top_k=10, memory_types=None, agent_id=None, track_access=True, n_probe=None, n_rerank=None)` | Embed `query` via the registered embedder and run `recall`. Same return shape and ANN-tuning kwargs as `recall` |
167
186
  | `cache_get_semantic_text(namespace, text, threshold=0.97)` | Text-side semantic cache lookup; uses the embedder's `name` as the semantic cache's `model` component |
168
- | `stats()` | Summary statistics |
187
+ | `stats()` | Summary statistics — includes `fsyncs_last_flush` (count of `sync_all` the most recent `flush()` on this handle issued; `0` on a fresh handle before any flush — Phase 6 instrumentation) |
169
188
  | `export_encrypted(dest)` | Copy the (already-encrypted) file elsewhere |
170
189
  | `len(db)` | Live memory count |
171
190
 
172
- Plus one module-level function:
191
+ Plus two module-level functions:
173
192
 
174
193
  | Function | Purpose |
175
194
  |---|---|
176
195
  | `mnemo.compact_file(path, passphrase)` | Rewrite the file out-of-place, dropping tombstoned and expired memories and reclaiming stale pages. Caller's own handle must be closed first. Returns `{"before": int, "after": int}` |
196
+ | `mnemo.build_info()` | Returns `{"version", "target", "aes_backend", "polyval_backend"}` for the installed wheel. Lets you verify an ARM wheel picked up the hardware AES path — `aes_backend` should read `"armv8 hardware"` on Apple Silicon / Graviton (anything else, like `"armv8 software (constant-time fallback)"`, means the compile missed the fix). PR 11.0, v0.5.1+ |
177
197
 
178
198
  `Session` methods: `add_turn(turn)`, `recall(query, top_k=10, memory_types=None)`,
179
199
  `close()`, `discard()`, `id()`, `agent()`, `turn_ids()`, `turn_count()`; also a
@@ -44,9 +44,28 @@ The scaffold tells the next agent what to do: replace it with one that
44
44
  records your real embedder and conventions. See the [main README](../mnemo/README.md#self-describing-databases)
45
45
  for the full pattern.
46
46
 
47
- ## Build & install
47
+ ## Install
48
48
 
49
- The bindings build with [maturin](https://www.maturin.rs):
49
+ The published wheel is the normal install path — nothing to build:
50
+
51
+ ```bash
52
+ pip install mnemo-engine
53
+ ```
54
+
55
+ Prebuilt wheels ship for CPython 3.8+ on macOS x86_64 + arm64, Linux
56
+ x86_64 + aarch64 (manylinux), and Windows x86_64. If none fits (e.g. a
57
+ BSD, aarch64 musl, or any other platform mnemo doesn't ship a wheel
58
+ for), pip falls back to the sdist and compiles from source — that path
59
+ needs a working Rust toolchain (`rustup`) and will take a few minutes.
60
+
61
+ > ARM users building from source (sdist, or `cargo install mnemo-engine`
62
+ > directly): set `RUSTFLAGS="--cfg aes_armv8 --cfg polyval_armv8"` before
63
+ > install to enable hardware AES. The prebuilt wheels have this baked
64
+ > in; your sdist build doesn't see our repo's `.cargo/config.toml`.
65
+ > Verify afterwards with `mnemo.build_info()['aes_backend']`, which
66
+ > should read `"armv8 hardware"` (not `"armv8 software"`).
67
+
68
+ ### From source (contributors)
50
69
 
51
70
  ```bash
52
71
  pip install maturin
@@ -124,7 +143,7 @@ its own agent.
124
143
  | Method | Purpose |
125
144
  |---|---|
126
145
  | `remember(content, memory_type, vector, *, agent_id, importance, session_id, ttl_secs, shared, metadata)` | Store a memory; returns its id |
127
- | `recall(query, top_k=10, memory_types=None, agent_id=None, track_access=True)` | Multi-signal ranked retrieval. `track_access=False` skips access-stat updates (fully read-only recall) |
146
+ | `recall(query, top_k=10, memory_types=None, agent_id=None, track_access=True, n_probe=None, n_rerank=None)` | Multi-signal ranked retrieval. `track_access=False` skips access-stat updates (fully read-only recall). `n_probe` / `n_rerank` (PR ANN-1): per-query ANN tuning — `None` uses the index's build-time defaults (ANN-1 auto-formulas: `(partitions+1)/2` and `500`) |
128
147
  | `search(query, top_k=10)` | Exact nearest-neighbour search |
129
148
  | `get(id)` / `delete(id)` | Fetch / soft-delete by id |
130
149
  | `about()` | Self-describing onboarding briefing — memories tagged `metadata.area="onboarding"`, manifest first |
@@ -149,17 +168,18 @@ its own agent.
149
168
  | `rekey(new_passphrase, fast=False)` | Change the passphrase. Re-derives the KEK and re-wraps the DEK; encrypted pages are not rewritten (O(1) in db size). `fast=True` uses the cheap-Argon2 test params — omit for production |
150
169
  | `set_embedder(fn, *, name, dimensions, normalize=True)` | Register a Python callable `embed(texts: list[str]) -> list[list[float]]` as this handle's text embedder (Phase 3). Dimension-checks against the database's width; identity-checks against (and on first use upserts) the scaffold manifest's `embedder_name` / `embedder_dimensions` / `embedder_normalize` keys, so swapping embedders between opens fails loudly |
151
170
  | `remember_text(content, memory_type, **kwargs)` | Embed `content` via the registered embedder and `remember` the result. Accepts the same per-memory kwargs as `remember` (`agent_id`, `importance`, `session_id`, `ttl_secs`, `shared`, `metadata`) |
152
- | `recall_text(query, top_k=10, memory_types=None, agent_id=None, track_access=True)` | Embed `query` via the registered embedder and run `recall`. Same return shape as `recall` |
171
+ | `recall_text(query, top_k=10, memory_types=None, agent_id=None, track_access=True, n_probe=None, n_rerank=None)` | Embed `query` via the registered embedder and run `recall`. Same return shape and ANN-tuning kwargs as `recall` |
153
172
  | `cache_get_semantic_text(namespace, text, threshold=0.97)` | Text-side semantic cache lookup; uses the embedder's `name` as the semantic cache's `model` component |
154
- | `stats()` | Summary statistics |
173
+ | `stats()` | Summary statistics — includes `fsyncs_last_flush` (count of `sync_all` the most recent `flush()` on this handle issued; `0` on a fresh handle before any flush — Phase 6 instrumentation) |
155
174
  | `export_encrypted(dest)` | Copy the (already-encrypted) file elsewhere |
156
175
  | `len(db)` | Live memory count |
157
176
 
158
- Plus one module-level function:
177
+ Plus two module-level functions:
159
178
 
160
179
  | Function | Purpose |
161
180
  |---|---|
162
181
  | `mnemo.compact_file(path, passphrase)` | Rewrite the file out-of-place, dropping tombstoned and expired memories and reclaiming stale pages. Caller's own handle must be closed first. Returns `{"before": int, "after": int}` |
182
+ | `mnemo.build_info()` | Returns `{"version", "target", "aes_backend", "polyval_backend"}` for the installed wheel. Lets you verify an ARM wheel picked up the hardware AES path — `aes_backend` should read `"armv8 hardware"` on Apple Silicon / Graviton (anything else, like `"armv8 software (constant-time fallback)"`, means the compile missed the fix). PR 11.0, v0.5.1+ |
163
183
 
164
184
  `Session` methods: `add_turn(turn)`, `recall(query, top_k=10, memory_types=None)`,
165
185
  `close()`, `discard()`, `id()`, `agent()`, `turn_ids()`, `turn_count()`; also a
@@ -610,7 +610,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
610
610
 
611
611
  [[package]]
612
612
  name = "mnemo-engine"
613
- version = "0.5.2"
613
+ version = "0.6.0"
614
614
  dependencies = [
615
615
  "aes-gcm",
616
616
  "argon2",
@@ -6,7 +6,7 @@
6
6
  # below stay `mnemo`, so consumers still write `use mnemo::...` and invoke
7
7
  # `mnemo` on the command line after `cargo add mnemo-engine`.
8
8
  name = "mnemo-engine"
9
- version = "0.5.2"
9
+ version = "0.6.0"
10
10
  edition = "2021"
11
11
  rust-version = "1.75"
12
12
  # `benches/corpus.rs` is a shared helper module pulled into real benches
@@ -350,6 +350,25 @@ region grows automatically when a transaction's control plane outgrows it.
350
350
  (`tests/integration.rs` exercises crash recovery, the torn-header heal, a
351
351
  discarded uncommitted log, and WAL growth.)
352
352
 
353
+ **Torn-header recovery (DUR-1).** Every post-`create` page-0 change —
354
+ WAL relocation when the control plane outgrows the current region, and
355
+ `rekey` when the passphrase moves — now goes through the WAL first:
356
+ commit a one-frame `(0, new_header)` transaction into the new region,
357
+ checkpoint page 0, then invalidate the predecessor region's first
358
+ frame header. Combined with a streaming file-wide scan on open, this
359
+ means a torn page-0 write at *any* WAL location is recoverable: open
360
+ walks every 8 KiB-aligned `MWAL` candidate in the file, picks the
361
+ highest-`txn_id` committed header frame whose `wal_start` + `wal_seq`
362
+ agree with the frame's region, whose DEK unwraps under the
363
+ passphrase, and whose AES-GCM seal validates, then replays it. Only
364
+ the initial `Mnemo::create` writes page 0 unlogged. **A header that
365
+ fails its seal is reported as `HeaderTampered` and never auto-healed**
366
+ — the entire 294-byte header lies inside one 512-byte sector that
367
+ storage writes atomically, so a surviving CRC with a failing seal
368
+ cannot be a torn write, and silently loading a stale authenticated
369
+ header from the WAL would erase the tamper-evidence the seal exists
370
+ to give.
371
+
353
372
  What this buys over a plain copy-on-write header swap: a single-fsync commit
354
373
  and an explicit, replayable transaction boundary.
355
374
 
@@ -466,6 +485,35 @@ accurate, lower is faster. The index is stored *inside* the encrypted file
466
485
  Internally the index ranks by squared-L2; the exact rerank uses the
467
486
  requested metric, so for cosine queries a generous `n_rerank` is advisable.
468
487
 
488
+ **Default auto-formulas (PR ANN-1 + ANN-1.1, pending v0.6.0).**
489
+ `IndexConfig::default()` now picks `pq_subspaces = dims/4` at build time
490
+ (was `dims/8`), plus query-time formulas:
491
+
492
+ - `n_probe = max(ceil(partitions / 2), min(partitions, 100))` (was fixed `8`)
493
+ — full probe up to ~10K-vector files (≤100 partitions), half probe above
494
+ - `n_rerank = (n_vectors / 200).clamp(100, 1000)` (ANN-1 shipped a fixed
495
+ 500; ANN-1.1 made it size-aware — see
496
+ `index::effective_auto_n_rerank`). Hits 100 at 10K, 500 at 100K, caps
497
+ at 1000.
498
+
499
+ Measured on the Phase 6 synthetic corpus: 10K hits recall@10 ≈ 0.98 at
500
+ ~1.4 ms/query; 100K hits ~0.98 at ~10.6 ms/query. The old pre-ANN-1
501
+ `n_probe=8, n_rerank=64` defaults capped 100K recall@10 at ~0.40.
502
+
503
+ **Upgrade path for existing files.** The `n_probe` / `n_rerank` fix
504
+ takes effect at query time (ANN-1.1), so a v0.5.x-built index gets the
505
+ new defaults immediately on first open under v0.6.0+ — no rebuild
506
+ needed. The `pq_subspaces` fix is build-time (baked into the PQ
507
+ codebook), so you'll stay on the old 48-subspace codebook until you run
508
+ `mnemo index --rebuild` (CLI) or `db.rebuild_index()` (Rust / Python).
509
+ With `pq_subspaces=48` + new query-time formulas, 100K tops out at
510
+ ~0.92; rebuilding to 96 subspaces lifts it to ~0.98. See
511
+ `test/scripts/perf_v0.5.0.json` keys `ann_recall_grid_v2` (grid) and
512
+ `ann_defaults_v0_6_0` (measurements at the shipped defaults).
513
+
514
+ Any explicit `n_probe` / `n_rerank` passed per call still overrides
515
+ everything above.
516
+
469
517
  `Mnemo::search` always stays exact — it is the brute-force ground truth.
470
518
 
471
519
  ## Security model
@@ -705,7 +753,7 @@ cargo run --bin mnemo -- demo # try it without any setup
705
753
 
706
754
  ```sh
707
755
  cargo build --release
708
- cargo test # 34 integration + 12 CLI smoke + 2 doctests + unit tests
756
+ cargo test # 66 integration + 26 CLI smoke + proptest state machine + unit tests
709
757
  cargo run --example quickstart
710
758
  ```
711
759
 
@@ -873,6 +921,14 @@ append history dominate everything else). The README design targets
873
921
  (<5 ms recall at 100 K, <15 ms at 1 M, <4 KB/memory) are aspirational
874
922
  goals at scale, not yet measured here.
875
923
 
924
+ > **Historical note (updated 2026-10).** The CLI timings in this
925
+ > baseline were taken on a *debug* `cargo run --bin mnemo` build. The
926
+ > v0.5.x Phase 6 harness re-measures the same operations with release
927
+ > builds and reports them in the Performance history row + the
928
+ > `test/scripts/perf_v0.5.0.json` artifact — on a release build with
929
+ > production KDF the open latency settles at ~15 ms, not ~330 ms.
930
+ > Keeping this row for history, not as a current baseline.
931
+
876
932
  **Knobs that should help small files.** Running `compact` collapses
877
933
  snapshot history back to a single page run; the default 64-page WAL
878
934
  reservation is fixed today and would need a config change to shrink. ANN
@@ -886,9 +942,17 @@ rows stay as-is so improvements (or regressions) are visible.
886
942
  | Version | Date | Corpus | File size | CLI `info` | Python recall | Notes |
887
943
  |---------|----------|------------|-----------|------------|---------------|------------------------|
888
944
  | v0.1.0 | 2026-05 | 31 mems, 384-dim, no index | 1.41 MB | ~330 ms | ~35 ms | Initial dogfood baseline |
889
- | v0.5.0 | 2026-10 | 10K mems, 384-dim, IVF+PQ | 82.9 MB | — (open 14.9 ms secure KDF) | 374 µs (IVF+PQ) / 623 ms (exact) | Phase 6: criterion benches + ranking harness + ANN recall@10 grid + file-size breakdown. IVF+PQ ≈ 1,660× faster than exact at 10K; 10,400× at 100K. Default `IndexConfig` recovers only 40% of exact top-10 — scoring v2 candidate. Full numbers in `test/scripts/perf_v0.5.0.json`. |
945
+ | v0.5.0 | 2026-10 | 10K mems, 384-dim, IVF+PQ | 82.9 MB | — (open 14.9 ms secure KDF) | 374 µs (IVF+PQ†) / 623 ms (exact‡) | Phase 6: criterion benches + ranking harness + ANN recall@10 grid + file-size breakdown. IVF+PQ ≈ 1,660× faster than exact at 10K; 10,400× at 100K. Default `IndexConfig` recovers only 40% of exact top-10 — **index-quality defect, fixed in ANN-1 below** (originally mis-filed as a scoring issue). Full numbers in `test/scripts/perf_v0.5.0.json`. |
890
946
  | v0.5.1 | 2026-10 | same corpus, hardware AES on ARM | — | — | 105 ms (exact 10K) / 1.05 s (exact 100K); IVF+PQ unchanged | PR 11.0: enable `--cfg aes_armv8 --cfg polyval_armv8` so aarch64 builds use hardware AES + PMULL instead of the constant-time software fallback. **6.0×** faster exact scan at 10K, **6.1×** at 100K; 1.3× on cache_put; IVF+PQ and Argon2id-bound open unchanged. See `test/scripts/perf_v0.5.0.json`'s `speed_hw_aes` block for the full set. No API or format change. If you build from crates.io see the "ARM users" section above. |
891
947
 
948
+ > † IVF+PQ speed at the old defaults; recall@10 was ~0.40 at those
949
+ > defaults, not 1.0. PR ANN-1 raises the default to ≥0.95 recall@10 at
950
+ > ~10 ms/query at 100K; see the `ann_recall_grid_v2` block in
951
+ > `perf_v0.5.0.json`.
952
+ > ‡ Both v0.5.0 timings were measured on software AES (the fix landed
953
+ > in v0.5.1). On hardware AES, exact scan at 10K drops to 105 ms;
954
+ > IVF+PQ was never AES-bound so it's unchanged.
955
+
892
956
  ## Scope: what is and isn't here
893
957
 
894
958
  This crate is a faithful build of the MNemo plan, built to actually compile,
@@ -0,0 +1,315 @@
1
+ //! PR ANN-1.1 measurement: recall@10 + latency at the exact shipped
2
+ //! v0.6.0 defaults, on each cached corpus tier.
3
+ //!
4
+ //! Two scenarios per tier:
5
+ //!
6
+ //! 1. **Fresh install** — index built with `IndexConfig::default()`
7
+ //! on v0.6.0+: `pq_subspaces = dims/4` (96 for 384-dim), auto
8
+ //! `n_probe` + size-aware auto `n_rerank` recomputed per query.
9
+ //! This is what every new user experiences.
10
+ //! 2. **Upgrade path** — index built on v0.5.x (so `pq_subspaces =
11
+ //! dims/8` = 48 on 384-dim; stored `n_probe = 8, n_rerank = 64`)
12
+ //! reopened under v0.6.0. The query-time auto formulas kick in
13
+ //! without a rebuild (see `IvfPqIndex::effective_defaults` — the
14
+ //! legacy sentinel `(8, 64)` triggers auto). Shows what a user
15
+ //! gets *before* running `mnemo index --rebuild`.
16
+ //!
17
+ //! Writes `test/scripts/perf_v0.5.0.json["ann_defaults_v0_6_0"]`
18
+ //! with per-tier `{fresh_install, upgrade_path}` cells carrying
19
+ //! `recall_at_10` + `mean_query_ms` + the exact `n_probe` / `n_rerank`
20
+ //! values the formulas picked.
21
+ //!
22
+ //! Run:
23
+ //! ```sh
24
+ //! cargo run --release --example ann_defaults_v0_6_0
25
+ //! ```
26
+
27
+ use std::collections::HashSet;
28
+ use std::fs;
29
+ use std::path::PathBuf;
30
+ use std::time::Instant;
31
+
32
+ use mnemo::{
33
+ IndexConfig, KdfParams, Memory, MemoryType, Metric, Mnemo, MnemoConfig, RecallRequest,
34
+ };
35
+ use serde_json::{json, Value};
36
+
37
+ const PASSPHRASE: &str = "bench-pw";
38
+ const DIMENSIONS: usize = 384;
39
+ const QUERY_COUNT: usize = 50;
40
+ const TOP_K: usize = 10;
41
+
42
+ // Deterministic clustered-vector gen — same as benches/corpus.rs so
43
+ // the cached files from Phase 6 / ANN-1 are byte-identical to what
44
+ // this harness would produce.
45
+ fn gen_clustered_vectors(n: usize, dims: usize, k_clusters: usize, seed: u64) -> Vec<Vec<f32>> {
46
+ let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(1);
47
+ let mut next_u64 = || -> u64 {
48
+ state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
49
+ let mut z = state;
50
+ z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
51
+ z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
52
+ z ^ (z >> 31)
53
+ };
54
+ let mut next_normal = || -> f32 {
55
+ let u1 = ((next_u64() >> 11) as f64 / (1u64 << 53) as f64).max(1e-12);
56
+ let u2 = (next_u64() >> 11) as f64 / (1u64 << 53) as f64;
57
+ let r = (-2.0 * u1.ln()).sqrt();
58
+ (r * (2.0 * std::f64::consts::PI * u2).cos()) as f32
59
+ };
60
+ let normalize = |v: &mut [f32]| {
61
+ let mut norm = 0.0f32;
62
+ for x in v.iter() {
63
+ norm += x * x;
64
+ }
65
+ norm = norm.sqrt();
66
+ if norm == 0.0 {
67
+ v[0] = 1.0;
68
+ } else {
69
+ for x in v.iter_mut() {
70
+ *x /= norm;
71
+ }
72
+ }
73
+ };
74
+ let mut centres: Vec<Vec<f32>> = Vec::with_capacity(k_clusters);
75
+ for _ in 0..k_clusters {
76
+ let mut c = (0..dims).map(|_| next_normal()).collect::<Vec<_>>();
77
+ normalize(&mut c);
78
+ centres.push(c);
79
+ }
80
+ const NOISE_SIGMA: f32 = 0.1;
81
+ let mut out = Vec::with_capacity(n);
82
+ for i in 0..n {
83
+ let cluster = i % k_clusters;
84
+ let mut v: Vec<f32> = centres[cluster]
85
+ .iter()
86
+ .map(|&c| c + NOISE_SIGMA * next_normal())
87
+ .collect();
88
+ normalize(&mut v);
89
+ out.push(v);
90
+ }
91
+ out
92
+ }
93
+
94
+ fn cached_corpus_path(n: usize) -> PathBuf {
95
+ let base = std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "target".to_string());
96
+ PathBuf::from(base)
97
+ .join("benches")
98
+ .join(format!("corpus_{n}_{DIMENSIONS}d.mnemo"))
99
+ }
100
+
101
+ fn ensure_corpus(n: usize) -> PathBuf {
102
+ let path = cached_corpus_path(n);
103
+ if path.exists() {
104
+ if let Ok(db) = Mnemo::open(&path, PASSPHRASE) {
105
+ if db.dimensions() == DIMENSIONS {
106
+ return path;
107
+ }
108
+ }
109
+ let _ = fs::remove_file(&path);
110
+ }
111
+ println!(
112
+ "[ann-defaults] building {n}-memory corpus at {}",
113
+ path.display()
114
+ );
115
+ fs::create_dir_all(path.parent().unwrap()).unwrap();
116
+ let cfg = MnemoConfig {
117
+ dimensions: DIMENSIONS,
118
+ kdf: KdfParams::fast(),
119
+ ..Default::default()
120
+ };
121
+ let mut db = Mnemo::create(&path, PASSPHRASE, cfg).unwrap();
122
+ let k_clusters = ((n as f64).sqrt().ceil() as usize).max(16);
123
+ let vectors = gen_clustered_vectors(n, DIMENSIONS, k_clusters, 0xBEEF_C0DE);
124
+ for (i, v) in vectors.into_iter().enumerate() {
125
+ db.remember(Memory::new(
126
+ format!("bench memory #{i}"),
127
+ MemoryType::Semantic,
128
+ v,
129
+ ))
130
+ .unwrap();
131
+ }
132
+ db.flush().unwrap();
133
+ db.build_index_with(IndexConfig::default()).unwrap();
134
+ db.flush().unwrap();
135
+ path
136
+ }
137
+
138
+ /// Measure one (pq_subspaces, scenario_label) config on a corpus.
139
+ ///
140
+ /// Rebuilds the ANN index in memory with the given `pq_subspaces` and
141
+ /// explicit `(n_probe, n_rerank)` cfg values (sentinels or auto, per
142
+ /// scenario), *doesn't flush* (cached file on disk is unchanged), and
143
+ /// runs 50 recall queries. Returns a JSON cell with recall@10, mean
144
+ /// latency, and the n_probe/n_rerank the index actually used at
145
+ /// query time.
146
+ fn measure_scenario(
147
+ cached: &PathBuf,
148
+ scenario: &str,
149
+ pq_subspaces: usize,
150
+ cfg_n_probe: usize,
151
+ cfg_n_rerank: usize,
152
+ ) -> Value {
153
+ let mut db = Mnemo::open(cached, PASSPHRASE).unwrap();
154
+
155
+ // Ground truth via exact search. Done before the index rebuild so
156
+ // it uses whatever's on disk — the vectors themselves are what
157
+ // matter, not the ANN state.
158
+ let queries = gen_clustered_vectors(QUERY_COUNT, DIMENSIONS, 100, 0xDEAD_BEEF);
159
+ let truth: Vec<HashSet<String>> = queries
160
+ .iter()
161
+ .map(|q| {
162
+ db.search(q, TOP_K, Metric::Cosine)
163
+ .unwrap()
164
+ .into_iter()
165
+ .map(|(m, _)| m.id.to_string())
166
+ .collect()
167
+ })
168
+ .collect();
169
+
170
+ // Rebuild the index in memory with this scenario's config.
171
+ db.drop_index();
172
+ let cfg = IndexConfig {
173
+ pq_subspaces,
174
+ n_probe: cfg_n_probe,
175
+ n_rerank: cfg_n_rerank,
176
+ ..Default::default()
177
+ };
178
+ let t_build = Instant::now();
179
+ db.build_index_with(cfg).unwrap();
180
+ let build_secs = t_build.elapsed().as_secs_f64();
181
+
182
+ // Both scenarios end up on the auto formulas at query time —
183
+ // fresh_install because build() resolves `IndexConfig::default()`
184
+ // to those values, upgrade_path because the stored (8, 64)
185
+ // sentinel triggers them. ANN-1.1 made `IvfPqIndex::info()`
186
+ // report the effective (shim-applied) numbers directly, so just
187
+ // read them back from `Stats::index` instead of reimplementing
188
+ // the formula here (where it would silently drift).
189
+ let info = db.stats().unwrap().index.unwrap();
190
+ let partitions = info.partitions;
191
+ let effective_n_probe = info.n_probe;
192
+ let effective_n_rerank = info.n_rerank;
193
+
194
+ let mut recalls = Vec::with_capacity(QUERY_COUNT);
195
+ let t0 = Instant::now();
196
+ for (q, truth_ids) in queries.iter().zip(truth.iter()) {
197
+ let req = RecallRequest::new(q.clone())
198
+ .top_k(TOP_K)
199
+ .track_access(false);
200
+ // No .n_probe()/.n_rerank() — this is the whole point of the
201
+ // test: measure what users get when they don't tune.
202
+ let hits = db.recall(&req).unwrap();
203
+ let got: HashSet<String> = hits.into_iter().map(|h| h.memory.id.to_string()).collect();
204
+ let hit = truth_ids.intersection(&got).count();
205
+ recalls.push(hit as f32 / TOP_K as f32);
206
+ }
207
+ let mean_secs = t0.elapsed().as_secs_f64() / QUERY_COUNT as f64;
208
+ let mean_recall = recalls.iter().sum::<f32>() / QUERY_COUNT as f32;
209
+
210
+ println!(
211
+ " {scenario:>14} pq={pq_subspaces:>3} partitions={partitions:>4} \
212
+ n_probe={effective_n_probe:>4} n_rerank={effective_n_rerank:>4} \
213
+ recall@10={mean_recall:.3} {:>7.2} ms/query build={build_secs:.1}s",
214
+ mean_secs * 1000.0,
215
+ );
216
+
217
+ json!({
218
+ "scenario": scenario,
219
+ "pq_subspaces": pq_subspaces,
220
+ "partitions": partitions,
221
+ "n_vectors": info.vectors,
222
+ "n_probe_used": effective_n_probe,
223
+ "n_rerank_used": effective_n_rerank,
224
+ "recall_at_10": round4(mean_recall as f64),
225
+ "mean_query_ms": round4(mean_secs * 1000.0),
226
+ "build_secs": round4(build_secs),
227
+ })
228
+ }
229
+
230
+ fn main() {
231
+ // Tiers to measure. 1M is "if feasible" — the harness tries it if
232
+ // the cached corpus is already on disk; otherwise it's skipped
233
+ // with a note. Avoids the 20-minute first-build cost unless the
234
+ // user already paid it in an earlier phase.
235
+ let tiers_to_measure: Vec<usize> = [10_000usize, 100_000, 1_000_000]
236
+ .iter()
237
+ .filter(|&&n| {
238
+ if n <= 100_000 {
239
+ return true;
240
+ }
241
+ // 1M only if cached; otherwise skip + print.
242
+ let p = cached_corpus_path(n);
243
+ if p.exists() {
244
+ true
245
+ } else {
246
+ println!(
247
+ "[ann-defaults] skipping 1M tier ({} not cached; label as unmeasured)",
248
+ p.display()
249
+ );
250
+ false
251
+ }
252
+ })
253
+ .copied()
254
+ .collect();
255
+
256
+ let mut tiers_json: Vec<Value> = Vec::new();
257
+ for n in tiers_to_measure {
258
+ println!("\n[ann-defaults] tier={n}");
259
+ let cached = ensure_corpus(n);
260
+ let cells = vec![
261
+ // Fresh install: new build defaults everywhere (dims/4
262
+ // pq + auto probe + auto rerank).
263
+ measure_scenario(&cached, "fresh_install", DIMENSIONS / 4, 0, 0),
264
+ // Upgrade path: v0.5.x build left pq_subspaces = dims/8 =
265
+ // 48 and stamped (8, 64) into the index. Simulate with
266
+ // explicit (8, 64) — ANN-1.1's legacy sentinel detection
267
+ // treats those as "auto" at query time.
268
+ measure_scenario(&cached, "upgrade_path", DIMENSIONS / 8, 8, 64),
269
+ ];
270
+ tiers_json.push(json!({
271
+ "corpus_size": n,
272
+ "dimensions": DIMENSIONS,
273
+ "scenarios": cells,
274
+ }));
275
+ }
276
+
277
+ let perf_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
278
+ .parent()
279
+ .unwrap()
280
+ .join("test/scripts/perf_v0.5.0.json");
281
+ let mut out: Value = if perf_path.exists() {
282
+ let txt = fs::read_to_string(&perf_path).unwrap();
283
+ let v: Value = serde_json::from_str(&txt).unwrap();
284
+ if v.is_array() {
285
+ json!({ "legacy_rows": v })
286
+ } else {
287
+ v
288
+ }
289
+ } else {
290
+ json!({})
291
+ };
292
+ out["ann_defaults_v0_6_0"] = json!({
293
+ "methodology":
294
+ "Per-tier (10K, 100K, 1M-if-cached): rebuild the ANN index in \
295
+ memory under two scenarios — 'fresh_install' = IndexConfig::default() \
296
+ with pq_subspaces=dims/4 and ANN-1.1 query-time auto formulas; \
297
+ 'upgrade_path' = explicit (pq=dims/8, n_probe=8, n_rerank=64) to \
298
+ simulate a v0.5.x-built index; ANN-1.1's legacy-sentinel detection \
299
+ substitutes the new auto formulas at query time. 50 queries per \
300
+ scenario, recall@10 against exact search, mean latency per query. \
301
+ Index is rebuilt in memory only — cached corpus on disk is \
302
+ untouched. See CHANGELOG `## [Unreleased]` → ANN-1.1.",
303
+ "tiers": tiers_json,
304
+ });
305
+ fs::write(
306
+ &perf_path,
307
+ serde_json::to_string_pretty(&out).unwrap() + "\n",
308
+ )
309
+ .unwrap();
310
+ println!("\n[ann-defaults] wrote {}", perf_path.display());
311
+ }
312
+
313
+ fn round4(x: f64) -> Value {
314
+ Value::from((x * 10000.0).round() / 10000.0)
315
+ }