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.
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/PKG-INFO +27 -7
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/README.md +26 -6
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/Cargo.lock +1 -1
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/Cargo.toml +1 -1
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/README.md +66 -2
- mnemo_engine-0.6.0/mnemo/examples/ann_defaults_v0_6_0.rs +315 -0
- mnemo_engine-0.6.0/mnemo/examples/ann_diagnose.rs +254 -0
- mnemo_engine-0.6.0/mnemo/examples/ann_recall_grid_v2.rs +326 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/bin/mnemo.rs +6 -4
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/crypto.rs +7 -3
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/embed_cmd.rs +37 -0
- mnemo_engine-0.6.0/mnemo/src/index.rs +783 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/pager.rs +0 -10
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/store.rs +724 -130
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/wal.rs +123 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/tests/cli_smoke.rs +87 -0
- mnemo_engine-0.6.0/mnemo/tests/dur1_torn_header.rs +402 -0
- mnemo_engine-0.6.0/mnemo/tests/fix1_correctness_sweep.rs +386 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/tests/proptest_store.rs +88 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo-python/Cargo.lock +2 -2
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo-python/Cargo.toml +1 -1
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo-python/README.md +26 -6
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo-python/src/lib.rs +349 -143
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo-python/test_mnemo.py +229 -9
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/pyproject.toml +1 -1
- mnemo_engine-0.5.2/mnemo/src/index.rs +0 -449
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/.gitignore +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/benches/cache.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/benches/corpus.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/benches/embed_overhead.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/benches/open.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/benches/recall.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/benches/write.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/deny.toml +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/examples/ann_recall_grid.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/examples/embedder_server.py +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/examples/file_size_breakdown.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/examples/quickstart.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/examples/session.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/cache.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/embed.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/error.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/format.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/lib.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/mcp.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/memory.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/result_cache.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/src/session.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/tests/integration.rs +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/tests/proptest_store.proptest-regressions +0 -0
- {mnemo_engine-0.5.2 → mnemo_engine-0.6.0}/mnemo/tests/sec1_nonce_uniqueness.rs +0 -0
- {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.
|
|
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
|
-
##
|
|
61
|
+
## Install
|
|
62
62
|
|
|
63
|
-
The
|
|
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
|
|
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
|
-
##
|
|
47
|
+
## Install
|
|
48
48
|
|
|
49
|
-
The
|
|
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
|
|
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
|
|
@@ -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.
|
|
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 #
|
|
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
|
|
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
|
+
}
|