diff --git a/CHANGELOG.md b/CHANGELOG.md index c197543fe..13df48a91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Make bounded JSON and SQLite adjacency queries retain the same canonical + edge-ID prefix before heuristic filtering. New store snapshots advertise the + edge-ordered adjacency capability; older sidecars remain valid recovery + inputs but directional store queries fail with an explicit rebuild + instruction instead of returning a backend-dependent truncated subset. + - Refactor universal language metadata around `UniversalEvidenceProducer` and `UniversalEvidencePipeline`. `UniversalCandidate`/`UniversalComplete` are now the clearer lifecycle states `Qualifying`/`Qualified`; the serialized diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 84b4dc7af..d3320f9ed 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -647,6 +647,14 @@ stream graph and database digests through fixed-size buffers and traverse the reachable immutable tree objects with bounded cache and path memory; they do not depend on the whole-JSON reader limit. +Directional adjacency indexes advertise an edge-ID-order capability. Bounded +JSON and store queries select the same canonical edge-ID prefix across the +requested relationship kinds before applying heuristic filtering. A sidecar +without that capability remains available for validation, backup, JSON export, +and recovery, but directional store queries reject it with a rebuild +instruction; they never reinterpret endpoint-ordered keys as edge-ordered +results. + The hard-cut boundary is the sidecar and all disposable indexes. When a physical format is invalid or outside the support window, preserve `graph.json`, run `scripts/rebuild_compass_store.sh`, or explicitly select diff --git a/Cargo.lock b/Cargo.lock index b1443541d..7eee57154 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1043,9 +1043,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", diff --git a/MIGRATION.md b/MIGRATION.md index 789a1ebfd..1c30f9a6d 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -5,6 +5,26 @@ sidecars. Its output root now preserves the familiar flat artifact shape so file-based workflows can transition while Compass's snapshot and store layout remains visible and clearly owned. +## Rebuild SQLite adjacency sidecars + +Store snapshots now declare edge-ID-ordered directional adjacency so bounded +JSON and SQLite queries retain the same canonical edge prefix. Existing +`graph.json` artifacts remain compatible. A SQLite sidecar created before this +capability can still be validated, backed up, and used to recover canonical +JSON, but directional store queries reject it with +`edge_id_ordered_adjacency_unavailable`. + +Preserve `graph.json`, then rebuild the disposable sidecar with: + +```bash +compass update --force --store sqlite +compass store validate compass-out --format json +``` + +Use `--engine json` until the rebuild completes. Do not edit or copy SQLite +tables to add the capability marker; the directional index key order must be +rebuilt from the validated graph. + ## Frontend graph vocabulary Recent pre-release builds can add React-oriented `renders` edges and UI/server diff --git a/crates/compass-graph/src/snapshot.rs b/crates/compass-graph/src/snapshot.rs index 038f2c143..b5a7a5549 100644 --- a/crates/compass-graph/src/snapshot.rs +++ b/crates/compass-graph/src/snapshot.rs @@ -14,8 +14,8 @@ use std::ops::Range; use std::sync::{Arc, Mutex}; use compass_model::code_graph::{ - CODE_GRAPH_SCHEMA_V1, EdgeRecord, FileRecord, GraphDiagnostic, GraphDocument, GraphMetadata, - NodeDetails, NodeKind, NodeRecord, + CODE_GRAPH_SCHEMA_V1, EdgeKind, EdgeRecord, FileRecord, GraphDiagnostic, GraphDocument, + GraphMetadata, NodeDetails, NodeKind, NodeRecord, }; use compass_model::validate_code_graph; use compass_store::{ @@ -38,6 +38,7 @@ pub const IDENTIFIER_SUBWORD_INDEX_CAPABILITY_V1: &str = "__compass_cap_identifi pub const OPERATION_ROLE_TERM_INDEX_CAPABILITY_V1: &str = "__compass_cap_operation_role_terms_v1__"; pub const DECLARATION_TERM_INDEX_CAPABILITY_V1: &str = "__compass_cap_declaration_terms_v1__"; pub const RELATIONSHIP_TERM_INDEX_CAPABILITY_V1: &str = "__compass_cap_relationship_terms_v1__"; +const EDGE_ID_ORDERED_ADJACENCY_CAPABILITY_V1: &str = "compass.edge-id-ordered-adjacency/1"; pub const GRAPH_SNAPSHOT_OBJECT_PARTITION: &str = "graph-snapshot/objects"; pub const GRAPH_SNAPSHOT_CATALOG_PARTITION: &str = "graph-snapshot/catalog"; pub const GRAPH_SNAPSHOT_ACTIVE_KEY: &str = "active"; @@ -60,6 +61,39 @@ const TREE_ZSTD_MAGIC: &[u8; 5] = b"CSTZ1"; const TREE_ZSTD_HEADER_BYTES: usize = TREE_ZSTD_MAGIC.len() + std::mem::size_of::(); const TREE_OBJECT_CACHE_MAX_BYTES: usize = 7 * 1024 * 1024; const TREE_OBJECT_CACHE_MAX_OBJECTS: usize = 1_024; +const ALL_EDGE_KINDS: &[EdgeKind] = &[ + EdgeKind::Contains, + EdgeKind::Embeds, + EdgeKind::Calls, + EdgeKind::Imports, + EdgeKind::Exports, + EdgeKind::Extends, + EdgeKind::Implements, + EdgeKind::MixesIn, + EdgeKind::References, + EdgeKind::TypeOf, + EdgeKind::Returns, + EdgeKind::Instantiates, + EdgeKind::Overrides, + EdgeKind::Decorates, + EdgeKind::RoutesTo, + EdgeKind::Reads, + EdgeKind::Writes, + EdgeKind::Aliases, + EdgeKind::Registers, + EdgeKind::Handles, + EdgeKind::Publishes, + EdgeKind::Subscribes, + EdgeKind::Produces, + EdgeKind::Consumes, + EdgeKind::Schedules, + EdgeKind::Triggers, + EdgeKind::Tests, + EdgeKind::DependsOn, + EdgeKind::Documents, + EdgeKind::MapsTo, + EdgeKind::Renders, +]; #[derive(Debug, thiserror::Error)] pub enum SnapshotError { @@ -801,6 +835,7 @@ impl GraphSnapshotBuilder { let reader = GraphSnapshotReader::open_active(store)?.ok_or_else(|| { SnapshotError::Unsupported("node-value delta requires an active snapshot".to_owned()) })?; + reader.require_edge_id_ordered_adjacency()?; let previous_snapshot_id = snapshot_identity(previous)?; if reader.selector().snapshot_id != previous_snapshot_id || reader.manifest().node_count != previous.nodes.len() as u64 @@ -876,6 +911,7 @@ impl GraphSnapshotBuilder { let reader = GraphSnapshotReader::open_active(store)?.ok_or_else(|| { SnapshotError::Unsupported("graph delta requires an active snapshot".to_owned()) })?; + reader.require_edge_id_ordered_adjacency()?; let previous_snapshot_id = snapshot_identity(previous)?; if reader.selector().snapshot_id != previous_snapshot_id || reader.manifest().node_count != previous.nodes.len() as u64 @@ -1844,7 +1880,7 @@ impl<'a, S: Store + ?Sized> GraphSnapshotReader<'a, S> { b"diagnostic" => graph .diagnostics .push(decode_json::(&entry.value)?), - b"diagnostic-code" | b"scope-capability" => {} + b"adjacency-capability" | b"diagnostic-code" | b"scope-capability" => {} _ => { return Err(SnapshotError::Corrupt( "metadata index contains an unknown supplement".to_owned(), @@ -2662,27 +2698,12 @@ impl<'a, S: Store + ?Sized> GraphSnapshotReader<'a, S> { &self, node_id: &str, incoming: bool, - kinds: &[compass_model::code_graph::EdgeKind], + kinds: &[EdgeKind], limits: SnapshotReadLimits, ) -> Result<(Vec, bool), SnapshotError> { - let index = if incoming { - IndexKind::Incoming - } else { - IndexKind::Outgoing - }; - let mut edge_ids = BTreeSet::new(); - let mut truncated = false; - for kind in kinds { - let prefix = - encode_graph_index_key(index, &[node_id.as_bytes(), kind.as_str().as_bytes()])?; - let (entries, bucket_truncated) = - self.scan_entries_bounded(index, Some(&prefix), limits)?; - truncated |= bucket_truncated; - for entry in entries { - let edge_id = index_entry_id(&entry, "directional adjacency")?; - edge_ids.insert(edge_id); - } - } + let (entries, truncated) = + self.directional_entries_by_kinds(node_id, incoming, kinds, limits)?; + let edge_ids = entries.into_keys().collect::>(); let edges = self .get_edges_by_ids_bounded_work(&edge_ids, point_lookup_batch_limits(edge_ids.len()))?; Ok((edges, truncated)) @@ -2696,21 +2717,7 @@ impl<'a, S: Store + ?Sized> GraphSnapshotReader<'a, S> { incoming: bool, limits: SnapshotReadLimits, ) -> Result<(Vec, bool), SnapshotError> { - let index = if incoming { - IndexKind::Incoming - } else { - IndexKind::Outgoing - }; - let prefix = encode_graph_index_key(index, &[node_id.as_bytes()])?; - let (entries, truncated) = self.scan_entries_bounded(index, Some(&prefix), limits)?; - let mut edge_ids = BTreeSet::new(); - for entry in entries { - let edge_id = index_entry_id(&entry, "directional adjacency")?; - edge_ids.insert(edge_id); - } - let edges = self - .get_edges_by_ids_bounded_work(&edge_ids, point_lookup_batch_limits(edge_ids.len()))?; - Ok((edges, truncated)) + self.adjacency_by_kinds(node_id, incoming, ALL_EDGE_KINDS, limits) } /// Read outgoing occurrences whose target is already in a bounded selected @@ -2722,30 +2729,15 @@ impl<'a, S: Store + ?Sized> GraphSnapshotReader<'a, S> { selected_node_ids: &BTreeSet, limits: SnapshotReadLimits, ) -> Result<(Vec, bool, usize), SnapshotError> { - let prefix = encode_graph_index_key(IndexKind::Outgoing, &[source_id.as_bytes()])?; let (entries, truncated) = - self.scan_entries_bounded(IndexKind::Outgoing, Some(&prefix), limits)?; + self.directional_entries_by_kinds(source_id, false, ALL_EDGE_KINDS, limits)?; let entries_examined = entries.len(); - let mut edge_ids = Vec::new(); - for entry in entries { - let segments = decode_key_segments(&entry.key).map_err(SnapshotError::from)?; - let target_id = segments - .get(3) - .and_then(|segment| std::str::from_utf8(segment).ok()) - .ok_or_else(|| { - SnapshotError::Corrupt("outgoing index key has an invalid target ID".to_owned()) - })?; - if !selected_node_ids.contains(target_id) { - continue; - } - let edge_id = segments - .get(4) - .and_then(|segment| std::str::from_utf8(segment).ok()) - .ok_or_else(|| { - SnapshotError::Corrupt("outgoing index key has an invalid edge ID".to_owned()) - })?; - edge_ids.push(edge_id.to_owned()); - } + let edge_ids = entries + .into_iter() + .filter_map(|(edge_id, target_id)| { + selected_node_ids.contains(&target_id).then_some(edge_id) + }) + .collect(); Ok((edge_ids, truncated, entries_examined)) } @@ -2754,20 +2746,72 @@ impl<'a, S: Store + ?Sized> GraphSnapshotReader<'a, S> { node_id: &str, limits: SnapshotReadLimits, ) -> Result<(Vec, bool), SnapshotError> { - let incoming_prefix = encode_graph_index_key(IndexKind::Incoming, &[node_id.as_bytes()])?; - let outgoing_prefix = encode_graph_index_key(IndexKind::Outgoing, &[node_id.as_bytes()])?; + let limits = limits.validate()?; let (incoming, incoming_truncated) = - self.scan_entries_bounded(IndexKind::Incoming, Some(&incoming_prefix), limits)?; + self.directional_entries_by_kinds(node_id, true, ALL_EDGE_KINDS, limits)?; let (outgoing, outgoing_truncated) = - self.scan_entries_bounded(IndexKind::Outgoing, Some(&outgoing_prefix), limits)?; - let mut edge_ids = BTreeSet::new(); - for entry in incoming.into_iter().chain(outgoing) { - let edge_id = index_entry_id(&entry, "incident adjacency")?; + self.directional_entries_by_kinds(node_id, false, ALL_EDGE_KINDS, limits)?; + let mut edge_ids = incoming.into_keys().collect::>(); + let mut truncated = incoming_truncated || outgoing_truncated; + for edge_id in outgoing.into_keys() { edge_ids.insert(edge_id); + if edge_ids.len() > limits.max_items { + edge_ids.pop_last(); + truncated = true; + } } let edges = self .get_edges_by_ids_bounded_work(&edge_ids, point_lookup_batch_limits(edge_ids.len()))?; - Ok((edges, incoming_truncated || outgoing_truncated)) + Ok((edges, truncated)) + } + + fn directional_entries_by_kinds( + &self, + node_id: &str, + incoming: bool, + kinds: &[EdgeKind], + limits: SnapshotReadLimits, + ) -> Result<(BTreeMap, bool), SnapshotError> { + let limits = limits.validate()?; + self.require_edge_id_ordered_adjacency()?; + let index = if incoming { + IndexKind::Incoming + } else { + IndexKind::Outgoing + }; + let mut entries_by_id = BTreeMap::new(); + let mut truncated = false; + for kind in kinds { + let prefix = + encode_graph_index_key(index, &[node_id.as_bytes(), kind.as_str().as_bytes()])?; + let (entries, bucket_truncated) = + self.scan_entries_bounded(index, Some(&prefix), limits)?; + truncated |= bucket_truncated; + for entry in entries { + let (edge_id, opposite_id) = directional_index_entry(&entry, index)?; + entries_by_id.insert(edge_id, opposite_id); + if entries_by_id.len() > limits.max_items { + entries_by_id.pop_last(); + truncated = true; + } + } + } + Ok((entries_by_id, truncated)) + } + + fn require_edge_id_ordered_adjacency(&self) -> Result<(), SnapshotError> { + let key = encode_graph_index_key(IndexKind::Metadata, &[b"adjacency-capability"])?; + let capability = self + .lookup(IndexKind::Metadata, &key)? + .map(|value| decode_json::(&value)) + .transpose()?; + if capability.as_deref() != Some(EDGE_ID_ORDERED_ADJACENCY_CAPABILITY_V1) { + return Err(SnapshotError::CapabilityUnavailable( + "edge_id_ordered_adjacency_unavailable; rebuild the graph store with this Compass version" + .to_owned(), + )); + } + Ok(()) } pub fn export_graph(&self) -> Result { @@ -2815,29 +2859,22 @@ impl<'a, S: Store + ?Sized> GraphSnapshotReader<'a, S> { node_id: &str, limits: SnapshotReadLimits, ) -> Result, SnapshotError> { - let prefix = encode_graph_index_key(index, &[node_id.as_bytes()])?; - let entries = self.scan_entries(index, Some(&prefix), limits)?; - let edge_ids = entries - .iter() - .map(|entry| index_entry_id(entry, "adjacency")) - .collect::, _>>()?; - let requested = edge_ids.iter().cloned().collect::>(); - let edges = self.get_edges_by_ids_bounded_work( - &requested, - point_lookup_batch_limits(requested.len()), - )?; - let by_id = edges - .into_iter() - .map(|edge| (edge.id.clone(), edge)) - .collect::>(); - let mut ordered = Vec::with_capacity(edge_ids.len()); - for edge_id in edge_ids { - let edge = by_id.get(&edge_id).cloned().ok_or_else(|| { - SnapshotError::Corrupt(format!("{index:?} index references missing edge {edge_id}")) - })?; - ordered.push(edge); + let incoming = match index { + IndexKind::Incoming => true, + IndexKind::Outgoing => false, + _ => { + return Err(SnapshotError::Corrupt(format!( + "{index:?} is not a directional adjacency index" + ))); + } + }; + let (edges, truncated) = self.directional_adjacency(node_id, incoming, limits)?; + if truncated { + return Err(SnapshotError::Limit(format!( + "{index:?} adjacency exceeds the snapshot item limit" + ))); } - Ok(ordered) + Ok(edges) } fn root(&self, index: IndexKind) -> Result<&SnapshotRoot, SnapshotError> { @@ -3031,13 +3068,34 @@ fn validate_tree_integrity( } } -fn index_entry_id(entry: &TreeEntry, label: &str) -> Result { +fn directional_index_entry( + entry: &TreeEntry, + index: IndexKind, +) -> Result<(String, String), SnapshotError> { let segments = decode_key_segments(&entry.key).map_err(SnapshotError::from)?; - let id = segments - .last() + if segments.len() != 5 || segments.first().map(Vec::as_slice) != Some(index.as_str().as_bytes()) + { + return Err(SnapshotError::Corrupt(format!( + "{} index key has an invalid edge-ordered layout", + index.as_str() + ))); + } + let edge_id = segments + .get(3) + .and_then(|segment| std::str::from_utf8(segment).ok()) + .ok_or_else(|| { + SnapshotError::Corrupt(format!("{} index edge ID is invalid", index.as_str())) + })?; + let opposite_id = segments + .get(4) .and_then(|segment| std::str::from_utf8(segment).ok()) - .ok_or_else(|| SnapshotError::Corrupt(format!("{label} ID is invalid")))?; - Ok(id.to_owned()) + .ok_or_else(|| { + SnapshotError::Corrupt(format!( + "{} index opposite endpoint is invalid", + index.as_str() + )) + })?; + Ok((edge_id.to_owned(), opposite_id.to_owned())) } /// Encode an index key using the portable, length-prefixed store encoding. @@ -3303,6 +3361,11 @@ fn build_index( encode_graph_index_key(IndexKind::Metadata, &[b"scope-capability"])?, &DISCOVERY_SCOPE_INDEX_CAPABILITY_V1, )?; + insert_json( + &mut entries, + encode_graph_index_key(IndexKind::Metadata, &[b"adjacency-capability"])?, + &EDGE_ID_ORDERED_ADJACENCY_CAPABILITY_V1, + )?; } IndexKind::Nodes => { for node in &graph.nodes { @@ -3577,8 +3640,8 @@ fn build_index( &[ edge.source.as_bytes(), kind, - edge.target.as_bytes(), edge.id.as_bytes(), + edge.target.as_bytes(), ], )?, &(), @@ -3595,8 +3658,8 @@ fn build_index( &[ edge.target.as_bytes(), kind, - edge.source.as_bytes(), edge.id.as_bytes(), + edge.source.as_bytes(), ], )?, &(), @@ -5313,6 +5376,56 @@ mod tests { Ok(()) } + #[test] + fn snapshot_without_edge_ordered_adjacency_requires_rebuild_for_directional_reads() + -> Result<(), SnapshotError> { + let graph = GraphDocument::empty_v1(BuildMetadata { + builder_version: "legacy-adjacency-test".to_owned(), + schema_fingerprint: "schema".to_owned(), + source_tree_digest: "tree".to_owned(), + configuration_digest: "config".to_owned(), + generation_id: "generation".to_owned(), + source_commit: None, + }); + let store = compass_store::MemoryStore::default(); + let builder = GraphSnapshotBuilder::new(); + let mut content = builder.prepare_content(&store, &graph)?; + let mut metadata_entries = build_index(&graph, IndexKind::Metadata, None)?; + metadata_entries.remove(&encode_graph_index_key( + IndexKind::Metadata, + &[b"adjacency-capability"], + )?); + let metadata_entry_count = metadata_entries.len() as u64; + let mut writer = ObjectWriter::new(&store)?; + let metadata_digest = build_index_tree(&mut writer, IndexKind::Metadata, metadata_entries)?; + let _ = writer.finish()?; + let metadata_root = content + .roots + .iter_mut() + .find(|root| root.index == IndexKind::Metadata) + .ok_or_else(|| SnapshotError::Corrupt("metadata root is missing".to_owned()))?; + metadata_root.digest = metadata_digest; + metadata_root.entry_count = metadata_entry_count; + let (graph_digest, graph_bytes) = digest_canonical_graph(&graph, false)?; + let prepared = builder.finish_content(&store, content, graph_digest, graph_bytes)?; + builder.activate(&store, &prepared)?; + + let reader = GraphSnapshotReader::open_active(&store)? + .ok_or_else(|| SnapshotError::Corrupt("active snapshot is missing".to_owned()))?; + assert_eq!(reader.export_graph()?, graph); + assert!(matches!( + reader.adjacency_by_kinds( + "missing", + false, + &[EdgeKind::Calls], + SnapshotReadLimits::default(), + ), + Err(SnapshotError::CapabilityUnavailable(message)) + if message.contains("edge_id_ordered_adjacency_unavailable") + )); + Ok(()) + } + #[test] fn legacy_snapshot_without_relationship_terms_remains_readable_and_degraded() -> Result<(), SnapshotError> { diff --git a/crates/compass-query/src/code_query.rs b/crates/compass-query/src/code_query.rs index 1fb11a0ed..ece651c29 100644 --- a/crates/compass-query/src/code_query.rs +++ b/crates/compass-query/src/code_query.rs @@ -1002,13 +1002,17 @@ impl PinnedDiscoveryBackend<'_> { } Self::Store(reader) => { let (mut edges, truncated) = reader - .directional_adjacency(node, inbound, snapshot_limits(limit.saturating_add(1))?) + .adjacency_by_kinds( + node, + inbound, + kinds, + snapshot_limits(limit.saturating_add(1))?, + ) .map_err(snapshot_error)?; edges.sort_by(|left, right| left.id.cmp(&right.id)); let truncated = truncated || edges.len() > limit; edges.truncate(limit); let examined = edges.len().saturating_add(usize::from(truncated)); - edges.retain(|edge| kinds.contains(&edge.kind)); if !include_heuristic { edges.retain(|edge| !is_heuristic(edge)); } diff --git a/crates/compass-query/tests/store_engine.rs b/crates/compass-query/tests/store_engine.rs index 2ff1e4791..fa88d45ac 100644 --- a/crates/compass-query/tests/store_engine.rs +++ b/crates/compass-query/tests/store_engine.rs @@ -1232,6 +1232,84 @@ fn store_engine_reads_the_immutable_phase2_snapshot_for_all_code_queries() Ok(()) } +#[test] +fn truncated_callers_use_the_same_edge_id_prefix_for_store_and_json() +-> Result<(), Box> { + let directory = tempfile::tempdir()?; + let graph_path = directory.path().join("graph.json"); + support::write_graph(&graph_path)?; + let mut graph = GraphDocument::load(&graph_path)?; + let mut target = graph + .nodes + .first() + .cloned() + .ok_or("fixture graph has no template node")?; + target.id = "n:bounded-target".to_owned(); + target.kind = NodeKind::Function; + target.name = "bounded_target".to_owned(); + target.qualified_name = "Bounded.target".to_owned(); + graph.nodes.push(target); + + let edge_template = graph + .links + .first() + .cloned() + .ok_or("fixture graph has no template edge")?; + for ordinal in 0..32 { + let mut caller = graph + .nodes + .first() + .cloned() + .ok_or("fixture graph has no template node")?; + caller.id = format!("n:bounded-caller:{ordinal:02}"); + caller.kind = NodeKind::Function; + caller.name = format!("bounded_caller_{ordinal:02}"); + caller.qualified_name = format!("Bounded.caller_{ordinal:02}"); + + let mut edge = edge_template.clone(); + edge.source = caller.id.clone(); + edge.target = "n:bounded-target".to_owned(); + edge.kind = EdgeKind::Calls; + edge.occurrence_rule = None; + edge.details = None; + edge.deferred = false; + edge.id = edge_id( + &edge.source, + edge.kind, + &edge.target, + edge.relationship_site.as_ref(), + None, + ); + edge.key.clone_from(&edge.id); + graph.nodes.push(caller); + graph.links.push(edge); + } + fs::write(&graph_path, serde_json::to_vec(&graph)?)?; + publish_phase2_snapshot(directory.path(), &graph_path)?; + + let cache = directory.path().join("cache"); + let store = open_with_engine(&graph_path, None, &cache, EngineSelection::Store)?; + let json = open_with_engine(&graph_path, None, &cache, EngineSelection::Json)?; + let request = CallRequest { + symbol: "Bounded.target".to_owned(), + include_heuristic: false, + limits: CodeQueryLimits { + max_edges: 1, + max_nodes: 2, + ..CodeQueryLimits::default() + }, + }; + let store_response = store.callers(request.clone())?; + let json_response = json.callers(request)?; + assert!(store_response.truncated); + assert!(json_response.truncated); + assert_eq!( + serde_json::to_value(store_response)?, + serde_json::to_value(json_response)?, + ); + Ok(()) +} + #[test] fn redb_store_runs_the_same_typed_queries_as_json() -> Result<(), Box> { let directory = tempfile::tempdir()?; diff --git a/docs/guides/compass-store-operations.md b/docs/guides/compass-store-operations.md index 91625f1a2..8e96b70f6 100644 --- a/docs/guides/compass-store-operations.md +++ b/docs/guides/compass-store-operations.md @@ -138,6 +138,13 @@ same-major SQLite store. Unknown majors, pre-release prototypes, a missing or invalid `store.ref`, and physical files from another adapter are not migrated in place. +Directional store queries additionally require the edge-ID-ordered adjacency +capability. Sidecars created before that capability remain valid for status, +validation, backup, and canonical JSON recovery, but must be rebuilt before +serving bounded callers, callees, impact, trail, or discovery queries. The +query engine reports the rebuild requirement explicitly and does not fall back +to a potentially different truncated prefix. + Rebuild a sidecar from the current graph without replacing the JSON artifact: ```bash diff --git a/docs/implementation/query-engine.md b/docs/implementation/query-engine.md index 74aa0e395..030fbe880 100644 --- a/docs/implementation/query-engine.md +++ b/docs/implementation/query-engine.md @@ -62,6 +62,13 @@ The corresponding immutable term-posting layout is `compass.store.graph-index/1`. Unknown layouts are rejected rather than being reinterpreted with the current analyzer. +Bounded directional queries merge the requested relationship-kind buckets by +canonical edge ID and retain one global prefix before heuristic filtering. +Store snapshots declare that edge-ordered adjacency capability explicitly. A +snapshot without it can still be exported and validated, but the store query +engine requires a sidecar rebuild instead of using the older endpoint-ordered +keys with different truncation semantics. + Search candidate truncation is observable and therefore backend-neutral. Both engines select matching candidates in canonical node-ID order, apply the bound, then run common Rust ranking and tie-breaking. Store prefix scans traverse the diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 271076b51..7196d90e1 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -763,9 +763,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0",