Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
limits, profiles and semantic mode, with bounded disposable storage and
corruption fallback. Reuse one pinned engine across CLI page widening.

- Fix six verified graph edge cases: unchanged communities are reclustered
after topology splits; hierarchy reconciliation keeps IDs unique; Markdown
and R identities preserve distinct rows, names and lexical scopes; empty
shell files publish without a zero-range entrypoint; and HTML table/link
occurrences retain their distinct identities and edge sources. Invalidate
old extraction and graph-publication caches while keeping known hierarchy
signature-v1 artifacts readable.

## 0.4.1 - 2026-10-01

- Match engineering business vocabulary through bounded synonyms and witnessed
Expand Down
36 changes: 36 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,42 @@ and AST cache semantics are unchanged. Content reads use the configured graph
byte cap; this correctness correction makes no latency or memory improvement
claim.

## Extractor identity and community reconciliation corrections

The AST extraction cache identity advances from 15 to 16, and graph publication
semantics advance from compass.graph.publication/1 to /2. Existing current
builds invalidate their cached extraction/publication state and rebuild
coherently. The compass.graph/1 schema and universal evidence schema do not
change; published historical realizations remain immutable.

An empty Bash script publishes its file inventory without a synthetic
zero-range entrypoint. R function identities now include lexical owners for
nested functions, and exact case and punctuation remain part of the function
name. Top-level R qualified names keep their existing spelling. Markdown table
row identities append a digest of the full, exact identity key to the readable
qualified-name prefix; row and cell IDs remain stable across line shifts and
non-identity edits. Existing Markdown table-row and cell IDs change once on
rebuild.

HTML table and row nodes use source-occurrence identity, so equal visible text
does not merge different elements. Local HTML references originate at their
<a> or <link> node and point to the uniquely resolved target. A link to its
own fragment remains part of the link count but does not publish a self-loop.
Existing HTML table and row IDs change once on rebuild.

Incremental community clustering rechecks whether each prior community remains
connected in the current topology, even when its source files are unchanged.
Disconnected prior communities join the bounded affected region and are
reclustered; the existing affected-region limit can still select the full
detector fallback.

Hierarchy reconciliation uses hierarchy-signature/v2. If a fresh group ID
collides with an inherited ID, it receives the first available deterministic
digest ID within a bounded retry count, and reconciliation events name the
final IDs. Known hierarchy-signature/v1 artifacts remain readable. The
hierarchy schema stays at /1, and existing historical sidecars are not
rewritten.

## Go struct field declarations

The universal Go producer now publishes one `field` declaration per explicitly
Expand Down
2 changes: 1 addition & 1 deletion crates/compass-files/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use sha2::{Digest, Sha256};
use crate::{FileError, StatHashIndex, file_hash, io_error, write_bytes_atomic, write_json_atomic};

/// Changes whenever cached extraction semantics change, even if the wire encoding does not.
pub const AST_CACHE_VERSION: &str = "15";
pub const AST_CACHE_VERSION: &str = "16";
/// Portable cache encoding version used in the on-disk namespace.
pub const CACHE_ENCODING_VERSION: u32 = 1;
const MESSAGEPACK_EXTENSION: &str = "msgpack";
Expand Down
51 changes: 51 additions & 0 deletions crates/compass-graph/src/community/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,57 @@ mod tests {
Ok(())
}

#[test]
fn quality_incremental_run_reclusters_a_prior_community_split_by_unchanged_nodes()
-> Result<(), Box<dyn std::error::Error>> {
let document = planted_graph();
let no_changes = BTreeSet::new();
let initial = build_communities(
&document,
&CommunityRequest {
profile: CommunityProfile::QualityV1,
resolution: ResolutionPolicy::Fixed(1.0),
exclude_hubs_percentile: None,
previous: None,
incremental: false,
changed_sources: &no_changes,
limits: CommunityLimits::default(),
},
)?;
let previous = previous_from(&initial);
assert_eq!(
canonical_memberships(&initial.communities),
[["a", "b", "c", "d"], ["w", "x", "y", "z"]]
);

let mut split = document;
split.links.retain(|edge| {
!matches!(
(edge.source.as_str(), edge.target.as_str()),
("a", "c") | ("a", "d") | ("b", "c") | ("b", "d")
)
});
let mut limits = CommunityLimits::default();
limits.incremental.max_affected_fraction = 1.0;
for changed_sources in [BTreeSet::new(), BTreeSet::from(["src/z.rs".to_owned()])] {
let request = CommunityRequest {
profile: CommunityProfile::QualityV1,
resolution: ResolutionPolicy::Fixed(1.0),
exclude_hubs_percentile: None,
previous: Some(&previous),
incremental: true,
changed_sources: &changed_sources,
limits,
};
let first = build_communities(&split, &request)?;
let second = build_communities(&split, &request)?;
assert_eq!(first.quality.disconnected_community_count, 0);
assert_eq!(first.quality.assigned_node_count, split.nodes.len());
assert_eq!(first.communities, second.communities);
}
Ok(())
}

