From 822fab51fb8ebdf62077128b661edf0d81c45e6a Mon Sep 17 00:00:00 2001 From: forhappy Date: Thu, 13 Aug 2026 17:36:26 -0700 Subject: [PATCH] Fix large graph resolution collapse --- CHANGELOG.md | 9 + COMPATIBILITY.md | 10 + crates/compass-cli/src/lib.rs | 46 +- crates/compass-core/src/pipeline.rs | 61 +- crates/compass-resolve/src/evidence/api.rs | 50 ++ crates/compass-resolve/src/evidence/mod.rs | 18 +- .../compass-resolve/src/evidence/partition.rs | 684 ++++++++++++++++++ .../src/evidence/projection/mod.rs | 180 +++-- crates/compass-resolve/src/lib.rs | 132 +++- docs/reference/universal-semantic-evidence.md | 23 + 10 files changed, 1123 insertions(+), 90 deletions(-) create mode 100644 crates/compass-resolve/src/evidence/partition.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c5c3bbc1..0aad3b36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- Prevent oversized universal-evidence collections from collapsing code graphs + to file scaffolding. Default-low builds compact unused leaf declaration + details, project retained declarations before resolution, and resolve safe + exact relationships in deterministic bounded partitions. Any remaining + omissions are published through `universal_resolution_partial`, counted as + omitted edges, and make the build exit nonzero while retaining the useful + partial graph. Pipeline workers now use an explicit portable stack bound so + deeply nested generated sources fail neither the resolver nor the process. + - Improve Markdown as first-class agent context. Repeated automatic heading slugs now follow deterministic source-order suffixes, every structural block has a section-qualified identity, and project resolution connects exact, diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 3fae291d..33874b74 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -97,6 +97,16 @@ The additive `compass.graph/1` endpoint matrix accepts exact `calls` edges to and object properties without changing node or edge identity; consumers that validate endpoint kinds should accept this existing-major widening. +Large universal-evidence collections now degrade explicitly instead of +silently publishing file scaffolding. Compass retains source declarations and +safe exact relationships in deterministic bounded partitions, records omitted +relationship candidates in publication statistics, and adds the +`universal_resolution_partial` error diagnostic to `compass.graph/1`. A build +that emits this diagnostic publishes the useful partial artifact but exits +nonzero. This is an additive diagnostic and completeness behavior change; the +graph schema major and identities of successfully resolved records are +unchanged. + The self-contained HTML viewer embeds `compass.viewer.workbench/1`, an additive ordered container for code, call, impact, affected, architecture, historical, and artifact-lens models. Each view carries explicit bounded coverage. Plain diff --git a/crates/compass-cli/src/lib.rs b/crates/compass-cli/src/lib.rs index cc3afdc1..0be80fbc 100644 --- a/crates/compass-cli/src/lib.rs +++ b/crates/compass-cli/src/lib.rs @@ -2217,9 +2217,7 @@ fn command_build_with_validation_inner( output.push_str(¬es.join("\n")); } let mut outcome = Outcome::success(output); - if let Some(warning) = format_partial_graph_warning(&result) { - outcome.stderr = warning; - } + apply_build_quality_outcome(&result, &mut outcome); if let Some(warning) = global_warning { if !outcome.stderr.is_empty() { outcome.stderr.push('\n'); @@ -5450,13 +5448,29 @@ fn format_program_analysis(result: &BuildResult) -> String { fn format_partial_graph_warning(result: &BuildResult) -> Option { result.partial_graph.then(|| { - format!( - "warning: Compass published a partial graph after omitting {} nodes and {} edges; {} identity collisions quarantined.", - result.omitted_nodes, result.omitted_edges, result.identity_collisions - ) + if result.resolution_degraded { + format!( + "error: Compass published a bounded partial graph because universal collection resolution omitted {} relationship candidates; declarations remain available. See graph diagnostic 'universal_resolution_partial'.", + result.omitted_edges + ) + } else { + format!( + "warning: Compass published a partial graph after omitting {} nodes and {} edges; {} identity collisions quarantined.", + result.omitted_nodes, result.omitted_edges, result.identity_collisions + ) + } }) } +fn apply_build_quality_outcome(result: &BuildResult, outcome: &mut Outcome) { + if let Some(warning) = format_partial_graph_warning(result) { + outcome.stderr = warning; + } + if result.resolution_degraded { + outcome.code = 1; + } +} + #[cfg(test)] mod mcp_option_tests { use super::*; @@ -5491,6 +5505,7 @@ mod mcp_option_tests { omitted_edges: 0, identity_collisions: 0, partial_graph: false, + resolution_degraded: false, html_written, outputs_changed, program_modules: 0, @@ -5522,6 +5537,23 @@ mod mcp_option_tests { ); } + #[test] + fn degraded_resolution_is_a_non_success_with_a_machine_diagnostic_pointer() { + let mut result = sample_build_result(true, false); + result.partial_graph = true; + result.resolution_degraded = true; + result.omitted_edges = 7; + let mut outcome = Outcome::success("graph published".to_owned()); + + apply_build_quality_outcome(&result, &mut outcome); + + assert_eq!(outcome.code, 1); + assert_eq!( + outcome.stderr, + "error: Compass published a bounded partial graph because universal collection resolution omitted 7 relationship candidates; declarations remain available. See graph diagnostic 'universal_resolution_partial'." + ); + } + #[test] fn html_open_confirmation_requires_explicit_yes() -> Result<(), Box> { let directory = tempfile::tempdir()?; diff --git a/crates/compass-core/src/pipeline.rs b/crates/compass-core/src/pipeline.rs index d27e792a..f134197d 100644 --- a/crates/compass-core/src/pipeline.rs +++ b/crates/compass-core/src/pipeline.rs @@ -53,7 +53,7 @@ use compass_output::{ use compass_resolve::{ ResolutionAdmission, apply_program_projection, collect_program_projection_sites, merge_decl_def_classes_if_needed, merge_decl_def_classes_if_needed_changed, - resolve_prevalidated_owned_with_root_at_inference, + resolve_prevalidated_owned_with_root_at_inference, universal_resolution_report, }; use compass_store::{ GRAPH_SCHEMA_V1, STORE_FILE_NAME, STORE_REF_FILE_NAME, SqliteStore, StoreRef, @@ -82,6 +82,10 @@ use crate::raw_guard::enforce_incomplete_raw_guard; pub const DEFAULT_MAX_SOURCE_BYTES: u64 = 16 * 1024 * 1024; const SEMANTIC_MARKER_FILE: &str = "semantic-marker.json"; const PIPELINE_RAYON_WORKER_CAP: usize = 12; +// Debug builds and deeply nested parser inputs can exhaust Rayon's platform +// default (commonly 2 MiB) while one worker owns the full collection pipeline. +// Keep the bound explicit and portable; stack pages remain demand-paged. +const PIPELINE_RAYON_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; const PARALLEL_AST_FACT_DIGEST_MIN_FILES: usize = 32; const STORE_SNAPSHOT_EXCLUSIONS: [&str; 3] = [STORE_FILE_NAME, "store.sqlite3-wal", "store.sqlite3-shm"]; @@ -202,6 +206,8 @@ struct OutputStats { omitted_edges: usize, #[serde(default)] identity_collisions: usize, + #[serde(default)] + resolution_degraded: bool, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -1855,6 +1861,7 @@ fn publish_fact_neutral_incremental( let published_nodes = current.nodes.len(); let published_edges = current.links.len(); let omissions = saved_publication_omissions(&output_dir); + let resolution_degraded = saved_resolution_degraded(&output_dir); let graph_path = output_dir.join("graph.json"); let (store_metrics, graph_seal) = if options.graph_storage.publishes_store() { let previous = previous.ok_or_else(|| { @@ -1929,6 +1936,7 @@ fn publish_fact_neutral_incremental( communities, clustered, omissions, + resolution_degraded, )?; write_ast_fact_digest_state(&output_dir, fact_state)?; write_semantic_marker(&output_dir, None)?; @@ -1978,7 +1986,8 @@ fn publish_fact_neutral_incremental( omitted_nodes: omissions.nodes, omitted_edges: omissions.edges, identity_collisions: omissions.identity_collisions, - partial_graph: omissions.is_partial(), + partial_graph: omissions.is_partial() || resolution_degraded, + resolution_degraded, html_written: false, outputs_changed: true, program_modules: 0, @@ -2010,6 +2019,8 @@ pub struct BuildResult { pub omitted_edges: usize, pub identity_collisions: usize, pub partial_graph: bool, + /// Universal collection resolution omitted candidates under its bounded strategy. + pub resolution_degraded: bool, pub html_written: bool, pub outputs_changed: bool, pub program_modules: usize, @@ -2245,6 +2256,7 @@ fn build_graph_inner( let worker_count = pipeline_rayon_workers(options); let pool = rayon::ThreadPoolBuilder::new() .num_threads(worker_count) + .stack_size(PIPELINE_RAYON_STACK_SIZE_BYTES) .thread_name(|index| format!("compass-pipeline-{index}")) .build() .map_err(|error| CoreError::WorkerPool(error.to_string()))?; @@ -2463,7 +2475,9 @@ fn build_graph_inner_unscoped( identity_collisions: state.stats.identity_collisions, partial_graph: state.stats.omitted_nodes > 0 || state.stats.omitted_edges > 0 - || state.stats.identity_collisions > 0, + || state.stats.identity_collisions > 0 + || saved_resolution_degraded(&output_dir), + resolution_degraded: saved_resolution_degraded(&output_dir), html_written: output_dir.join("graph.html").is_file(), outputs_changed: false, program_modules: state.stats.program_modules, @@ -2546,7 +2560,8 @@ fn build_graph_inner_unscoped( omitted_nodes: stats.omitted_nodes, omitted_edges: stats.omitted_edges, identity_collisions: stats.identity_collisions, - partial_graph: stats.omissions().is_partial(), + partial_graph: stats.omissions().is_partial() || stats.resolution_degraded, + resolution_degraded: stats.resolution_degraded, html_written: output_dir.join("graph.html").is_file(), outputs_changed: false, program_modules: program_modules(unchanged_program.as_ref()), @@ -3232,6 +3247,9 @@ fn build_graph_inner_unscoped( &root, resolution_admission, ); + let resolution_report = universal_resolution_report(&resolved).unwrap_or_default(); + let resolution_degraded = resolution_report.degraded; + let resolution_omitted_candidates = resolution_report.omitted_candidates; profile_internal("cross-file resolution total", &mut internal_started); drop(source_text); internal_started = Instant::now(); @@ -3310,6 +3328,7 @@ fn build_graph_inner_unscoped( && !source_removed && supplemental.is_empty() && semantic.is_some_and(semantic_layer_is_empty) + && !resolution_degraded && let Ok(document) = GraphDocument::load(&output_dir.join("graph.json")) { let omissions = saved_publication_omissions(&output_dir); @@ -3360,6 +3379,7 @@ fn build_graph_inner_unscoped( omitted_edges: omissions.edges, identity_collisions: omissions.identity_collisions, partial_graph: omissions.is_partial(), + resolution_degraded: false, html_written: false, outputs_changed: false, program_modules: program_modules(program.as_ref()), @@ -3436,7 +3456,10 @@ fn build_graph_inner_unscoped( if published.document.nodes.is_empty() { return Err(CoreError::EmptyGraph); } - let omissions = published.omissions; + let mut omissions = published.omissions; + omissions.edges = omissions + .edges + .saturating_add(resolution_omitted_candidates); let published_nodes = published.document.nodes.len(); let published_edges = published.document.links.len(); let no_cluster_graph_write_started = Instant::now(); @@ -3481,6 +3504,7 @@ fn build_graph_inner_unscoped( 0, false, omissions, + resolution_degraded, )?; write_ast_fact_digest_state(&output_dir, ¤t_fact_state)?; write_semantic_marker(&output_dir, semantic)?; @@ -3556,7 +3580,8 @@ fn build_graph_inner_unscoped( omitted_nodes: omissions.nodes, omitted_edges: omissions.edges, identity_collisions: omissions.identity_collisions, - partial_graph: omissions.is_partial(), + partial_graph: omissions.is_partial() || resolution_degraded, + resolution_degraded, html_written: false, outputs_changed: true, program_modules: program_modules(program.as_ref()), @@ -3633,7 +3658,8 @@ fn build_graph_inner_unscoped( apply_inference_level(&mut preflight.document, options.inference_level); let preflight_document = preflight.document.to_legacy_document()?; profile_internal_duration("graph.json v1 preflight", preflight_started.elapsed()); - if !preflight.omissions.is_partial() + if !resolution_degraded + && !preflight.omissions.is_partial() && GraphDocument::load(&output_dir.join("graph.json")) .is_ok_and(|existing| topology_is_unchanged(&existing, &preflight_document)) { @@ -3693,6 +3719,7 @@ fn build_graph_inner_unscoped( omitted_edges: 0, identity_collisions: 0, partial_graph: false, + resolution_degraded: false, html_written: output_dir.join("graph.html").is_file(), outputs_changed: false, program_modules: program_modules(program.as_ref()), @@ -3762,7 +3789,10 @@ fn build_graph_inner_unscoped( if published.document.nodes.is_empty() { return Err(CoreError::EmptyGraph); } - let omissions = published.omissions; + let mut omissions = published.omissions; + omissions.edges = omissions + .edges + .saturating_add(resolution_omitted_candidates); let report_health = current_orientation_health(options, omissions); // Legacy clustering and report code needs a compatibility projection, but // retaining it beside the complete typed authority doubles the dominant @@ -4063,6 +4093,7 @@ fn build_graph_inner_unscoped( communities.len(), true, omissions, + resolution_degraded, ) }, ); @@ -4114,7 +4145,8 @@ fn build_graph_inner_unscoped( omitted_nodes: omissions.nodes, omitted_edges: omissions.edges, identity_collisions: omissions.identity_collisions, - partial_graph: omissions.is_partial(), + partial_graph: omissions.is_partial() || resolution_degraded, + resolution_degraded, html_written, outputs_changed: true, program_modules: program_modules(program.as_ref()), @@ -6851,6 +6883,7 @@ fn unchanged_output_stats(options: &BuildOptions, output_dir: &Path) -> Option Option PublicationOmissions { .map_or_else(PublicationOmissions::default, |stats| stats.omissions()) } +fn saved_resolution_degraded(output_dir: &Path) -> bool { + fs::read(output_dir.join(OUTPUT_STATS_FILE)) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .is_some_and(|stats| stats.resolution_degraded) +} + fn save_output_stats( output_dir: &Path, nodes: usize, @@ -6898,6 +6939,7 @@ fn save_output_stats( communities: usize, clustered: bool, omissions: PublicationOmissions, + resolution_degraded: bool, ) -> Result<(), CoreError> { let graph_bytes = fs::metadata(output_dir.join("graph.json")) .map_err(|source| compass_files::FileError::Io { @@ -6916,6 +6958,7 @@ fn save_output_stats( omitted_nodes: omissions.nodes, omitted_edges: omissions.edges, identity_collisions: omissions.identity_collisions, + resolution_degraded, }, true, )?; diff --git a/crates/compass-resolve/src/evidence/api.rs b/crates/compass-resolve/src/evidence/api.rs index 479d1ead..1bbac654 100644 --- a/crates/compass-resolve/src/evidence/api.rs +++ b/crates/compass-resolve/src/evidence/api.rs @@ -1,5 +1,55 @@ //! Stable public contracts for universal evidence resolution. +use serde::{Deserialize, Serialize}; + +/// Internal extraction extension carrying the bounded collection-resolution outcome. +/// +/// The build pipeline consumes this before publication. The corresponding public +/// contract is the graph diagnostic emitted for degraded resolution. +pub const UNIVERSAL_RESOLUTION_REPORT_EXTENSION: &str = "_compass_universal_resolution_report"; + +/// Aggregate fact cardinalities used to select a bounded resolution strategy. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UniversalResolutionCounts { + pub declarations: usize, + pub bindings: usize, + pub occurrences: usize, + pub candidates: usize, + pub scopes: usize, +} + +impl UniversalResolutionCounts { + #[must_use] + pub const fn fits(self, limits: UniversalResolutionLimits) -> bool { + self.declarations <= limits.declarations + && self.bindings <= limits.bindings + && self.occurrences <= limits.occurrences + && self.candidates <= limits.candidates + && self.scopes <= limits.candidates + } +} + +/// Outcome of project-wide universal resolution. +/// +/// `degraded` means some relationship candidates could not safely be resolved +/// under the bounded partition strategy. Declarations that survive the selected +/// inference profile are still projected. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UniversalResolutionReport { + pub partitioned: bool, + pub degraded: bool, + pub partitions: usize, + pub failed_partitions: usize, + pub compacted_declarations: usize, + pub omitted_candidates: usize, + pub input: UniversalResolutionCounts, + pub retained: UniversalResolutionCounts, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + /// Aggregate and per-lookup limits for the universal evidence resolver. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct UniversalResolutionLimits { diff --git a/crates/compass-resolve/src/evidence/mod.rs b/crates/compass-resolve/src/evidence/mod.rs index 107433bb..4c416999 100644 --- a/crates/compass-resolve/src/evidence/mod.rs +++ b/crates/compass-resolve/src/evidence/mod.rs @@ -21,6 +21,7 @@ mod budget; mod facts; mod index; mod languages; +mod partition; mod project; mod projection; mod resolve; @@ -28,7 +29,22 @@ mod resolve; use projection::is_deferred_receiver; pub(crate) use projection::is_replaced_relation; -pub use api::{ResolutionDecision, ResolutionEvidence, ResolutionRule, UniversalResolutionLimits}; +pub use api::{ + ResolutionDecision, ResolutionEvidence, ResolutionRule, UNIVERSAL_RESOLUTION_REPORT_EXTENSION, + UniversalResolutionCounts, UniversalResolutionLimits, UniversalResolutionReport, +}; +pub(crate) use partition::materialize_bounded_owned; + +#[must_use] +pub fn universal_resolution_report( + extraction: &compass_languages::Extraction, +) -> Option { + extraction + .extensions + .get(UNIVERSAL_RESOLUTION_REPORT_EXTENSION) + .cloned() + .and_then(|value| serde_json::from_value(value).ok()) +} use budget::LookupBudget; use facts::{ CandidateSlot, CandidateTable, CandidateTableBuilder, FactStore, FactTable, OccurrenceRef, diff --git a/crates/compass-resolve/src/evidence/partition.rs b/crates/compass-resolve/src/evidence/partition.rs new file mode 100644 index 00000000..64ff240d --- /dev/null +++ b/crates/compass-resolve/src/evidence/partition.rs @@ -0,0 +1,684 @@ +//! Bounded collection resolution and low-inference evidence compaction. + +use std::collections::{BTreeSet, VecDeque}; +use std::path::Path; + +use ahash::AHashSet; +use compass_languages::{ + CandidateRelation, EvidenceLimits, HierarchyConstraint, RawEdgeRecord as EdgeRecord, + RawNodeRecord as NodeRecord, SemanticEvidenceBatch, validate_evidence, +}; + +use crate::ResolutionAdmission; + +use super::{ + UniversalResolutionCounts, UniversalResolutionIndex, UniversalResolutionLimits, + UniversalResolutionReport, +}; + +const LOW_DETAIL_DECLARATION_KINDS: &[&str] = &[ + "const_parameter", + "lifetime_parameter", + "parameter", + "property", + "type_parameter", +]; + +pub(crate) fn materialize_bounded_owned( + mut batches: Vec, + project_edges: &[EdgeRecord], + root: &Path, + limits: UniversalResolutionLimits, + admission: ResolutionAdmission, + prevalidated: bool, + output: (&mut Vec, &mut Vec), +) -> UniversalResolutionReport { + let (nodes, edges) = output; + let inventory_node_count = nodes.len(); + batches.sort_by(|left, right| batch_source_key(left).cmp(&batch_source_key(right))); + let input = evidence_counts(&batches); + if !prevalidated && let Err(reason) = validate_batches_for_projection(&batches) { + return UniversalResolutionReport { + degraded: true, + failed_partitions: 1, + omitted_candidates: input.candidates, + input, + retained: input, + reason: Some(reason), + ..UniversalResolutionReport::default() + }; + } + let compacted_declarations = if admission == ResolutionAdmission::Low { + compact_low_inference_evidence(&mut batches) + } else { + 0 + }; + let retained = evidence_counts(&batches); + + // Declaration projection is independent from project-wide target selection. + // Do it before constructing any large indexes so an index limit or corrupt + // secondary lookup can never collapse a repository to file scaffolding. + let graph_ids = super::projection::project_declaration_batches(&batches, nodes); + for declaration in batches.iter_mut().flat_map(|batch| &mut batch.declarations) { + if let Some(graph_node_id) = graph_ids.get(&declaration.id) { + declaration.graph_node_id.clone_from(graph_node_id); + } + } + drop(graph_ids); + + if retained.fits(limits) { + let candidate_count = retained.candidates; + let index = if prevalidated { + UniversalResolutionIndex::new_with_prevalidated_project_inventory_owned_at_inference( + batches, + &nodes[..inventory_node_count], + project_edges, + root, + limits, + admission, + ) + } else { + UniversalResolutionIndex::new_with_project_inventory_owned( + batches, + &nodes[..inventory_node_count], + project_edges, + root, + limits, + ) + }; + return match index { + Ok(index) => { + index.materialize_relationships_at_inference(nodes, edges, admission); + UniversalResolutionReport { + partitions: 1, + compacted_declarations, + input, + retained, + ..UniversalResolutionReport::default() + } + } + Err(error) => UniversalResolutionReport { + degraded: true, + partitions: 1, + failed_partitions: 1, + compacted_declarations, + omitted_candidates: candidate_count, + input, + retained, + reason: Some(error), + ..UniversalResolutionReport::default() + }, + }; + } + + let mut omitted_candidates = 0_usize; + for batch in &mut batches { + let before = batch.candidates.len(); + batch + .candidates + .retain(|candidate| candidate.constraints.exact_target_declaration_id.is_some()); + omitted_candidates = + omitted_candidates.saturating_add(before.saturating_sub(batch.candidates.len())); + let retained_occurrences = batch + .candidates + .iter() + .filter_map(|candidate| candidate.occurrence_id.clone()) + .collect::>(); + batch + .occurrences + .retain(|occurrence| retained_occurrences.contains(occurrence.id.as_str())); + // Exact-target resolution does not consult lexical scopes or bindings. + // Declaration definition anchors were already projected above. + batch.scopes.clear(); + batch.bindings.clear(); + } + let partition_limits = partition_target(limits); + let mut queue = VecDeque::from(pack_partitions(batches, partition_limits)); + let mut report = UniversalResolutionReport { + partitioned: true, + degraded: true, + compacted_declarations, + omitted_candidates, + input, + retained, + reason: Some(format!( + "aggregate universal evidence exceeds one bounded resolver: declarations {}>{}, bindings {}>{}, occurrences {}>{}, candidates {}>{}, scopes {}>{}", + retained.declarations, + limits.declarations, + retained.bindings, + limits.bindings, + retained.occurrences, + limits.occurrences, + retained.candidates, + limits.candidates, + retained.scopes, + limits.candidates, + )), + ..UniversalResolutionReport::default() + }; + + while let Some(mut partition) = queue.pop_front() { + let declaration_ids = partition + .iter() + .flat_map(|batch| batch.declarations.iter()) + .map(|declaration| declaration.id.clone()) + .collect::>(); + let mut omitted = 0_usize; + for batch in &mut partition { + let before = batch.candidates.len(); + batch.candidates.retain(|candidate| { + declaration_ids.contains(candidate.source_declaration_id.as_str()) + && candidate + .constraints + .exact_target_declaration_id + .as_deref() + .is_some_and(|target| declaration_ids.contains(target)) + }); + omitted = omitted.saturating_add(before.saturating_sub(batch.candidates.len())); + } + report.omitted_candidates = report.omitted_candidates.saturating_add(omitted); + + let retained_candidates = partition + .iter() + .map(|batch| batch.candidates.len()) + .sum::(); + // An aggregate-overflow partition has only local visibility. Force the + // conservative admission profile even when the caller requested a + // richer one, because closed-world inference would be unsound inside + // a partial repository view. Every retained candidate carries an + // adapter-proven exact target ID. + let partition_admission = ResolutionAdmission::Low; + let index = + UniversalResolutionIndex::new_with_prevalidated_project_inventory_owned_at_inference( + partition, + &nodes[..inventory_node_count], + &[], + root, + limits, + partition_admission, + ); + report.partitions = report.partitions.saturating_add(1); + match index { + Ok(index) => { + index.materialize_relationships_at_inference(nodes, edges, partition_admission); + } + Err(error) => { + report.failed_partitions = report.failed_partitions.saturating_add(1); + report.omitted_candidates = report + .omitted_candidates + .saturating_add(retained_candidates); + if report.reason.as_deref().is_none_or(str::is_empty) { + report.reason = Some(error); + } + } + } + } + report +} + +fn validate_batches_for_projection(batches: &[SemanticEvidenceBatch]) -> Result<(), String> { + let mut fact_ids = AHashSet::new(); + for batch in batches { + validate_evidence(batch, EvidenceLimits::default()).map_err(|error| error.to_string())?; + for id in batch + .declarations + .iter() + .map(|fact| &fact.id) + .chain(batch.scopes.iter().map(|fact| &fact.id)) + .chain(batch.bindings.iter().map(|fact| &fact.id)) + .chain(batch.occurrences.iter().map(|fact| &fact.id)) + .chain(batch.candidates.iter().map(|fact| &fact.id)) + { + if !fact_ids.insert(id.as_str()) { + return Err(format!("duplicate universal fact id `{id}` across batches")); + } + } + } + Ok(()) +} + +fn compact_low_inference_evidence(batches: &mut [SemanticEvidenceBatch]) -> usize { + let mut required_details = AHashSet::::new(); + let mut used_bindings = AHashSet::::new(); + for candidate in batches.iter().flat_map(|batch| &batch.candidates) { + if !matches!( + candidate.relation, + CandidateRelation::Contains | CandidateRelation::Owns + ) { + required_details.insert(candidate.source_declaration_id.clone()); + if let Some(target) = &candidate.constraints.exact_target_declaration_id { + required_details.insert(target.clone()); + } + if let Some(binding) = &candidate.binding_id { + used_bindings.insert(binding.clone()); + } + if let Some(HierarchyConstraint::RustAssociatedType { + receiver_declaration_id, + .. + }) = &candidate.constraints.hierarchy + { + required_details.insert(receiver_declaration_id.clone()); + } + } + } + for binding in batches + .iter() + .flat_map(|batch| &batch.bindings) + .filter(|binding| used_bindings.contains(&binding.id)) + { + if let Some(target) = &binding.target_declaration_id { + required_details.insert(target.clone()); + } + } + // A scope owner is part of the lexical evidence contract. Preserve detail + // declarations that own scopes so compaction cannot leave dangling scope + // references in an otherwise valid batch. + required_details.extend( + batches + .iter() + .flat_map(|batch| &batch.scopes) + .filter_map(|scope| scope.owner_declaration_id.clone()), + ); + // Some adapters intentionally leave lexical targets for the collection + // resolver instead of stamping an exact declaration ID. Preserve a leaf + // declaration whenever its spelling is used in the same source batch; + // otherwise compaction could erase a parameter/property before lexical + // resolution has the opportunity to prove it. + for batch in batches.iter() { + let referenced_spellings = batch + .occurrences + .iter() + .map(|occurrence| occurrence.spelling.as_str()) + .collect::>(); + required_details.extend( + batch + .declarations + .iter() + .filter(|declaration| { + LOW_DETAIL_DECLARATION_KINDS.contains(&declaration.kind.as_str()) + && referenced_spellings.contains(declaration.name.as_str()) + }) + .map(|declaration| declaration.id.clone()), + ); + } + + let mut removed_ids = AHashSet::::new(); + for declaration in batches.iter().flat_map(|batch| &batch.declarations) { + if LOW_DETAIL_DECLARATION_KINDS.contains(&declaration.kind.as_str()) + && !required_details.contains(&declaration.id) + { + removed_ids.insert(declaration.id.clone()); + } + } + if removed_ids.is_empty() { + return 0; + } + + let mut removed_binding_ids = AHashSet::new(); + for batch in batches.iter_mut() { + batch + .declarations + .retain(|declaration| !removed_ids.contains(&declaration.id)); + batch + .occurrences + .retain(|occurrence| !removed_ids.contains(&occurrence.owner_declaration_id)); + batch.bindings.retain(|binding| { + let keep = binding + .target_declaration_id + .as_ref() + .is_none_or(|target| !removed_ids.contains(target)); + if !keep { + removed_binding_ids.insert(binding.id.clone()); + } + keep + }); + } + for batch in batches { + batch.candidates.retain(|candidate| { + !removed_ids.contains(&candidate.source_declaration_id) + && candidate + .constraints + .exact_target_declaration_id + .as_ref() + .is_none_or(|target| !removed_ids.contains(target)) + && candidate + .binding_id + .as_ref() + .is_none_or(|binding| !removed_binding_ids.contains(binding)) + }); + } + removed_ids.len() +} + +fn pack_partitions( + batches: Vec, + limits: UniversalResolutionLimits, +) -> Vec> { + let mut partitions = Vec::new(); + let mut current = Vec::new(); + let mut counts = UniversalResolutionCounts::default(); + for batch in batches { + let batch_counts = evidence_counts(std::slice::from_ref(&batch)); + let next = add_counts(counts, batch_counts); + if !current.is_empty() && !next.fits(limits) { + partitions.push(std::mem::take(&mut current)); + counts = UniversalResolutionCounts::default(); + } + counts = add_counts(counts, batch_counts); + current.push(batch); + } + if !current.is_empty() { + partitions.push(current); + } + partitions +} + +fn partition_target(limits: UniversalResolutionLimits) -> UniversalResolutionLimits { + UniversalResolutionLimits { + declarations: (limits.declarations / 2).max(1), + bindings: (limits.bindings / 2).max(1), + occurrences: (limits.occurrences / 2).max(1), + candidates: (limits.candidates / 2).max(1), + candidates_per_lookup: limits.candidates_per_lookup, + } +} + +fn evidence_counts(batches: &[SemanticEvidenceBatch]) -> UniversalResolutionCounts { + batches + .iter() + .fold(UniversalResolutionCounts::default(), |counts, batch| { + add_counts( + counts, + UniversalResolutionCounts { + declarations: batch.declarations.len(), + bindings: batch.bindings.len(), + occurrences: batch.occurrences.len(), + candidates: batch.candidates.len(), + scopes: batch.scopes.len(), + }, + ) + }) +} + +fn add_counts( + left: UniversalResolutionCounts, + right: UniversalResolutionCounts, +) -> UniversalResolutionCounts { + UniversalResolutionCounts { + declarations: left.declarations.saturating_add(right.declarations), + bindings: left.bindings.saturating_add(right.bindings), + occurrences: left.occurrences.saturating_add(right.occurrences), + candidates: left.candidates.saturating_add(right.candidates), + scopes: left.scopes.saturating_add(right.scopes), + } +} + +fn batch_source_key(batch: &SemanticEvidenceBatch) -> (&str, &str) { + let source = batch + .declarations + .first() + .map(|fact| fact.range.source_file.as_str()) + .or_else(|| { + batch + .scopes + .first() + .map(|fact| fact.range.source_file.as_str()) + }) + .or_else(|| { + batch + .bindings + .first() + .map(|fact| fact.range.source_file.as_str()) + }) + .or_else(|| { + batch + .occurrences + .first() + .map(|fact| fact.range.source_file.as_str()) + }) + .unwrap_or_default(); + (source, batch.adapter.language.as_str()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use compass_languages::Engine; + use serde_json::{Map, Value}; + + use super::*; + + #[test] + fn low_compaction_drops_unreferenced_detail_declarations() + -> Result<(), Box> { + let mut extraction = Engine::default().extract_source( + Path::new("src/example.ts"), + b"export function run(unused: string): void { return }", + )?; + let mut batches = vec![ + extraction + .semantic_evidence + .take() + .ok_or("missing evidence")?, + ]; + let before = batches[0].declarations.len(); + + let removed = compact_low_inference_evidence(&mut batches); + + assert!(removed > 0); + assert!(batches[0].declarations.len() < before); + assert!(batches[0].declarations.iter().all(|declaration| { + declaration.kind != "parameter" || declaration.name != "unused" + })); + validate_evidence(&batches[0], EvidenceLimits::default())?; + Ok(()) + } + + #[test] + fn low_compaction_preserves_referenced_detail_declarations() + -> Result<(), Box> { + let mut extraction = Engine::default().extract_source( + Path::new("src/example.ts"), + b"export function run(used: () => void): void { used() }", + )?; + let mut batches = vec![ + extraction + .semantic_evidence + .take() + .ok_or("missing evidence")?, + ]; + + compact_low_inference_evidence(&mut batches); + + assert!( + batches[0].declarations.iter().any(|declaration| { + declaration.kind == "parameter" && declaration.name == "used" + }), + "retained declarations: {:?}; occurrences: {:?}", + batches[0] + .declarations + .iter() + .map(|declaration| (&declaration.kind, &declaration.name)) + .collect::>(), + batches[0] + .occurrences + .iter() + .map(|occurrence| &occurrence.spelling) + .collect::>() + ); + validate_evidence(&batches[0], EvidenceLimits::default())?; + Ok(()) + } + + #[test] + fn partition_packing_is_source_ordered_and_bounded() -> Result<(), Box> { + let mut engine = Engine::default(); + let right = engine + .extract_source(Path::new("z.py"), b"def right():\n pass\n")? + .semantic_evidence + .ok_or("missing right evidence")?; + let left = engine + .extract_source(Path::new("a.py"), b"def left():\n pass\n")? + .semantic_evidence + .ok_or("missing left evidence")?; + let per_batch = evidence_counts(std::slice::from_ref(&left)); + let limits = UniversalResolutionLimits { + declarations: per_batch.declarations, + bindings: per_batch.bindings.max(1), + occurrences: per_batch.occurrences.max(1), + candidates: per_batch.candidates.max(per_batch.scopes).max(1), + candidates_per_lookup: 16, + }; + let mut batches = vec![right, left]; + batches.sort_by(|a, b| batch_source_key(a).cmp(&batch_source_key(b))); + + let partitions = pack_partitions(batches, limits); + + assert_eq!(partitions.len(), 2); + assert_eq!(batch_source_key(&partitions[0][0]).0, "a.py"); + assert_eq!(batch_source_key(&partitions[1][0]).0, "z.py"); + assert!( + partitions + .iter() + .all(|partition| evidence_counts(partition).fits(limits)) + ); + Ok(()) + } + + #[test] + fn aggregate_overflow_projects_all_declarations_deterministically() + -> Result<(), Box> { + let mut engine = Engine::default(); + let left = engine + .extract_source( + Path::new("a.py"), + b"def left():\n return 1\n\ndef call_left():\n return left()\n", + )? + .semantic_evidence + .ok_or("missing left evidence")?; + let right = engine + .extract_source( + Path::new("z.py"), + b"def right():\n return 2\n\ndef call_right():\n return right()\n", + )? + .semantic_evidence + .ok_or("missing right evidence")?; + let left_counts = evidence_counts(std::slice::from_ref(&left)); + let right_counts = evidence_counts(std::slice::from_ref(&right)); + let limits = UniversalResolutionLimits { + declarations: left_counts.declarations.max(right_counts.declarations), + bindings: left_counts.bindings.max(right_counts.bindings).max(1), + occurrences: left_counts.occurrences.max(right_counts.occurrences).max(1), + candidates: left_counts + .candidates + .max(right_counts.candidates) + .max(left_counts.scopes) + .max(right_counts.scopes) + .max(1), + candidates_per_lookup: 32, + }; + let materialize = |batches| { + let mut nodes = Vec::new(); + let mut edges = Vec::new(); + let report = materialize_bounded_owned( + batches, + &[], + Path::new("."), + limits, + ResolutionAdmission::Max, + true, + (&mut nodes, &mut edges), + ); + (report, nodes, edges) + }; + + let forward = materialize(vec![left.clone(), right.clone()]); + let reverse = materialize(vec![right, left]); + + assert!(forward.0.partitioned); + assert!(forward.0.degraded); + assert_eq!(forward.0.partitions, 2); + assert!(forward.1.len() >= forward.0.retained.declarations); + assert_eq!(forward, reverse); + Ok(()) + } + + #[test] + fn bounded_single_envelope_matches_direct_materialization() + -> Result<(), Box> { + let batch = Engine::default() + .extract_source( + Path::new("src/example.py"), + b"class Service:\n def run(self):\n return 1\n\ndef invoke():\n service = Service()\n return service.run()\n", + )? + .semantic_evidence + .ok_or("missing evidence")?; + let limits = UniversalResolutionLimits::default(); + let inventory_id = batch + .declarations + .iter() + .find(|declaration| declaration.kind == "file") + .map(|declaration| declaration.graph_node_id.clone()) + .filter(|id| !id.is_empty()) + .ok_or("missing file graph node id")?; + let inventory = vec![NodeRecord { + id: inventory_id, + attributes: Map::from_iter([("inventory_marker".to_owned(), Value::Bool(true))]), + }]; + let direct = UniversalResolutionIndex::new_with_project_inventory_owned( + vec![batch.clone()], + &inventory, + &[], + Path::new("."), + limits, + )?; + let mut direct_nodes = inventory.clone(); + let mut direct_edges = Vec::new(); + direct.materialize(&mut direct_nodes, &mut direct_edges); + let mut bounded_nodes = inventory; + let mut bounded_edges = Vec::new(); + + let report = materialize_bounded_owned( + vec![batch], + &[], + Path::new("."), + limits, + ResolutionAdmission::Max, + false, + (&mut bounded_nodes, &mut bounded_edges), + ); + + assert!(!report.degraded); + assert_eq!(bounded_nodes, direct_nodes); + assert_eq!(bounded_edges, direct_edges); + Ok(()) + } + + #[test] + fn unvalidated_evidence_is_rejected_before_declaration_projection() + -> Result<(), Box> { + let mut batch = Engine::default() + .extract_source(Path::new("src/example.py"), b"def run():\n return 1\n")? + .semantic_evidence + .ok_or("missing evidence")?; + batch.adapter.language.clear(); + let mut nodes = Vec::new(); + let mut edges = Vec::new(); + + let report = materialize_bounded_owned( + vec![batch], + &[], + Path::new("."), + UniversalResolutionLimits::default(), + ResolutionAdmission::Max, + false, + (&mut nodes, &mut edges), + ); + + assert!(report.degraded); + assert!(nodes.is_empty()); + assert!(edges.is_empty()); + Ok(()) + } +} diff --git a/crates/compass-resolve/src/evidence/projection/mod.rs b/crates/compass-resolve/src/evidence/projection/mod.rs index 6b3f2659..ffd897d1 100644 --- a/crates/compass-resolve/src/evidence/projection/mod.rs +++ b/crates/compass-resolve/src/evidence/projection/mod.rs @@ -45,13 +45,13 @@ impl UniversalResolutionIndex { self.materialize_inner(nodes, edges, ResolutionAdmission::Max, false); } - pub(crate) fn materialize_at_inference( + pub(crate) fn materialize_relationships_at_inference( &self, nodes: &mut Vec, edges: &mut Vec, admission: ResolutionAdmission, ) { - self.materialize_inner(nodes, edges, admission, true); + self.materialize_inner_with_declarations(nodes, edges, admission, true, false); } fn materialize_inner( @@ -60,50 +60,69 @@ impl UniversalResolutionIndex { edges: &mut Vec, admission: ResolutionAdmission, release_resolution_indexes: bool, + ) { + self.materialize_inner_with_declarations( + nodes, + edges, + admission, + release_resolution_indexes, + true, + ); + } + + fn materialize_inner_with_declarations( + &self, + nodes: &mut Vec, + edges: &mut Vec, + admission: ResolutionAdmission, + release_resolution_indexes: bool, + project_declarations: bool, ) { let mut profile_started = Instant::now(); - let overloads = declaration_overloads(self.facts.declarations.values()); let graph_ids = materialized_declaration_ids(self.facts.declarations.values()); - let existing_positions = nodes - .iter() - .enumerate() - .map(|(index, node)| (node.id.clone(), index)) - .collect::>(); - let mut existing_nodes = nodes - .iter() - .map(|node| node.id.clone()) - .collect::>(); - let mut declarations = self.facts.declarations.values().collect::>(); - declarations.sort_unstable_by(|left, right| left.id.cmp(&right.id)); - const DECLARATION_BATCH_SIZE: usize = 8_192; - for declaration_batch in declarations.chunks(DECLARATION_BATCH_SIZE) { - let prepared = declaration_batch - .par_iter() - .map(|declaration| { - let graph_node_id = &graph_ids[&declaration.id]; - let definition_range = self.facts.definition_ranges.get(&declaration.id); - let node = declaration_node(declaration, definition_range, graph_node_id); - let discriminator = overloads.get(&declaration.id).cloned(); - (node, discriminator) - }) - .collect::>(); - for (mut node, discriminator) in prepared { - if let Some(index) = existing_positions.get(&node.id) { - nodes[*index].attributes.extend(node.attributes); - if let Some(discriminator) = discriminator { - nodes[*index].attributes.insert( - "overload_discriminator".to_owned(), - Value::String(discriminator), - ); - } - } else if existing_nodes.insert(node.id.clone()) { - if let Some(discriminator) = discriminator { - node.attributes.insert( - "overload_discriminator".to_owned(), - Value::String(discriminator), - ); + if project_declarations { + let overloads = declaration_overloads(self.facts.declarations.values()); + let existing_positions = nodes + .iter() + .enumerate() + .map(|(index, node)| (node.id.clone(), index)) + .collect::>(); + let mut existing_nodes = nodes + .iter() + .map(|node| node.id.clone()) + .collect::>(); + let mut declarations = self.facts.declarations.values().collect::>(); + declarations.sort_unstable_by(|left, right| left.id.cmp(&right.id)); + const DECLARATION_BATCH_SIZE: usize = 8_192; + for declaration_batch in declarations.chunks(DECLARATION_BATCH_SIZE) { + let prepared = declaration_batch + .par_iter() + .map(|declaration| { + let graph_node_id = &graph_ids[&declaration.id]; + let definition_range = self.facts.definition_ranges.get(&declaration.id); + let node = declaration_node(declaration, definition_range, graph_node_id); + let discriminator = overloads.get(&declaration.id).cloned(); + (node, discriminator) + }) + .collect::>(); + for (mut node, discriminator) in prepared { + if let Some(index) = existing_positions.get(&node.id) { + nodes[*index].attributes.extend(node.attributes); + if let Some(discriminator) = discriminator { + nodes[*index].attributes.insert( + "overload_discriminator".to_owned(), + Value::String(discriminator), + ); + } + } else if existing_nodes.insert(node.id.clone()) { + if let Some(discriminator) = discriminator { + node.attributes.insert( + "overload_discriminator".to_owned(), + Value::String(discriminator), + ); + } + nodes.push(node); } - nodes.push(node); } } } @@ -327,6 +346,10 @@ impl UniversalResolutionIndex { .flatten() .collect::>(); let mut resolved_targets = Vec::with_capacity(prepared_targets.len()); + let mut existing_nodes = nodes + .iter() + .map(|node| node.id.clone()) + .collect::>(); for mut prepared in prepared_targets { let Some(original_candidate) = self.facts.candidates.at(prepared.candidate_slot) else { continue; @@ -608,6 +631,79 @@ impl UniversalResolutionIndex { } } +pub(super) fn project_declaration_batches( + batches: &[SemanticEvidenceBatch], + nodes: &mut Vec, +) -> AHashMap { + let mut declarations = batches + .iter() + .flat_map(|batch| &batch.declarations) + .collect::>(); + let overloads = declaration_overloads(declarations.iter().copied()); + let graph_ids = materialized_declaration_ids(declarations.iter().copied()); + let definition_ranges = batch_definition_ranges(batches, &declarations); + let existing_positions = nodes + .iter() + .enumerate() + .map(|(index, node)| (node.id.clone(), index)) + .collect::>(); + let mut existing_nodes = nodes + .iter() + .map(|node| node.id.clone()) + .collect::>(); + declarations.sort_unstable_by(|left, right| left.id.cmp(&right.id)); + for declaration in declarations { + let mut node = declaration_node( + declaration, + definition_ranges.get(&declaration.id), + &graph_ids[&declaration.id], + ); + if let Some(discriminator) = overloads.get(&declaration.id) { + node.attributes.insert( + "overload_discriminator".to_owned(), + Value::String(discriminator.clone()), + ); + } + if let Some(index) = existing_positions.get(&node.id) { + nodes[*index].attributes.extend(node.attributes); + } else if existing_nodes.insert(node.id.clone()) { + nodes.push(node); + } + } + graph_ids +} + +fn batch_definition_ranges( + batches: &[SemanticEvidenceBatch], + declarations: &[&DeclarationFact], +) -> BTreeMap { + let declarations = declarations + .iter() + .map(|declaration| (declaration.id.as_str(), *declaration)) + .collect::>(); + let mut ranges = BTreeMap::new(); + let mut ambiguous = BTreeSet::new(); + for scope in batches.iter().flat_map(|batch| &batch.scopes) { + let Some(owner_id) = scope.owner_declaration_id.as_ref() else { + continue; + }; + let Some(declaration) = declarations.get(owner_id.as_str()) else { + continue; + }; + if !range_contains(&scope.range, &declaration.range) || ambiguous.contains(owner_id) { + continue; + } + if ranges + .insert(owner_id.clone(), scope.range.clone()) + .is_some() + { + ranges.remove(owner_id); + ambiguous.insert(owner_id.clone()); + } + } + ranges +} + #[cfg(test)] mod tests { use super::{EDGE_MATERIALIZATION_BATCH_SIZE, next_edge_materialization_batch}; diff --git a/crates/compass-resolve/src/lib.rs b/crates/compass-resolve/src/lib.rs index d62de5a3..56d33956 100644 --- a/crates/compass-resolve/src/lib.rs +++ b/crates/compass-resolve/src/lib.rs @@ -5,6 +5,7 @@ pub mod frameworks; mod members; mod program; +pub use evidence::universal_resolution_report; pub use members::resolve_language_calls; pub use program::{ ProgramProjectionSites, apply_program_projection, collect_program_projection_sites, @@ -59,6 +60,7 @@ use compass_languages::{ RawNodeRecord as NodeRecord, SemanticEvidenceBatch, SemanticRole, file_stem, is_language_builtin_global, make_id, parse_jsonc, }; +use compass_model::code_graph::{DiagnosticSeverity, GraphDiagnostic}; use compass_model::provenance::{ EndpointRewriteEvidence, EndpointRewriteRule, append_endpoint_rewrite_evidence, preserve_occurrence_rule, @@ -69,6 +71,7 @@ use sha1::{Digest, Sha1}; const DECLARATION_SUFFIXES: &[&str] = &["h", "hpp", "hh", "hxx"]; const IMPLEMENTATION_SUFFIXES: &[&str] = &["m", "mm", "cpp", "cc", "cxx", "c"]; +const GRAPH_DIAGNOSTICS_EXTENSION: &str = "_compass_v1_graph_diagnostics"; /// Collapse a clean sibling header/implementation declaration pair before /// portable file-prefix remapping would split their shared symbol IDs. @@ -1117,37 +1120,16 @@ fn finish_resolution( let project_edges = project_resolution .as_ref() .map_or(&[][..], |project| project.edges.as_slice()); - let index = if evidence_prevalidated { - evidence::UniversalResolutionIndex::new_with_prevalidated_project_inventory_owned_at_inference( - evidence_batches, - &merged.nodes, - project_edges, - &canonical_root, - evidence::UniversalResolutionLimits::default(), - admission, - ) - } else { - evidence::UniversalResolutionIndex::new_with_project_inventory_owned( - evidence_batches, - &merged.nodes, - project_edges, - &canonical_root, - evidence::UniversalResolutionLimits::default(), - ) - }; - match index { - Ok(index) => { - index.materialize_at_inference(&mut merged.nodes, &mut merged.edges, admission) - } - Err(error) => { - if std::env::var_os("COMPASS_PROFILE_INTERNAL").is_some() { - eprintln!("[compass internal] universal resolution failed: {error}"); - } - merged - .error - .get_or_insert_with(|| format!("universal resolution failed: {error}")); - } - } + let report = evidence::materialize_bounded_owned( + evidence_batches, + project_edges, + &canonical_root, + evidence::UniversalResolutionLimits::default(), + admission, + evidence_prevalidated, + (&mut merged.nodes, &mut merged.edges), + ); + append_universal_resolution_report(&mut merged, &report); } profile_internal("resolver universal evidence", &mut profile_started); restore_framework_callable_names(&mut merged, sources, &canonical_root); @@ -1252,6 +1234,65 @@ fn finish_resolution( merged } +fn append_universal_resolution_report( + extraction: &mut Extraction, + report: &evidence::UniversalResolutionReport, +) { + if let Ok(value) = serde_json::to_value(report) { + extraction.extensions.insert( + evidence::UNIVERSAL_RESOLUTION_REPORT_EXTENSION.to_owned(), + value, + ); + } + let mut diagnostics = extraction + .extensions + .remove(GRAPH_DIAGNOSTICS_EXTENSION) + .and_then(|value| serde_json::from_value::>(value).ok()) + .unwrap_or_default(); + if report.compacted_declarations > 0 { + diagnostics.push(GraphDiagnostic { + severity: DiagnosticSeverity::Info, + code: "low_inference_declaration_compaction".to_owned(), + message: format!( + "low inference omitted {} unreferenced parameter/property declarations before project resolution", + report.compacted_declarations + ), + anchor: None, + related_ids: Vec::new(), + }); + } + if report.degraded { + let reason = report + .reason + .as_deref() + .unwrap_or("bounded universal resolution could not complete") + .chars() + .take(1_024) + .collect::(); + diagnostics.push(GraphDiagnostic { + severity: DiagnosticSeverity::Error, + code: "universal_resolution_partial".to_owned(), + message: format!( + "published bounded partial universal resolution across {} partitions; {} relationship candidates were omitted and {} partitions failed: {reason}", + report.partitions, report.omitted_candidates, report.failed_partitions + ), + anchor: None, + related_ids: Vec::new(), + }); + } + diagnostics.sort_by(|left, right| { + left.code + .cmp(&right.code) + .then_with(|| left.message.cmp(&right.message)) + }); + diagnostics.dedup(); + if let Ok(value) = serde_json::to_value(diagnostics) { + extraction + .extensions + .insert(GRAPH_DIAGNOSTICS_EXTENSION.to_owned(), value); + } +} + fn profile_internal(label: &str, started: &mut Instant) { if std::env::var_os("COMPASS_PROFILE_INTERNAL").is_some() { eprintln!( @@ -5628,6 +5669,35 @@ mod tests { use compass_graph::{build_from_extraction, normalize_document_v1}; use serde_json::json; + #[test] + fn degraded_universal_resolution_is_machine_visible() { + let mut extraction = Extraction::default(); + let report = evidence::UniversalResolutionReport { + partitioned: true, + degraded: true, + partitions: 3, + failed_partitions: 1, + omitted_candidates: 17, + reason: Some("bounded test failure".to_owned()), + ..evidence::UniversalResolutionReport::default() + }; + + append_universal_resolution_report(&mut extraction, &report); + + assert_eq!(universal_resolution_report(&extraction), Some(report)); + let diagnostics = extraction + .extensions + .get(GRAPH_DIAGNOSTICS_EXTENSION) + .cloned() + .and_then(|value| serde_json::from_value::>(value).ok()) + .unwrap_or_default(); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic.severity == DiagnosticSeverity::Error + && diagnostic.code == "universal_resolution_partial" + && diagnostic.message.contains("17 relationship candidates") + })); + } + fn node(id: &str, label: &str, source_file: &str, kind: &str) -> NodeRecord { let mut attributes = Map::new(); attributes.insert("label".to_owned(), Value::String(label.to_owned())); diff --git a/docs/reference/universal-semantic-evidence.md b/docs/reference/universal-semantic-evidence.md index 1db33da3..9c7d340b 100644 --- a/docs/reference/universal-semantic-evidence.md +++ b/docs/reference/universal-semantic-evidence.md @@ -307,6 +307,29 @@ sorts candidate identities, and applies this order: 6. source-scoped qualified external endpoint when explicitly allowed; 7. ambiguous or unresolved. +At low inference, Compass first removes unreferenced leaf declaration details +such as parameters and type parameters that cannot contribute to an admitted +relationship. This compaction is deterministic and preserves every detail +used as a relationship source, exact target, binding target, or hierarchy +constraint. + +When aggregate evidence still exceeds one resolver envelope, Compass projects +all retained source declarations before building resolution indexes, then +packs source files into deterministic bounded partitions. Partitioned +resolution publishes only relationships whose source and parser-proven exact +target are both present in the same partition. It does not use partition-local +uniqueness for name-based target selection, because declarations outside that +partition could make such a selection ambiguous. Candidates that cannot be +proved safely are counted as omitted. + +A partitioned or failed collection resolution publishes a useful partial graph +rather than file scaffolding: retained declarations remain queryable, exact +safe relationships are preserved, and `compass.graph/1` includes the error +diagnostic `universal_resolution_partial` with bounded counts and a reason. +The build command reports the exact relationship omission count and exits +nonzero so automation cannot mistake partial collection resolution for a +complete graph. Low-profile compaction alone is informational and successful. + Explicit bindings precede lexical lookup because a source import is direct use-site evidence and must shadow a same-named enclosing declaration. A binding can name an exact declaration, qualified declaration, or source