diff --git a/.claude/board/entries/2026-10-12-sag-reprojection-defects.md b/.claude/board/entries/2026-10-12-sag-reprojection-defects.md new file mode 100644 index 000000000..c45302ca2 --- /dev/null +++ b/.claude/board/entries/2026-10-12-sag-reprojection-defects.md @@ -0,0 +1,21 @@ +# 2026-10-12 — D-SAG-1R — SAG reprojection defects A and B, found by #1510 + +**Status:** In PR. **Found by:** #1510's audit of #1509. + +A: `SoftwareGraph::link` deduplicated on `(src, rel, dst, tier)`, so a fact two +methods share (the claim behind a field both read) belonged to whichever wrote +it first. When that method stopped reading the field, its reprojection +retracted the fact for both. `ctx` is now part of the key; `out` and +`capability` read each fact once. + +B: `project_compute` never retracted the DAG it replaced. Cyclic then acyclic +kept the `ComputeCycle` refusal and the stale `ComputesFrom` edges. It now +retracts its own class's compute edges and verdict first. + +MEASURED (tests/awareness.rs): for every method, reprojecting it with a body +that reads nothing gives the same facts as a fresh projection; three DAG +sequences give the expected edge count and verdict, the other class untouched. +Three disable runs red on the tests that name each guard. + +OPEN: C, `certify` accepts any test name. Who may issue a verified receipt is +an operator decision, not a code fix. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index a7624e066..76c6c2fb1 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,11 +25,12 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately opposite directions; the stranding this convention prevents shows up in exactly one of them, never both. -319 entries, 2026-08-06 .. 2026-10-12. +320 entries, 2026-08-06 .. 2026-10-12. | date | entry id | finding | file | |---|---|---|---| | 2026-10-12 | `D-SAG-1` | | [2026-10-12-software-awareness-graph.md](2026-10-12-software-awareness-graph.md) | +| 2026-10-12 | `D-SAG-1R` | SAG reprojection defects A and B, found by #1510 | [2026-10-12-sag-reprojection-defects.md](2026-10-12-sag-reprojection-defects.md) | | 2026-10-12 | `D-CMH-G1-6` | | [2026-10-12-ordered-conditions-and-extrema.md](2026-10-12-ordered-conditions-and-extrema.md) | | 2026-10-12 | `opencog-pln-entropy-research-map` | Source inventory and falsifiable PLN/ECAN/Shannon/Moore attention comparison: entropy target, evidence overlap, graph projection, resource fairness | [2026-10-12-opencog-pln-entropy-research-map.md](2026-10-12-opencog-pln-entropy-research-map.md) | | 2026-10-12 | `nars-self-evidence-literature` | NARS internal trace versus evidence, Jøsang dependent/independent fusion and fission, and the snapshot-stable fold assumption; source-grounded red tests for merged #1496 | [2026-10-12-nars-self-evidence-literature.md](2026-10-12-nars-self-evidence-literature.md) | diff --git a/crates/lance-graph-glove-parity/src/awareness.rs b/crates/lance-graph-glove-parity/src/awareness.rs index 3189d130a..2ac2f4e8b 100644 --- a/crates/lance-graph-glove-parity/src/awareness.rs +++ b/crates/lance-graph-glove-parity/src/awareness.rs @@ -518,12 +518,18 @@ impl SoftwareGraph { (self.nodes.len() - 1) as u32 } - /// Add an edge unless the same `(src, rel, dst, tier)` is already there. + /// Add an edge unless the same `(src, rel, dst, tier, ctx)` is already + /// there. + /// + /// `ctx` is part of the key: a fact two methods contribute (the claim + /// behind a field both read) is one row per method, so a method's + /// reprojection retracts only its own row and the other method's + /// survives. Readers that ask for facts ([`Self::out`]) see one fact. pub fn link(&mut self, src: u32, rel: Rel, dst: u32, tier: Tier, ctx: u32, prov: u32) { let dup = self .edges .iter() - .any(|e| (e.src, e.rel, e.dst, e.tier) == (src, rel, dst, tier)); + .any(|e| (e.src, e.rel, e.dst, e.tier, e.ctx) == (src, rel, dst, tier, ctx)); if !dup { self.edges.push(Edge { src, @@ -536,17 +542,21 @@ impl SoftwareGraph { } } - /// The edges `src REL ?`, any tier. + /// The facts `src REL ?`, any tier, each once however many contexts + /// wrote it, in first-written order. #[must_use] pub fn out(&self, src: &str, rel: Rel) -> Vec<(&str, Tier)> { let Some(s) = self.node(src) else { return Vec::new(); }; - self.edges - .iter() - .filter(|e| e.src == s && e.rel == rel) - .map(|e| (self.nodes[e.dst as usize].ident.as_str(), e.tier)) - .collect() + let mut v: Vec<(&str, Tier)> = Vec::new(); + for e in self.edges.iter().filter(|e| e.src == s && e.rel == rel) { + let f = (self.nodes[e.dst as usize].ident.as_str(), e.tier); + if !v.contains(&f) { + v.push(f); + } + } + v } /// The current body digest of a projected method. @@ -715,6 +725,9 @@ impl SoftwareGraph { None => {} } } + // After every replacement verdict is written, so a refusal the new + // bodies reproduce still supports its blocker. + self.prune_unsupported_blockers(); } /// Record one lowered program of `owner`: its primitives and lowering, @@ -798,6 +811,26 @@ impl SoftwareGraph { /// # Errors /// /// The refusal identity, when no compiled refusal carries it. + /// Drop every compiled, refusal-sourced `BlockedBy` edge whose refusal + /// no longer has a compiled `RefusedFor`: such a blocker is admitted only + /// on a demonstrated refusal (see [`Self::blocked_by`]), so it is + /// retracted with the last one. Claimed blockers are left alone. + fn prune_unsupported_blockers(&mut self) { + let supported: std::collections::HashSet = self + .edges + .iter() + .filter(|e| e.rel == Rel::RefusedFor && e.tier == Tier::Compiled) + .map(|e| e.dst) + .collect(); + let nodes = &self.nodes; + self.edges.retain(|e| { + e.rel != Rel::BlockedBy + || e.tier != Tier::Compiled + || nodes[e.src as usize].kind != Kind::Refusal + || supported.contains(&e.src) + }); + } + pub fn blocked_by(&mut self, refusal: &str, capability: &str, prov: u32) -> Result<(), String> { let r = self .node(refusal) @@ -825,6 +858,20 @@ impl SoftwareGraph { prov: u32, ) { let class = self.intern(Kind::Class, format!("class:{concept:#06x}")); + // A class's DAG projection replaces its earlier one: its edges and + // its cycle verdict described the DAG it had then. Other facts + // written in the class's context (a note from it) are kept. + let cycle = self.node("refusal:ComputeCycle"); + // Only the rows this projection wrote are retracted: derived + // `ComputesFrom` edges run field -> field, so a claim made from the + // class itself (`src == ctx`) or at another tier is kept. + self.edges.retain(|e| { + e.ctx != class + || !((e.rel == Rel::ComputesFrom && e.tier == Tier::Derived && e.src != e.ctx) + || (e.rel == Rel::RefusedFor + && Some(e.dst) == cycle + && e.tier == Tier::Compiled)) + }); let name = |p: u8| { basis .get(usize::from(p)) @@ -842,6 +889,7 @@ impl SoftwareGraph { let r = self.intern(Kind::Refusal, "refusal:ComputeCycle".into()); self.link(class, Rel::RefusedFor, r, Tier::Compiled, class, prov); } + self.prune_unsupported_blockers(); } /// Accept a certificate when the body and seal it names are current. @@ -1017,11 +1065,14 @@ impl SoftwareGraph { return CapStatus::Unknown; }; let into = |p: &dyn Fn(&Edge) -> bool| -> Vec { - self.edges - .iter() - .filter(|e| e.dst == c && p(e)) - .map(|e| self.nodes[e.src as usize].ident.clone()) - .collect() + let mut v: Vec = Vec::new(); + for e in self.edges.iter().filter(|e| e.dst == c && p(e)) { + let s = &self.nodes[e.src as usize].ident; + if !v.contains(s) { + v.push(s.clone()); + } + } + v }; if !into(&|e| e.rel == Rel::UsesPrimitive && e.tier == Tier::Compiled).is_empty() { return CapStatus::Shipped; diff --git a/crates/lance-graph-glove-parity/tests/awareness.rs b/crates/lance-graph-glove-parity/tests/awareness.rs index 58265a961..91644a79e 100644 --- a/crates/lance-graph-glove-parity/tests/awareness.rs +++ b/crates/lance-graph-glove-parity/tests/awareness.rs @@ -1131,3 +1131,245 @@ fn a_reprojection_replaces_the_methods_earlier_edges() { // Other methods' edges are untouched. assert_eq!(g.out(&method_ident(COUNT_BILLABLE), Rel::LowersTo).len(), 1); } + +/// The distinct `(src, rel, dst, tier)` facts of a graph, by identity. +fn facts(g: &SoftwareGraph) -> Vec<(String, String, String, Tier)> { + let id = |i: u32| g.nodes()[i as usize].ident.clone(); + let mut v: Vec<_> = g + .edges() + .iter() + .map(|e| (id(e.src), format!("{:?}", e.rel), id(e.dst), e.tier)) + .collect(); + v.sort(); + v.dedup(); + v +} + +/// Defect A (#1510). FAILS IF: reprojecting a subset of methods loses a +/// fact another, unchanged method still contributes. An edge two methods +/// share (the claim behind a field both read) belongs to each of them: one +/// method's reprojection may retract only its own copy. +#[test] +fn reprojecting_a_subset_keeps_the_facts_of_the_other_methods() { + let b = batch(65); + let basis = canonical_fields(); + let cats = CatsBinder::new(&b); + let claims = with_duration(); + let all = specs(); + let t = MethodTable::prepare(&glove(&basis, &cats, &claims), &all); + let mut full = SoftwareGraph::new(); + full.project(&t, &all, &claims, SOURCE, REV); + let want = facts(&full); + let billable = field_ident(BILLABLE_WORK_ENTRY, seal(), "billable"); + // Non-vacuity: `billable` is read by more than one method, so its claim + // edge is shared and the subset below would drop it under first-owner + // dedup. + let readers = all + .iter() + .filter(|(_, s)| { + full.out(&method_ident(s.key), Rel::ReadsField) + .iter() + .any(|(f, _)| *f == billable) + }) + .count(); + assert!(readers >= 2, "billable has {readers} readers"); + for i in 0..all.len() { + // Unchanged: a reprojection of one method changes nothing. + let mut g = full.clone(); + g.project(&t, &all[i..=i], &claims, SOURCE, REV); + assert_eq!(facts(&g), want, "reprojecting {} alone", all[i].0); + // Changed: the method stops reading any field. The graph must equal + // a fresh projection of the new declarations, so a fact another + // method still contributes survives. + let mut changed = all.clone(); + changed[i].1.body = query(&[], &[Fold::Count]); + let t2 = MethodTable::prepare(&glove(&basis, &cats, &claims), &changed); + let mut fresh = SoftwareGraph::new(); + fresh.project(&t2, &changed, &claims, SOURCE, REV); + let mut g = full.clone(); + g.project(&t2, &changed[i..=i], &claims, SOURCE, REV); + assert_eq!(facts(&g), facts(&fresh), "{} stops reading", all[i].0); + } + assert_eq!( + full.out(&billable, Rel::DependsOnClaim).len(), + 1, + "a shared fact reads as one fact" + ); +} + +/// Defect B (#1510). FAILS IF: reprojecting a class's compute DAG keeps +/// edges or the cycle verdict of the DAG it replaced, or touches another +/// class's DAG. +#[test] +fn reprojecting_a_compute_dag_replaces_the_earlier_one() { + let basis = canonical_fields(); + let cyclic = [ + ComputeEdge { + target: 2, + inputs: &[3], + }, + ComputeEdge { + target: 3, + inputs: &[2], + }, + ]; + let acyclic = [ComputeEdge { + target: 2, + inputs: &[3], + }]; + let state = |g: &SoftwareGraph, concept: u16| { + let class = g.node(&format!("class:{concept:#06x}")).unwrap(); + let from = g + .edges() + .iter() + .filter(|e| e.rel == Rel::ComputesFrom && e.ctx == class) + .count(); + let cycle = g + .out(&format!("class:{concept:#06x}"), Rel::RefusedFor) + .len(); + (from, cycle) + }; + for (first, then, want) in [ + (&cyclic[..], &acyclic[..], (1, 0)), + (&acyclic[..], &cyclic[..], (2, 1)), + (&cyclic[..], &cyclic[..], (2, 1)), + ] { + let mut g = SoftwareGraph::new(); + let p = g.note(SOURCE, REV); + g.project_compute(DOCUMENT, seal(), &basis, &cyclic, p); + g.project_compute(BILLABLE_WORK_ENTRY, seal(), &basis, first, p); + g.project_compute(BILLABLE_WORK_ENTRY, seal(), &basis, then, p); + assert_eq!( + state(&g, BILLABLE_WORK_ENTRY), + want, + "{first:?} then {then:?}" + ); + assert_eq!(state(&g, DOCUMENT), (2, 1), "the other class is untouched"); + } +} + +/// FAILS IF a capability stays `Demonstrated` by a refusal the compiler no +/// longer records: a blocker names a compiled refusal, so when the last +/// compiled `RefusedFor` for it is retracted, the blocker goes with it. A +/// refusal another class still records keeps it. +#[test] +fn a_blocker_is_retracted_with_its_last_compiled_refusal() { + let basis = canonical_fields(); + let cyclic = [ + ComputeEdge { + target: 2, + inputs: &[3], + }, + ComputeEdge { + target: 3, + inputs: &[2], + }, + ]; + let acyclic = [ComputeEdge { + target: 2, + inputs: &[3], + }]; + let cap = "cap:ComputeCycleBreak"; + // One class: its cycle goes, the blocker goes. + let mut g = SoftwareGraph::new(); + let p = g.note(SOURCE, REV); + g.project_compute(BILLABLE_WORK_ENTRY, seal(), &basis, &cyclic, p); + g.blocked_by("refusal:ComputeCycle", cap, p).unwrap(); + assert!(matches!(g.capability(cap), CapStatus::Demonstrated { .. })); + g.project_compute(BILLABLE_WORK_ENTRY, seal(), &basis, &acyclic, p); + assert_eq!(g.capability(cap), CapStatus::Unknown); + assert!(g.out("refusal:ComputeCycle", Rel::BlockedBy).is_empty()); + // Two classes: the other still refuses, so the blocker stays. + let mut g = SoftwareGraph::new(); + let p = g.note(SOURCE, REV); + g.project_compute(DOCUMENT, seal(), &basis, &cyclic, p); + g.project_compute(BILLABLE_WORK_ENTRY, seal(), &basis, &cyclic, p); + g.blocked_by("refusal:ComputeCycle", cap, p).unwrap(); + g.project_compute(BILLABLE_WORK_ENTRY, seal(), &basis, &acyclic, p); + assert!(matches!(g.capability(cap), CapStatus::Demonstrated { .. })); +} + +/// FAILS IF reprojecting a class's DAG erases a fact a note recorded in the +/// class's context: only the rows the projection itself wrote (derived +/// `ComputesFrom` from a field, the compiled cycle verdict) are replaced. +#[test] +fn reprojecting_a_compute_dag_keeps_claimed_class_facts() { + let basis = canonical_fields(); + let acyclic = [ComputeEdge { + target: 2, + inputs: &[3], + }]; + let class = format!("class:{BILLABLE_WORK_ENTRY:#06x}"); + let field = field_ident(BILLABLE_WORK_ENTRY, seal(), &basis[3]); + let mut g = SoftwareGraph::new(); + let p = g.note(SOURCE, REV); + g.project_compute(BILLABLE_WORK_ENTRY, seal(), &basis, &acyclic, p); + g.claim(&class, Rel::ComputesFrom, &field, Tier::Hypothesis, p) + .unwrap(); + g.claim( + &class, + Rel::RefusedFor, + "refusal:ComputeCycle", + Tier::Hypothesis, + p, + ) + .unwrap(); + g.claim(&class, Rel::ComputesFrom, &field, Tier::Derived, p) + .unwrap(); + let before = facts(&g); + g.project_compute(BILLABLE_WORK_ENTRY, seal(), &basis, &acyclic, p); + assert_eq!(facts(&g), before, "the same DAG again changes nothing"); + for (rel, tier) in [ + (Rel::ComputesFrom, Tier::Hypothesis), + (Rel::RefusedFor, Tier::Hypothesis), + (Rel::ComputesFrom, Tier::Derived), + ] { + assert!( + g.out(&class, rel).iter().any(|&(_, t)| t == tier), + "{rel:?} at {tier:?} survives" + ); + } +} + +/// FAILS IF a reprojection that reproduces the same cycle loses its blocker: +/// pruning must see the replacement verdict, not the intermediate state. +#[test] +fn reprojecting_the_same_cycle_keeps_its_blocker() { + let basis = canonical_fields(); + let cyclic = [ + ComputeEdge { + target: 2, + inputs: &[3], + }, + ComputeEdge { + target: 3, + inputs: &[2], + }, + ]; + let cap = "cap:ComputeCycleBreak"; + let mut g = SoftwareGraph::new(); + let p = g.note(SOURCE, REV); + g.project_compute(BILLABLE_WORK_ENTRY, seal(), &basis, &cyclic, p); + g.blocked_by("refusal:ComputeCycle", cap, p).unwrap(); + g.project_compute(BILLABLE_WORK_ENTRY, seal(), &basis, &cyclic, p); + assert!(matches!(g.capability(cap), CapStatus::Demonstrated { .. })); +} + +/// FAILS IF pruning removes a claimed `BlockedBy` that no refusal sources: +/// only compiled, refusal-sourced blockers depend on a compiled refusal. +#[test] +fn reprojection_keeps_a_claimed_method_blocker() { + let basis = canonical_fields(); + let acyclic = [ComputeEdge { + target: 2, + inputs: &[3], + }]; + let cap = "cap:SomethingElse"; + let mut g = SoftwareGraph::new(); + let p = g.note(SOURCE, REV); + g.claim("method:other", Rel::BlockedBy, cap, Tier::Hypothesis, p) + .unwrap(); + assert!(matches!(g.capability(cap), CapStatus::Claimed { .. })); + g.project_compute(BILLABLE_WORK_ENTRY, seal(), &basis, &acyclic, p); + assert!(matches!(g.capability(cap), CapStatus::Claimed { .. })); +}