#[test]
fn quality_profile_recovers_planted_groups_and_is_permutation_invariant()
-> Result<(), Box<dyn std::error::Error>> {
Expand Down
165 changes: 143 additions & 22 deletions crates/compass-graph/src/community/hierarchy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@ pub const COMMUNITY_HIERARCHY_BUDGET: &str = "community-hierarchy-budget/v1";
pub const COMMUNITY_HIERARCHY_MERGE_POLICY: &str = "relationship-then-location-affinity/v1";

/// Identity of the rule that derives group signatures and durable ids.
pub const COMMUNITY_HIERARCHY_SIGNATURE_ALGORITHM: &str = "hierarchy-signature/v1";
pub const COMMUNITY_HIERARCHY_SIGNATURE_ALGORITHM: &str = "hierarchy-signature/v2";
const LEGACY_COMMUNITY_HIERARCHY_SIGNATURE_ALGORITHM: &str = "hierarchy-signature/v1";

/// Length of a group signature in hex characters.
const GROUP_SIGNATURE_LENGTH: usize = 16;
const GROUP_ID_DISAMBIGUATION_ATTEMPTS: usize = 1_024;

/// Root groups a hierarchy aims for.
pub const DEFAULT_ROOT_TARGET: usize = 24;
Expand Down Expand Up @@ -446,7 +448,9 @@ impl CommunityHierarchy {
if self.merge_policy != COMMUNITY_HIERARCHY_MERGE_POLICY {
return Err(invalid("unexpected merge policy"));
}
if self.signature_algorithm != COMMUNITY_HIERARCHY_SIGNATURE_ALGORITHM {
if self.signature_algorithm != COMMUNITY_HIERARCHY_SIGNATURE_ALGORITHM
&& self.signature_algorithm != LEGACY_COMMUNITY_HIERARCHY_SIGNATURE_ALGORITHM
{
return Err(invalid("unexpected signature algorithm"));
}
if self.budget.max_levels == 0
Expand Down Expand Up @@ -1067,7 +1071,7 @@ pub fn reconcile_hierarchy(
.iter()
.map(|group| group.member_count)
.collect::<Vec<_>>();
let next_ids = next.levels[position]
let mut next_ids = next.levels[position]
.groups
.iter()
.map(|group| group.id.clone())
Expand Down Expand Up @@ -1116,9 +1120,9 @@ pub fn reconcile_hierarchy(
if predecessors.get(*next_index).map_or(0, Vec::len) > 1 {
continue;
}
let Some(next_id) = next_ids.get(*next_index) else {
if next_ids.get(*next_index).is_none() {
continue;
};
}
if position > 0
&& let (Some(previous_parent), Some(next_parent)) = (
previous_parent_of(previous_index),
Expand All @@ -1135,33 +1139,48 @@ pub fn reconcile_hierarchy(
{
group.id = previous_id.clone();
}
let _ = next_id;
matched_here.insert(previous_index, *next_index);
stable += 1;
}
[(best, best_index), (runner_up, _), ..] => {
[(best, _), (runner_up, _), ..] => {
// Two successors this close mean the evidence does not name
// a single heir, so no id is inherited.
if best - runner_up <= policy.ambiguity_margin {
ambiguous += 1;
events.push(HierarchyEvent {
kind: HierarchyEventKind::Ambiguous,
level: next_level_number,
previous_ids: vec![previous_id.clone()],
next_ids: row
.iter()
.filter_map(|(_, next_index)| next_ids.get(*next_index).cloned())
.collect(),
overlap: *best,
member_count: next_members
.get(*best_index)
.copied()
.unwrap_or_default(),
});
}
}
}
}
let claimed = matched_here.values().copied().collect::<BTreeSet<_>>();
disambiguate_reconciled_group_ids(
next,
position,
next_level_number,
&mut next_ids,
&claimed,
)?;
for (previous_index, row) in rows.iter().enumerate() {
let [(best, best_index), (runner_up, _), ..] = row.as_slice() else {
continue;
};
if best - runner_up > policy.ambiguity_margin {
continue;
}
let Some(previous_id) = previous_ids.get(previous_index) else {
continue;
};
events.push(HierarchyEvent {
kind: HierarchyEventKind::Ambiguous,
level: next_level_number,
previous_ids: vec![previous_id.clone()],
next_ids: row
.iter()
.filter_map(|(_, next_index)| next_ids.get(*next_index).cloned())
.collect(),
overlap: *best,
member_count: next_members.get(*best_index).copied().unwrap_or_default(),
});
}
for (previous_index, row) in rows.iter().enumerate() {
if row.len() < 2 {
continue;
Expand Down Expand Up @@ -1230,7 +1249,6 @@ pub fn reconcile_hierarchy(
});
}
}
let claimed = matched_here.values().copied().collect::<BTreeSet<_>>();
for (next_index, next_id) in next_ids.iter().enumerate() {
if claimed.contains(&next_index) || !predecessors[next_index].is_empty() {
continue;
Expand Down Expand Up @@ -1286,6 +1304,100 @@ pub fn reconcile_hierarchy(
})
}

fn disambiguate_reconciled_group_ids(
next: &mut CommunityHierarchy,
level_index: usize,
level_number: usize,
next_ids: &mut [String],
retained_indices: &BTreeSet<usize>,
) -> Result<(), CommunityHierarchyArtifactError> {
let mut occupied = BTreeSet::<String>::new();
for index in retained_indices {
let id = next
.levels
.get(level_index)
.and_then(|level| level.groups.get(*index))
.map(|group| group.id.clone())
.ok_or_else(|| {
CommunityHierarchyArtifactError::InvalidEvidence(
"retained group is missing from its level".to_owned(),
)
})?;
if !occupied.insert(id.clone()) {
return Err(CommunityHierarchyArtifactError::InvalidEvidence(
"retained group ids are not unique within a level".to_owned(),
));
}
let Some(slot) = next_ids.get_mut(*index) else {
return Err(CommunityHierarchyArtifactError::InvalidEvidence(
"retained group id is missing its level entry".to_owned(),
));
};
slot.clone_from(&id);
}

let mut collisions = Vec::new();
for (index, id) in next_ids.iter().enumerate() {
if retained_indices.contains(&index) {
continue;
}
if !occupied.insert(id.clone()) {
collisions.push(index);
}
}

for index in collisions {
let signature = next
.levels
.get(level_index)
.and_then(|level| level.groups.get(index))
.map(|group| group.signature.clone())
.ok_or_else(|| {
CommunityHierarchyArtifactError::InvalidEvidence(
"colliding group is missing its signature".to_owned(),
)
})?;
let mut replacement = None;
for attempt in 0..GROUP_ID_DISAMBIGUATION_ATTEMPTS {
let seed = format!(
"{COMMUNITY_HIERARCHY_SIGNATURE_ALGORITHM}:collision:{level_number}:{signature}:{attempt}"
);
let digest = format!("{:x}", Sha256::digest(seed.as_bytes()));
let suffix = digest
.chars()
.take(GROUP_SIGNATURE_LENGTH)
.collect::<String>();
let candidate = format!("h{level_number}-{suffix}");
if occupied.insert(candidate.clone()) {
replacement = Some(candidate);
break;
}
}
let Some(id) = replacement else {
return Err(CommunityHierarchyArtifactError::InvalidEvidence(
"bounded group id collision disambiguation was exhausted".to_owned(),
));
};
let Some(slot) = next_ids.get_mut(index) else {
return Err(CommunityHierarchyArtifactError::InvalidEvidence(
"colliding group id is missing its level entry".to_owned(),
));
};
slot.clone_from(&id);
let Some(group) = next
.levels
.get_mut(level_index)
.and_then(|level| level.groups.get_mut(index))
else {
return Err(CommunityHierarchyArtifactError::InvalidEvidence(
"colliding group is missing from its level".to_owned(),
));
};
group.id = id;
}
Ok(())
}

struct LevelState {
/// The graph this level partitions: the typed projection for the finest
/// level, and the previous level's group graph above it.
Expand Down Expand Up @@ -2604,6 +2716,15 @@ mod tests {
Ok(())
}

#[test]
fn artifact_accepts_the_previous_known_signature_algorithm() -> TestResult {
let mut artifact = artifact(4, HierarchyBudget::default())?;
artifact.signature_algorithm = LEGACY_COMMUNITY_HIERARCHY_SIGNATURE_ALGORITHM.to_owned();
artifact.result_digest = artifact.calculate_digest()?;
artifact.validate()?;
Ok(())
}

#[test]
fn artifact_rejects_unknown_major_and_wrong_graph() -> TestResult {
let mut artifact = artifact(2, HierarchyBudget::default())?;
Expand Down
Loading
Loading