mirror of
https://github.com/Dicklesworthstone/coding_agent_session_search.git
synced 2026-09-22 11:59:25 +08:00
607a9a1133
Carry the reviewed session-scope implementation as an ordinary source patch and apply it once on a GitHub runner to avoid retranscribing the nearly 1 MB query module through the contents API. The workflow verifies exact source and patch blobs, rejects existing module/dirty checkout, stages only three source paths, and pushes a normal fast-forward commit directly to main. It does not open a PR, force-push, change branch protection, or alter existing CI/release workflows. The patch resolves bounded authoritative message IDs through the existing read snapshot before vector top-k, intersects all metadata predicates, uses exact retained owners for scoped ANN requests, and suppresses unused ANN/two-tier setup. Nine authored tests cover candidate starvation, metadata and source restrictions, malformed/negative IDs, literal Unicode paths, bounded collection, rollback, and multi-shard merge. Correct the old patch's mismatched io::Error/FrankenError row mapper. Local validation: git apply on synthetic contexts produces the exact reviewed edits and new module; whitespace and test-count checks pass. No full checkout application or Rust compilation/tests/Clippy/rustfmt/UBS/RCH execution has occurred in this editing environment. The workflow is transport, not runtime validation.
614 lines
25 KiB
Diff
614 lines
25 KiB
Diff
diff --git a/src/search/query.rs b/src/search/query.rs
|
|
--- a/src/search/query.rs
|
|
+++ b/src/search/query.rs
|
|
@@ -726,3 +726,9 @@
|
|
+mod session_scope;
|
|
+use session_scope::{
|
|
+ SEMANTIC_SESSION_SCOPE_MAX_MESSAGES, SessionScopedSemanticFilter,
|
|
+ load_semantic_session_message_ids,
|
|
+};
|
|
+
|
|
const PROGRESSIVE_EMBEDDING_CACHE_CAPACITY: usize = 64;
|
|
const ANN_CANDIDATE_MULTIPLIER: usize = 4;
|
|
const HYBRID_NO_LIMIT_PLANNING_WINDOW: usize = 64;
|
|
@@ -5051,4 +5057,45 @@
|
|
semantic_filter = semantic_filter.with_roles(Some(roles));
|
|
}
|
|
|
|
+ if !filters.session_paths.is_empty() {
|
|
+ let message_ids = {
|
|
+ let sqlite_guard = self.sqlite_guard()?;
|
|
+ let conn = sqlite_guard.as_ref().ok_or_else(|| {
|
|
+ anyhow!("session-scoped semantic search requires database connection")
|
|
+ })?;
|
|
+ load_semantic_session_message_ids(
|
|
+ conn,
|
|
+ &filters.session_paths,
|
|
+ SEMANTIC_SESSION_SCOPE_MAX_MESSAGES,
|
|
+ )?
|
|
+ };
|
|
+ // No selected archive messages means no matching vector records.
|
|
+ // In particular, never interpret an empty allowlist as unrestricted.
|
|
+ if message_ids.is_empty() {
|
|
+ return Ok((
|
|
+ Vec::new(),
|
|
+ SemanticCandidateRetryState {
|
|
+ has_more_candidates: false,
|
|
+ exact_window_may_omit_competitor: false,
|
|
+ },
|
|
+ None,
|
|
+ ));
|
|
+ }
|
|
+ let scoped_filter = SessionScopedSemanticFilter {
|
|
+ metadata: &semantic_filter,
|
|
+ message_ids: &message_ids,
|
|
+ };
|
|
+ // The native ANN lane selects a global window before filtering.
|
|
+ // It cannot guarantee recall for a narrow session selection; use
|
|
+ // the retained exact owners with the filter inside top-k instead.
|
|
+ // None here means no ANN statistics: this was an exact search.
|
|
+ let (results, retry_state) = Self::search_exact_semantic_indexes(
|
|
+ context,
|
|
+ embedding,
|
|
+ request.fetch_limit,
|
|
+ Some(&scoped_filter),
|
|
+ )?;
|
|
+ return Ok((results, retry_state, None));
|
|
+ }
|
|
+
|
|
if request.tier_mode.wants_two_tier() && !request.approximate {
|
|
@@ -6326,9 +6373,18 @@
|
|
- let in_memory_two_tier_index = if tier_mode.wants_two_tier() && !approximate {
|
|
+ let in_memory_two_tier_index = if tier_mode.wants_two_tier()
|
|
+ && !approximate
|
|
+ && filters.session_paths.is_empty()
|
|
+ {
|
|
self.in_memory_two_tier_index(tier_mode)?
|
|
} else {
|
|
None
|
|
};
|
|
- let ann_index = if approximate { self.ann_index()? } else { None };
|
|
+ // Session-scoped selection uses exact filtered vectors. Do not
|
|
+ // load the global ANN graph for a query that cannot use it.
|
|
+ let ann_index = if approximate && filters.session_paths.is_empty() {
|
|
+ self.ann_index()?
|
|
+ } else {
|
|
+ None
|
|
+ };
|
|
let effective_approximate = approximate && ann_index.is_some();
|
|
let effective_tier_mode = if approximate {
|
|
SemanticTierMode::Single
|
|
@@ -6990,3 +7046,4 @@
|
|
+ let session_scoped = !filters.session_paths.is_empty();
|
|
let (semantic_hits, semantic_ann_stats) = self.search_semantic_with_tier(
|
|
semantic_query,
|
|
filters,
|
|
@@ -7004,4 +7061,6 @@
|
|
- let semantic_ann_unavailable_reason = if approximate {
|
|
+ let semantic_ann_unavailable_reason = if approximate && session_scoped {
|
|
+ Some(SemanticAnnUnavailableReason::SessionScopeRequiresExact)
|
|
+ } else if approximate {
|
|
self.ann_unavailability_reason()?
|
|
} else {
|
|
None
|
|
diff --git a/src/search/vector_index.rs b/src/search/vector_index.rs
|
|
--- a/src/search/vector_index.rs
|
|
+++ b/src/search/vector_index.rs
|
|
@@ -133,4 +133,7 @@
|
|
pub enum SemanticAnnUnavailableReason {
|
|
+ /// Global ANN candidate selection cannot enforce an authoritative session
|
|
+ /// allowlist before top-k. This query uses the filtered exact FSVI reader.
|
|
+ SessionScopeRequiresExact,
|
|
/// More than one exact shard is active, but no sharded ANN topology was
|
|
/// explicitly selected.
|
|
MultipleExactShards,
|
|
@@ -150,6 +153,7 @@
|
|
#[must_use]
|
|
pub const fn code(self) -> &'static str {
|
|
match self {
|
|
+ Self::SessionScopeRequiresExact => "session_scope_requires_exact",
|
|
Self::MultipleExactShards => "multiple_exact_shards",
|
|
Self::SidecarMissing => "ann_sidecar_missing",
|
|
Self::SidecarOpenFailed => "ann_sidecar_open_failed",
|
|
diff --git a/src/search/query/session_scope.rs b/src/search/query/session_scope.rs
|
|
new file mode 100644
|
|
--- /dev/null
|
|
+++ b/src/search/query/session_scope.rs
|
|
@@ -0,0 +1,496 @@
|
|
+//! Session membership is enforced before vector top-k selection. The archive
|
|
+//! connection and its read-snapshot guard stay owned by the parent search
|
|
+//! client; this module never opens another database or reads message bodies.
|
|
+
|
|
+use super::*;
|
|
+
|
|
+// Session scope must constrain vector selection, not just the hydrated page.
|
|
+// Otherwise unrelated sessions can consume both bounded candidate windows and
|
|
+// turn a valid chained search into an empty result.
|
|
+const SEMANTIC_SESSION_SCOPE_MAX_PATHS: usize = 1_024;
|
|
+const SEMANTIC_SESSION_SCOPE_MAX_CONVERSATIONS: usize = 4_096;
|
|
+pub(super) const SEMANTIC_SESSION_SCOPE_MAX_MESSAGES: usize = 65_536;
|
|
+const SEMANTIC_SESSION_SCOPE_PATH_BATCH: usize = 64;
|
|
+
|
|
+/// Intersect authoritative session membership with the existing vector filters.
|
|
+/// This wrapper deliberately does not widen agent, source, role, or time scope.
|
|
+pub(super) struct SessionScopedSemanticFilter<'a> {
|
|
+ pub(super) metadata: &'a SemanticFilter,
|
|
+ pub(super) message_ids: &'a HashSet<u64>,
|
|
+}
|
|
+
|
|
+impl FsSearchFilter for SessionScopedSemanticFilter<'_> {
|
|
+ fn matches(&self, doc_id: &str, metadata: Option<&serde_json::Value>) -> bool {
|
|
+ // Reject most unrelated records without decoding the optional content
|
|
+ // hash. The existing filter still validates the remaining ID fields.
|
|
+ let message_id = doc_id
|
|
+ .strip_prefix("m|")
|
|
+ .and_then(|rest| rest.split_once('|'))
|
|
+ .and_then(|(message_id, rest)| {
|
|
+ // An invalid chunk would be dropped during hydration; reject
|
|
+ // it before it can consume a valid candidate's top-k slot.
|
|
+ let (chunk, _) = rest.split_once('|')?;
|
|
+ chunk.parse::<u8>().ok()?;
|
|
+ message_id.parse::<u64>().ok()
|
|
+ });
|
|
+ message_id.is_some_and(|id| self.message_ids.contains(&id))
|
|
+ && self.metadata.matches(doc_id, metadata)
|
|
+ }
|
|
+
|
|
+ fn matches_doc_id_hash(
|
|
+ &self,
|
|
+ _doc_id_hash: u64,
|
|
+ _metadata: Option<&serde_json::Value>,
|
|
+ ) -> Option<bool> {
|
|
+ // A hash does not prove membership in the selected archive sessions.
|
|
+ None
|
|
+ }
|
|
+
|
|
+ fn name(&self) -> &str {
|
|
+ "cass_session_scoped_semantic_filter"
|
|
+ }
|
|
+}
|
|
+
|
|
+/// Read IDs only, in one archive snapshot, without hydrating session contents.
|
|
+/// All limits fail explicitly; a truncated allowlist must never look complete.
|
|
+/// Row/allocation bounds are not a deadline for an individual engine query.
|
|
+pub(super) fn load_semantic_session_message_ids(
|
|
+ conn: &SearchSqliteConnection,
|
|
+ session_paths: &HashSet<String>,
|
|
+ max_messages: usize,
|
|
+) -> Result<HashSet<u64>> {
|
|
+ if session_paths.len() > SEMANTIC_SESSION_SCOPE_MAX_PATHS {
|
|
+ bail!(
|
|
+ "semantic session scope exceeds {} paths; narrow the session selection or use lexical search",
|
|
+ SEMANTIC_SESSION_SCOPE_MAX_PATHS
|
|
+ );
|
|
+ }
|
|
+ if session_paths.is_empty() {
|
|
+ return Ok(HashSet::new());
|
|
+ }
|
|
+ let max_messages = max_messages.min(SEMANTIC_SESSION_SCOPE_MAX_MESSAGES);
|
|
+ let mut paths = session_paths.iter().map(String::as_str).collect::<Vec<_>>();
|
|
+ paths.sort_unstable();
|
|
+ let mut transaction = SearchReadTransaction::begin(conn)?;
|
|
+ let mut conversation_ids = HashSet::new();
|
|
+ for chunk in paths.chunks(SEMANTIC_SESSION_SCOPE_PATH_BATCH) {
|
|
+ let remaining = SEMANTIC_SESSION_SCOPE_MAX_CONVERSATIONS - conversation_ids.len();
|
|
+ // Exact, case-sensitive path equality is the same contract used by
|
|
+ // postprocess_hits_page_core. Path text is always parameterized.
|
|
+ let sql = format!(
|
|
+ "SELECT id FROM conversations WHERE source_path COLLATE BINARY IN ({}) LIMIT {}",
|
|
+ sql_placeholders(chunk.len()),
|
|
+ remaining + 1
|
|
+ );
|
|
+ let params = chunk
|
|
+ .iter()
|
|
+ .map(|path| ParamValue::from(*path))
|
|
+ .collect::<Vec<_>>();
|
|
+ let rows: Vec<i64> = transaction.query_map_collect(&sql, ¶ms, |row| row.get_typed(0))?;
|
|
+ conversation_ids.extend(rows);
|
|
+ if conversation_ids.len() > SEMANTIC_SESSION_SCOPE_MAX_CONVERSATIONS {
|
|
+ bail!(
|
|
+ "semantic session scope exceeds {} conversations; narrow the session selection or use lexical search",
|
|
+ SEMANTIC_SESSION_SCOPE_MAX_CONVERSATIONS
|
|
+ );
|
|
+ }
|
|
+ }
|
|
+
|
|
+ let mut conversation_ids = conversation_ids.into_iter().collect::<Vec<_>>();
|
|
+ conversation_ids.sort_unstable();
|
|
+ let mut message_ids = HashSet::new();
|
|
+ for conversation_id in conversation_ids {
|
|
+ let remaining = max_messages - message_ids.len();
|
|
+ // A single equality uses the existing conversation-message lookup
|
|
+ // rather than materializing every archive message or its contents.
|
|
+ let sql = format!(
|
|
+ "SELECT id FROM messages WHERE conversation_id = ? ORDER BY id LIMIT {}",
|
|
+ remaining + 1
|
|
+ );
|
|
+ let rows: Vec<i64> = transaction.query_map_collect(
|
|
+ &sql,
|
|
+ &[ParamValue::from(conversation_id)],
|
|
+ |row| row.get_typed(0),
|
|
+ )?;
|
|
+ // The SQL mapper returns FrankenError; ID range validation returns
|
|
+ // io::Error. Convert outside the mapper through this function's
|
|
+ // anyhow::Result rather than mixing the two error types.
|
|
+ for message_id in rows {
|
|
+ message_ids.insert(semantic_message_id_from_db(message_id)?);
|
|
+ }
|
|
+ if message_ids.len() > max_messages {
|
|
+ bail!(
|
|
+ "semantic session scope exceeds {max_messages} messages; narrow the session selection or use lexical search"
|
|
+ );
|
|
+ }
|
|
+ }
|
|
+ transaction.rollback()?;
|
|
+ Ok(message_ids)
|
|
+}
|
|
+
|
|
+#[cfg(test)]
|
|
+mod tests {
|
|
+ use super::*;
|
|
+ use crate::search::vector_index::{Quantization, SemanticDocId};
|
|
+
|
|
+ fn archive() -> SearchSqliteFixture {
|
|
+ let conn = SearchSqliteFixture::in_memory().expect("open scope archive");
|
|
+ conn.execute_batch(
|
|
+ "CREATE TABLE conversations (id INTEGER PRIMARY KEY, source_path TEXT NOT NULL);
|
|
+ CREATE TABLE messages (id INTEGER PRIMARY KEY, conversation_id INTEGER NOT NULL);
|
|
+ CREATE INDEX scope_conversation_path ON conversations(source_path);
|
|
+ CREATE INDEX scope_message_conversation ON messages(conversation_id);",
|
|
+ )
|
|
+ .expect("scope schema");
|
|
+ conn
|
|
+ }
|
|
+
|
|
+ fn insert_conversation(conn: &SearchSqliteFixture, id: i64, path: &str) {
|
|
+ conn.execute_compat(
|
|
+ "INSERT INTO conversations (id, source_path) VALUES (?, ?)",
|
|
+ &[ParamValue::from(id), ParamValue::from(path)],
|
|
+ )
|
|
+ .expect("insert scope conversation");
|
|
+ }
|
|
+
|
|
+ fn insert_message(conn: &SearchSqliteFixture, id: i64, conversation: i64) {
|
|
+ conn.execute_compat(
|
|
+ "INSERT INTO messages (id, conversation_id) VALUES (?, ?)",
|
|
+ &[ParamValue::from(id), ParamValue::from(conversation)],
|
|
+ )
|
|
+ .expect("insert scope message");
|
|
+ }
|
|
+
|
|
+ fn client(conn: SearchSqliteFixture) -> SearchClient {
|
|
+ // Exercise the actual dedicated-owner connection and candidate method;
|
|
+ // no lexical assets, model inference, or provider scans are needed.
|
|
+ SearchClient {
|
|
+ reader: None,
|
|
+ sqlite: Mutex::new(Some(conn.into_connection())),
|
|
+ sqlite_path: None,
|
|
+ strict_read_only: true,
|
|
+ prefix_cache: Mutex::new(CacheShards::new(128, 1024 * 1024)),
|
|
+ reload_on_search: false,
|
|
+ last_reload: Mutex::new(None),
|
|
+ last_generation: Mutex::new(None),
|
|
+ reload_epoch: Arc::new(AtomicU64::new(0)),
|
|
+ warm_tx: None,
|
|
+ _warm_handle: None,
|
|
+ metrics: Metrics::default(),
|
|
+ cache_namespace: format!(
|
|
+ "semantic-scope-test:{}",
|
|
+ SEARCH_CLIENT_INSTANCE_COUNTER.fetch_add(1, Ordering::Relaxed)
|
|
+ ),
|
|
+ semantic: Mutex::new(None),
|
|
+ last_tantivy_total_count: Mutex::new(None),
|
|
+ last_lexical_degrade_reason: Mutex::new(None),
|
|
+ }
|
|
+ }
|
|
+
|
|
+ fn id(message: u64, source: u32) -> String {
|
|
+ SemanticDocId {
|
|
+ message_id: message,
|
|
+ chunk_idx: 0,
|
|
+ agent_id: 1,
|
|
+ workspace_id: 2,
|
|
+ source_id: source,
|
|
+ role: 1,
|
|
+ created_at_ms: 100,
|
|
+ content_hash: None,
|
|
+ }
|
|
+ .to_doc_id_string()
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn scope_membership_intersects_every_existing_metadata_constraint() {
|
|
+ let base = SemanticFilter {
|
|
+ agents: Some(HashSet::from([1])),
|
|
+ workspaces: Some(HashSet::from([2])),
|
|
+ sources: Some(HashSet::from([3])),
|
|
+ roles: Some(HashSet::from([1])),
|
|
+ created_from: Some(100),
|
|
+ created_to: Some(100),
|
|
+ };
|
|
+ let message_ids = HashSet::from([42]);
|
|
+ let filter = SessionScopedSemanticFilter {
|
|
+ metadata: &base,
|
|
+ message_ids: &message_ids,
|
|
+ };
|
|
+ assert!(filter.matches(&id(42, 3), None));
|
|
+ for rejected in [
|
|
+ "m|43|0|1|2|3|1|100",
|
|
+ "m|42|0|9|2|3|1|100",
|
|
+ "m|42|0|1|9|3|1|100",
|
|
+ "m|42|0|1|2|9|1|100",
|
|
+ "m|42|0|1|2|3|0|100",
|
|
+ "m|42|0|1|2|3|1|99",
|
|
+ "m|42|0|1|2|3|1|101",
|
|
+ "m|-1|0|1|2|3|1|100",
|
|
+ "m|18446744073709551616|0|1|2|3|1|100",
|
|
+ "m|42|garbled",
|
|
+ "m|42|256|1|2|3|1|100",
|
|
+ "m|42|bad|1|2|3|1|100",
|
|
+ "42",
|
|
+ ] {
|
|
+ assert!(!filter.matches(rejected, None), "admitted {rejected}");
|
|
+ }
|
|
+ assert_eq!(filter.matches_doc_id_hash(42, None), None);
|
|
+ let empty = HashSet::new();
|
|
+ let empty_filter = SessionScopedSemanticFilter {
|
|
+ metadata: &base,
|
|
+ message_ids: &empty,
|
|
+ };
|
|
+ assert!(!empty_filter.matches(&id(42, 3), None));
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn session_path_lookup_is_literal_case_sensitive_and_retains_distinct_conversations() {
|
|
+ let conn = archive();
|
|
+ let path = "/private/δ/one' OR 1=1 -- %_.jsonl";
|
|
+ insert_conversation(&conn, 1, path);
|
|
+ insert_conversation(&conn, 2, path);
|
|
+ insert_conversation(&conn, 3, "/private/δ/ONE' OR 1=1 -- %_.jsonl");
|
|
+ insert_conversation(&conn, 4, "/unrelated.jsonl");
|
|
+ for n in 1..=4 {
|
|
+ insert_message(&conn, n, n);
|
|
+ }
|
|
+ let selected = HashSet::from([path.to_string()]);
|
|
+ assert_eq!(
|
|
+ load_semantic_session_message_ids(conn.connection(), &selected, 10).unwrap(),
|
|
+ HashSet::from([1, 2])
|
|
+ );
|
|
+ assert!(
|
|
+ load_semantic_session_message_ids(
|
|
+ conn.connection(),
|
|
+ &HashSet::from(["/absent".into()]),
|
|
+ 10,
|
|
+ )
|
|
+ .unwrap()
|
|
+ .is_empty()
|
|
+ );
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn path_batches_preserve_every_selected_message() {
|
|
+ let conn = archive();
|
|
+ let mut paths = HashSet::new();
|
|
+ for n in 1..=70_i64 {
|
|
+ let path = format!("/scope/{n}.jsonl");
|
|
+ insert_conversation(&conn, n, &path);
|
|
+ insert_message(&conn, n, n);
|
|
+ paths.insert(path);
|
|
+ }
|
|
+ let ids = load_semantic_session_message_ids(conn.connection(), &paths, 70).unwrap();
|
|
+ assert_eq!(ids, (1..=70_u64).collect());
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn an_exceeded_message_limit_fails_without_a_partial_allowlist_or_open_transaction() {
|
|
+ let conn = archive();
|
|
+ insert_conversation(&conn, 1, "/scope");
|
|
+ insert_message(&conn, 1, 1);
|
|
+ insert_message(&conn, 2, 1);
|
|
+ let paths = HashSet::from(["/scope".into()]);
|
|
+ let error = load_semantic_session_message_ids(conn.connection(), &paths, 1).unwrap_err();
|
|
+ assert!(error.to_string().contains("exceeds 1 messages"));
|
|
+ // A fresh transaction must work after the early-error rollback.
|
|
+ assert_eq!(
|
|
+ load_semantic_session_message_ids(conn.connection(), &paths, 2).unwrap(),
|
|
+ HashSet::from([1, 2])
|
|
+ );
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn an_exceeded_path_limit_fails_before_querying_the_archive() {
|
|
+ let conn = SearchSqliteFixture::in_memory().unwrap(); // deliberately no schema
|
|
+ let paths = (0..=SEMANTIC_SESSION_SCOPE_MAX_PATHS)
|
|
+ .map(|n| format!("/scope/{n}"))
|
|
+ .collect();
|
|
+ let error = load_semantic_session_message_ids(conn.connection(), &paths, 1).unwrap_err();
|
|
+ assert!(error.to_string().contains("exceeds 1024 paths"));
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn scoped_candidate_search_recovers_matches_below_the_global_window() {
|
|
+ let conn = archive();
|
|
+ insert_conversation(&conn, 1, "/unrelated");
|
|
+ insert_conversation(&conn, 2, "/selected");
|
|
+ let dir = tempfile::tempdir().unwrap();
|
|
+ let path = dir.path().join("selected-generation.fsvi");
|
|
+ let mut writer = FsVectorIndex::create_with_revision(
|
|
+ &path,
|
|
+ "fnv1a-2",
|
|
+ "scope-test",
|
|
+ 2,
|
|
+ Quantization::F16,
|
|
+ )
|
|
+ .unwrap();
|
|
+ for n in 1..=128_i64 {
|
|
+ insert_message(&conn, n, 1);
|
|
+ writer.write_record(&id(n as u64, 3), &[1.0, 0.0]).unwrap();
|
|
+ }
|
|
+ insert_message(&conn, 129, 2);
|
|
+ insert_message(&conn, 130, 2);
|
|
+ writer.write_record(&id(129, 3), &[0.6, 0.8]).unwrap();
|
|
+ writer.write_record(&id(130, 4), &[0.3, 0.9539392]).unwrap();
|
|
+ writer.finish().unwrap();
|
|
+ let before = std::fs::read(&path).unwrap();
|
|
+ let artifact = SemanticIndexArtifact::open(&path, None).unwrap();
|
|
+ let global = artifact
|
|
+ .index()
|
|
+ .search_top_k(&[1.0, 0.0], 24, None)
|
|
+ .unwrap();
|
|
+ assert!(global.iter().all(|hit| {
|
|
+ parse_semantic_doc_id(&hit.doc_id).unwrap().message_id < 129
|
|
+ }));
|
|
+ let context = SemanticCandidateContext {
|
|
+ artifacts: Arc::new(vec![artifact]),
|
|
+ filter_maps: SemanticFilterMaps::for_tests(
|
|
+ HashMap::new(),
|
|
+ HashMap::new(),
|
|
+ HashMap::from([("remote".into(), 3), ("local".into(), 4)]),
|
|
+ HashSet::from([3]),
|
|
+ ),
|
|
+ roles: None,
|
|
+ };
|
|
+ let client = client(conn);
|
|
+ let filters = SearchFilters {
|
|
+ session_paths: HashSet::from(["/selected".into()]),
|
|
+ ..Default::default()
|
|
+ };
|
|
+ for approximate in [false, true] {
|
|
+ let (hits, retry, stats) = client.search_semantic_candidates(
|
|
+ &context,
|
|
+ &[1.0, 0.0],
|
|
+ &filters,
|
|
+ SemanticCandidateSearchRequest {
|
|
+ fetch_limit: 2,
|
|
+ approximate,
|
|
+ tier_mode: SemanticTierMode::Single,
|
|
+ in_memory_two_tier_index: None,
|
|
+ ann_index: None,
|
|
+ },
|
|
+ )
|
|
+ .unwrap();
|
|
+ assert_eq!(
|
|
+ hits.iter().map(|hit| hit.message_id).collect::<Vec<_>>(),
|
|
+ vec![129, 130]
|
|
+ );
|
|
+ assert!(!retry.has_more_candidates);
|
|
+ assert!(!retry.exact_window_may_omit_competitor);
|
|
+ assert!(
|
|
+ stats.is_none(),
|
|
+ "exact scope fallback must not claim ANN execution"
|
|
+ );
|
|
+ }
|
|
+ let remote_only = SearchFilters {
|
|
+ source_filter: SourceFilter::SourceId("remote".into()),
|
|
+ ..filters
|
|
+ };
|
|
+ let (hits, _, _) = client.search_semantic_candidates(
|
|
+ &context,
|
|
+ &[1.0, 0.0],
|
|
+ &remote_only,
|
|
+ SemanticCandidateSearchRequest {
|
|
+ fetch_limit: 2,
|
|
+ approximate: false,
|
|
+ tier_mode: SemanticTierMode::Single,
|
|
+ in_memory_two_tier_index: None,
|
|
+ ann_index: None,
|
|
+ },
|
|
+ )
|
|
+ .unwrap();
|
|
+ assert_eq!(
|
|
+ hits.iter().map(|hit| hit.message_id).collect::<Vec<_>>(),
|
|
+ vec![129]
|
|
+ );
|
|
+ assert_eq!(std::fs::read(&path).unwrap(), before);
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn invalid_archive_id_rejects_scope_and_rolls_back_the_snapshot() {
|
|
+ let conn = archive();
|
|
+ insert_conversation(&conn, 1, "/negative");
|
|
+ insert_message(&conn, -1, 1);
|
|
+ let selected = HashSet::from(["/negative".to_string()]);
|
|
+ let error = load_semantic_session_message_ids(conn.connection(), &selected, 10).unwrap_err();
|
|
+ assert!(error.to_string().contains("negative message_id"));
|
|
+ // A failed conversion must leave no transaction behind. A separate
|
|
+ // missing session still has a complete, empty membership snapshot.
|
|
+ assert!(load_semantic_session_message_ids(
|
|
+ conn.connection(), &HashSet::from(["/missing".into()]), 10,
|
|
+ ).unwrap().is_empty());
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn zero_message_budget_accepts_empty_sessions_but_not_a_partial_scope() {
|
|
+ let conn = archive();
|
|
+ insert_conversation(&conn, 1, "/empty");
|
|
+ insert_conversation(&conn, 2, "/one-message");
|
|
+ insert_message(&conn, 1, 2);
|
|
+ let empty = HashSet::from(["/empty".to_string()]);
|
|
+ assert!(load_semantic_session_message_ids(conn.connection(), &empty, 0)
|
|
+ .unwrap().is_empty());
|
|
+ let populated = HashSet::from(["/one-message".to_string()]);
|
|
+ let error = load_semantic_session_message_ids(conn.connection(), &populated, 0).unwrap_err();
|
|
+ assert!(error.to_string().contains("exceeds 0 messages"));
|
|
+ assert_eq!(
|
|
+ load_semantic_session_message_ids(conn.connection(), &populated, 1).unwrap(),
|
|
+ HashSet::from([1]),
|
|
+ );
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn scoped_exact_search_merges_shards_and_missing_session_never_broadens() {
|
|
+ let conn = archive();
|
|
+ insert_conversation(&conn, 1, "/selected");
|
|
+ insert_conversation(&conn, 2, "/unrelated");
|
|
+ insert_message(&conn, 1, 1);
|
|
+ insert_message(&conn, 2, 1);
|
|
+ insert_message(&conn, 3, 2);
|
|
+ let dir = tempfile::tempdir().unwrap();
|
|
+ let mut artifacts = Vec::new();
|
|
+ for (ordinal, selected_id, vector) in [
|
|
+ (0, 1_u64, [0.6, 0.8]),
|
|
+ (1, 2_u64, [0.8, 0.6]),
|
|
+ ] {
|
|
+ let path = dir.path().join(format!("shard-{ordinal}.fsvi"));
|
|
+ let mut writer = FsVectorIndex::create_with_revision(
|
|
+ &path, "fnv1a-2", "scope-shards", 2, Quantization::F16,
|
|
+ ).unwrap();
|
|
+ writer.write_record(&id(3, 3), &[1.0, 0.0]).unwrap();
|
|
+ writer.write_record(&id(selected_id, 3), &vector).unwrap();
|
|
+ writer.finish().unwrap();
|
|
+ artifacts.push(SemanticIndexArtifact::open(&path, None).unwrap());
|
|
+ }
|
|
+ let context = SemanticCandidateContext {
|
|
+ artifacts: Arc::new(artifacts),
|
|
+ filter_maps: SemanticFilterMaps::for_tests(
|
|
+ HashMap::new(), HashMap::new(), HashMap::new(), HashSet::new(),
|
|
+ ),
|
|
+ roles: None,
|
|
+ };
|
|
+ let client = client(conn);
|
|
+ for (path, expected) in [("/selected", vec![2, 1]), ("/missing", vec![])] {
|
|
+ let (hits, retry, stats) = client.search_semantic_candidates(
|
|
+ &context,
|
|
+ &[1.0, 0.0],
|
|
+ &SearchFilters {
|
|
+ session_paths: HashSet::from([path.to_string()]),
|
|
+ ..Default::default()
|
|
+ },
|
|
+ SemanticCandidateSearchRequest {
|
|
+ fetch_limit: 2,
|
|
+ approximate: true,
|
|
+ tier_mode: SemanticTierMode::Single,
|
|
+ in_memory_two_tier_index: None,
|
|
+ ann_index: None,
|
|
+ },
|
|
+ ).unwrap();
|
|
+ assert_eq!(hits.iter().map(|hit| hit.message_id).collect::<Vec<_>>(), expected);
|
|
+ assert!(!retry.has_more_candidates);
|
|
+ assert!(!retry.exact_window_may_omit_competitor);
|
|
+ assert!(stats.is_none());
|
|
+ }
|
|
+ }
|
|
+}
|