Skip to content

Commit e599868

Browse files
hyperpolymathclaude
andcommitted
feat(corpus): reverse-dep index — Step 1 of N-dim VeriSim plan
Adds `Corpus.dependents: HashMap<String, Vec<usize>>` populated by `reindex` from the inverse of each entry's `dependencies`. Two new helpers (`reverse_deps`, `reverse_closure`) and two new CLI flags (`corpus query <name> --reverse-deps --reverse-closure`). Why: the existing 2-coord index (by_name + by_qualified) is one-way. "What proofs would break if I change `wf-<`?" was un-answerable from the index — only forward reachability worked. This is the smallest move toward an N-dimensional corpus and unlocks impact-analysis as a first-class capability. Verified on echo-types: `wf-<` has 1 direct reverse-dep (`<-irrefl`, which derives irreflexivity from WF via `wf⇒asym`) and a 4-entry reverse closure spanning Brouwer, Buchholz.Order, OmegaMarkers. Known limitation (deferred to Step 2): the reverse-dep table is keyed on short names because that's what `dependencies` stores. Cross-module name collisions (e.g. `<-irrefl` exists in three modules) widen the closure beyond strict-truth — it's over- conservative, not under-conservative, so safer for impact analysis but worth tightening when Step 2 introduces qualified-name dependency resolution alongside the cross-prover adapters. Tests: 1 new unit test covering direct + transitive reverse, including unknown-name fallback. Existing 6 corpus tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ca56b96 commit e599868

2 files changed

Lines changed: 188 additions & 2 deletions

File tree

src/rust/corpus/mod.rs

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,14 @@ pub struct Corpus {
143143
pub by_name: HashMap<String, Vec<usize>>,
144144
/// `entries` index by `qualified`. One-to-one within a project.
145145
pub by_qualified: HashMap<String, usize>,
146+
/// **Reverse-dep index.** Maps a name (canonical key, same domain
147+
/// as `by_name`) to the entry indices of decls that *reference*
148+
/// it. Inverse of `CorpusEntry::dependencies`.
149+
///
150+
/// Answers "what would break if I change X?". Computed by
151+
/// `reindex` after `dependencies` are filled in.
152+
#[serde(default)]
153+
pub dependents: HashMap<String, Vec<usize>>,
146154
}
147155

148156
impl Corpus {
@@ -216,14 +224,72 @@ impl Corpus {
216224
Ok(c)
217225
}
218226

219-
/// Re-build the by_name / by_qualified indices after mutation.
227+
/// Re-build the by_name / by_qualified / dependents indices
228+
/// after mutation. `dependents` is computed by inverting each
229+
/// entry's `dependencies` list — the same domain (short names) is
230+
/// used so callers can pivot from `by_name` to `dependents` with
231+
/// the same key.
220232
pub fn reindex(&mut self) {
221233
self.by_name.clear();
222234
self.by_qualified.clear();
235+
self.dependents.clear();
223236
for (i, e) in self.entries.iter().enumerate() {
224237
self.by_name.entry(e.name.clone()).or_default().push(i);
225238
self.by_qualified.insert(e.qualified.clone(), i);
226239
}
240+
for (i, e) in self.entries.iter().enumerate() {
241+
for dep in &e.dependencies {
242+
self.dependents.entry(dep.clone()).or_default().push(i);
243+
}
244+
}
245+
// Stable order so output is deterministic.
246+
for v in self.dependents.values_mut() {
247+
v.sort_unstable();
248+
v.dedup();
249+
}
250+
}
251+
252+
/// Direct reverse dependencies of `qualified`: every entry whose
253+
/// `dependencies` list mentions this entry's short name. Single-
254+
/// hop only; use `reverse_closure` for transitive impact.
255+
pub fn reverse_deps(&self, qualified: &str) -> Vec<&CorpusEntry> {
256+
let entry = match self.by_qualified.get(qualified) {
257+
Some(&i) => &self.entries[i],
258+
None => return vec![],
259+
};
260+
let indices = match self.dependents.get(&entry.name) {
261+
Some(v) => v,
262+
None => return vec![],
263+
};
264+
indices.iter().map(|&i| &self.entries[i]).collect()
265+
}
266+
267+
/// Transitive reverse closure: everything that would (potentially)
268+
/// break if `qualified` changed. Walks `dependents` repeatedly
269+
/// from the start node.
270+
pub fn reverse_closure(&self, qualified: &str) -> Vec<&CorpusEntry> {
271+
use std::collections::HashSet;
272+
let mut seen: HashSet<usize> = HashSet::new();
273+
let mut stack: Vec<usize> = Vec::new();
274+
if let Some(&start) = self.by_qualified.get(qualified) {
275+
stack.push(start);
276+
} else {
277+
return vec![];
278+
}
279+
while let Some(i) = stack.pop() {
280+
if !seen.insert(i) {
281+
continue;
282+
}
283+
let name = &self.entries[i].name;
284+
if let Some(deps) = self.dependents.get(name) {
285+
for &j in deps {
286+
if !seen.contains(&j) {
287+
stack.push(j);
288+
}
289+
}
290+
}
291+
}
292+
seen.iter().map(|&i| &self.entries[i]).collect()
227293
}
228294

229295
/// Summary counts.
@@ -320,4 +386,87 @@ mod tests {
320386
assert_eq!(hits.len(), 1);
321387
assert_eq!(hits[0].name, "osuc-mono-≤");
322388
}
389+
390+
#[test]
391+
fn corpus_reverse_dep_index() {
392+
// Three entries in a small chain:
393+
// wf-< depends on pred-of-osuc
394+
// pred-of-osuc depends on _<_
395+
// _<_ has no deps
396+
// Reverse: changing _<_ should flag pred-of-osuc; changing
397+
// pred-of-osuc should flag wf-<.
398+
let mut c = Corpus::default();
399+
c.modules.push(ModuleEntry {
400+
name: "Ordinal.Brouwer".into(),
401+
path: PathBuf::from("Ordinal/Brouwer.agda"),
402+
options: vec![],
403+
imports: vec![],
404+
entries: vec![0, 1, 2],
405+
});
406+
c.entries.push(CorpusEntry {
407+
name: "_<_".into(),
408+
qualified: "Ordinal.Brouwer._<_".into(),
409+
module_idx: 0,
410+
kind: DeclKind::Function,
411+
statement: "Ord -> Ord -> Set".into(),
412+
proof: None,
413+
line: 56,
414+
dependencies: vec![],
415+
axiom_usage: AxiomUsage::default(),
416+
});
417+
c.entries.push(CorpusEntry {
418+
name: "pred-of-osuc".into(),
419+
qualified: "Ordinal.Brouwer.pred-of-osuc".into(),
420+
module_idx: 0,
421+
kind: DeclKind::Function,
422+
statement: "...".into(),
423+
proof: None,
424+
line: 115,
425+
dependencies: vec!["_<_".into()],
426+
axiom_usage: AxiomUsage::default(),
427+
});
428+
c.entries.push(CorpusEntry {
429+
name: "wf-<".into(),
430+
qualified: "Ordinal.Brouwer.wf-<".into(),
431+
module_idx: 0,
432+
kind: DeclKind::Function,
433+
statement: "WellFounded _<_".into(),
434+
proof: None,
435+
line: 130,
436+
dependencies: vec!["pred-of-osuc".into(), "_<_".into()],
437+
axiom_usage: AxiomUsage::default(),
438+
});
439+
c.reindex();
440+
441+
// Direct reverse-deps of _<_: both pred-of-osuc and wf-<.
442+
let mut rev: Vec<&str> = c
443+
.reverse_deps("Ordinal.Brouwer._<_")
444+
.iter()
445+
.map(|e| e.name.as_str())
446+
.collect();
447+
rev.sort();
448+
assert_eq!(rev, vec!["pred-of-osuc", "wf-<"]);
449+
450+
// Direct reverse-deps of pred-of-osuc: just wf-<.
451+
let rev: Vec<&str> = c
452+
.reverse_deps("Ordinal.Brouwer.pred-of-osuc")
453+
.iter()
454+
.map(|e| e.name.as_str())
455+
.collect();
456+
assert_eq!(rev, vec!["wf-<"]);
457+
458+
// Transitive reverse-closure of _<_: includes itself
459+
// (start node) plus pred-of-osuc plus wf-<.
460+
let mut closure: Vec<&str> = c
461+
.reverse_closure("Ordinal.Brouwer._<_")
462+
.iter()
463+
.map(|e| e.name.as_str())
464+
.collect();
465+
closure.sort();
466+
assert_eq!(closure, vec!["_<_", "pred-of-osuc", "wf-<"]);
467+
468+
// Empty for unknown name.
469+
assert!(c.reverse_deps("Nope").is_empty());
470+
assert!(c.reverse_closure("Nope").is_empty());
471+
}
323472
}

