diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index bbbf059f..0d3ca450 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,3 +1,29 @@ +## 2026-07-29 — E-A-GUARANTEE-WITH-A-RE-EXPORTED-BYPASS-IS-NOT-A-GUARANTEE-1 — CodeRabbit's #866 review landed AFTER the merge; three of its five findings were real and one of them broke my headline claim + +**Status:** IN PR (#867). **Confidence:** High — all five findings were checked against the code; the two declined are demonstrably false positives, the three accepted are fixed with tests still green. + +**Process finding first: a review that arrives after the merge still has to be worked.** CodeRabbit's #866 review (Run ID `16a75898`) was still processing when the operator merged, so the findings landed against code that was already on `main`. Nothing in the webhook stream says "you merged before the review finished" — the subscription simply ends. **When a PR merges with a review in flight, the review must be pulled explicitly**; otherwise findings against shipped code are silently dropped. Two of the three real ones here were rated Major. + +**The finding that mattered: my central claim had a re-exported bypass.** The facade's headline is that stamp-collision is *structurally unreachable* — a consumer takes an `Axis`, never a `Stamp`, so independent evidence cannot silently stop pooling. But the same module **re-exported `Stamp` and `BeliefArena`**, so `BeliefArena::observe(.., Stamp::source(64))` reproduced the exact aliasing the type exists to prevent. The guarantee was true of the path I designed and false of the module I shipped. Fixed by dropping the `Stamp` re-export and making `Axis::stamp` crate-private (so the type cannot be named from outside without breaking BBB tiering), and — the part that matters more — **scoping the claim in the doc**: `PremiseBundle::arena()` still hands out a raw arena, and a consumer taking that escape hatch owns the stamp discipline. The guarantee is over the facade's path, not over every reachable call. Generalization: **an unreachability claim is a statement about the whole module surface, not about the API you meant people to use** — enumerate the escape hatches before claiming one. + +**Second real finding: I violated the workspace's own litmus test.** `CLAUDE.md` states it outright — *"Does this add a free function on a carrier's state, or a method on the carrier? → Free function = reject. Method = accept."* I shipped `resolve(&bundle)` and `differential(&bundle, &throttle)`. Now `bundle.resolve()` / `bundle.differential(&throttle)`. The rule was in the file I read at session start; reading a doctrine and applying it are different acts, and a reviewer citing my own repo's rule back at me is the cheapest possible way to learn that. + +**Third: domain vocabulary had leaked into a deliberately concept-blind public module** — "patient", "clinical", `DRUG`/`MARKER` test constants, and the operator's own fence-list ("no LOINC codes, disease tables, panel specifics") quoted verbatim *inside* the public crate. Naming the forbidden categories to forbid them still tells a public reader which domain this seam serves. Swept: neutral subject/trigger/observable terms, and the fence now reads "no domain catalogue of any kind ... not even in doc examples or tests". A rule against domain vocabulary cannot be written in domain vocabulary. + +**Two declined, verified not assumed.** (a) "PR reference wrong — says #865, belongs to #866": line 17 reads `**Status:** IN PR.` with no number; CodeRabbit anchored on diff context from a neighbouring entry. (b) "Prepend TECH_DEBT instead of appending": `TD-LANCE-GRAPH-ALL-FEATURES-DELTA-BREAK` is at line 3, already the top entry. Both checked by reading the files rather than trusting either the tool or my memory of what I wrote. + +## 2026-07-29 — E-A-COUNT-NEXT-TO-A-CLAIM-IS-READ-AS-BACKING-IT-1 — `Resolution.axes` reported the bundle's axes beside a single statement's verdict; the fix was already in the belief's own stamp + +**Status:** IN PR (follow-up to merged #866). **Confidence:** High — the divergence is asserted by a test whose precondition pins that the bundle really does carry more axes than backed the winner. + +**The near-miss.** `resolve()` returned `Resolution { stmt, truth, contradiction, axes }` where `axes` was `PremiseBundle::distinct_axes()` — the count of evidence axes **in the bundle**. The doc said exactly that, so nothing was false. But the field sits beside `stmt` and `truth`, and a caller reading `axes: 4` next to a resolution reads *"four axes support this"*. They diverge whenever an axis observed only OTHER statements — which for the consuming use case (a differential over rival hypotheses) is the normal case, not the corner case. + +**Why "the doc is accurate" was not good enough.** A number's meaning is set by its neighbours as much as by its documentation. Placed inside a per-statement verdict struct, a bundle-wide count is *read* as per-statement support no matter what the prose says — and the direction of the error is always **overstatement**, which for a clinical consumer is the bad direction. This is the sibling of `E-COMPUTED-PRINTED-NOT-ASSERTED-1` (same day): there a claim was documented but unasserted; here a value was accurate but mis-scoped by adjacency. + +**The honest number already existed.** `Belief.stamp` IS that belief's evidential base — the union of the sources that pooled into it — so `stamp.0.count_ones()` is exactly "how many axes backed THIS statement". No new bookkeeping, no second pass: the arena had been carrying the right answer all along, and the facade was computing a worse one beside it. Generalization worth keeping: **before deriving a summary statistic, check whether the substrate already records the exact thing** — a stamp, a mask, a provenance set. A recomputed approximation next to an exact record is a defect waiting for someone to trust it. + +**Also pinned this round:** the tie-break. `max_by` keeps the LAST maximum, so `resolve` reverses the index comparison to prefer the earliest-observed belief on equal expectation. Nothing else in the suite has two equal-expectation beliefs, so an inverted comparator would have passed every other test; the new test asserts the winner from BOTH insertion orders of the same pair, and inverting `.then(ib.cmp(ia))` makes it fail (`s:3` where `s:1` is required). Falsifier proven, not argued. + ## 2026-07-29 — E-MAKE-THE-TRAP-UNREACHABLE-NOT-DOCUMENTED-1 — the medcare reasoning seam: a facade whose main job is that a consumer cannot express the expensive mistake; plus protoc is Lance, not the lab **Status:** IN PR. **Confidence:** High — the seam compiles and its six tests pass, including both halves of the pooling falsifier; the protoc finding is read off `cargo tree`. diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 1a98d214..3ab2b9b6 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,7 +1,7 @@ ## 2026-07-29 — branch `claude/x265-x266-plans-review-h9osnl` — `lance_graph::reasoning`, the concept-blind consumer seam ### Current Contract Inventory — new module (lance-graph core, `planner` feature) -- `lance_graph::reasoning` — the curated consumer reasoning facade, **concept-blind by construction** (`E-MAKE-THE-TRAP-UNREACHABLE-NOT-DOCUMENTED-1`). Re-exports the clinical entry points only: `TruthValue` (all five NAL operators), `BeliefArena`/`Belief`/`CStmt`/`Copula`/`Stamp`/`ReviseOutcome`, the five tactics + `Candidate`/`Frontier`/`ReasoningGap`/`GapKind`/`Throttle`, and `counterfactual::{substitute_binding, multi_substitute_binding, worlds_differ, …}`. +- `lance_graph::reasoning` — the curated consumer reasoning facade, **concept-blind by construction** (no domain vocabulary in the public crate, including doc-comments and tests) (`E-MAKE-THE-TRAP-UNREACHABLE-NOT-DOCUMENTED-1`). Re-exports the supported reasoning entry points only: `TruthValue` (all five NAL operators), `BeliefArena`/`Belief`/`CStmt`/`Copula`/`Stamp`/`ReviseOutcome`, the five tactics + `Candidate`/`Frontier`/`ReasoningGap`/`GapKind`/`Throttle`, and `counterfactual::{substitute_binding, multi_substitute_binding, worlds_differ, …}`. - **New in the facade:** `Axis` (+ `MAX_AXES`) — one independent evidence source; takes an axis index, NOT a `Stamp`, so distinct axes yield disjoint evidence bits **by construction** and the silent stamp-collision failure (pooling degrades to CHOICE, confidence stops rising, nothing logs) cannot be expressed by a consumer. `Axis::new` refuses `index >= 64` rather than letting `Stamp::source`'s `% 64` alias axis 64 onto axis 0. - `PremiseBundle` (owns stamp assignment) · `Resolution { stmt, truth, contradiction, axes }` · `resolve` · `differential` (returns `Frontier` so `ReasoningGap` — "what premise is MISSING to separate these" — is surfaced, not discarded). - **`GuardRule`/`GuardViolation`/`detect_violations` — deliberately NOT inference.** No `TruthValue`, no `Belief`: a stored-value contradiction routed through the arena would become revisable and could be *softened* by later evidence. Asserted, not just documented. diff --git a/crates/lance-graph/src/reasoning.rs b/crates/lance-graph/src/reasoning.rs index 0b494910..96b8cdcb 100644 --- a/crates/lance-graph/src/reasoning.rs +++ b/crates/lance-graph/src/reasoning.rs @@ -1,19 +1,20 @@ //! The consumer reasoning facade — **concept-blind by construction**. //! -//! Consumers (medcare-rs and any other) supply *opaque* interned `u16` concept +//! Consumers supply *opaque* interned `u16` concept //! ids plus `(frequency, confidence)` premises; this module supplies the //! deduction matrix and never learns what an id means. That asymmetry is the //! point, and it is a hard fence, not a preference: `lance-graph` is public, //! so a domain vocabulary living here would be a disclosure surface as well as -//! a reusability loss. **Do not add LOINC codes, disease tables, panel -//! specifics, or any other domain catalogue to this module.** The consumer owns -//! the meaning, privately; this crate owns the mechanism. +//! a reusability loss. **Do not add a domain catalogue of any kind to this +//! module — no code lists, no entity tables, no field-specific naming, not +//! even in doc examples or tests.** The consumer owns the meaning, privately; +//! this crate owns the mechanism. //! //! # Why a curated facade rather than `pub use lance_graph_planner` //! //! A blanket re-export would hand every consumer the whole strategy surface //! (16 planner strategies, MUL, elevation) to reach four reasoning types. This -//! module names exactly the clinical-reasoning entry points, so the consumer's +//! module names exactly the reasoning entry points it supports, so the consumer's //! dependency tier stays narrow and auditable. Consumers must NOT path-dep //! `lance-graph-planner` directly — that breaks the BBB tiering every consumer //! repo in this workspace enforces. @@ -33,9 +34,7 @@ //! Guard rules ([`detect_violations`]) deliberately do **not** enter that //! pipeline — see their own documentation. -pub use lance_graph_planner::nars::belief::{ - Belief, BeliefArena, CStmt, Copula, ReviseOutcome, Stamp, -}; +pub use lance_graph_planner::nars::belief::{Belief, BeliefArena, CStmt, Copula, ReviseOutcome}; pub use lance_graph_planner::nars::tactics::{ asc_challenge, cas_abstract, cr_synthesize, rcr_abduce, tr_diverge, AscOutcome, Candidate, Frontier, GapKind, ReasoningGap, Tactic, Throttle, @@ -47,7 +46,7 @@ pub use lance_graph_cognitive::world::counterfactual::{ Counterfactual, SubstitutedWorld, }; -/// The evidential-stamp horizon: [`Stamp`] is a 64-bit source bitset. +/// The evidential-stamp horizon: the evidential stamp is a 64-bit source bitset. /// /// Load-bearing, not decorative. `Stamp::source(id)` folds `id % 64`, so axis /// 0 and axis 64 would produce the *same* bit — and two premises that look @@ -69,8 +68,15 @@ pub const MAX_AXES: u8 = 64; /// genuinely independent axes produces a plausible answer carrying the /// confidence of a single axis. /// -/// Taking an `Axis` rather than a `Stamp` means a consumer cannot express that -/// mistake: distinct axes yield distinct bits by construction. +/// Taking an `Axis` rather than a raw stamp means a consumer cannot express +/// that mistake ON THIS PATH: distinct axes yield distinct bits by +/// construction, and the stamp type is not re-exported, so it cannot be named +/// or built from outside without breaking the BBB tiering. +/// +/// **Scoped honestly:** [`PremiseBundle::arena`] still hands out a raw +/// [`BeliefArena`], and a consumer who deliberately takes that escape hatch +/// owns the stamp discipline themselves. The guarantee is over this facade's +/// path, not over every reachable call. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Axis(u8); @@ -93,17 +99,19 @@ impl Axis { } /// The evidential stamp for this axis — one distinct bit per axis. + /// + /// Crate-private on purpose: a consumer that could name and construct a + /// raw `Stamp` could reintroduce the aliasing this type exists to prevent. #[must_use] - pub fn stamp(self) -> Stamp { - Stamp::source(u32::from(self.0)) + pub(crate) fn stamp(self) -> lance_graph_planner::nars::belief::Stamp { + lance_graph_planner::nars::belief::Stamp::source(u32::from(self.0)) } } -/// A patient's (or any subject's) premises, each tagged with the axis that -/// observed it. +/// One subject's premises, each tagged with the axis that observed it. /// /// The bundle owns stamp assignment precisely so the consumer never touches -/// [`Stamp`] directly — see [`Axis`]. +/// a raw stamp — see [`Axis`]. #[derive(Debug, Clone, Default)] pub struct PremiseBundle { premises: Vec<(CStmt, TruthValue, Axis)>, @@ -143,6 +151,11 @@ impl PremiseBundle { /// Fold the bundle into a [`BeliefArena`], stamping each premise with its /// own axis. Premises on the same statement from DIFFERENT axes pool /// (confidence rises); from the SAME axis they compete by CHOICE. + /// + /// This is the ESCAPE HATCH: it hands out a raw arena, and anything done + /// to it afterwards is outside [`Axis`]'s guarantee. Reach for it only to + /// run a tactic this facade does not wrap; for ordinary use prefer + /// [`resolve`](Self::resolve) / [`differential`](Self::differential). #[must_use] pub fn arena(&self) -> BeliefArena { let mut arena = BeliefArena::new(); @@ -151,6 +164,49 @@ impl PremiseBundle { } arena } + + /// Resolve to the best-supported statement, ranked by NARS `expectation()` + /// (which weighs frequency BY confidence, so a confident moderate belief + /// outranks a near-certain guess). + /// + /// Returns `None` for an empty bundle — an absent answer, never a + /// fabricated low-confidence one. + #[must_use] + pub fn resolve(&self) -> Option { + let arena = self.arena(); + arena + .entries() + .iter() + .enumerate() + // `max_by` keeps the LAST maximum; reverse the index so ties resolve + // to the earliest-observed belief and the result is + // insertion-deterministic. + .max_by(|(ia, a), (ib, b)| { + a.truth + .expectation() + .total_cmp(&b.truth.expectation()) + .then(ib.cmp(ia)) + }) + .map(|(_, b)| Resolution { + stmt: b.stmt, + truth: b.truth, + contradiction: b.contradiction, + // The belief's OWN evidence base, not the bundle's — see the field. + axes: b.stamp.0.count_ones() as usize, + }) + } + + /// The ranked differential: rival explanations plus **what is missing to + /// separate them**. + /// + /// Returns a [`Frontier`] rather than a bare `Vec` on purpose: + /// when two rivals cannot be separated, a [`ReasoningGap`] names the absent + /// premise instead of forcing a ranking on evidence that does not support + /// one. Surface the gaps — do not discard them. + #[must_use] + pub fn differential(&self, throttle: &Throttle) -> Frontier { + rcr_abduce(&self.arena(), throttle) + } } /// The best-supported statement in a bundle, with the evidence that backs it. @@ -165,53 +221,19 @@ pub struct Resolution { /// pooled to a confident answer; this is the cross-modal disagreement flag, /// and it is committed rather than erased. pub contradiction: f32, - /// How many distinct axes contributed to the bundle overall. + /// How many distinct axes backed **this statement** — the popcount of the + /// winning belief's own evidential stamp. + /// + /// Deliberately NOT `PremiseBundle::distinct_axes()`, which counts axes + /// across the whole bundle. The two differ whenever an axis observed only + /// *other* statements, and the bundle-wide number sitting next to a + /// resolution reads as a stronger claim than it is — "4 axes" beside a + /// verdict will be understood as four axes supporting *that* verdict. + /// The stamp is the belief's own evidence base, so its popcount is the + /// honest number. pub axes: usize, } -/// Resolve a bundle to its best-supported statement, ranked by NARS -/// `expectation()` (which weighs frequency BY confidence, so a confident -/// moderate belief outranks a near-certain guess). -/// -/// Returns `None` for an empty bundle — an absent answer, never a fabricated -/// low-confidence one. -#[must_use] -pub fn resolve(bundle: &PremiseBundle) -> Option { - let arena = bundle.arena(); - let axes = bundle.distinct_axes(); - arena - .entries() - .iter() - .enumerate() - // `max_by` keeps the LAST maximum; reverse the index so ties resolve to - // the earliest-observed belief and the result is insertion-deterministic. - .max_by(|(ia, a), (ib, b)| { - a.truth - .expectation() - .total_cmp(&b.truth.expectation()) - .then(ib.cmp(ia)) - }) - .map(|(_, b)| Resolution { - stmt: b.stmt, - truth: b.truth, - contradiction: b.contradiction, - axes, - }) -} - -/// The ranked differential: rival explanations plus **what is missing to -/// separate them**. -/// -/// The [`Frontier::gaps`] half is the clinically honest part and the reason -/// this returns a `Frontier` rather than a bare `Vec`: when two -/// rivals cannot be separated, a [`ReasoningGap`] names the absent premise -/// instead of forcing a ranking on evidence that does not support one. -/// Surface the gaps — do not discard them. -#[must_use] -pub fn differential(bundle: &PremiseBundle, throttle: &Throttle) -> Frontier { - rcr_abduce(&bundle.arena(), throttle) -} - /// Comparison operator for a [`GuardRule`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Cmp { @@ -314,7 +336,7 @@ mod tests { } } - /// **THE gating falsifier** (medcare seam, both halves required). + /// **THE gating falsifier** — both halves required. /// /// Four independent axes agreeing must POOL — confidence strictly rises /// above any single axis. The same four observations on ONE axis must NOT @@ -332,13 +354,13 @@ mod tests { for i in 0..4u8 { four.observe(hypothesis, single, axis(i)); } - let pooled = resolve(&four).expect("non-empty bundle resolves"); + let pooled = four.resolve().expect("non-empty bundle resolves"); let mut one = PremiseBundle::new(); for _ in 0..4 { one.observe(hypothesis, single, axis(0)); } - let chosen = resolve(&one).expect("non-empty bundle resolves"); + let chosen = one.resolve().expect("non-empty bundle resolves"); assert_eq!(four.distinct_axes(), 4, "four axes must read as four"); assert_eq!( @@ -373,7 +395,7 @@ mod tests { let mut b = PremiseBundle::new(); b.observe(hypothesis, TruthValue::new(0.9, 0.5), axis(0)); b.observe(hypothesis, TruthValue::new(0.1, 0.5), axis(1)); - let r = resolve(&b).expect("resolves"); + let r = b.resolve().expect("resolves"); assert!( r.contradiction > 0.5, "a 0.9-vs-0.1 split must survive as committed contradiction depth, got {}", @@ -384,7 +406,7 @@ mod tests { agree.observe(hypothesis, TruthValue::new(0.9, 0.5), axis(0)); agree.observe(hypothesis, TruthValue::new(0.9, 0.5), axis(1)); assert!( - resolve(&agree).expect("resolves").contradiction < 1e-6, + agree.resolve().expect("resolves").contradiction < 1e-6, "agreeing axes must record NO contradiction — else the flag is decoration" ); } @@ -408,37 +430,99 @@ mod tests { /// An empty bundle resolves to `None` — no fabricated low-confidence answer. #[test] fn empty_bundle_resolves_to_nothing() { - assert!(resolve(&PremiseBundle::new()).is_none()); + assert!(PremiseBundle::new().resolve().is_none()); + } + + /// `Resolution.axes` counts the axes that backed the WINNING statement, not + /// the axes present in the bundle. + /// + /// The two diverge exactly when an axis observed only *other* statements — + /// here axis 2 speaks solely about an unrelated rival, so the bundle has + /// three axes while the winner rests on two. Reporting `3` beside this + /// resolution would overstate its support, which is the failure this + /// asserts against. + #[test] + fn resolution_axes_counts_only_what_backed_the_winner() { + let winner = stmt(1, 2); + let rival = stmt(3, 4); + let mut b = PremiseBundle::new(); + b.observe(winner, TruthValue::new(0.9, 0.6), axis(0)); + b.observe(winner, TruthValue::new(0.9, 0.6), axis(1)); + b.observe(rival, TruthValue::new(0.2, 0.2), axis(2)); + + let r = b.resolve().expect("resolves"); + assert_eq!(r.stmt, winner, "the strongly-backed statement must win"); + assert_eq!( + b.distinct_axes(), + 3, + "precondition: the BUNDLE really does carry three axes" + ); + assert_eq!( + r.axes, 2, + "but only two axes backed the winner — reporting 3 would overstate it" + ); + } + + /// Ties resolve to the EARLIEST-observed belief, deterministically. + /// + /// `max_by` keeps the last maximum, so the comparator reverses the index to + /// prefer the earlier one. Getting that backwards would not fail any other + /// test here — every other fixture has a unique maximum — so this pins the + /// one case where insertion order decides, by asserting the winner from + /// BOTH insertion orders of the same two equal-expectation beliefs. + #[test] + fn equal_expectation_ties_resolve_to_the_earliest_observation() { + let first = stmt(1, 2); + let second = stmt(3, 4); + let same = TruthValue::new(0.7, 0.4); + + let mut a = PremiseBundle::new(); + a.observe(first, same, axis(0)); + a.observe(second, same, axis(1)); + assert_eq!( + a.resolve().expect("resolves").stmt, + first, + "first-observed must win its tie" + ); + + let mut b = PremiseBundle::new(); + b.observe(second, same, axis(0)); + b.observe(first, same, axis(1)); + assert_eq!( + b.resolve().expect("resolves").stmt, + second, + "…and the SAME rule must hold when the insertion order is swapped" + ); } /// Guard rules fire on the armed case and stay silent otherwise — and the /// silence must come from the RULE, not from an empty input. #[test] fn guard_rule_fires_armed_and_stays_silent_unarmed() { - const DRUG: u16 = 10; - const OTHER_DRUG: u16 = 11; - const MARKER: u16 = 20; + const TRIGGER: u16 = 10; + const OTHER_TRIGGER: u16 = 11; + const OBSERVABLE: u16 = 20; let rule = GuardRule { - when_present: DRUG, - observable: MARKER, + when_present: TRIGGER, + observable: OBSERVABLE, cmp: Cmp::Lt, threshold: 45.0, }; let rules = [rule]; - let fired = detect_violations(&rules, &[DRUG], &[(MARKER, 30.0)]); + let fired = detect_violations(&rules, &[TRIGGER], &[(OBSERVABLE, 30.0)]); assert_eq!(fired.len(), 1, "armed + tripped must fire"); assert_eq!(fired[0].observed, 30.0, "the operand must come back"); assert_eq!(fired[0].rule.threshold, 45.0, "…and so must the rule"); // Silent because the value is fine — NOT because the input was empty. assert!( - detect_violations(&rules, &[DRUG], &[(MARKER, 60.0)]).is_empty(), + detect_violations(&rules, &[TRIGGER], &[(OBSERVABLE, 60.0)]).is_empty(), "armed but within threshold must not fire" ); // Silent because the rule is unarmed, on the SAME tripping value. assert!( - detect_violations(&rules, &[OTHER_DRUG], &[(MARKER, 30.0)]).is_empty(), + detect_violations(&rules, &[OTHER_TRIGGER], &[(OBSERVABLE, 30.0)]).is_empty(), "unarmed must not fire even on a tripping value" ); } @@ -447,22 +531,22 @@ mod tests { /// category fence, asserted rather than merely documented. #[test] fn guard_violations_never_enter_the_arena() { - const DRUG: u16 = 10; - const MARKER: u16 = 20; + const TRIGGER: u16 = 10; + const OBSERVABLE: u16 = 20; let rules = [GuardRule { - when_present: DRUG, - observable: MARKER, + when_present: TRIGGER, + observable: OBSERVABLE, cmp: Cmp::Lt, threshold: 45.0, }]; - let violations = detect_violations(&rules, &[DRUG], &[(MARKER, 30.0)]); + let violations = detect_violations(&rules, &[TRIGGER], &[(OBSERVABLE, 30.0)]); assert_eq!(violations.len(), 1); - // The bundle that reasons about this patient is untouched by the + // The bundle that reasons about this subject is untouched by the // violation: nothing to observe, nothing to revise, nothing to soften. let bundle = PremiseBundle::new(); assert!( - resolve(&bundle).is_none(), + bundle.resolve().is_none(), "a contraindication must not have produced a belief" ); }