Skip to content

Commit fcfb163

Browse files
authored
Merge pull request #16 from tinyhumansai/memory-module-port
feat(module): five new capability families and OpenStore for per-subtree stores
2 parents cfb4d08 + dc3a725 commit fcfb163

43 files changed

Lines changed: 3918 additions & 2250 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/src/capabilities.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ use crate::error::MemoryError;
5151

5252
/// One capability family a memory driver may advertise.
5353
///
54-
/// The variants are exactly the thirteen families of the memory contract. Each
54+
/// The variants are exactly the sixteen families of the memory contract. Each
5555
/// maps to a trait family in the contract, a group of RPC methods, and a group
5656
/// of agent tools; a driver that does not advertise a family simply has that
5757
/// surface absent.
@@ -85,6 +85,17 @@ pub enum Capability {
8585
Maintenance,
8686
/// Export and import of the whole store as a stream. **Mandatory.**
8787
Portability,
88+
/// Contacts, handle resolution, and closeness scoring.
89+
People,
90+
/// Direct read access to the stored chunk tier.
91+
Chunks,
92+
/// Deterministic retrieval primitives: graph walk, time-window cover,
93+
/// entity-index search.
94+
Retrieval,
95+
/// Learned facets about the user.
96+
Profile,
97+
/// The turn-by-turn conversation record and its segment lifecycle.
98+
Episodic,
8899
}
89100

