use std::collections::btree_map::Entry;
use std::collections::{BTreeSet, HashMap};
use crate::id::{deterministic_id, id_bucket_key};
use crate::index::AnnotationId;
use crate::walk::DiscoveredAnnotation;
use super::cache::{CacheEntry, CanonMatchesFile, PendingMatch, RejectedMatch};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReconcileReport {
pub pruned: Vec<AnnotationId>,
pub pruned_bound: Vec<AnnotationId>,
pub demoted: Vec<AnnotationId>,
pub missing_binding: Vec<AnnotationId>,
pub preserved_for_recovery: Vec<AnnotationId>,
}
impl ReconcileReport {
pub fn changed(&self) -> bool {
!(self.pruned.is_empty() && self.demoted.is_empty())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RawLiveIds {
pub ids: BTreeSet<AnnotationId>,
pub unparseable_explicit_ids: usize,
}
#[aristo::intent(
"The reconcile's live-id authority is derived from the RAW walk, \
before build_entries validation: an annotation skipped with a \
warning (invalid parent id, invalid verify value, duplicate id) \
still contributes its id, and an idless annotation contributes \
the same deterministic aret_* id build_entries would mint \
(same bucket key, same ordinal assignment). Treating \
'skipped from the index' as 'deleted from source' would prune — \
and destroy — the accepted bindings and rejected-match memory of \
annotations that still exist. The one id class the set cannot \
represent is an explicit id that fails to parse; those are \
counted so the caller can skip reconciliation instead of \
mispruning.",
verify = "test",
id = "raw_live_ids_counts_skipped_annotations_as_live"
)]
pub fn raw_live_ids(discovered: &[DiscoveredAnnotation]) -> RawLiveIds {
let mut out = RawLiveIds::default();
let mut ordinal_counter: HashMap<(crate::index::AnnotationKind, String, String), usize> =
HashMap::new();
for d in discovered {
match &d.annotation.id {
Some(s) => match AnnotationId::parse(s) {
Ok(id) => {
out.ids.insert(id);
}
Err(_) => out.unparseable_explicit_ids += 1,
},
None => {
let key = id_bucket_key(d.annotation.kind, &d.annotation.text, &d.annotation.site);
let ordinal = ordinal_counter.entry(key).or_insert(0);
out.ids.insert(deterministic_id(
d.annotation.kind,
&d.annotation.text,
&d.annotation.site,
*ordinal,
));
*ordinal += 1;
}
}
}
out
}
#[aristo::intent(
"Reconciliation is source-authoritative and idempotent: the live-id \
set from a fresh source walk is the sole authority. A cache row \
whose key (either key form, bare or canon-prefixed) matches no \
live annotation id is removed whole; a live but unprefixed id \
that still carries accepted_matches loses exactly that bucket \
(source says local, source wins). Two refinements keep memory \
the live set says is still meaningful. A dead canon-prefixed key \
whose BARE form is live is a demotion, not a removal: the row is \
rekeyed under the bare id with accepted_matches dropped and \
rejected-match memory preserved. And a dead bare id is preserved \
untouched only while a pending match's canon-prefixed form is \
live in source AND that prefixed id has no accepted row in the \
cache — the interrupted-accept window, where pruning would \
destroy the pending entry a re-run `aristo canon accept` needs \
to finish the rekey. Once the prefixed row carries an accepted \
match the binding completed (via this or another annotation) and \
the dead bare row is ordinary garbage. `__meta__` is never \
touched, and a second run over the same inputs reports no \
changes.",
verify = "test",
id = "canon_reconcile_is_source_authoritative_and_idempotent"
)]
pub fn reconcile(
cache: &mut CanonMatchesFile,
live_ids: &BTreeSet<AnnotationId>,
) -> ReconcileReport {
let mut report = ReconcileReport::default();
let dead: Vec<AnnotationId> = cache
.entries
.keys()
.filter(|id| !live_ids.contains(*id))
.cloned()
.collect();
for id in dead {
if id.is_canon_bound() {
if let Some(bare) = bare_form(&id).filter(|b| live_ids.contains(b)) {
let mut row = cache
.entries
.remove(&id)
.expect("dead key was collected from this map");
row.accepted_matches.clear();
match cache.entries.entry(bare.clone()) {
Entry::Vacant(slot) => {
slot.insert(row);
}
Entry::Occupied(mut slot) => {
merge_rejected(&mut slot.get_mut().rejected_matches, row.rejected_matches);
}
}
report.demoted.push(bare);
continue;
}
}
let is_recovery_row = !id.is_canon_bound()
&& cache
.entries
.get(&id)
.is_some_and(|entry| pending_rekey_awaits_completion(entry, cache, live_ids));
if is_recovery_row {
report.preserved_for_recovery.push(id);
continue;
}
let row = cache
.entries
.remove(&id)
.expect("dead key was collected from this map");
if !row.accepted_matches.is_empty() {
report.pruned_bound.push(id.clone());
}
report.pruned.push(id);
}
for (id, entry) in cache.entries.iter_mut() {
if !id.is_canon_bound() && live_ids.contains(id) && !entry.accepted_matches.is_empty() {
entry.accepted_matches.clear();
report.demoted.push(id.clone());
}
}
report.demoted.sort();
report.demoted.dedup();
for id in live_ids {
if !id.is_canon_bound() {
continue;
}
let has_accepted = cache
.entries
.get(id)
.is_some_and(|e| !e.accepted_matches.is_empty());
if !has_accepted {
report.missing_binding.push(id.clone());
}
}
report
}
fn pending_rekey_awaits_completion(
entry: &CacheEntry,
cache: &CanonMatchesFile,
live_ids: &BTreeSet<AnnotationId>,
) -> bool {
entry.pending_matches.iter().any(|p| {
pending_prefixed_form(p).is_some_and(|pid| {
live_ids.contains(&pid)
&& cache
.entries
.get(&pid)
.is_none_or(|e| e.accepted_matches.is_empty())
})
})
}
fn pending_prefixed_form(p: &PendingMatch) -> Option<AnnotationId> {
AnnotationId::parse(&format!("{}{}", p.prefix_tier.as_prefix(), p.canon_id)).ok()
}
fn bare_form(id: &AnnotationId) -> Option<AnnotationId> {
let s = id.as_str();
let body = s
.strip_prefix("aristos:")
.or_else(|| s.strip_prefix("kanon:"))?;
AnnotationId::parse(body).ok()
}
fn merge_rejected(into: &mut Vec<RejectedMatch>, from: Vec<RejectedMatch>) {
for r in from {
let dup = into
.iter()
.any(|x| x.canon_id == r.canon_id && x.text_hash == r.text_hash);
if !dup {
into.push(r);
}
}
}
#[cfg(test)]
mod tests {
use super::super::cache::{AcceptedMatch, Disposition};
use super::super::types::PrefixTier;
use super::*;
use crate::index::AnnotationKind;
fn aid(s: &str) -> AnnotationId {
AnnotationId::parse(s).unwrap_or_else(|e| panic!("parse {s:?}: {e}"))
}
fn live(ids: &[&str]) -> BTreeSet<AnnotationId> {
ids.iter().map(|s| aid(s)).collect()
}
fn pending(canon_id: &str, tier: PrefixTier) -> PendingMatch {
PendingMatch {
canon_id: canon_id.into(),
version: "v0.2.1".into(),
canonical_text: "canonical".into(),
canon_version: "v0.2.0".into(),
confidence: 0.92,
prefix_tier: tier,
backed_by: None,
linked: None,
verification: None,
disposition: Disposition::Open,
found_at: "2026-06-15T09:14:22Z".into(),
found_by: "aristo stamp".into(),
}
}
fn accepted(canon_id: &str) -> AcceptedMatch {
AcceptedMatch {
canon_id: canon_id.into(),
version: "v0.2.1".into(),
canonical_text: "canonical".into(),
canon_version: "v0.2.0".into(),
confidence: 1.0,
prefix_tier: PrefixTier::Aristos,
backed_by: None,
linked: None,
verification: None,
accepted_at: "2026-06-15T09:20:00Z".into(),
bound_at: "2026-06-15T09:20:00Z".into(),
}
}
fn rejected(canon_id: &str) -> RejectedMatch {
RejectedMatch {
canon_id: canon_id.into(),
version: "v0.1.2".into(),
text_hash: "blake3:c2f7a912".into(),
rejected_at: "2026-06-13T11:48:00Z".into(),
reason: None,
}
}
fn row(
pending_matches: Vec<PendingMatch>,
accepted_matches: Vec<AcceptedMatch>,
rejected_matches: Vec<RejectedMatch>,
) -> CacheEntry {
CacheEntry {
last_match_text_hash: "blake3:x".into(),
canon_fetched_at: "2026-06-15T09:14:22Z".into(),
pending_matches,
accepted_matches,
rejected_matches,
}
}
#[test]
fn prune_removes_bare_entry_for_removed_annotation() {
let mut cache = CanonMatchesFile::default();
cache
.entries
.insert(aid("deleted_one"), row(vec![], vec![], vec![]));
cache
.entries
.insert(aid("still_here"), row(vec![], vec![], vec![]));
let report = reconcile(&mut cache, &live(&["still_here"]));
assert_eq!(report.pruned, vec![aid("deleted_one")]);
assert!(report.pruned_bound.is_empty(), "no accepted bucket pruned");
assert!(report.changed());
assert!(!cache.entries.contains_key(&aid("deleted_one")));
assert!(cache.entries.contains_key(&aid("still_here")));
}
#[test]
fn prune_removes_prefixed_entry_for_removed_annotation() {
let mut cache = CanonMatchesFile::default();
cache.entries.insert(
aid("aristos:wal_checkpoint_error_no_db_leak"),
row(
vec![],
vec![accepted("wal_checkpoint_error_no_db_leak")],
vec![],
),
);
cache.entries.insert(
aid("kanon:checkout_total_non_negative"),
row(
vec![],
vec![accepted("checkout_total_non_negative")],
vec![],
),
);
let report = reconcile(&mut cache, &live(&["unrelated_live_id"]));
assert_eq!(
report.pruned,
vec![
aid("aristos:wal_checkpoint_error_no_db_leak"),
aid("kanon:checkout_total_non_negative"),
]
);
assert_eq!(report.pruned_bound, report.pruned);
assert!(cache.entries.is_empty());
}
#[test]
fn prune_preserves_interrupted_accept_recovery_row() {
let mut cache = CanonMatchesFile::default();
let mut p = pending("cell_write_once", PrefixTier::Aristos);
p.disposition = Disposition::Accepted; cache
.entries
.insert(aid("edit_page_invariant"), row(vec![p], vec![], vec![]));
let report = reconcile(&mut cache, &live(&["aristos:cell_write_once"]));
assert!(report.pruned.is_empty());
assert_eq!(
report.preserved_for_recovery,
vec![aid("edit_page_invariant")]
);
assert!(!report.changed(), "preservation must not force a write");
assert!(cache.entries.contains_key(&aid("edit_page_invariant")));
}
#[test]
fn prune_preserves_recovery_row_when_prefixed_row_exists_without_accepted() {
let mut cache = CanonMatchesFile::default();
cache.entries.insert(
aid("edit_page_invariant"),
row(
vec![pending("cell_write_once", PrefixTier::Aristos)],
vec![],
vec![],
),
);
cache
.entries
.insert(aid("aristos:cell_write_once"), row(vec![], vec![], vec![]));
let report = reconcile(&mut cache, &live(&["aristos:cell_write_once"]));
assert_eq!(
report.preserved_for_recovery,
vec![aid("edit_page_invariant")]
);
assert!(report.pruned.is_empty());
}
#[test]
fn prune_does_not_preserve_dead_row_whose_rekey_target_is_already_bound() {
let mut cache = CanonMatchesFile::default();
cache.entries.insert(
aid("deleted_ann"),
row(
vec![pending("cell_write_once", PrefixTier::Aristos)],
vec![],
vec![],
),
);
cache.entries.insert(
aid("aristos:cell_write_once"),
row(vec![], vec![accepted("cell_write_once")], vec![]),
);
let report = reconcile(&mut cache, &live(&["aristos:cell_write_once"]));
assert_eq!(report.pruned, vec![aid("deleted_ann")]);
assert!(report.preserved_for_recovery.is_empty());
assert!(!cache.entries.contains_key(&aid("deleted_ann")));
assert!(
cache.entries.contains_key(&aid("aristos:cell_write_once")),
"the completed binding is untouched"
);
}
#[test]
fn prune_does_not_preserve_dead_row_whose_rekey_target_is_not_live() {
let mut cache = CanonMatchesFile::default();
cache.entries.insert(
aid("gone_annotation"),
row(
vec![pending("some_canon_entry", PrefixTier::Kanon)],
vec![],
vec![],
),
);
let report = reconcile(&mut cache, &live(&["unrelated_live_id"]));
assert_eq!(report.pruned, vec![aid("gone_annotation")]);
assert!(report.preserved_for_recovery.is_empty());
}
#[test]
fn demote_drops_accepted_matches_on_live_unprefixed_id() {
let mut cache = CanonMatchesFile::default();
cache.entries.insert(
aid("hand_stripped"),
row(
vec![pending("other_entry", PrefixTier::Aristos)],
vec![accepted("stripped_entry")],
vec![rejected("rejected_entry")],
),
);
let report = reconcile(&mut cache, &live(&["hand_stripped"]));
assert_eq!(report.demoted, vec![aid("hand_stripped")]);
assert!(report.changed());
let entry = &cache.entries[&aid("hand_stripped")];
assert!(entry.accepted_matches.is_empty(), "accepted dropped");
assert_eq!(entry.pending_matches.len(), 1, "pending kept");
assert_eq!(entry.rejected_matches.len(), 1, "rejection memory kept");
assert_eq!(entry.last_match_text_hash, "blake3:x", "hash kept");
}
#[test]
fn demote_rekeys_dead_prefixed_row_onto_live_bare_form() {
let mut cache = CanonMatchesFile::default();
cache.entries.insert(
aid("aristos:cell_write_once"),
row(
vec![],
vec![accepted("cell_write_once")],
vec![rejected("other_entry")],
),
);
let report = reconcile(&mut cache, &live(&["cell_write_once"]));
assert!(report.pruned.is_empty(), "nothing was removed from source");
assert_eq!(report.demoted, vec![aid("cell_write_once")]);
assert!(report.changed());
assert!(!cache.entries.contains_key(&aid("aristos:cell_write_once")));
let entry = &cache.entries[&aid("cell_write_once")];
assert!(entry.accepted_matches.is_empty(), "accepted dropped");
assert_eq!(
entry.rejected_matches.len(),
1,
"rejection memory rekeyed onto the bare id"
);
}
#[test]
fn demote_rekey_merges_rejected_memory_into_existing_bare_row() {
let mut cache = CanonMatchesFile::default();
cache.entries.insert(
aid("cell_write_once"),
row(
vec![pending("other_entry", PrefixTier::Kanon)],
vec![],
vec![rejected("already_here")],
),
);
cache.entries.insert(
aid("aristos:cell_write_once"),
row(
vec![],
vec![accepted("cell_write_once")],
vec![rejected("already_here"), rejected("only_in_prefixed")],
),
);
let report = reconcile(&mut cache, &live(&["cell_write_once"]));
assert_eq!(report.demoted, vec![aid("cell_write_once")]);
assert!(report.pruned.is_empty());
assert!(!cache.entries.contains_key(&aid("aristos:cell_write_once")));
let entry = &cache.entries[&aid("cell_write_once")];
assert_eq!(entry.pending_matches.len(), 1, "live row's pending kept");
let mut names: Vec<&str> = entry
.rejected_matches
.iter()
.map(|r| r.canon_id.as_str())
.collect();
names.sort_unstable();
assert_eq!(
names,
vec!["already_here", "only_in_prefixed"],
"rejected memory merged without duplicates"
);
}
#[test]
fn demote_ignores_live_prefixed_id_with_accepted_matches() {
let mut cache = CanonMatchesFile::default();
cache.entries.insert(
aid("aristos:cell_write_once"),
row(vec![], vec![accepted("cell_write_once")], vec![]),
);
let report = reconcile(&mut cache, &live(&["aristos:cell_write_once"]));
assert!(!report.changed());
assert_eq!(
cache.entries[&aid("aristos:cell_write_once")]
.accepted_matches
.len(),
1
);
}
#[test]
fn warn_collects_live_prefixed_ids_without_a_derivable_binding() {
let mut cache = CanonMatchesFile::default();
cache.entries.insert(
aid("kanon:row_without_accepted"),
row(vec![], vec![], vec![]),
);
cache.entries.insert(
aid("aristos:fully_bound"),
row(vec![], vec![accepted("fully_bound")], vec![]),
);
let report = reconcile(
&mut cache,
&live(&[
"aristos:no_row_at_all",
"kanon:row_without_accepted",
"aristos:fully_bound",
]),
);
assert_eq!(
report.missing_binding,
vec![
aid("aristos:no_row_at_all"),
aid("kanon:row_without_accepted")
]
);
assert!(!report.changed(), "warnings never mutate the cache");
assert!(
cache
.entries
.contains_key(&aid("kanon:row_without_accepted")),
"the warned row is a legitimate state, never pruned"
);
}
#[test]
fn reconcile_is_idempotent() {
let mut cache = CanonMatchesFile::default();
cache
.entries
.insert(aid("deleted_one"), row(vec![], vec![], vec![]));
cache.entries.insert(
aid("hand_stripped"),
row(vec![], vec![accepted("stripped_entry")], vec![]),
);
cache.entries.insert(
aid("aristos:rekeyed_demote"),
row(vec![], vec![accepted("rekeyed_demote")], vec![]),
);
let live_ids = live(&[
"hand_stripped",
"rekeyed_demote",
"aristos:orphaned_binding",
]);
let first = reconcile(&mut cache, &live_ids);
assert!(first.changed());
let after_first = cache.clone();
let second = reconcile(&mut cache, &live_ids);
assert!(!second.changed(), "second run must report no changes");
assert!(second.pruned.is_empty());
assert!(second.demoted.is_empty());
assert_eq!(cache, after_first, "second run must not mutate");
assert_eq!(first.missing_binding, second.missing_binding);
}
#[test]
fn meta_is_untouched_even_when_entries_are_pruned() {
let mut cache = CanonMatchesFile::default();
cache.meta.canon_version = Some("v0.2.0".into());
cache.meta.last_fetched = Some("2026-06-15T09:14:22Z".into());
cache
.entries
.insert(aid("deleted_one"), row(vec![], vec![], vec![]));
let report = reconcile(&mut cache, &live(&[]));
assert_eq!(report.pruned, vec![aid("deleted_one")]);
assert_eq!(cache.meta.schema_version, 1);
assert_eq!(cache.meta.canon_version.as_deref(), Some("v0.2.0"));
assert_eq!(
cache.meta.last_fetched.as_deref(),
Some("2026-06-15T09:14:22Z")
);
}
#[test]
fn empty_cache_is_a_no_op() {
let mut cache = CanonMatchesFile::default();
let report = reconcile(&mut cache, &live(&["anything_live"]));
assert_eq!(report, ReconcileReport::default());
assert!(!report.changed());
}
#[test]
fn empty_live_set_prunes_every_entry() {
let mut cache = CanonMatchesFile::default();
cache
.entries
.insert(aid("bare_one"), row(vec![], vec![], vec![]));
cache.entries.insert(
aid("aristos:bound_one"),
row(vec![], vec![accepted("bound_one")], vec![]),
);
let report = reconcile(&mut cache, &BTreeSet::new());
assert_eq!(
report.pruned,
vec![aid("aristos:bound_one"), aid("bare_one")]
);
assert_eq!(report.pruned_bound, vec![aid("aristos:bound_one")]);
assert!(cache.entries.is_empty());
}
fn discovered(
explicit_id: Option<&str>,
text: &str,
site: &str,
parent: Option<&str>,
) -> DiscoveredAnnotation {
use crate::walk::{AnnotationForm, ExtractedAnnotation, ParentRaw};
DiscoveredAnnotation {
file: std::path::PathBuf::from("src/lib.rs"),
annotation: ExtractedAnnotation {
kind: AnnotationKind::Intent,
form: AnnotationForm::Attribute,
text: text.to_string(),
verify: None,
parent: parent.map(|p| ParentRaw::Single(p.to_string())),
id: explicit_id.map(str::to_string),
site: site.to_string(),
line: 1,
covered_region: crate::index::CoveredRegion::Function,
text_hash: crate::hash::text_hash(text),
body_hash: crate::hash::body_hash("fn body() {}"),
},
}
}
#[test]
fn raw_live_ids_includes_annotations_build_entries_would_skip() {
let d = discovered(
Some("aristos:cell_write_once"),
"some invariant",
"fn foo",
Some("Bad Parent Typo!!"),
);
let raw = raw_live_ids(&[d]);
assert_eq!(raw.unparseable_explicit_ids, 0);
assert!(raw.ids.contains(&aid("aristos:cell_write_once")));
}
#[test]
fn raw_live_ids_derives_the_same_deterministic_id_as_build_entries() {
let a = discovered(None, "each cell written once", "fn edit_page", None);
let b = discovered(None, "each cell written once", "fn edit_page", None);
let raw = raw_live_ids(&[a, b]);
let id0 = deterministic_id(
AnnotationKind::Intent,
"each cell written once",
"fn edit_page",
0,
);
let id1 = deterministic_id(
AnnotationKind::Intent,
"each cell written once",
"fn edit_page",
1,
);
assert!(raw.ids.contains(&id0));
assert!(raw.ids.contains(&id1));
assert_eq!(raw.ids.len(), 2);
}
#[test]
fn raw_live_ids_counts_unparseable_explicit_ids() {
let bad = discovered(Some("Not A Valid Id!"), "text", "fn foo", None);
let good = discovered(Some("fine_id"), "text2", "fn bar", None);
let raw = raw_live_ids(&[bad, good]);
assert_eq!(raw.unparseable_explicit_ids, 1);
assert_eq!(raw.ids.len(), 1);
assert!(raw.ids.contains(&aid("fine_id")));
}
}