refactor(tinycortex): W7 — shim memory_conversations over the crate (#4787)

This commit is contained in:
Steven Enamakel
2026-07-11 11:11:24 -07:00
committed by GitHub
parent d575f9dd6e
commit 9c5f5a03e4
7 changed files with 17 additions and 3535 deletions
@@ -1,34 +0,0 @@
# conversations
Workspace-backed conversation thread/message storage. Lives at
`<workspace>/memory/conversations/` as plain JSONL — easy to inspect,
recover, and back up. Used by the desktop UI for chat threads and by
non-web channel adapters (Slack, Telegram, …) so all surfaces share one
persistence path.
## Files
- **`mod.rs`** — re-exports the public surface
(`ConversationStore`, `ConversationThread`, `ConversationMessage`,
`CreateConversationThread`, `ConversationMessagePatch`,
`ConversationPurgeStats`, free-function shims, and
`register_conversation_persistence_subscriber`).
- **`types.rs`** — wire/storage structs: thread metadata, message
records, create requests, partial-update patches.
- **`store.rs`** — `ConversationStore` plus free-function shims.
Thread metadata is appended to `threads.jsonl` (upsert/delete log);
messages live in `threads/<thread_id>.jsonl`. A process-wide mutex
serialises every on-disk mutation.
- **`bus.rs`** — `EventHandler` that mirrors inbound `DomainEvent`
channel messages into the store, so non-web providers persist
alongside UI-driven threads.
- **`store_tests.rs`** — unit tests covering upsert, append, label/
title updates, deletion, and purge.
## Where it fits
Sits next to the unified memory store but is intentionally separate:
the conversation log is append-only chat history with no embeddings or
graph relations. Ingestion into the searchable memory tree happens via
`tree/` and the per-provider ingestion modules (e.g. `slack_ingestion/`)
— this folder only owns durable transcript storage.
@@ -1,670 +0,0 @@
//! In-memory inverted index for `search_cross_thread_messages`.
//!
//! ## Architecture (v1)
//!
//! ```text
//! ┌───────────────┐
//! query "cat" ───▶ │ Phase 1: │ posting-list intersection
//! │ ngram lookup │ on character n-grams
//! └──────┬────────┘
//! │ candidate doc ids per term
//! ┌──────▼────────┐
//! │ Phase 2: │ exact substring verify on
//! │ verify+score │ normalized content, score
//! └──────┬────────┘ by `matched_terms / total_terms`
//! │
//! ▼
//! Vec<Hit>
//! ```
//!
//! ## Ownership choices for scale
//!
//! Conversation corpora can grow to hundreds of thousands of messages per
//! workspace. The data structures here are picked to keep the resident-set
//! size predictable at that scale:
//!
//! - **`thread_id` and `role` are interned `Arc<str>`** (see
//! `intern_thread_id` / `intern_role`). A workspace with one thread of
//! N messages would otherwise store N copies of the same thread id; a
//! role string only ever takes two distinct values in practice. The
//! interner amortises both to a single heap allocation per distinct
//! value, plus one `Arc` clone per `DocEntry`.
//! - **Posting-map keys are `Box<str>`** (16 bytes) rather than `String`
//! (24 bytes). Saves 8 bytes per distinct ngram in the corpus — at
//! ~17k Latin trigrams plus CJK bigrams that adds up.
//! - **Posting lists are still `BTreeSet<u32>`** for ergonomic ordered
//! iteration. The Phase 1 intersection is performed against a
//! single-allocation `Vec<u32>` accumulator via a two-pointer
//! sort-merge (no per-iteration `BTreeSet` rebuilds), so the BTreeSet
//! shape only affects insertion and removal, not query latency.
//! Roaring Bitmaps + FST + LSM segments are the long-term destination
//! (Gemini Deep Research write-up); we defer that until corpus sizes
//! justify the complexity.
//! - **Whole index lives in RAM**, rebuilt from JSONL on first access in
//! the process. The JSONL files remain the source of truth.
//! - **Scoring matches the previous linear scan**:
//! `score = matched_terms / total_terms` with a `created_at` tiebreaker.
//! - **Pathological query short-circuit**: if Phase 1 produces a
//! candidate set larger than `LARGE_CANDIDATE_LIMIT` for any term, the
//! index returns recency-ordered hits without running Phase 2. This
//! genuinely caps tail latency — the check fires *before* the
//! substring-verification loop, not after.
use std::collections::{BTreeSet, HashMap};
use std::sync::Arc;
use super::tokenize::{ngrams, normalize};
use super::types::{ConversationMessage, CrossThreadHit};
/// Minimum byte length for a query term to be considered. Matches the
/// historical behaviour of `search_cross_thread_messages` so existing
/// callers (and tests) see no change. Single-byte ASCII tokens like "a"
/// or "is" are filtered out; a single CJK character (3 bytes in UTF-8)
/// passes through.
const MIN_TERM_BYTES: usize = 3;
/// When Phase 1 returns more than this many candidates we skip Phase 2
/// verification and fall back to a pure recency-ranked truncation. This
/// is the mitigation for the "user types `e`" pathological case.
const LARGE_CANDIDATE_LIMIT: usize = 10_000;
/// One indexed message. Carries enough state to (a) reconstruct a
/// `CrossThreadHit` without re-reading JSONL on the hot path and (b)
/// verify Phase 1 candidates by exact substring match on the normalized
/// form.
///
/// `thread_id` and `role` are `Arc<str>` because they repeat heavily
/// across messages (N messages per thread → N references to the same
/// thread id; only ~2 distinct role values across the entire corpus).
/// `message_id`, `content`, `content_normalized` and `created_at` are
/// per-message unique so they stay as `String`.
#[derive(Debug, Clone)]
struct DocEntry {
thread_id: Arc<str>,
message_id: String,
role: Arc<str>,
content: String, // original, returned verbatim in hits
content_normalized: String, // for Phase 2 substring verification
created_at: String,
}
/// In-memory trigram/bigram inverted index over conversation messages.
///
/// Documents are addressed by a dense `u32` doc-id assigned in insertion
/// order. Deletes leave tombstones (`docs[i] = None`) rather than shifting
/// the array, so posting-list integers stay valid without rebuilding.
#[derive(Debug, Default)]
pub(crate) struct InvertedIndex {
/// `ngram -> sorted set of doc-ids`. BTreeSet so per-doc removals
/// are O(log n) and iteration is in sorted order (drives the
/// sort-merge intersect in `candidates_for_term`). Keys are
/// `Box<str>` to shave 8 bytes per entry vs `String`.
postings: HashMap<Box<str>, BTreeSet<u32>>,
/// Tombstoned: `docs[i] == None` means the message was deleted. We
/// keep the slot so existing doc-ids in posting lists stay valid.
docs: Vec<Option<DocEntry>>,
/// Reverse lookup for incremental removal: `(thread_id, message_id)`
/// → `doc_id`. Letting us drop a single message without re-walking
/// the corpus.
by_message: HashMap<(String, String), u32>,
/// Interner pools. Keep a single `Arc<str>` per distinct thread id
/// and role so every `DocEntry` referencing them can hold a cheap
/// 16-byte `Arc` clone instead of a 24-byte `String`.
thread_id_pool: HashMap<String, Arc<str>>,
role_pool: HashMap<String, Arc<str>>,
}
impl InvertedIndex {
pub fn new() -> Self {
Self::default()
}
/// Insert one message. Takes the message by value so the caller's
/// owned strings can be moved into the index without an internal
/// clone of each field. If the (thread, message_id) pair is already
/// in the index this is a no-op (messages are append-only in the
/// store, so duplicate IDs indicate a corrupt JSONL — silently
/// ignore rather than panic).
pub fn insert(&mut self, thread_id: &str, msg: ConversationMessage) {
let ConversationMessage {
id,
content,
sender,
created_at,
message_type: _,
extra_metadata: _,
} = msg;
let key = (thread_id.to_string(), id.clone());
if self.by_message.contains_key(&key) {
return;
}
let normalized = normalize(&content);
let doc_id = self.docs.len() as u32;
for ngram in ngrams(&normalized) {
if let Some(posting) = self.postings.get_mut(ngram) {
posting.insert(doc_id);
} else {
let mut set = BTreeSet::new();
set.insert(doc_id);
self.postings.insert(ngram.into(), set);
}
}
let thread_arc = self.intern_thread_id(thread_id);
let role_arc = self.intern_role(&sender);
self.docs.push(Some(DocEntry {
thread_id: thread_arc,
message_id: id,
role: role_arc,
content,
content_normalized: normalized,
created_at,
}));
self.by_message.insert(key, doc_id);
}
/// Drop every document belonging to a thread. Used by
/// `delete_thread` and during full purge.
pub fn remove_thread(&mut self, thread_id: &str) {
let to_remove: Vec<u32> = self
.by_message
.iter()
.filter(|((t, _), _)| t == thread_id)
.map(|(_, id)| *id)
.collect();
for doc_id in to_remove {
self.remove_doc(doc_id);
}
self.thread_id_pool.remove(thread_id);
}
/// Reset the index to its empty state. Cheaper than dropping and
/// re-allocating when a workspace is being rebuilt.
pub fn clear(&mut self) {
self.postings.clear();
self.docs.clear();
self.by_message.clear();
self.thread_id_pool.clear();
self.role_pool.clear();
}
fn remove_doc(&mut self, doc_id: u32) {
let idx = doc_id as usize;
let Some(entry) = self.docs.get_mut(idx).and_then(|slot| slot.take()) else {
return;
};
self.by_message
.remove(&(entry.thread_id.to_string(), entry.message_id.clone()));
// Remove doc_id from every posting list referencing it. We re-
// tokenize the normalized content rather than tracking the
// per-doc ngram set; tokenization is allocation-free now that
// `ngrams` returns borrowed slices, so this stays cheap.
for ngram in ngrams(&entry.content_normalized) {
if let Some(posting) = self.postings.get_mut(ngram) {
posting.remove(&doc_id);
if posting.is_empty() {
self.postings.remove(ngram);
}
}
}
}
/// The Phase 1 + Phase 2 query pipeline. Mirrors the contract of
/// `ConversationStore::search_cross_thread_messages` so the store
/// method can be a thin shim.
pub fn search(
&self,
query: &str,
limit: usize,
exclude_thread_id: Option<&str>,
) -> Vec<CrossThreadHit> {
if limit == 0 {
return Vec::new();
}
let query_lower = normalize(query);
// Filter terms by raw byte length (matches the historical
// 3-byte threshold; single CJK chars are 3 bytes and pass).
let terms: Vec<String> = query_lower
.split_whitespace()
.filter(|t| t.len() >= MIN_TERM_BYTES)
.map(|s| s.to_string())
.collect();
if terms.is_empty() {
return Vec::new();
}
// Phase 1: collect candidate doc-ids per term. Short-circuit to
// recency-only ordering if any single term's candidate set
// already exceeds the pathological threshold — this is the cap
// on tail latency, and it must fire BEFORE we run the substring
// verification loop.
let mut per_term: Vec<Vec<u32>> = Vec::with_capacity(terms.len());
for term in &terms {
let candidates = match self.candidates_for_term(term) {
Some(v) => v,
None => self
.docs
.iter()
.enumerate()
.filter_map(|(i, slot)| slot.as_ref().map(|_| i as u32))
.collect::<Vec<u32>>(),
};
if candidates.len() > LARGE_CANDIDATE_LIMIT {
return self.recency_fallback(exclude_thread_id, limit);
}
per_term.push(candidates);
}
// Phase 2: verify each candidate by exact substring match.
// Count distinct terms per doc for the score.
let mut hit_counts: HashMap<u32, usize> = HashMap::new();
for (term, candidates) in terms.iter().zip(per_term) {
for doc_id in candidates {
let Some(entry) = self.docs[doc_id as usize].as_ref() else {
continue;
};
if exclude_thread_id == Some(entry.thread_id.as_ref()) {
continue;
}
if entry.content_normalized.contains(term.as_str()) {
*hit_counts.entry(doc_id).or_insert(0) += 1;
}
}
}
let total_terms = terms.len() as f64;
// Rank on cheap keys first — the match count and a borrowed `created_at`
// — then materialize the heavy CrossThreadHit (which clones the KB-sized
// `content`) only for the `limit` survivors. Phase 2 can leave thousands
// of candidates in `hit_counts` while callers ask for 3-10 results, so
// cloning every candidate's content before truncating is ~99% wasted.
// Ranking by `matched` (usize) is order-equivalent to ranking by
// `score = matched / total_terms` since `total_terms` is a positive
// constant, so the returned order is unchanged.
let mut ranked: Vec<(u32, usize, &str)> = hit_counts
.into_iter()
.map(|(doc_id, matched)| {
let entry = self.docs[doc_id as usize]
.as_ref()
.expect("doc_id from hit_counts must be live");
(doc_id, matched, entry.created_at.as_str())
})
.collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| b.2.cmp(a.2)));
ranked.truncate(limit);
ranked
.into_iter()
.map(|(doc_id, matched, _)| {
let entry = self.docs[doc_id as usize]
.as_ref()
.expect("doc_id from hit_counts must be live");
CrossThreadHit {
thread_id: entry.thread_id.to_string(),
message_id: entry.message_id.clone(),
role: entry.role.to_string(),
content: entry.content.clone(),
created_at: entry.created_at.clone(),
score: matched as f64 / total_terms,
}
})
.collect()
}
/// Build the Phase 1 candidate set for one query term.
///
/// Returns `Some(vec)` containing the sorted intersection of posting
/// lists for every ngram of `term`. If `term` is too short to
/// produce any ngram (e.g. a single CJK char of length 1) returns
/// `None` so the caller can fall back to a linear scan.
///
/// The intersect is a two-pointer sort-merge over the already-sorted
/// posting lists: `acc` is rewritten in place once per remaining
/// ngram, allocating zero intermediate sets.
fn candidates_for_term(&self, term: &str) -> Option<Vec<u32>> {
let term_ngrams = ngrams(term);
if term_ngrams.is_empty() {
return None;
}
let mut iter = term_ngrams.iter();
let first = iter.next().expect("non-empty by check above");
let mut acc: Vec<u32> = match self.postings.get(*first) {
Some(p) => p.iter().copied().collect(),
None => return Some(Vec::new()),
};
for ng in iter {
if acc.is_empty() {
return Some(acc);
}
match self.postings.get(*ng) {
Some(p) => intersect_sorted_with_btreeset(&mut acc, p),
None => return Some(Vec::new()),
}
}
Some(acc)
}
fn intern_thread_id(&mut self, thread_id: &str) -> Arc<str> {
if let Some(existing) = self.thread_id_pool.get(thread_id) {
return Arc::clone(existing);
}
let arc: Arc<str> = Arc::from(thread_id);
self.thread_id_pool
.insert(thread_id.to_string(), Arc::clone(&arc));
arc
}
fn intern_role(&mut self, role: &str) -> Arc<str> {
if let Some(existing) = self.role_pool.get(role) {
return Arc::clone(existing);
}
let arc: Arc<str> = Arc::from(role);
self.role_pool.insert(role.to_string(), Arc::clone(&arc));
arc
}
fn recency_fallback(
&self,
exclude_thread_id: Option<&str>,
limit: usize,
) -> Vec<CrossThreadHit> {
// Rank the whole corpus by recency on a borrowed `created_at`, then
// clone the heavy fields only for the `limit` newest survivors — this
// fallback fires when a term matches >10k docs, so cloning every doc's
// content before truncating could allocate hundreds of MB needlessly.
let mut ranked: Vec<(usize, &str)> = self
.docs
.iter()
.enumerate()
.filter_map(|(i, slot)| slot.as_ref().map(|entry| (i, entry)))
.filter(|(_, entry)| exclude_thread_id != Some(entry.thread_id.as_ref()))
.map(|(i, entry)| (i, entry.created_at.as_str()))
.collect();
ranked.sort_by(|a, b| b.1.cmp(a.1));
ranked.truncate(limit);
ranked
.into_iter()
.map(|(i, _)| {
let entry = self.docs[i]
.as_ref()
.expect("index from a live doc slot must stay live");
CrossThreadHit {
thread_id: entry.thread_id.to_string(),
message_id: entry.message_id.clone(),
role: entry.role.to_string(),
content: entry.content.clone(),
created_at: entry.created_at.clone(),
// Score 0.0 signals "matched via recency fallback only" —
// documented in the function rustdoc above. Callers
// sorting by `(score desc, created_at desc)` still see
// the newest entries first.
score: 0.0,
}
})
.collect()
}
}
/// Two-pointer sort-merge intersect. `acc` and `other` are both sorted
/// ascending; on return `acc` contains only the elements present in
/// both, in the same sorted order. Runs in O(|acc| + |other|) with zero
/// allocations.
fn intersect_sorted_with_btreeset(acc: &mut Vec<u32>, other: &BTreeSet<u32>) {
let mut other_iter = other.iter().copied().peekable();
let mut write = 0usize;
for read in 0..acc.len() {
let target = acc[read];
// Advance `other_iter` past everything strictly less than the
// current `target`. After this loop the next peeked value is
// either equal to `target` (keep) or strictly greater (drop).
while let Some(&o) = other_iter.peek() {
if o < target {
other_iter.next();
} else {
break;
}
}
if other_iter.peek().copied() == Some(target) {
acc[write] = target;
write += 1;
other_iter.next();
}
}
acc.truncate(write);
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn msg(id: &str, content: &str, created: &str) -> ConversationMessage {
ConversationMessage {
id: id.to_string(),
content: content.to_string(),
message_type: "text".to_string(),
extra_metadata: json!({}),
sender: "user".to_string(),
created_at: created.to_string(),
}
}
#[test]
fn substring_inside_word_matches() {
// Canonical substring-inside-word case: querying "cat" must find
// "concatenate" — a token-boundary tokenizer would miss this.
let mut idx = InvertedIndex::new();
idx.insert(
"t1",
msg("m1", "concatenate the strings", "2026-04-10T10:00:00Z"),
);
let hits = idx.search("cat", 10, None);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].message_id, "m1");
}
#[test]
fn polish_diacritics_normalized_both_sides() {
let mut idx = InvertedIndex::new();
idx.insert(
"t1",
msg("m1", "Lecę dziś do Krakowa", "2026-04-10T10:00:00Z"),
);
// Query with no diacritics finds content with diacritics.
let hits = idx.search("krakow", 10, None);
assert_eq!(hits.len(), 1, "krakow should match Krakowa");
}
#[test]
fn japanese_bigram_match() {
let mut idx = InvertedIndex::new();
idx.insert(
"t1",
msg("m1", "東京タワーが見える", "2026-04-10T10:00:00Z"),
);
let hits = idx.search("東京", 10, None);
assert_eq!(hits.len(), 1, "two-char CJK query should match");
}
#[test]
fn arabic_harakat_stripped() {
let mut idx = InvertedIndex::new();
// "wrote" with full vocalization vs bare consonants.
idx.insert("t1", msg("m1", "كَتَبَ الطالب", "2026-04-10T10:00:00Z"));
// The bare-consonant form should still find the vocalized one.
let hits = idx.search("كتب", 10, None);
assert_eq!(hits.len(), 1, "harakat stripping should equalize forms");
}
#[test]
fn excludes_active_thread() {
let mut idx = InvertedIndex::new();
idx.insert(
"active",
msg("ma", "postgres deploy", "2026-04-10T10:00:00Z"),
);
idx.insert(
"other",
msg("mo", "postgres deploy", "2026-04-10T10:01:00Z"),
);
let hits = idx.search("postgres", 10, Some("active"));
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].thread_id, "other");
}
#[test]
fn empty_query_returns_empty() {
let mut idx = InvertedIndex::new();
idx.insert("t", msg("m", "anything here", "2026-04-10T10:00:00Z"));
assert!(idx.search("", 10, None).is_empty());
}
#[test]
fn short_terms_only_returns_empty() {
// Mirrors the legacy `search_cross_thread_messages_skips_short_terms`
// behaviour — terms < 3 bytes are dropped.
let mut idx = InvertedIndex::new();
idx.insert("t", msg("m", "Postgres", "2026-04-10T10:00:00Z"));
assert!(idx.search("a is on", 10, None).is_empty());
}
#[test]
fn limit_zero_returns_empty() {
let mut idx = InvertedIndex::new();
idx.insert("t", msg("m", "Postgres", "2026-04-10T10:00:00Z"));
assert!(idx.search("postgres", 0, None).is_empty());
}
#[test]
fn score_matches_legacy_semantics() {
// 5-term query, 2 substring matches → score = 0.4.
let mut idx = InvertedIndex::new();
idx.insert(
"t1",
msg(
"m1",
"Remember: my project is called Phoenix and uses Go and PostgreSQL.",
"2026-04-10T10:00:00Z",
),
);
let hits = idx.search("What database does my project use", 10, None);
assert_eq!(hits.len(), 1);
// "project" + "use" (substring of "uses") → 2 of 5 terms.
assert!(
(hits[0].score - 0.4).abs() < 1e-9,
"score = {}",
hits[0].score
);
}
#[test]
fn ranks_by_score_then_recency_before_truncating() {
// More matches than `limit`, so truncation must keep the top-ranked
// hits by (score desc, created_at desc) — this pins that ranking still
// happens before the result set is cut, not after.
let mut idx = InvertedIndex::new();
// Both terms → score 1.0, but oldest.
idx.insert(
"t1",
msg("both", "alpha beta gamma", "2026-04-10T10:00:00Z"),
);
// One term → score 0.5, newest of the 0.5 group.
idx.insert("t1", msg("newest", "alpha delta", "2026-04-10T10:03:00Z"));
// One term → score 0.5, middle.
idx.insert("t1", msg("middle", "alpha epsilon", "2026-04-10T10:02:00Z"));
// One term → score 0.5, oldest of the 0.5 group.
idx.insert("t1", msg("oldest", "beta zeta", "2026-04-10T10:01:00Z"));
let hits = idx.search("alpha beta", 2, None);
assert_eq!(hits.len(), 2, "must respect the limit");
// Highest score wins outright; the recency tiebreak then picks the
// newest of the equal-score remainder. "middle"/"oldest" are dropped.
assert_eq!(hits[0].message_id, "both");
assert!(
(hits[0].score - 1.0).abs() < 1e-9,
"score = {}",
hits[0].score
);
assert_eq!(hits[1].message_id, "newest");
assert!(
(hits[1].score - 0.5).abs() < 1e-9,
"score = {}",
hits[1].score
);
}
#[test]
fn remove_thread_drops_all_messages() {
let mut idx = InvertedIndex::new();
idx.insert("t1", msg("m1", "postgres deploy", "2026-04-10T10:00:00Z"));
idx.insert("t1", msg("m2", "postgres backup", "2026-04-10T10:01:00Z"));
idx.insert("t2", msg("m3", "postgres replica", "2026-04-10T10:02:00Z"));
assert_eq!(idx.search("postgres", 10, None).len(), 3);
idx.remove_thread("t1");
let hits = idx.search("postgres", 10, None);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].thread_id, "t2");
}
#[test]
fn duplicate_message_id_is_idempotent() {
let mut idx = InvertedIndex::new();
let m = msg("m1", "postgres deploy", "2026-04-10T10:00:00Z");
idx.insert("t1", m.clone());
idx.insert("t1", m); // dup — should be ignored
let hits = idx.search("postgres", 10, None);
assert_eq!(hits.len(), 1, "duplicate insert must not duplicate hits");
}
#[test]
fn pathological_query_short_circuits_to_recency() {
// Build a corpus big enough to trip LARGE_CANDIDATE_LIMIT.
let mut idx = InvertedIndex::new();
let n = LARGE_CANDIDATE_LIMIT + 50;
for i in 0..n {
// "common" appears in every doc, so the trigram "com" hits
// all of them.
let created = format!("2026-04-10T10:{:02}:{:02}Z", i / 60, i % 60);
idx.insert("bulk", msg(&format!("m{i}"), "common payload", &created));
}
let hits = idx.search("common", 5, None);
assert_eq!(hits.len(), 5, "fallback must still respect limit");
// Score 0.0 is the recency-fallback marker.
assert!(hits.iter().all(|h| h.score == 0.0));
}
#[test]
fn intern_pool_dedupes_repeated_thread_id_and_role() {
// Smoke-test the Arc<str> interning: many messages on one
// thread must share a single Arc backing the thread_id.
let mut idx = InvertedIndex::new();
for i in 0..5 {
let ts = format!("2026-04-10T10:00:{:02}Z", i);
idx.insert("shared-thread", msg(&format!("m{i}"), "payload", &ts));
}
assert_eq!(idx.thread_id_pool.len(), 1);
assert_eq!(idx.role_pool.len(), 1);
// After removing the thread, the pool entry is dropped too.
idx.remove_thread("shared-thread");
assert_eq!(idx.thread_id_pool.len(), 0);
}
#[test]
fn intersect_sorted_with_btreeset_basic() {
let other: BTreeSet<u32> = [2, 4, 5, 9].into_iter().collect();
let mut acc: Vec<u32> = vec![1, 2, 3, 5, 7, 9];
intersect_sorted_with_btreeset(&mut acc, &other);
assert_eq!(acc, vec![2, 5, 9]);
}
#[test]
fn intersect_sorted_with_btreeset_empty_other() {
let other: BTreeSet<u32> = BTreeSet::new();
let mut acc: Vec<u32> = vec![1, 2, 3];
intersect_sorted_with_btreeset(&mut acc, &other);
assert!(acc.is_empty());
}
}
+17 -19
View File
@@ -1,27 +1,25 @@
//! Workspace-backed conversation thread/message storage for the desktop UI.
//! Workspace-backed conversation thread/message storage for the desktop UI
//! thin host shim over `tinycortex::memory::conversations` (W7).
//!
//! Conversations are stored as JSONL files under `<workspace>/memory/conversations/`.
//! Thread metadata is append-only in `threads.jsonl`; each thread's messages live
//! in a dedicated JSONL file for straightforward inspection and recovery.
//! Conversations are stored as JSONL files under the workspace (thread metadata
//! append-only in `threads.jsonl`; each thread's messages in a dedicated JSONL
//! file). The store / inverted-index / tokenizer / types engine is the crate's
//! (a byte-identical port, incl. the D1 rank-before-materialize fix); this
//! module re-exports that surface so the ~30 host consumers
//! (`openhuman::memory` re-exports it as `memory::conversations`, plus jsonrpc,
//! agent orchestration, agent_memory, threads, channels) keep their import paths
//! and identical `Result<_, String>` / on-disk behaviour unchanged.
//!
//! This module was split out of `openhuman::memory` into the top-level
//! `openhuman::memory_conversations` namespace so the high-level memory policy
//! layer does not also own UI thread persistence. `openhuman::memory` re-exports
//! this module as `memory::conversations` during the migration.
//! Host-retained: [`bus`] — the `core::event_bus` persistence subscriber that
//! bridges typed channel events onto the crate store (the crate abstracts the
//! bus behind its own `ConversationEventBus` trait; the host wires the real one).
mod bus;
mod inverted_index;
mod store;
mod tokenize;
mod types;
pub use bus::register_conversation_persistence_subscriber;
pub use store::{
pub use tinycortex::memory::conversations::{
append_message, delete_thread, ensure_thread, get_messages, list_threads, purge_threads,
update_message, update_thread_labels, update_thread_title, ConversationPurgeStats,
ConversationStore,
};
pub use types::{
ConversationMessage, ConversationMessagePatch, ConversationThread, CreateConversationThread,
CrossThreadHit,
update_message, update_thread_labels, update_thread_title, ConversationMessage,
ConversationMessagePatch, ConversationPurgeStats, ConversationStore, ConversationThread,
CreateConversationThread, CrossThreadHit,
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,283 +0,0 @@
//! Multilingual normalization + character n-gram generation for the
//! cross-thread search inverted index.
//!
//! ## Why character n-grams (not word tokens)?
//!
//! The cross-thread search must find substrings *inside* words — querying
//! `cat` should return messages containing `concatenate` or `Kotlin`. A
//! whitespace/word-boundary tokenizer fundamentally cannot do that, and
//! it also breaks down for CJK scripts that have no whitespace at all.
//! Character n-grams sidestep both problems.
//!
//! ## Why a hybrid trigram + CJK-bigram scheme?
//!
//! Trigrams strike a good balance between recall and dictionary size for
//! alphabetic scripts (~26³ ≈ 17k Latin trigrams). For CJK scripts the
//! alphabet is tens of thousands of characters, so character trigrams
//! explode the dictionary while character bigrams stay tractable. The
//! Gemini Deep Research write-up (see PR description) flagged this as the
//! single most important multilingual mitigation. We therefore generate:
//!
//! - **bigrams** for contiguous runs of CJK characters (Han / Hiragana /
//! Katakana / Hangul),
//! - **trigrams** for everything else.
//!
//! Tokens from both schemes coexist in the same posting map. As long as
//! query-time tokenization runs the *same* code path, lookups stay
//! consistent.
//!
//! ## Normalization pipeline
//!
//! Implemented in `normalize()` as a single iterator chain. The order
//! matters — strip-marks must run on the decomposed form, lowercase must
//! run on stripped code points, and the final NFKC re-compose unifies
//! compatibility variants for byte-stable indexing/querying:
//!
//! 1. **NFKD** — decompose so combining marks become standalone code points
//! (Polish ą → `a` + ̨, Arabic kataba+harakat → base letters + marks).
//! 2. **Strip combining marks** — uses `canonical_combining_class` to
//! drop diacritics across all scripts (Polish ą→a, ć→c; Arabic harakat;
//! Hebrew niqqud; combining tone marks; etc.) without needing
//! per-language tables.
//! 3. **Lowercase** — Unicode-aware case folding for cross-alphabet
//! case insensitivity.
//! 4. **NFKC** — re-compose to canonical form (and unify compatibility
//! characters: half/full-width CJK variants, Arabic presentation forms,
//! ligatures) so byte equality lines up at lookup time.
//! 5. **Non-decomposing fold** (`fold_non_decomposing`) — small per-letter
//! table for decorated letters NFKD leaves untouched (Polish ł, German
//! ß, Norwegian ø, Icelandic þ/ð, Latin æ/œ, Turkish ı, Croatian đ,
//! Maltese ħ, Sami ŋ).
use unicode_normalization::char::canonical_combining_class;
use unicode_normalization::UnicodeNormalization;
/// Normalize a piece of text for indexing or querying. Idempotent: running
/// the output through `normalize` again yields the same string.
///
/// After the standard NFKD + strip-combining-marks pass we additionally
/// fold a small set of letters that *look* decorated but don't decompose
/// canonically (so NFKD alone leaves them unchanged). Polish ł/Ł is the
/// motivating case — a Polish user typing `lacka` reasonably expects to
/// find `łącka`. Same idea for German ß, Norwegian ø, Icelandic þ/ð and
/// Latin æ.
pub fn normalize(text: &str) -> String {
let stripped: String = text
.nfkd() // decompose so combining marks become standalone code points
.filter(|c| canonical_combining_class(*c) == 0)
.flat_map(char::to_lowercase)
.nfkc() // re-compose to canonical form for downstream byte equality
.collect();
fold_non_decomposing(&stripped)
}
/// Apply per-letter folds for non-decomposing "decorated" letters that
/// NFKD leaves untouched. Run only after lowercase + NFKD so we don't
/// need uppercase entries.
fn fold_non_decomposing(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'ł' => out.push('l'),
'ø' => out.push('o'),
'ß' => out.push_str("ss"),
'æ' => out.push_str("ae"),
'œ' => out.push_str("oe"),
'þ' => out.push_str("th"),
'ð' => out.push('d'),
'đ' => out.push('d'),
'ħ' => out.push('h'),
'ı' => out.push('i'), // Turkish dotless i
'ŋ' => out.push('n'),
other => out.push(other),
}
}
out
}
/// Returns true for code points that should be tokenized as CJK bigrams.
///
/// Covers Han ideographs (CJK Unified + Ext A + Compatibility), Japanese
/// kana (Hiragana, Katakana), and Hangul (Jamo + precomposed syllables).
/// CJK punctuation and symbols (U+3000..=U+303F) are intentionally
/// excluded — they should be treated as token delimiters, not content.
pub fn is_cjk(c: char) -> bool {
matches!(
c as u32,
0x3040..=0x309F // Hiragana
| 0x30A0..=0x30FF // Katakana
| 0x3400..=0x4DBF // CJK Unified Ideographs Extension A
| 0x4E00..=0x9FFF // CJK Unified Ideographs
| 0xF900..=0xFAFF // CJK Compatibility Ideographs
| 0x1100..=0x11FF // Hangul Jamo
| 0xAC00..=0xD7AF // Hangul Syllables
)
}
/// Tokenize already-normalized text into character n-grams as borrowed
/// slices into `normalized`.
///
/// Returning `&str` (instead of owned `String`s) keeps the hot search
/// path allocation-free: query-time ngram extraction only needs to look
/// up posting-list keys, never to insert. On the insert side, the index
/// allocates a fresh key only when an ngram is brand-new to the corpus —
/// see `InvertedIndex::insert`.
///
/// - CJK runs (≥2 chars) → bigrams.
/// - Non-CJK runs (≥3 chars) → trigrams.
/// - Runs shorter than the relevant n are dropped (they cannot be
/// substring-matched against any document containing them anyway, so
/// the Phase 2 verification will catch them via the linear fallback in
/// `InvertedIndex::search`).
///
/// Word boundaries inside a run do NOT split the n-gram window — we
/// deliberately want substring matches that span punctuation.
pub fn ngrams(normalized: &str) -> Vec<&str> {
let mut out = Vec::new();
// Capture (byte_offset, is_cjk) per char. Byte offsets let us slice
// `normalized` directly to return `&str` views; the cjk flag drives
// the script-class run partitioning below.
let chars: Vec<(usize, bool)> = normalized
.char_indices()
.map(|(b, c)| (b, is_cjk(c)))
.collect();
if chars.is_empty() {
return out;
}
let end_byte = normalized.len();
// Walk contiguous runs of "same script class" (CJK vs non-CJK) and
// emit the appropriate n-gram size for each run.
let mut i = 0;
while i < chars.len() {
let cjk = chars[i].1;
let mut j = i + 1;
while j < chars.len() && chars[j].1 == cjk {
j += 1;
}
let n = if cjk { 2 } else { 3 };
if j - i >= n {
for k in i..=j - n {
let start = chars[k].0;
let end = if k + n < chars.len() {
chars[k + n].0
} else {
end_byte
};
out.push(&normalized[start..end]);
}
}
i = j;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_lowercases_ascii() {
assert_eq!(normalize("Hello World"), "hello world");
}
#[test]
fn normalize_strips_polish_diacritics() {
// Polish: Kraków, żółć, łąka, plus a Spanish parity case.
// ł/Ł are *not* canonically decomposable (no combining ogonek
// form) so we fold them manually via `fold_non_decomposing`.
assert_eq!(normalize("Kraków"), "krakow");
assert_eq!(normalize("żółć"), "zolc");
assert_eq!(normalize("łąka"), "laka");
assert_eq!(normalize("Mañana"), "manana");
}
#[test]
fn normalize_folds_non_decomposing_letters() {
// Letters that NFKD leaves untouched but a user typing without
// the diacritic would still expect to find.
assert_eq!(normalize("Łódź"), "lodz");
assert_eq!(normalize("Straße"), "strasse");
assert_eq!(normalize("Bjørn"), "bjorn");
assert_eq!(normalize("Þórr"), "thorr");
}
#[test]
fn normalize_strips_arabic_harakat() {
// Arabic: word "kataba" (he wrote) with harakat marks vs without
let with_marks = "كَتَبَ";
let without_marks = "كتب";
assert_eq!(normalize(with_marks), normalize(without_marks));
}
#[test]
fn normalize_unifies_cjk_halfwidth_fullwidth() {
// NFKC maps half-width katakana to full-width.
let halfwidth = "カタカナ"; // half-width
let fullwidth = "カタカナ"; // full-width
assert_eq!(normalize(halfwidth), normalize(fullwidth));
}
#[test]
fn normalize_is_idempotent() {
let s = "Café — 東京 — żółć";
let once = normalize(s);
let twice = normalize(&once);
assert_eq!(once, twice);
}
#[test]
fn ngrams_emits_trigrams_for_latin() {
let g = ngrams("kitten");
assert_eq!(g, vec!["kit", "itt", "tte", "ten"]);
}
#[test]
fn ngrams_emits_bigrams_for_cjk() {
// 日本語 → 日本, 本語
let g = ngrams("日本語");
assert_eq!(g, vec!["日本", "本語"]);
}
#[test]
fn ngrams_mixed_script_splits_at_boundary() {
// "東京tokyo" → CJK run [東京] gives bigram "東京",
// Latin run [tokyo] gives trigrams tok, oky, kyo.
let g = ngrams("東京tokyo");
assert_eq!(g, vec!["東京", "tok", "oky", "kyo"]);
}
#[test]
fn ngrams_drops_runs_too_short() {
// "ab東" → Latin run [ab] is only 2 chars → dropped; CJK run [東]
// is only 1 char → dropped. Empty result.
let g = ngrams("ab東");
assert!(g.is_empty(), "got {:?}", g);
}
#[test]
fn ngrams_substring_inside_word_is_indexable() {
// After normalize, "Concatenate" → "concatenate" → trigrams include
// "cat". This is the canonical substring-inside-word scenario that
// motivates the character-n-gram scheme over word tokenization.
let normalized = normalize("Concatenate");
let g = ngrams(&normalized);
assert!(g.contains(&"cat"), "trigrams: {:?}", g);
}
#[test]
fn ngrams_empty_input_returns_empty() {
assert!(ngrams("").is_empty());
}
#[test]
fn is_cjk_classifies_common_scripts() {
assert!(is_cjk('東'));
assert!(is_cjk('あ')); // hiragana
assert!(is_cjk('カ')); // katakana
assert!(is_cjk('한')); // hangul syllable
assert!(!is_cjk('a'));
assert!(!is_cjk('ą'));
assert!(!is_cjk('')); // CJK punctuation — intentionally NOT cjk
}
}
-143
View File
@@ -1,143 +0,0 @@
//! Wire/storage types for the workspace-backed conversation store: threads,
//! messages, create requests, and partial-update patches.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// A persisted conversation thread, mirroring one entry in `threads.jsonl`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ConversationThread {
pub id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chat_id: Option<i64>,
pub is_active: bool,
pub message_count: usize,
pub last_message_at: String,
pub created_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_thread_id: Option<String>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub personality_id: Option<String>,
}
/// A single message appended to a thread's JSONL log.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ConversationMessage {
pub id: String,
pub content: String,
#[serde(rename = "type")]
pub message_type: String,
#[serde(default)]
pub extra_metadata: Value,
pub sender: String,
pub created_at: String,
}
/// Input payload to create-or-update a thread via [`super::ensure_thread`].
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateConversationThread {
pub id: String,
pub title: String,
pub created_at: String,
#[serde(default)]
pub parent_thread_id: Option<String>,
#[serde(default)]
pub labels: Option<Vec<String>>,
#[serde(default)]
pub personality_id: Option<String>,
}
/// Partial update to apply to a stored message (e.g. rewriting `extraMetadata`).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ConversationMessagePatch {
#[serde(default)]
pub extra_metadata: Option<Value>,
}
/// A single match returned by
/// [`super::store::ConversationStore::search_cross_thread_messages`]. Carries
/// the source `thread_id` so the caller can render provenance into the
/// `[Cross-chat context]` block (issue #1505).
#[derive(Debug, Clone, PartialEq)]
pub struct CrossThreadHit {
pub thread_id: String,
pub message_id: String,
pub role: String,
pub content: String,
pub created_at: String,
pub score: f64,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn conversation_thread_serde_uses_camel_case_and_defaults_labels() {
let raw = json!({
"id": "thread-1",
"title": "Memory",
"chatId": 42,
"isActive": true,
"messageCount": 3,
"lastMessageAt": "2026-05-24T08:00:00Z",
"createdAt": "2026-05-24T07:00:00Z",
"parentThreadId": "parent-1"
});
let thread: ConversationThread = serde_json::from_value(raw).unwrap();
assert_eq!(thread.chat_id, Some(42));
assert_eq!(thread.parent_thread_id.as_deref(), Some("parent-1"));
assert!(thread.labels.is_empty(), "labels should default to []");
let encoded = serde_json::to_value(&thread).unwrap();
assert_eq!(encoded["chatId"], json!(42));
assert_eq!(encoded["parentThreadId"], json!("parent-1"));
assert!(encoded.get("chat_id").is_none());
assert!(encoded.get("parent_thread_id").is_none());
}
#[test]
fn conversation_message_patch_defaults_to_no_changes() {
let patch: ConversationMessagePatch = serde_json::from_value(json!({})).unwrap();
assert!(patch.extra_metadata.is_none());
let patch_with_metadata: ConversationMessagePatch =
serde_json::from_value(json!({"extraMetadata": {"source": "mock"}})).unwrap();
assert_eq!(
patch_with_metadata.extra_metadata,
Some(json!({"source": "mock"}))
);
}
#[test]
fn create_thread_optional_fields_roundtrip() {
let create = CreateConversationThread {
id: "thread-2".into(),
title: "Thread".into(),
created_at: "2026-05-24T08:00:00Z".into(),
parent_thread_id: None,
labels: Some(vec!["important".into(), "memory".into()]),
personality_id: None,
};
let encoded = serde_json::to_value(&create).unwrap();
assert_eq!(encoded["labels"], json!(["important", "memory"]));
assert_eq!(encoded["parentThreadId"], Value::Null);
let decoded: CreateConversationThread = serde_json::from_value(encoded).unwrap();
assert_eq!(
decoded.labels,
Some(vec!["important".to_string(), "memory".to_string()])
);
assert!(decoded.parent_thread_id.is_none());
}
}