90101
impl Capability {
@@ -93,7 +104,7 @@ impl Capability {
93104
/// Declaration order is also bit order in [`Capabilities`] and iteration
94105
/// order in its serialized form, so this slice is the single ordering
95106
/// authority for the whole module.
96-
pub const ALL: [Capability; 13] = [
107+
pub const ALL: [Capability; 18] = [
97108
Capability::Core,
98109
Capability::Recall,
99110
Capability::Ingest,
@@ -107,6 +118,14 @@ impl Capability {
107118
Capability::Sources,
108119
Capability::Maintenance,
109120
Capability::Portability,
121+
// Appended, never inserted: declaration order is bit order in
122+
// `Capabilities`, so moving an existing variant would silently change
123+
// what an already-persisted or already-transmitted bitset means.
124+
Capability::People,
125+
Capability::Chunks,
126+
Capability::Retrieval,
127+
Capability::Profile,
128+
Capability::Episodic,
110129
];
111130

112131
/// The families a driver must advertise to be bindable at all.
@@ -145,6 +164,11 @@ impl Capability {
145164
Self::Sources => "sources",
146165
Self::Maintenance => "maintenance",
147166
Self::Portability => "portability",
167+
Self::People => "people",
168+
Self::Chunks => "chunks",
169+
Self::Retrieval => "retrieval",
170+
Self::Profile => "profile",
171+
Self::Episodic => "episodic",
148172
}
149173
}
150174

@@ -187,6 +211,11 @@ impl Capability {
187211
Self::Sources => 10,
188212
Self::Maintenance => 11,
189213
Self::Portability => 12,
214+
Self::People => 13,
215+
Self::Chunks => 14,
216+
Self::Retrieval => 15,
217+
Self::Profile => 16,
218+
Self::Episodic => 17,
190219
}
191220
}
192221

api/src/capabilities_tests.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//!
33
//! Three properties are load-bearing and each has its own test:
44
//!
5-
//! 1. the enum has exactly the thirteen contract families and no more;
5+
//! 1. the enum has exactly the sixteen contract families and no more;
66
//! 2. the serialized form is stable snake_case **strings**, never discriminant
77
//! integers — a driver deployed against an older build must keep advertising
88
//! the same set after a variant is inserted mid-enum;
@@ -13,9 +13,9 @@ use super::*;
1313
use serde_json::json;
1414

1515
#[test]
16-
fn capability_has_exactly_the_thirteen_contract_families() {
17-
assert_eq!(Capability::ALL.len(), 13);
18-
assert_eq!(Capability::all().len(), 13);
16+
fn capability_has_exactly_the_eighteen_contract_families() {
17+
assert_eq!(Capability::ALL.len(), 18);
18+
assert_eq!(Capability::all().len(), 18);
1919

2020
let names: Vec<&str> = Capability::ALL.iter().map(|c| c.as_str()).collect();
2121
assert_eq!(
@@ -34,6 +34,11 @@ fn capability_has_exactly_the_thirteen_contract_families() {
3434
"sources",
3535
"maintenance",
3636
"portability",
37+
"people",
38+
"chunks",
39+
"retrieval",
40+
"profile",
41+
"episodic",
3742
]
3843
);
3944
}
@@ -141,7 +146,7 @@ fn capabilities_empty_contains_nothing() {
141146
}
142147

143148
#[test]
144-
fn capabilities_bit_width_has_room_well_beyond_the_current_thirteen_families() {
149+
fn capabilities_bit_width_has_room_well_beyond_the_current_sixteen_families() {
145150
// A `u16` bitset (the original representation) has exactly 16 bit
146151
// positions, leaving room for only 3 more families before a family's
147152
// `1 << index` bit-shift overflows. Pin the wider `u64` representation so

api/src/host/embeddings.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,114 @@ use async_trait::async_trait;
2020
/// provider. Drift between the two silently splits one embedding space into
2121
/// two, and every vector written on the wrong side of the split becomes
2222
/// unsearchable without a re-embed.
23+
/// # Delimiters in a component
24+
///
25+
/// A component containing `;`, `=` or `%` is percent-encoded, because without
26+
/// that the format is ambiguous: `("a;model=b", "c")` and `("a", "b;model=c")`
27+
/// are different embedding spaces that would otherwise produce one identical
28+
/// key, and vectors from both would then be compared as though they came from
29+
/// the same model.
30+
///
31+
/// Encoding only those three characters is what keeps this from being a
32+
/// migration. Every provider and model identifier actually in use is
33+
/// alphanumeric plus `-`, `_`, `.`, `/` or `:`, and each of those passes
34+
/// through untouched — so every signature already on disk still formats to the
35+
/// same bytes. Only a name that could have collided changes, and such a name
36+
/// has never been written.
2337
#[must_use]
2438
pub fn format_embedding_signature(name: &str, model_id: &str, dims: usize) -> String {
39+
let name = escape_component(name);
40+
let model_id = escape_component(model_id);
2541
format!("provider={name};model={model_id};dims={dims}")
2642
}
2743

44+
/// Percent-encode the three characters that carry structure in a signature.
45+
///
46+
/// `%` goes first and must: encoding it afterwards would re-encode the `%` this
47+
/// function just introduced, and `a;b` would arrive as `a%3Bb` from one path
48+
/// and `a%253Bb` from another.
49+
fn escape_component(value: &str) -> String {
50+
if !value.contains(['%', ';', '=']) {
51+
// The overwhelmingly common path, and the one that guarantees existing
52+
// keys are untouched: no allocation beyond the copy, no rewriting.
53+
return value.to_string();
54+
}
55+
value
56+
.replace('%', "%25")
57+
.replace(';', "%3B")
58+
.replace('=', "%3D")
59+
}
60+
61+
#[cfg(test)]
62+
mod embedding_signature_tests {
63+
use super::format_embedding_signature;
64+
65+
/// The signature format is a **persisted key**, pinned to literal values.
66+
///
67+
/// Written against golden strings rather than against another copy of the
68+
/// function on purpose: the host used to hold a byte-identical duplicate of
69+
/// this file and the two silently diverged once already. A guard that
70+
/// compares two implementations stops protecting anything the moment one of
71+
/// them goes away — which is exactly what happened when the duplicate was
72+
/// removed. Literals outlive that.
73+
///
74+
/// Every vector on disk is keyed by one of these strings, so a change here
75+
/// is a migration, never an edit.
76+
#[test]
77+
fn signature_format_is_pinned_to_its_persisted_form() {
78+
assert_eq!(
79+
format_embedding_signature("ollama", "nomic-embed-text", 768),
80+
"provider=ollama;model=nomic-embed-text;dims=768"
81+
);
82+
assert_eq!(
83+
format_embedding_signature("none", "none", 0),
84+
"provider=none;model=none;dims=0"
85+
);
86+
}
87+
88+
/// Two distinct embedding spaces must never share one signature.
89+
///
90+
/// Without escaping these two collide exactly: both format to
91+
/// `provider=a;model=b;model=c;dims=3`. A collision here is not a cosmetic
92+
/// problem — the signature is what decides which vectors are comparable, so
93+
/// two models' vectors would be scored against each other as though they
94+
/// came from one space.
95+
#[test]
96+
fn delimiter_characters_cannot_make_distinct_spaces_collide() {
97+
let first = format_embedding_signature("a;model=b", "c", 3);
98+
let second = format_embedding_signature("a", "b;model=c", 3);
99+
assert_ne!(first, second);
100+
}
101+
102+
/// Escaping `%` last would make the encoding itself ambiguous.
103+
#[test]
104+
fn an_already_percent_encoded_name_does_not_collide_with_a_literal_one() {
105+
assert_ne!(
106+
format_embedding_signature("a%3Bb", "m", 3),
107+
format_embedding_signature("a;b", "m", 3)
108+
);
109+
}
110+
111+
/// The escaping is not a migration: every identifier shaped like the ones
112+
/// actually in use formats to the same bytes it always did.
113+
#[test]
114+
fn identifiers_in_real_use_are_untouched_by_the_escaping() {
115+
for (provider, model) in [
116+
("ollama", "nomic-embed-text"),
117+
("openai", "text-embedding-3-small"),
118+
("huggingface", "sentence-transformers/all-MiniLM-L6-v2"),
119+
("local", "bge_base.en-v1.5"),
120+
("backend", "tinyhumans:default"),
121+
] {
122+
assert_eq!(
123+
format_embedding_signature(provider, model, 768),
124+
format!("provider={provider};model={model};dims=768"),
125+
"{provider}/{model} must not be rewritten — it is a persisted key"
126+
);
127+
}
128+
}
129+
}
130+
28131
/// Converts text into numerical vectors.
29132
#[async_trait]
30133
pub trait EmbeddingProvider: Send + Sync {

api/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,10 @@
4141
//! - [`recall`]: the borrowed [`recall::RecallOpts`] and owned, serde-derived
4242
//! [`recall::OwnedRecallOpts`] recall filters (both re-exported from
4343
//! [`types`]).
44-
//! - [`capabilities`]: the thirteen [`capabilities::Capability`] families and
44+
//! - [`capabilities`]: the sixteen [`capabilities::Capability`] families and
4545
//! the [`capabilities::Capabilities`] set negotiated at bind time.
4646
//! - [`provider`]: the driver contract — [`provider::MemoryProvider`] plus the
47-
//! thirteen capability family traits and the value types they need.
47+
//! sixteen capability family traits and the value types they need.
4848
//! - [`null`]: [`null::NullMemoryProvider`], the reference driver a
4949
//! compiled-out or unconfigured memory subsystem binds to.
5050
//! - [`health`]: [`health::MemoryHealth`], the liveness state a driver reports.

0 commit comments

Comments
 (0)