src/rust/main.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,17 @@ enum CorpusOp {
242242
/// `data/corpus/<basename>.json` for the cwd's basename.
243243
#[arg(short, long)]
244244
index: Option<PathBuf>,
245-
/// Show transitive dependencies of the matched entry.
245+
/// Show transitive (forward) dependencies of the matched entry.
246246
#[arg(long)]
247247
deps: bool,
248+
/// Show direct reverse-dependencies — entries that reference
249+
/// the matched name, i.e. things that would (potentially)
250+
/// break if the matched entry changed.
251+
#[arg(long)]
252+
reverse_deps: bool,
253+
/// Show transitive reverse-closure — full impact set.
254+
#[arg(long)]
255+
reverse_closure: bool,
248256
},
249257
/// Print summary statistics for a corpus index.
250258
Stats {
@@ -1131,6 +1139,8 @@ fn corpus_command(op: CorpusOp, formatter: &OutputFormatter) -> Result<()> {
11311139
pattern,
11321140
index,
11331141
deps,
1142+
reverse_deps,
1143+
reverse_closure,
11341144
} => {
11351145
let path = match index {
11361146
Some(p) => p,
@@ -1182,6 +1192,33 @@ fn corpus_command(op: CorpusOp, formatter: &OutputFormatter) -> Result<()> {
11821192
println!(" {}", n);
11831193
}
11841194
}
1195+
if reverse_deps && hits.len() == 1 {
1196+
let rev = corpus.reverse_deps(&hits[0].qualified);
1197+
println!(
1198+
"\ndirect reverse-deps of {} ({} entries):",
1199+
hits[0].qualified,
1200+
rev.len()
1201+
);
1202+
let mut names: Vec<&str> = rev.iter().map(|e| e.qualified.as_str()).collect();
1203+
names.sort();
1204+
for n in names {
1205+
println!(" {}", n);
1206+
}
1207+
}
1208+
if reverse_closure && hits.len() == 1 {
1209+
let closure = corpus.reverse_closure(&hits[0].qualified);
1210+
println!(
1211+
"\nreverse closure (impact set) of {} ({} entries):",
1212+
hits[0].qualified,
1213+
closure.len()
1214+
);
1215+
let mut names: Vec<&str> =
1216+
closure.iter().map(|e| e.qualified.as_str()).collect();
1217+
names.sort();
1218+
for n in names {
1219+
println!(" {}", n);
1220+
}
1221+
}
11851222
}
11861223
CorpusOp::Stats { index } => {
11871224
let path = match index {

0 commit comments

Comments
 (0)