diff --git a/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs b/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs index 0c65098e48c..429644ba3c3 100644 --- a/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs +++ b/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs @@ -543,21 +543,6 @@ fn configuration_tool_success( } } -fn wait_for_daemon_socket(socket_path: &Path) { - let deadline = Instant::now() + Duration::from_secs(10); - common::poll_until( - deadline, - Duration::from_millis(25), - || UnixStream::connect(socket_path).is_ok().then_some(()), - || { - format!( - "timed out waiting for daemon socket at {}", - socket_path.display() - ) - }, - ); -} - /// Enroll a project for the native hook capture contract: an enrollment /// marker binds the project root to a profile shard, and a daemon-issued /// hook configuration binding is published under that shard's data root so @@ -1718,21 +1703,9 @@ fn daemon_socket_is_owner_only() { let home = TempDir::new().unwrap(); let home_path = canonical_existing_path(home.path()); let socket_path = common::daemon_socket_path(&home_path); - let _ = std::fs::remove_file(&socket_path); - let mut daemon = common::DaemonProcess::new( - tracedecay_command_with_home(&home_path) - .arg("daemon") - .arg("run") - .arg("--socket") - .arg(&socket_path) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("tracedecay daemon should start"), - ); - - wait_for_daemon_socket(&socket_path); + let mut daemon = common::spawn_tracedecay_daemon_with(&home_path, |command| { + command.arg("--socket").arg(&socket_path); + }); let mode = std::fs::metadata(&socket_path) .expect("socket metadata") .permissions() diff --git a/crates/tracedecay-code-index/src/production/file_evidence_rows.rs b/crates/tracedecay-code-index/src/production/file_evidence_rows.rs index cabe28fec67..515bf951aba 100644 --- a/crates/tracedecay-code-index/src/production/file_evidence_rows.rs +++ b/crates/tracedecay-code-index/src/production/file_evidence_rows.rs @@ -623,6 +623,10 @@ impl PersistedFileEvidenceV1 { .any(|path| paths.contains(path.as_str())) } + pub(super) fn target_paths(&self) -> impl Iterator { + self.target_paths.iter().map(String::as_str) + } + pub(super) fn cross_file_edge_count(&self) -> usize { self.edges.len() } diff --git a/crates/tracedecay-code-index/src/production/partitioned_codec.rs b/crates/tracedecay-code-index/src/production/partitioned_codec.rs index e0485899463..fa6c55abdcd 100644 --- a/crates/tracedecay-code-index/src/production/partitioned_codec.rs +++ b/crates/tracedecay-code-index/src/production/partitioned_codec.rs @@ -142,6 +142,72 @@ pub(super) struct PartitionedFileEvidenceDescriptorV1 { /// that carries the file knows to reseal it with identity lineage. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub(super) explicit_lineage: bool, + /// Negative edge-target filter authenticated against this segment at restore. + /// A missing filter is a format violation, never a reason to omit an edge. + pub(super) target_filter: EvidenceTargetFilterV1, +} + +/// Four independent path-hash probes. False positives decode evidence as +/// before; false negatives would suppress required incoming-edge repoints. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub(super) struct EvidenceTargetFilterV1([u64; 4]); + +impl EvidenceTargetFilterV1 { + fn path_bits(path: &str) -> [usize; 4] { + let digest = Sha256::digest(path.as_bytes()); + std::array::from_fn(|probe| { + usize::from(u16::from_be_bytes([ + digest[probe * 2], + digest[probe * 2 + 1], + ])) % 256 + }) + } + + fn from_paths<'a>(paths: impl Iterator) -> Self { + let mut bits = [0_u64; 4]; + for path in paths { + for bit in Self::path_bits(path) { + bits[bit / 64] |= 1_u64 << (bit % 64); + } + } + Self(bits) + } + + fn might_contain(&self, path: &str) -> bool { + Self::path_bits(path) + .into_iter() + .all(|bit| self.0[bit / 64] & (1_u64 << (bit % 64)) != 0) + } +} + +impl PartitionedFileEvidenceDescriptorV1 { + pub(super) fn might_target_any<'a>(&self, mut paths: impl Iterator) -> bool { + paths.any(|path| self.target_filter.might_contain(path)) + } +} + +#[cfg(test)] +mod evidence_target_filter_tests { + use super::EvidenceTargetFilterV1; + + #[test] + fn sealed_target_filter_round_trip_has_no_false_negatives() { + let paths = ["src/util.rs", "src/other.rs", "src/sub/leaf.rs"]; + let filter = EvidenceTargetFilterV1::from_paths(paths.into_iter()); + let bytes = serde_json::to_vec(&filter).expect("encode target filter"); + let restored: EvidenceTargetFilterV1 = + serde_json::from_slice(&bytes).expect("decode target filter"); + assert_eq!(restored, filter); + for path in paths { + assert!(restored.might_contain(path), "{path} must not be filtered"); + } + let empty = EvidenceTargetFilterV1::from_paths(std::iter::empty()); + assert!( + paths.into_iter().all(|path| !empty.might_contain(path)), + "empty edge set must safely skip unrelated evidence" + ); + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -1724,6 +1790,7 @@ pub(super) fn encode_file_evidence_segment_charged( })?, decoded_size_bytes, explicit_lineage: evidence.has_explicit_lineage(), + target_filter: EvidenceTargetFilterV1::from_paths(evidence.target_paths()), }; Ok((descriptor, bytes)) } @@ -1748,7 +1815,10 @@ pub(super) fn decode_file_evidence_segment( "sealed file evidence decoding failed: {error}" )) })?; - if evidence.has_explicit_lineage() != descriptor.explicit_lineage || evidence.is_empty() { + if evidence.has_explicit_lineage() != descriptor.explicit_lineage + || evidence.is_empty() + || descriptor.target_filter != EvidenceTargetFilterV1::from_paths(evidence.target_paths()) + { return Err(CodeIndexProductionErrorV1::Contract( "sealed file evidence does not match its descriptor".to_owned(), )); diff --git a/crates/tracedecay-code-index/src/production/sealed_codec.rs b/crates/tracedecay-code-index/src/production/sealed_codec.rs index e53b0d13286..265d650455b 100644 --- a/crates/tracedecay-code-index/src/production/sealed_codec.rs +++ b/crates/tracedecay-code-index/src/production/sealed_codec.rs @@ -55,7 +55,7 @@ use super::*; /// from source rather than migrated. Revisions through eight also predate /// required clone-body source rows, so the rebuild keeps them from reading as /// successful empty clone evidence. -pub const SEALED_GENERATION_FORMAT_REVISION_V1: u32 = 21; +pub const SEALED_GENERATION_FORMAT_REVISION_V1: u32 = 22; /// The typed refusal for a sealed generation this build no longer reads. pub fn superseded_sealed_generation_revision(revision: u32) -> CodeIndexProductionErrorV1 { diff --git a/crates/tracedecay-code-index/src/production/sparse_increment_tests.rs b/crates/tracedecay-code-index/src/production/sparse_increment_tests.rs index 01eb1dcb764..602e4af2267 100644 --- a/crates/tracedecay-code-index/src/production/sparse_increment_tests.rs +++ b/crates/tracedecay-code-index/src/production/sparse_increment_tests.rs @@ -328,12 +328,12 @@ fn a_comment_beside_same_named_symbols_decodes_only_what_it_reaches() { .collect::() + UTIL; let edited = format!("{util}// probe line\n"); - let other = "pub struct Other;\nimpl Other { pub fn new() -> Self { Other } }\n"; + let other = "pub fn new() -> u32 { 7 }\n"; let callers = (0..4) .map(|n| { ( format!("src/caller{n}.rs"), - format!("pub fn caller{n}() {{ let _ = crate::other::Other::new(); }}\n"), + format!("pub fn caller{n}() {{ let _ = crate::other::new(); }}\n"), ) }) .collect::>(); @@ -343,6 +343,8 @@ fn a_comment_beside_same_named_symbols_decodes_only_what_it_reaches() { callers: &'a [(String, String)], ) -> Vec<(&'a str, &'a str, &'a str)> { let mut tree = rust_tree(util, MAIN); + tree[0].2 = "pub mod consumer;\npub mod util;\npub mod other;\n"; + tree[1].0 = "src/consumer.rs"; tree.push(("src/other.rs", RUST, other)); tree.extend( callers @@ -388,6 +390,28 @@ fn a_comment_beside_same_named_symbols_decodes_only_what_it_reaches() { .iter() .map(|(_, digest)| store.segment_reads(digest)) .collect::>(); + let evidence_of = |path: &str| { + let key = snapshot + .iter() + .position(|file| file.logical_path == path) + .expect("parent evidence file"); + manifest + .file_evidence + .iter() + .find(|descriptor| descriptor.file_key as usize == key) + .map(|descriptor| descriptor.segment_digest.clone()) + .expect("a resolved cross-file call has sealed evidence") + }; + let unrelated_evidence = callers + .iter() + .map(|(path, _)| (path.as_str(), evidence_of(path))) + .collect::>(); + let evidence_reads_before = unrelated_evidence + .iter() + .map(|(_, digest)| store.segment_reads(digest)) + .collect::>(); + let dependent_evidence = evidence_of("src/consumer.rs"); + let dependent_reads_before = store.segment_reads(&dependent_evidence); let sparse = publish( &mut incremental, @@ -406,6 +430,17 @@ fn a_comment_beside_same_named_symbols_decodes_only_what_it_reaches() { "{path} names `new` but binds nowhere in the edited file, so its segment stays sealed" ); } + for ((path, digest), before) in unrelated_evidence.iter().zip(evidence_reads_before) { + assert_eq!( + store.segment_reads(digest), + before, + "{path} has unrelated sealed edges and must not decode its evidence" + ); + } + assert!( + store.segment_reads(&dependent_evidence) > dependent_reads_before, + "a caller whose sealed edge lands in the edited file must still be repointed" + ); assert_sparse_matches_cold( &caller_tree(&util, other, &callers), &caller_tree(&edited, other, &callers), diff --git a/crates/tracedecay-code-index/src/production/sparse_resolution.rs b/crates/tracedecay-code-index/src/production/sparse_resolution.rs index 5db6040d8d7..095e16cb105 100644 --- a/crates/tracedecay-code-index/src/production/sparse_resolution.rs +++ b/crates/tracedecay-code-index/src/production/sparse_resolution.rs @@ -711,9 +711,16 @@ pub(super) fn resolve_edit( let parent_key = parent_key_of_path .get(files[file_index].logical_path()) .ok_or_else(|| contract("a carried file has no parent segment"))?; - let repoints = parent - .file_evidence(*parent_key)? - .is_some_and(|evidence| evidence.targets_any(&edited_paths)); + let might_repoint = + parent + .file_evidence_descriptor(*parent_key) + .is_some_and(|descriptor| { + descriptor.might_target_any(edited_paths.iter().copied()) + }); + let repoints = might_repoint + && parent + .file_evidence(*parent_key)? + .is_some_and(|evidence| evidence.targets_any(&edited_paths)); if repoints { files[file_index].binding()?; } diff --git a/crates/tracedecay-code-index/tests/code_index_suite/main.rs b/crates/tracedecay-code-index/tests/code_index_suite/main.rs index 072d3c32a3e..59aeb59dd68 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/main.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/main.rs @@ -23,6 +23,7 @@ mod retained_parse_canonical_identity; mod sanitized_intake; mod sealed_generation_restore; mod search_chunks; +mod sparse_comment_publication; mod support; mod symbol_span_digest; mod test_attribution; diff --git a/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs b/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs index 1e1558def46..855b5e553bf 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs @@ -3171,7 +3171,7 @@ fn partitioned_codec_fixture() -> ( } const PARTITIONED_FORMAT_STATE_DIGEST: &str = - "sha256:0ba68ca337a4760c0221c1212eec669965f43abdd244f0b21be7f705f7449918"; + "sha256:08e91a89b2dd82615897c5df08b74740fc483551f8f8cef1ef57cf96a93c837f"; const PARTITIONED_FORMAT_SEGMENTS: &[(&str, u64)] = &[ ( "sha256:9b175e6aa1ddf6db4138181c0dd1cd522f37381898a58d46e26b1165b613727b", @@ -3458,6 +3458,35 @@ fn partitioned_codec_has_stable_bytes_and_round_trips() { !file_evidence_digests.is_empty(), "the fixture must seal file evidence" ); + // This revision requires each evidence descriptor's authenticated target + // filter. A missing filter is not a permissive legacy manifest. + let evidence_descriptors = envelope["generation"]["file_evidence"] + .as_array() + .expect("file evidence descriptors"); + for descriptor in evidence_descriptors { + assert_eq!( + descriptor["target_filter"].as_array().map(Vec::len), + Some(4), + "each sealed evidence descriptor has four target-filter words" + ); + } + let mut filterless = envelope.clone(); + filterless["generation"]["file_evidence"] + .as_array_mut() + .expect("file evidence descriptors") + .first_mut() + .expect("fixture has evidence") + .as_object_mut() + .expect("typed evidence descriptor") + .remove("target_filter") + .expect("filter must be present"); + let error = + CodeIndexPublishedGenerationV1::partitioned_text_metadata(&reseal_manifest(filterless)) + .expect_err("a current-revision manifest without a target filter must be refused"); + assert!( + error.to_string().contains("missing field `target_filter`"), + "filterless manifest reached the wrong refusal: {error}" + ); // Buffer address -> its capacity after the last read it served. let mut file_buffers = BTreeMap::new(); let mut file_evidence_buffers = BTreeMap::new(); diff --git a/crates/tracedecay-code-index/tests/code_index_suite/sparse_comment_publication.rs b/crates/tracedecay-code-index/tests/code_index_suite/sparse_comment_publication.rs new file mode 100644 index 00000000000..266f5c98109 --- /dev/null +++ b/crates/tracedecay-code-index/tests/code_index_suite/sparse_comment_publication.rs @@ -0,0 +1,435 @@ +//! Sparse publication must preserve the cold graph and exact chunk delta, +//! including callers whose source did not change when a target was re-keyed. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use sha2::{Digest, Sha256}; +use tracedecay_code_index::{ + chunks::content_digest, + incremental::plan_chunk_increment, + production::{ + CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, CodeIndexProductionErrorV1, + CodeIndexProductionOwnerV1, CodeIndexPublishedGenerationV1, MemorySealedPublicationStoreV1, + }, +}; +use tracedecay_domain::{ + FileOccurrenceId, LanguageId, ManifestDigest, RelationEdgeKindV1, SanitizedCodeFileV1, + SensitivityLevelV1, SnapshotFileDispositionV1, SymbolOccurrenceId, +}; +use tracedecay_runtime_core::logging::set_tracing_capture; + +use crate::{ + production_orchestration::{ActiveControl, config, request_with_source}, + support::{PartitionedSealV1, cold_generation, id, reseal_manifest}, +}; + +// Count actual graph row emission, not publication writes: a redundant seal +// can regenerate the same digest and be deduplicated before reaching the store. +struct CountGraphEmissions(Arc); + +impl tracing::Subscriber for CountGraphEmissions { + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + metadata.name() == "code_index.seal.collect.emit" + } + + fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(self.0.fetch_add(1, Ordering::Relaxed) + 1) + } + + fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {} + + fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {} + + fn event(&self, _event: &tracing::Event<'_>) {} + + fn enter(&self, _span: &tracing::span::Id) {} + + fn exit(&self, _span: &tracing::span::Id) {} +} + +fn with_graph_emission_count(build: impl FnOnce() -> T) -> (T, u64) { + let emissions = Arc::new(AtomicU64::new(0)); + let _capture = set_tracing_capture(CountGraphEmissions(Arc::clone(&emissions))); + let result = build(); + (result, emissions.load(Ordering::Relaxed)) +} + +fn tree_request(util: &str, sealed_at: i64, phase: &str) -> CodeIndexBuildRequestV1 { + let root = concat!( + "pub mod consumer;\npub mod util;\npub mod other;\npub mod sibling;\n", + "pub mod caller0;\npub mod caller1;\npub mod caller2;\npub mod caller3;\n", + ); + let mut request = request_with_source( + "file.sparse.seed", + sealed_at, + &format!("commit.sparse.{phase}"), + &format!("tree.sparse.{phase}"), + root, + ); + let mut sources = vec![ + ("src/lib.rs".to_owned(), root.to_owned()), + ( + "src/consumer.rs".to_owned(), + "pub fn run() -> u32 { crate::util::helper() + crate::util::extra() }\n".to_owned(), + ), + ( + "src/sibling.rs".to_owned(), + "pub fn second_caller() -> u32 { crate::util::helper() }\n".to_owned(), + ), + ("src/util.rs".to_owned(), util.to_owned()), + ( + "src/other.rs".to_owned(), + "pub fn new() -> u32 { 7 }\n".to_owned(), + ), + ]; + sources.extend((0..4).map(|n| { + ( + format!("src/caller{n}.rs"), + format!("pub fn caller{n}() {{ let _ = crate::other::new(); }}\n"), + ) + })); + sources.sort(); + request.snapshot.files.clear(); + request.captured_files.clear(); + request.changed_files.clear(); + let mut identity = Sha256::new(); + for (path, source) in sources { + let digest = Sha256::digest([path.as_bytes(), b"\0", source.as_bytes()].concat()); + let occurrence = id::(&format!( + "file.sparse.{}", + digest + .iter() + .take(12) + .map(|byte| format!("{byte:02x}")) + .collect::() + )); + identity.update(digest); + request.snapshot.files.push(SanitizedCodeFileV1 { + file_occurrence_id: occurrence.clone(), + logical_path: path.clone(), + language: Some(id::("rust")), + content_digest: content_digest(source.as_bytes()), + disposition: SnapshotFileDispositionV1::Present, + }); + request.captured_files.push(CodeIndexCapturedFileV1 { + file_occurrence_id: occurrence, + sanitized_bytes: Arc::from(source.as_bytes()), + sensitivity_level: SensitivityLevelV1::Public, + }); + request.changed_files.insert(path); + } + request.snapshot.content_identity = content_digest(&identity.finalize()); + request +} + +fn symbol(generation: &CodeIndexPublishedGenerationV1, name: &str) -> SymbolOccurrenceId { + let matches = generation + .symbols() + .symbols + .iter() + .filter(|symbol| symbol.qualified_name == name) + .map(|symbol| symbol.occurrence.clone()) + .collect::>(); + assert_eq!(matches.len(), 1, "fixture must have exactly one {name}"); + matches[0].clone() +} + +fn assert_unrelated_calls(generation: &CodeIndexPublishedGenerationV1) { + let target = symbol(generation, "src/other.rs::new"); + for n in 0..4 { + let caller = symbol(generation, &format!("src/caller{n}.rs::caller{n}")); + assert_eq!( + generation + .edges() + .iter() + .filter(|edge| { + edge.from_occurrence == caller + && edge.to_occurrence == target + && edge.kind == RelationEdgeKindV1::Calls + }) + .count(), + 1, + "caller{n} must bind the unrelated file, not just seal a descriptor" + ); + } +} + +#[test] +fn comment_successor_skips_unrelated_evidence_and_publishes_the_cold_chunk_delta() { + let util = (0..8) + .map(|n| { + format!("pub struct Kind{n};\nimpl Kind{n} {{ pub fn new() -> Self {{ Kind{n} }} }}\n") + }) + .collect::() + + "pub fn helper() -> u32 { 1 }\npub fn extra() -> u32 { 2 }\n"; + let store = MemorySealedPublicationStoreV1::default(); + let mut owner = CodeIndexProductionOwnerV1::new(config(), store.clone()).expect("owner"); + let before_request = tree_request(&util, 1_100_000, "before"); + let (parent, cold_emissions) = with_graph_emission_count(|| { + owner.build_and_publish(before_request.clone(), &ActiveControl) + }); + let parent = parent.expect("parent publishes"); + assert!(parent.cold_reason().is_some()); + assert_eq!( + cold_emissions, + before_request.snapshot.files.len() as u64, + "the cold control must observe actual graph work for every fixture file" + ); + // Descriptor presence alone does not prove a resolved call. Establish + // the four actual unrelated edges before taking the read baselines. + let before = cold_generation(&parent); + assert_unrelated_calls(&before); + let envelope: serde_json::Value = + serde_json::from_slice(parent.manifest_bytes()).expect("sealed manifest envelope"); + let segment_digest = |kind: &str, path: &str| { + let key = before_request + .snapshot + .files + .iter() + .position(|file| file.logical_path == path) + .expect("fixture path"); + let key = u64::try_from(key).expect("file key fits u64"); + let descriptor = envelope["generation"][kind] + .as_array() + .expect("segment descriptors") + .iter() + .find(|descriptor| descriptor["file_key"].as_u64() == Some(key)) + .unwrap_or_else(|| panic!("{path} must have a sealed {kind} descriptor")); + serde_json::from_value::(descriptor["segment_digest"].clone()) + .expect("segment digest") + }; + let unrelated = (0..4) + .map(|n| segment_digest("file_evidence", &format!("src/caller{n}.rs"))) + .map(|digest| { + let reads = store.segment_reads(&digest); + (digest, reads) + }) + .collect::>(); + let unrelated_files = (0..4) + .map(|n| format!("src/caller{n}.rs")) + .chain(["src/other.rs".to_owned()]) + .map(|path| { + let digest = segment_digest("file_segments", &path); + let reads = store.segment_reads(&digest); + (path, digest, reads) + }) + .collect::>(); + let dependent = segment_digest("file_evidence", "src/consumer.rs"); + let dependent_reads = store.segment_reads(&dependent); + let edited = format!("{util}// comment only\n"); + let cold_request = tree_request(&edited, 1_200_000, "after"); + let mut sparse_request = cold_request.clone(); + let edited_occurrence = sparse_request + .snapshot + .files + .iter() + .find(|file| file.logical_path == "src/util.rs") + .expect("edited file") + .file_occurrence_id + .clone(); + sparse_request + .captured_files + .retain(|file| file.file_occurrence_id == edited_occurrence); + assert_eq!(sparse_request.captured_files.len(), 1); + sparse_request.changed_files = ["src/util.rs".to_owned()].into_iter().collect(); + let (sparse, sparse_emissions) = + with_graph_emission_count(|| owner.build_and_publish(sparse_request, &ActiveControl)); + let sparse = sparse.expect("sparse successor publishes without recapturing callers"); + assert_eq!(sparse.cold_reason(), None); + + // Observe reads before restoring either result, not on a second build. + for (digest, reads) in unrelated { + assert_eq!(store.segment_reads(&digest), reads, "unrelated evidence"); + } + for (path, digest, reads) in unrelated_files { + assert_eq!(store.segment_reads(&digest), reads, "unrelated file {path}"); + } + assert!(store.segment_reads(&dependent) > dependent_reads); + + // Check the same measured publication before either result is restored. + // Equal descriptors alone would miss needless resealing of identical bytes. + let successor_envelope: serde_json::Value = + serde_json::from_slice(sparse.manifest_bytes()).expect("successor manifest"); + let parent_pages = envelope["generation"]["code_graph_pages"] + .as_array() + .expect("parent graph pages"); + let successor_pages = successor_envelope["generation"]["code_graph_pages"] + .as_array() + .expect("successor graph pages"); + assert_eq!(parent_pages.len(), before_request.snapshot.files.len()); + assert_eq!(successor_pages.len(), parent_pages.len()); + let mut changed_graph_paths = Vec::new(); + for parent_page in parent_pages { + let key = parent_page["file_key"].as_u64().expect("graph file key"); + let file_index = usize::try_from(key).expect("file key fits usize"); + let file = &before_request.snapshot.files[file_index]; + let path = file.logical_path.as_str(); + let successor_page = successor_pages + .iter() + .find(|page| page["file_key"].as_u64() == Some(key)) + .expect("every parent file retains a graph page"); + if successor_page["page_digest"] != parent_page["page_digest"] { + changed_graph_paths.push(path); + } else { + assert_eq!(successor_page, parent_page, "carried graph page {path}"); + } + } + changed_graph_paths.sort_unstable(); + assert_eq!( + changed_graph_paths, + vec!["src/consumer.rs", "src/sibling.rs", "src/util.rs"], + "only the edited file and its two real dependent owners change graph pages" + ); + assert_eq!( + sparse_emissions, + changed_graph_paths.len() as u64, + "unchanged graph pages must not be emitted again before digest deduplication" + ); + + let mut cold_owner = + CodeIndexProductionOwnerV1::new(config(), MemorySealedPublicationStoreV1::default()) + .expect("cold owner"); + let cold = cold_owner + .build_and_publish(cold_request, &ActiveControl) + .expect("cold successor publishes"); + assert!(cold.cold_reason().is_some()); + let after = cold_generation(&sparse); + let expected = cold_generation(&cold); + assert_unrelated_calls(&after); + let expected_delta = plan_chunk_increment(Some(before.chunks()), expected.chunks()) + .expect("cold chunks define the complete expected delta"); + assert!(!expected_delta.added_or_changed.is_empty()); + assert!(expected_delta.reused_count > 0); + // A cold seal starts at repository sequence one, while a real sparse + // successor advances its parent's generation sequence. Their canonical + // partitions must agree, but their seal/generation identities must not. + let changes = &sparse.projection().request().changes; + changes + .validate() + .expect("published chunk delta is canonical"); + assert_eq!( + changes.from_generation.as_ref(), + Some(&parent.manifest().generation_id) + ); + assert_eq!(changes.to_generation, sparse.manifest().generation_id); + assert_ne!(changes.to_generation, expected_delta.to_generation); + assert_eq!(changes.added_or_changed, expected_delta.added_or_changed); + assert_eq!(changes.deleted, expected_delta.deleted); + assert_eq!(changes.reused_count, expected_delta.reused_count); + let parent_replay = &parent + .manifest() + .source_commitments + .as_ref() + .expect("parent sealed its source") + .full_replay_digest; + let expected_reuse = tracedecay_domain::code_reused_partition_arc_share_digest( + parent_replay, + &parent.manifest().generation_id, + &sparse.manifest().generation_id, + expected_delta.reused_count, + ) + .expect("sparse reuse binds the parent to the cold delta's unchanged chunks"); + assert_eq!(changes.reused_digest, expected_reuse); + let corpus = |generation: &CodeIndexPublishedGenerationV1| { + generation + .chunks() + .chunks() + .iter() + .map(|chunk| (chunk.id.clone(), chunk.content_digest.clone())) + .collect::>() + }; + assert_eq!(corpus(&after), corpus(&expected)); + assert_eq!(after.edges(), expected.edges()); + + let old_helper = symbol(&before, "src/util.rs::helper"); + let helper = symbol(&after, "src/util.rs::helper"); + let old_other = symbol(&before, "src/util.rs::extra"); + let other = symbol(&after, "src/util.rs::extra"); + let run = symbol(&after, "src/consumer.rs::run"); + let sibling = symbol(&after, "src/sibling.rs::second_caller"); + assert_ne!(old_helper, helper, "comment edit re-keys its target"); + assert_ne!(old_other, other); + assert_eq!(run, symbol(&before, "src/consumer.rs::run")); + assert_eq!(sibling, symbol(&before, "src/sibling.rs::second_caller")); + for (from, to) in [(&run, &helper), (&run, &other), (&sibling, &helper)] { + assert_eq!( + after + .edges() + .iter() + .filter(|edge| &edge.from_occurrence == from && &edge.to_occurrence == to) + .count(), + 1, + "each carried caller must point to the new occurrence exactly once" + ); + } + assert!(after.edges().iter().all(|edge| { + edge.from_occurrence != old_helper + && edge.to_occurrence != old_helper + && edge.from_occurrence != old_other + && edge.to_occurrence != old_other + })); + assert_eq!( + after + .edges() + .iter() + .filter(|edge| edge.to_occurrence == helper) + .count(), + 2, + "target fan-in survives repointing" + ); + assert_eq!( + after + .edges() + .iter() + .filter(|edge| edge.from_occurrence == run) + .count(), + 2, + "untouched caller fan-out survives repointing" + ); +} + +#[test] +fn sealed_evidence_refuses_a_resealed_false_negative_target_filter() { + let util = "pub fn helper() -> u32 { 1 }\npub fn extra() -> u32 { 2 }\n"; + let mut owner = + CodeIndexProductionOwnerV1::new(config(), MemorySealedPublicationStoreV1::default()) + .expect("owner"); + let published = owner + .build_and_publish(tree_request(util, 1_100_000, "filter"), &ActiveControl) + .expect("generation publishes"); + let generation = cold_generation(&published); + assert_unrelated_calls(&generation); + let seal = PartitionedSealV1::of(&generation); + let mut envelope = seal.envelope(); + let descriptor = envelope["generation"]["file_evidence"] + .as_array_mut() + .expect("evidence descriptors") + .iter_mut() + .find(|descriptor| { + descriptor["target_filter"] + .as_array() + .expect("target filter words") + .iter() + .any(|word| word.as_u64().expect("target filter word") != 0) + }) + .expect("real cross-file evidence must have a nonempty target filter"); + descriptor["target_filter"] = serde_json::json!([0_u64, 0_u64, 0_u64, 0_u64]); + // Recompute the outer digest so this reaches semantic descriptor checking, + // not an unrelated manifest authentication failure. Segment bytes stay intact. + let tampered = reseal_manifest(envelope); + CodeIndexPublishedGenerationV1::partitioned_text_metadata(&tampered) + .expect("resealed metadata is structurally valid"); + let error = seal + .restore(&tampered) + .expect_err("a false-negative target filter must not restore"); + assert!( + matches!( + &error, + CodeIndexProductionErrorV1::Contract(message) + if message == "sealed file evidence does not match its descriptor" + ), + "unexpected evidence refusal: {error}" + ); + assert_eq!(seal.restored().edges(), generation.edges()); +}