Skip to main content

mimir_core/
inference_methods.rs

1//! Inference-method registry per `docs/concepts/librarian-pipeline.md` § 5.
2//!
3//! Fourteen named methods, each with:
4//!
5//! 1. A **parent-count rule** (exactly 1, exactly 2, N ≥ 2, N ≥ 1, etc.).
6//! 2. A **deterministic confidence formula** computed over parent confidences.
7//! 3. A **staleness predicate** — the condition under which the method's
8//!    output must be flagged stale if a parent is superseded.
9//!
10//! All arithmetic is integer fixed-point at the `u16` confidence
11//! resolution (per `ir-canonical-form.md` § 3.1) so output is
12//! bit-identical across architectures. Intermediate products use u128
13//! to accommodate up to eight 16-bit parents without overflow; methods
14//! accepting more than eight parents cap their input at eight and
15//! return `InferenceMethodError::TooManyParents` otherwise (this
16//! bounded-determinism caveat is flagged in CHANGELOG pending a
17//! follow-up log-table implementation for unbounded N).
18
19use thiserror::Error;
20
21use crate::confidence::Confidence;
22
23// -------------------------------------------------------------------
24// Method enum
25// -------------------------------------------------------------------
26
27/// One of the 14 registered inference methods. Every Inferential memory's
28/// `method` field resolves to a symbol whose canonical name maps to one
29/// of these variants.
30#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
31pub enum InferenceMethod {
32    /// Pass-through from a single parent. `output.conf = parent.conf`.
33    ///
34    /// ```
35    /// # #![allow(clippy::unwrap_used)]
36    /// use mimir_core::inference_methods::InferenceMethod;
37    /// use mimir_core::Confidence;
38    /// let out = InferenceMethod::DirectLookup
39    ///     .compute(&[Confidence::try_from_f32(0.73).unwrap()])
40    ///     .unwrap();
41    /// assert!((out.as_f32() - 0.73).abs() < 0.001);
42    /// ```
43    DirectLookup,
44    /// Majority vote across an odd number of parents (N ≥ 3). Per the
45    /// spec v1 convention (`librarian-pipeline.md` § 5.1) the write
46    /// surface carries only voters-in-favor in `derived_from`, so
47    /// `votes_for == N` and the formula collapses to `min(parents.conf)`.
48    ///
49    /// ```
50    /// # #![allow(clippy::unwrap_used)]
51    /// use mimir_core::inference_methods::InferenceMethod;
52    /// use mimir_core::Confidence;
53    /// let p = |f| Confidence::try_from_f32(f).unwrap();
54    /// let out = InferenceMethod::MajorityVote.compute(&[p(0.9), p(0.6), p(0.7)]).unwrap();
55    /// assert!((out.as_f32() - 0.6).abs() < 0.001);
56    /// ```
57    MajorityVote,
58    /// Citation-linked pair of parents.
59    /// `output.conf = min(parents.conf) * 0.9`.
60    ///
61    /// ```
62    /// # #![allow(clippy::unwrap_used)]
63    /// use mimir_core::inference_methods::InferenceMethod;
64    /// use mimir_core::Confidence;
65    /// let p = |f| Confidence::try_from_f32(f).unwrap();
66    /// let out = InferenceMethod::CitationLink.compute(&[p(1.0), p(1.0)]).unwrap();
67    /// assert!((out.as_f32() - 0.9).abs() < 0.001);
68    /// ```
69    CitationLink,
70    /// Analogical mapping from source to target.
71    /// `output.conf = product(parents.conf) * 0.7`.
72    ///
73    /// ```
74    /// # #![allow(clippy::unwrap_used)]
75    /// use mimir_core::inference_methods::InferenceMethod;
76    /// use mimir_core::Confidence;
77    /// let p = |f| Confidence::try_from_f32(f).unwrap();
78    /// let out = InferenceMethod::AnalogyInference.compute(&[p(1.0), p(1.0)]).unwrap();
79    /// assert!((out.as_f32() - 0.7).abs() < 0.001);
80    /// ```
81    AnalogyInference,
82    /// Geometric-mean summarization over N ≥ 2 parents.
83    /// `output.conf = geomean(parents.conf) * 0.8`.
84    ///
85    /// ```
86    /// # #![allow(clippy::unwrap_used)]
87    /// use mimir_core::inference_methods::InferenceMethod;
88    /// use mimir_core::Confidence;
89    /// let p = |f| Confidence::try_from_f32(f).unwrap();
90    /// // geomean(0.5, 0.5) = 0.5; * 0.8 = 0.4.
91    /// let out = InferenceMethod::PatternSummarize.compute(&[p(0.5), p(0.5)]).unwrap();
92    /// assert!((out.as_f32() - 0.4).abs() < 0.001);
93    /// ```
94    PatternSummarize,
95    /// Chained architectural derivation.
96    /// `output.conf = product(parents.conf)`.
97    ///
98    /// ```
99    /// # #![allow(clippy::unwrap_used)]
100    /// use mimir_core::inference_methods::InferenceMethod;
101    /// use mimir_core::Confidence;
102    /// let p = |f| Confidence::try_from_f32(f).unwrap();
103    /// // 0.5 * 0.5 * 0.5 = 0.125.
104    /// let out = InferenceMethod::ArchitecturalChain
105    ///     .compute(&[p(0.5), p(0.5), p(0.5)]).unwrap();
106    /// assert!((out.as_f32() - 0.125).abs() < 0.001);
107    /// ```
108    ArchitecturalChain,
109    /// Dominance / ranking analysis.
110    /// `output.conf = min(parents.conf) * 0.6`.
111    ///
112    /// ```
113    /// # #![allow(clippy::unwrap_used)]
114    /// use mimir_core::inference_methods::InferenceMethod;
115    /// use mimir_core::Confidence;
116    /// let p = |f| Confidence::try_from_f32(f).unwrap();
117    /// // min(0.9, 0.5) * 0.6 = 0.3.
118    /// let out = InferenceMethod::DominanceAnalysis.compute(&[p(0.9), p(0.5)]).unwrap();
119    /// assert!((out.as_f32() - 0.3).abs() < 0.001);
120    /// ```
121    DominanceAnalysis,
122    /// Cardinality / count over N ≥ 1 parents.
123    /// `output.conf = min(parents.conf) * 0.8`.
124    ///
125    /// ```
126    /// # #![allow(clippy::unwrap_used)]
127    /// use mimir_core::inference_methods::InferenceMethod;
128    /// use mimir_core::Confidence;
129    /// let p = |f| Confidence::try_from_f32(f).unwrap();
130    /// let out = InferenceMethod::EntityCount.compute(&[p(0.7)]).unwrap();
131    /// assert!((out.as_f32() - 0.56).abs() < 0.001); // 0.7 * 0.8.
132    /// ```
133    EntityCount,
134    /// Interval calculation between two endpoint parents.
135    /// `output.conf = min(parents.conf) * 0.9`.
136    ///
137    /// ```
138    /// # #![allow(clippy::unwrap_used)]
139    /// use mimir_core::inference_methods::InferenceMethod;
140    /// use mimir_core::Confidence;
141    /// let p = |f| Confidence::try_from_f32(f).unwrap();
142    /// let out = InferenceMethod::IntervalCalc.compute(&[p(0.8), p(0.6)]).unwrap();
143    /// assert!((out.as_f32() - 0.54).abs() < 0.001); // min=0.6; *0.9.
144    /// ```
145    IntervalCalc,
146    /// Feedback consolidation over N ≥ 1 parents.
147    /// `output.conf = min(parents.conf) * 0.85`.
148    ///
149    /// ```
150    /// # #![allow(clippy::unwrap_used)]
151    /// use mimir_core::inference_methods::InferenceMethod;
152    /// use mimir_core::Confidence;
153    /// let p = |f| Confidence::try_from_f32(f).unwrap();
154    /// let out = InferenceMethod::FeedbackConsolidation.compute(&[p(0.6)]).unwrap();
155    /// assert!((out.as_f32() - 0.51).abs() < 0.001); // 0.6 * 0.85.
156    /// ```
157    FeedbackConsolidation,
158    /// Qualitative / narrative inference.
159    /// `output.conf = min(parents.conf) * 0.5`.
160    ///
161    /// ```
162    /// # #![allow(clippy::unwrap_used)]
163    /// use mimir_core::inference_methods::InferenceMethod;
164    /// use mimir_core::Confidence;
165    /// let p = |f| Confidence::try_from_f32(f).unwrap();
166    /// let out = InferenceMethod::QualitativeInference.compute(&[p(0.8)]).unwrap();
167    /// assert!((out.as_f32() - 0.4).abs() < 0.001);
168    /// ```
169    QualitativeInference,
170    /// Provenance chain through N ≥ 2 parents.
171    /// `output.conf = product(parents.conf)`.
172    ///
173    /// ```
174    /// # #![allow(clippy::unwrap_used)]
175    /// use mimir_core::inference_methods::InferenceMethod;
176    /// use mimir_core::Confidence;
177    /// let p = |f| Confidence::try_from_f32(f).unwrap();
178    /// let out = InferenceMethod::ProvenanceChain.compute(&[p(0.9), p(0.8)]).unwrap();
179    /// assert!((out.as_f32() - 0.72).abs() < 0.001);
180    /// ```
181    ProvenanceChain,
182    /// Noisy-OR consensus across independent parents.
183    /// `output.conf = 1 - product(1 - parents.conf)`.
184    ///
185    /// ```
186    /// # #![allow(clippy::unwrap_used)]
187    /// use mimir_core::inference_methods::InferenceMethod;
188    /// use mimir_core::Confidence;
189    /// let p = |f| Confidence::try_from_f32(f).unwrap();
190    /// // 1 - (0.5)(0.5) = 0.75.
191    /// let out = InferenceMethod::MultiSourceConsensus.compute(&[p(0.5), p(0.5)]).unwrap();
192    /// assert!((out.as_f32() - 0.75).abs() < 0.001);
193    /// ```
194    MultiSourceConsensus,
195    /// Conflict reconciliation over contested peers.
196    /// `output.conf = max(parents.conf) * 0.8`.
197    ///
198    /// ```
199    /// # #![allow(clippy::unwrap_used)]
200    /// use mimir_core::inference_methods::InferenceMethod;
201    /// use mimir_core::Confidence;
202    /// let p = |f| Confidence::try_from_f32(f).unwrap();
203    /// // max(0.3, 0.9) * 0.8 = 0.72.
204    /// let out = InferenceMethod::ConflictReconciliation.compute(&[p(0.3), p(0.9)]).unwrap();
205    /// assert!((out.as_f32() - 0.72).abs() < 0.001);
206    /// ```
207    ConflictReconciliation,
208}
209
210impl InferenceMethod {
211    /// The canonical symbol name (with leading `@`) for this method.
212    #[must_use]
213    pub fn symbol_name(self) -> &'static str {
214        match self {
215            Self::DirectLookup => "@direct_lookup",
216            Self::MajorityVote => "@majority_vote",
217            Self::CitationLink => "@citation_link",
218            Self::AnalogyInference => "@analogy_inference",
219            Self::PatternSummarize => "@pattern_summarize",
220            Self::ArchitecturalChain => "@architectural_chain",
221            Self::DominanceAnalysis => "@dominance_analysis",
222            Self::EntityCount => "@entity_count",
223            Self::IntervalCalc => "@interval_calc",
224            Self::FeedbackConsolidation => "@feedback_consolidation",
225            Self::QualitativeInference => "@qualitative_inference",
226            Self::ProvenanceChain => "@provenance_chain",
227            Self::MultiSourceConsensus => "@multi_source_consensus",
228            Self::ConflictReconciliation => "@conflict_reconciliation",
229        }
230    }
231
232    /// Resolve a canonical method name (with or without leading `@`) to
233    /// a variant, or `None` if the name is not a registered method.
234    #[must_use]
235    pub fn from_symbol_name(name: &str) -> Option<Self> {
236        let bare = name.strip_prefix('@').unwrap_or(name);
237        Some(match bare {
238            "direct_lookup" => Self::DirectLookup,
239            "majority_vote" => Self::MajorityVote,
240            "citation_link" => Self::CitationLink,
241            "analogy_inference" => Self::AnalogyInference,
242            "pattern_summarize" => Self::PatternSummarize,
243            "architectural_chain" => Self::ArchitecturalChain,
244            "dominance_analysis" => Self::DominanceAnalysis,
245            "entity_count" => Self::EntityCount,
246            "interval_calc" => Self::IntervalCalc,
247            "feedback_consolidation" => Self::FeedbackConsolidation,
248            "qualitative_inference" => Self::QualitativeInference,
249            "provenance_chain" => Self::ProvenanceChain,
250            "multi_source_consensus" => Self::MultiSourceConsensus,
251            "conflict_reconciliation" => Self::ConflictReconciliation,
252            _ => return None,
253        })
254    }
255
256    /// Parent-count rule expected by this method.
257    #[must_use]
258    pub fn parent_count_rule(self) -> ParentCountRule {
259        match self {
260            Self::DirectLookup => ParentCountRule::Exactly(1),
261            Self::MajorityVote => ParentCountRule::AtLeastOdd(3),
262            Self::CitationLink | Self::AnalogyInference | Self::IntervalCalc => {
263                ParentCountRule::Exactly(2)
264            }
265            Self::PatternSummarize
266            | Self::ArchitecturalChain
267            | Self::DominanceAnalysis
268            | Self::ProvenanceChain
269            | Self::MultiSourceConsensus
270            | Self::ConflictReconciliation => ParentCountRule::AtLeast(2),
271            Self::EntityCount | Self::FeedbackConsolidation | Self::QualitativeInference => {
272                ParentCountRule::AtLeast(1)
273            }
274        }
275    }
276
277    /// Staleness predicate per spec § 5.1.
278    #[must_use]
279    pub fn staleness_rule(self) -> StalenessRule {
280        match self {
281            Self::DirectLookup
282            | Self::MajorityVote
283            | Self::ArchitecturalChain
284            | Self::DominanceAnalysis
285            | Self::FeedbackConsolidation
286            | Self::QualitativeInference
287            | Self::ProvenanceChain => StalenessRule::AnyParentSuperseded,
288            Self::CitationLink | Self::AnalogyInference | Self::IntervalCalc => {
289                StalenessRule::EitherEndpointSuperseded
290            }
291            Self::PatternSummarize => StalenessRule::OverHalfSuperseded,
292            Self::EntityCount => StalenessRule::ParentCountChanges,
293            Self::MultiSourceConsensus => StalenessRule::FewerThanTwoRemain,
294            Self::ConflictReconciliation => StalenessRule::AnyParentSupersededOrNewConflict,
295        }
296    }
297
298    /// Compute the output confidence for this method given parent
299    /// confidences. See variant docs for each formula.
300    ///
301    /// # Errors
302    ///
303    /// - [`InferenceMethodError::WrongParentCount`] if the supplied
304    ///   count violates the method's [`ParentCountRule`].
305    /// - [`InferenceMethodError::TooManyParents`] if `parents.len() > 8`
306    ///   for methods whose formulas require a joint product
307    ///   (`ArchitecturalChain`, `ProvenanceChain`, `AnalogyInference`,
308    ///   `PatternSummarize`, `MultiSourceConsensus`). The u128
309    ///   intermediate accommodates up to eight 16-bit factors; larger N
310    ///   is reserved for a log-table follow-up.
311    ///
312    /// # Example
313    ///
314    /// ```
315    /// # #![allow(clippy::unwrap_used)]
316    /// use mimir_core::inference_methods::InferenceMethod;
317    /// use mimir_core::Confidence;
318    ///
319    /// let parents = [Confidence::ONE, Confidence::ONE];
320    /// let out = InferenceMethod::CitationLink.compute(&parents).unwrap();
321    /// // min(1.0, 1.0) * 0.9 ≈ 0.9; allow ±1 fixed-point step of drift.
322    /// let expected = (f32::from(u16::MAX) * 0.9) as u16;
323    /// assert!((i32::from(out.as_u16()) - i32::from(expected)).abs() <= 1);
324    /// ```
325    #[allow(clippy::too_many_lines, clippy::match_same_arms)]
326    pub fn compute(self, parents: &[Confidence]) -> Result<Confidence, InferenceMethodError> {
327        self.parent_count_rule().validate(self, parents.len())?;
328
329        match self {
330            Self::DirectLookup => Ok(parents[0]),
331
332            Self::MajorityVote => Ok(min_conf(parents)),
333
334            Self::CitationLink => {
335                let m = min_conf(parents);
336                Ok(scale_rational(m, 9, 10))
337            }
338
339            Self::AnalogyInference => {
340                let prod = product_conf(self, parents)?;
341                Ok(scale_rational(prod, 7, 10))
342            }
343
344            Self::PatternSummarize => {
345                let g = geomean_conf(parents)?;
346                Ok(scale_rational(g, 4, 5))
347            }
348
349            Self::ArchitecturalChain => product_conf(self, parents),
350
351            Self::DominanceAnalysis => {
352                let m = min_conf(parents);
353                Ok(scale_rational(m, 3, 5))
354            }
355
356            Self::EntityCount => {
357                let m = min_conf(parents);
358                Ok(scale_rational(m, 4, 5))
359            }
360
361            Self::IntervalCalc => {
362                let m = min_conf(parents);
363                Ok(scale_rational(m, 9, 10))
364            }
365
366            Self::FeedbackConsolidation => {
367                let m = min_conf(parents);
368                Ok(scale_rational(m, 17, 20))
369            }
370
371            Self::QualitativeInference => {
372                let m = min_conf(parents);
373                Ok(scale_rational(m, 1, 2))
374            }
375
376            Self::ProvenanceChain => product_conf(self, parents),
377
378            Self::MultiSourceConsensus => {
379                // Noisy-OR: 1 - product(1 - p_i). Build a vector of
380                // complements and product them.
381                if parents.len() > 8 {
382                    return Err(InferenceMethodError::TooManyParents {
383                        method: self,
384                        limit: 8,
385                        got: parents.len(),
386                    });
387                }
388                let complements: Vec<Confidence> = parents
389                    .iter()
390                    .map(|c| Confidence::from_u16(u16::MAX - c.as_u16()))
391                    .collect();
392                let prod_complement = product_conf(self, &complements)?;
393                Ok(Confidence::from_u16(u16::MAX - prod_complement.as_u16()))
394            }
395
396            Self::ConflictReconciliation => {
397                let m = max_conf(parents);
398                Ok(scale_rational(m, 4, 5))
399            }
400        }
401    }
402}
403
404// -------------------------------------------------------------------
405// Rules
406// -------------------------------------------------------------------
407
408/// Parent-count constraint for a method.
409#[derive(Copy, Clone, Debug, PartialEq, Eq)]
410pub enum ParentCountRule {
411    /// Exactly this many parents.
412    Exactly(usize),
413    /// At least this many parents (no parity constraint).
414    AtLeast(usize),
415    /// At least this many parents, and the count must be odd.
416    AtLeastOdd(usize),
417}
418
419impl ParentCountRule {
420    fn validate(self, method: InferenceMethod, n: usize) -> Result<(), InferenceMethodError> {
421        let ok = match self {
422            Self::Exactly(k) => n == k,
423            Self::AtLeast(k) => n >= k,
424            Self::AtLeastOdd(k) => n >= k && n % 2 == 1,
425        };
426        if ok {
427            Ok(())
428        } else {
429            Err(InferenceMethodError::WrongParentCount {
430                method,
431                rule: self,
432                got: n,
433            })
434        }
435    }
436}
437
438/// Staleness predicate — when the method's output is flagged stale.
439#[derive(Copy, Clone, Debug, PartialEq, Eq)]
440pub enum StalenessRule {
441    /// Flag stale if *any* parent is superseded.
442    AnyParentSuperseded,
443    /// Flag stale if *either* of the two endpoint parents is superseded.
444    EitherEndpointSuperseded,
445    /// Flag stale if more than 50% of parents are superseded.
446    OverHalfSuperseded,
447    /// Flag stale when the entity count changes (e.g., parent membership
448    /// gains / loses an item).
449    ParentCountChanges,
450    /// Flag stale when fewer than two non-superseded parents remain.
451    FewerThanTwoRemain,
452    /// Flag stale on any parent supersession OR when a new conflicting
453    /// memory lands on the same conflict key.
454    AnyParentSupersededOrNewConflict,
455}
456
457// -------------------------------------------------------------------
458// Errors
459// -------------------------------------------------------------------
460
461/// Errors from [`InferenceMethod::compute`].
462#[derive(Debug, Error, PartialEq, Eq)]
463pub enum InferenceMethodError {
464    /// Parent count did not match the method's [`ParentCountRule`].
465    #[error("method {method:?} requires {rule:?} parents, got {got}")]
466    WrongParentCount {
467        /// The method.
468        method: InferenceMethod,
469        /// The rule that was violated.
470        rule: ParentCountRule,
471        /// Actual parent count supplied.
472        got: usize,
473    },
474
475    /// Supplied parent count exceeds the u128 product capacity. Bounded
476    /// at 8 for methods that compute a joint product across all
477    /// parents; relaxing this requires a log-table implementation.
478    #[error("method {method:?} supports at most {limit} parents, got {got}")]
479    TooManyParents {
480        /// The method.
481        method: InferenceMethod,
482        /// Maximum supported parent count.
483        limit: usize,
484        /// Actual parent count supplied.
485        got: usize,
486    },
487}
488
489// -------------------------------------------------------------------
490// Fixed-point arithmetic helpers
491// -------------------------------------------------------------------
492
493/// Minimum confidence. Spec requires a non-empty list; callers are
494/// responsible for upstream validation.
495fn min_conf(parents: &[Confidence]) -> Confidence {
496    parents.iter().min().copied().unwrap_or(Confidence::ZERO)
497}
498
499fn max_conf(parents: &[Confidence]) -> Confidence {
500    parents.iter().max().copied().unwrap_or(Confidence::ZERO)
501}
502
503/// Fixed-point product in u16 scale. `a * b / u16::MAX`, round-to-nearest.
504/// Implementation: `((a as u64 * b as u64) + u16::MAX / 2) / u16::MAX`.
505fn mul_conf(a: Confidence, b: Confidence) -> Confidence {
506    let a64 = u64::from(a.as_u16());
507    let b64 = u64::from(b.as_u16());
508    let max = u64::from(u16::MAX);
509    let raw = (a64 * b64 + max / 2) / max;
510    // raw ≤ u16::MAX because a, b ≤ u16::MAX and (a*b)/max ≤ max.
511    #[allow(clippy::cast_possible_truncation)]
512    Confidence::from_u16(raw as u16)
513}
514
515/// Product of a non-empty slice of confidences. Bounded at 8 parents to
516/// keep the u128 intermediate below overflow; `method` is taken as a
517/// parameter so the typed error reports the correct caller.
518fn product_conf(
519    method: InferenceMethod,
520    parents: &[Confidence],
521) -> Result<Confidence, InferenceMethodError> {
522    if parents.len() > 8 {
523        return Err(InferenceMethodError::TooManyParents {
524            method,
525            limit: 8,
526            got: parents.len(),
527        });
528    }
529    let mut acc = Confidence::ONE;
530    for p in parents {
531        acc = mul_conf(acc, *p);
532    }
533    Ok(acc)
534}
535
536/// Scale a confidence by a rational `num/den`, round-to-nearest.
537/// Precondition: `num ≤ den ≤ u16::MAX` so the result stays in range.
538fn scale_rational(c: Confidence, num: u64, den: u64) -> Confidence {
539    debug_assert!(num <= den);
540    let v = u64::from(c.as_u16());
541    let raw = (v * num + den / 2) / den;
542    #[allow(clippy::cast_possible_truncation)]
543    Confidence::from_u16(raw as u16)
544}
545
546/// Geometric mean of u16 confidences. Integer Newton's method on u128
547/// product. Bounded at N ≤ 8 parents so the u128 product cannot overflow
548/// (`65535^8 ≈ 3.2e38 < 3.4e38 = u128::MAX`). Spec § 5.1 permits unbounded
549/// N; lifting this cap waits on a log-table implementation.
550fn geomean_conf(parents: &[Confidence]) -> Result<Confidence, InferenceMethodError> {
551    if parents.len() > 8 {
552        return Err(InferenceMethodError::TooManyParents {
553            method: InferenceMethod::PatternSummarize,
554            limit: 8,
555            got: parents.len(),
556        });
557    }
558    if parents.iter().any(|c| c.as_u16() == 0) {
559        return Ok(Confidence::ZERO);
560    }
561    // With the `p/65535` scaling convention, the geomean of u16 values
562    // simplifies to `prod(p_i)^(1/n)` — the outer 65535 scaling cancels.
563    let n = parents.len();
564    let mut prod: u128 = 1;
565    for p in parents {
566        prod *= u128::from(p.as_u16());
567    }
568    #[allow(clippy::cast_possible_truncation)]
569    let root = integer_nth_root_u128(prod, n as u32) as u16;
570    Ok(Confidence::from_u16(root))
571}
572
573/// Integer `n`-th root of `x` via Newton's method. Returns `floor(x^(1/n))`.
574fn integer_nth_root_u128(x: u128, n: u32) -> u128 {
575    if x < 2 {
576        return x;
577    }
578    if n <= 1 {
579        return x;
580    }
581    // Initial estimate: 2^(bits(x) / n) is at-least the true root.
582    let bits = 128 - x.leading_zeros();
583    let shift = (bits / n).min(127);
584    let mut y: u128 = 1u128 << shift;
585    // Ensure upper bound.
586    while y.checked_pow(n).is_none_or(|v| v < x) {
587        y = y.saturating_mul(2);
588        if y >= 1u128 << 127 {
589            break;
590        }
591    }
592    // Newton's iteration.
593    loop {
594        let y_pow = checked_pow_u128(y, n - 1);
595        if y_pow == 0 {
596            break;
597        }
598        let y_new = ((u128::from(n) - 1) * y + x / y_pow) / u128::from(n);
599        if y_new >= y {
600            break;
601        }
602        y = y_new;
603    }
604    // Fine-tune down to floor.
605    while y > 0 && y.checked_pow(n).is_none_or(|v| v > x) {
606        y -= 1;
607    }
608    y
609}
610
611/// `y^n` saturating to 0 on overflow (0 is a sentinel for "too big to
612/// divide by"); Newton's iteration handles the sentinel by breaking.
613fn checked_pow_u128(y: u128, n: u32) -> u128 {
614    y.checked_pow(n).unwrap_or(0)
615}
616
617// -------------------------------------------------------------------
618// Tests
619// -------------------------------------------------------------------
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    fn c(f: f32) -> Confidence {
626        Confidence::try_from_f32(f).expect("in range")
627    }
628
629    // ±1 fixed-point step tolerance for round-trip comparisons.
630    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
631    fn approx(a: Confidence, expected_f: f32) {
632        let expected = (f32::from(u16::MAX) * expected_f).round() as i32;
633        let actual = i32::from(a.as_u16());
634        assert!(
635            (actual - expected).abs() <= 1,
636            "expected ≈{expected_f} (u16={expected}), got {} (u16={actual})",
637            a.as_f32()
638        );
639    }
640
641    // ----- symbol-name round-trip -----
642
643    #[test]
644    fn every_variant_roundtrips_through_symbol_name() {
645        let variants = [
646            InferenceMethod::DirectLookup,
647            InferenceMethod::MajorityVote,
648            InferenceMethod::CitationLink,
649            InferenceMethod::AnalogyInference,
650            InferenceMethod::PatternSummarize,
651            InferenceMethod::ArchitecturalChain,
652            InferenceMethod::DominanceAnalysis,
653            InferenceMethod::EntityCount,
654            InferenceMethod::IntervalCalc,
655            InferenceMethod::FeedbackConsolidation,
656            InferenceMethod::QualitativeInference,
657            InferenceMethod::ProvenanceChain,
658            InferenceMethod::MultiSourceConsensus,
659            InferenceMethod::ConflictReconciliation,
660        ];
661        assert_eq!(variants.len(), 14);
662        for m in variants {
663            let name = m.symbol_name();
664            let back = InferenceMethod::from_symbol_name(name).expect("known");
665            assert_eq!(m, back);
666        }
667    }
668
669    #[test]
670    fn from_symbol_name_accepts_without_at_prefix() {
671        assert_eq!(
672            InferenceMethod::from_symbol_name("direct_lookup"),
673            Some(InferenceMethod::DirectLookup)
674        );
675        assert_eq!(
676            InferenceMethod::from_symbol_name("@direct_lookup"),
677            Some(InferenceMethod::DirectLookup)
678        );
679    }
680
681    #[test]
682    fn unknown_name_returns_none() {
683        assert!(InferenceMethod::from_symbol_name("@my_custom_method").is_none());
684    }
685
686    // ----- parent-count rules -----
687
688    #[test]
689    fn direct_lookup_requires_exactly_one() {
690        let method = InferenceMethod::DirectLookup;
691        assert!(method.compute(&[c(0.5)]).is_ok());
692        assert!(matches!(
693            method.compute(&[]).unwrap_err(),
694            InferenceMethodError::WrongParentCount { .. }
695        ));
696        assert!(matches!(
697            method.compute(&[c(0.5), c(0.6)]).unwrap_err(),
698            InferenceMethodError::WrongParentCount { .. }
699        ));
700    }
701
702    #[test]
703    fn majority_vote_rejects_even_n() {
704        let method = InferenceMethod::MajorityVote;
705        assert!(method.compute(&[c(0.5), c(0.6), c(0.7)]).is_ok());
706        assert!(matches!(
707            method.compute(&[c(0.5), c(0.6)]).unwrap_err(),
708            InferenceMethodError::WrongParentCount { .. }
709        ));
710        assert!(matches!(
711            method.compute(&[c(0.5)]).unwrap_err(),
712            InferenceMethodError::WrongParentCount { .. }
713        ));
714    }
715
716    #[test]
717    fn exactly_two_methods_reject_other_counts() {
718        for m in [
719            InferenceMethod::CitationLink,
720            InferenceMethod::AnalogyInference,
721            InferenceMethod::IntervalCalc,
722        ] {
723            assert!(m.compute(&[c(0.5), c(0.6)]).is_ok());
724            assert!(matches!(
725                m.compute(&[c(0.5)]).unwrap_err(),
726                InferenceMethodError::WrongParentCount { .. }
727            ));
728            assert!(matches!(
729                m.compute(&[c(0.5), c(0.6), c(0.7)]).unwrap_err(),
730                InferenceMethodError::WrongParentCount { .. }
731            ));
732        }
733    }
734
735    // ----- formula behavior -----
736
737    #[test]
738    fn direct_lookup_is_identity() {
739        let out = InferenceMethod::DirectLookup
740            .compute(&[c(0.75)])
741            .expect("ok");
742        approx(out, 0.75);
743    }
744
745    #[test]
746    fn citation_link_scales_by_0_9() {
747        let out = InferenceMethod::CitationLink
748            .compute(&[c(1.0), c(1.0)])
749            .expect("ok");
750        approx(out, 0.9);
751    }
752
753    #[test]
754    fn citation_link_uses_min() {
755        let out = InferenceMethod::CitationLink
756            .compute(&[c(0.8), c(0.5)])
757            .expect("ok");
758        approx(out, 0.5 * 0.9);
759    }
760
761    #[test]
762    fn analogy_inference_scales_product_by_0_7() {
763        let out = InferenceMethod::AnalogyInference
764            .compute(&[c(1.0), c(1.0)])
765            .expect("ok");
766        approx(out, 0.7);
767        let out = InferenceMethod::AnalogyInference
768            .compute(&[c(0.5), c(0.5)])
769            .expect("ok");
770        approx(out, 0.25 * 0.7);
771    }
772
773    #[test]
774    fn architectural_chain_is_raw_product() {
775        let out = InferenceMethod::ArchitecturalChain
776            .compute(&[c(0.5), c(0.5), c(0.5)])
777            .expect("ok");
778        approx(out, 0.125);
779    }
780
781    #[test]
782    fn dominance_analysis_scales_min_by_0_6() {
783        let out = InferenceMethod::DominanceAnalysis
784            .compute(&[c(0.9), c(0.5)])
785            .expect("ok");
786        approx(out, 0.5 * 0.6);
787    }
788
789    #[test]
790    fn entity_count_scales_min_by_0_8() {
791        let out = InferenceMethod::EntityCount.compute(&[c(0.7)]).expect("ok");
792        approx(out, 0.7 * 0.8);
793    }
794
795    #[test]
796    fn interval_calc_scales_min_by_0_9() {
797        let out = InferenceMethod::IntervalCalc
798            .compute(&[c(0.8), c(0.6)])
799            .expect("ok");
800        approx(out, 0.6 * 0.9);
801    }
802
803    #[test]
804    fn feedback_consolidation_scales_min_by_0_85() {
805        let out = InferenceMethod::FeedbackConsolidation
806            .compute(&[c(0.6)])
807            .expect("ok");
808        approx(out, 0.6 * 0.85);
809    }
810
811    #[test]
812    fn qualitative_inference_scales_min_by_0_5() {
813        let out = InferenceMethod::QualitativeInference
814            .compute(&[c(0.8)])
815            .expect("ok");
816        approx(out, 0.8 * 0.5);
817    }
818
819    #[test]
820    fn provenance_chain_is_raw_product() {
821        let out = InferenceMethod::ProvenanceChain
822            .compute(&[c(0.9), c(0.8), c(0.7)])
823            .expect("ok");
824        approx(out, 0.9 * 0.8 * 0.7);
825    }
826
827    #[test]
828    fn multi_source_consensus_noisy_or_raises_confidence() {
829        // Two independent 0.5 parents: 1 - (0.5 * 0.5) = 0.75.
830        let out = InferenceMethod::MultiSourceConsensus
831            .compute(&[c(0.5), c(0.5)])
832            .expect("ok");
833        approx(out, 0.75);
834    }
835
836    #[test]
837    fn multi_source_consensus_saturates_at_one_with_strong_parents() {
838        let out = InferenceMethod::MultiSourceConsensus
839            .compute(&[c(1.0), c(1.0)])
840            .expect("ok");
841        approx(out, 1.0);
842    }
843
844    #[test]
845    fn conflict_reconciliation_scales_max_by_0_8() {
846        let out = InferenceMethod::ConflictReconciliation
847            .compute(&[c(0.3), c(0.9)])
848            .expect("ok");
849        approx(out, 0.9 * 0.8);
850    }
851
852    #[test]
853    fn pattern_summarize_geomean_of_uniform_is_identity_scaled() {
854        // geomean(0.5, 0.5, 0.5, 0.5) = 0.5; * 0.8 = 0.4.
855        let out = InferenceMethod::PatternSummarize
856            .compute(&[c(0.5), c(0.5), c(0.5), c(0.5)])
857            .expect("ok");
858        approx(out, 0.4);
859    }
860
861    #[test]
862    fn pattern_summarize_of_two_is_sqrt_product_scaled() {
863        // geomean(0.25, 1.0) = 0.5; * 0.8 = 0.4.
864        let out = InferenceMethod::PatternSummarize
865            .compute(&[c(0.25), c(1.0)])
866            .expect("ok");
867        approx(out, 0.4);
868    }
869
870    // ----- range + monotonicity -----
871
872    #[test]
873    fn every_method_output_stays_in_range_for_saturated_input() {
874        // Build a valid parent list for each method and assert the
875        // output is a valid confidence (trivially in [0, 1]).
876        let parents_for = |m: InferenceMethod| -> Vec<Confidence> {
877            let k = match m.parent_count_rule() {
878                ParentCountRule::Exactly(k)
879                | ParentCountRule::AtLeast(k)
880                | ParentCountRule::AtLeastOdd(k) => k,
881            };
882            vec![c(1.0); k]
883        };
884        let variants = [
885            InferenceMethod::DirectLookup,
886            InferenceMethod::MajorityVote,
887            InferenceMethod::CitationLink,
888            InferenceMethod::AnalogyInference,
889            InferenceMethod::PatternSummarize,
890            InferenceMethod::ArchitecturalChain,
891            InferenceMethod::DominanceAnalysis,
892            InferenceMethod::EntityCount,
893            InferenceMethod::IntervalCalc,
894            InferenceMethod::FeedbackConsolidation,
895            InferenceMethod::QualitativeInference,
896            InferenceMethod::ProvenanceChain,
897            InferenceMethod::MultiSourceConsensus,
898            InferenceMethod::ConflictReconciliation,
899        ];
900        for m in variants {
901            let parents = parents_for(m);
902            let out = m.compute(&parents).expect("compute");
903            let v = out.as_f32();
904            assert!(
905                (0.0..=1.0).contains(&v),
906                "method {m:?} output {v} out of range"
907            );
908        }
909    }
910
911    #[test]
912    fn too_many_parents_errors_for_product_methods() {
913        let nine = vec![c(0.5); 9];
914        for m in [
915            InferenceMethod::AnalogyInference,
916            InferenceMethod::ArchitecturalChain,
917            InferenceMethod::ProvenanceChain,
918            InferenceMethod::MultiSourceConsensus,
919            InferenceMethod::PatternSummarize,
920        ] {
921            let parents = match m.parent_count_rule() {
922                ParentCountRule::Exactly(2) => continue, // AnalogyInference: fixed at 2.
923                _ => nine.clone(),
924            };
925            let err = m.compute(&parents).expect_err("overflow");
926            // Error must identify the actual calling method — not a
927            // hardcoded placeholder (regression guard).
928            let InferenceMethodError::TooManyParents { method, .. } = err else {
929                panic!("expected TooManyParents, got {err:?}");
930            };
931            assert_eq!(method, m);
932        }
933    }
934
935    // ----- integer Nth root sanity -----
936
937    #[test]
938    fn integer_nth_root_matches_known_values() {
939        assert_eq!(integer_nth_root_u128(0, 2), 0);
940        assert_eq!(integer_nth_root_u128(1, 2), 1);
941        assert_eq!(integer_nth_root_u128(4, 2), 2);
942        assert_eq!(integer_nth_root_u128(9, 2), 3);
943        assert_eq!(integer_nth_root_u128(27, 3), 3);
944        assert_eq!(integer_nth_root_u128(1000, 3), 10);
945        // Non-exact: floor(sqrt(10)) = 3, floor(cbrt(28)) = 3.
946        assert_eq!(integer_nth_root_u128(10, 2), 3);
947        assert_eq!(integer_nth_root_u128(28, 3), 3);
948    }
949
950    // ----- staleness rules -----
951
952    #[test]
953    fn staleness_rules_match_spec() {
954        use StalenessRule::*;
955        assert_eq!(
956            InferenceMethod::DirectLookup.staleness_rule(),
957            AnyParentSuperseded
958        );
959        assert_eq!(
960            InferenceMethod::PatternSummarize.staleness_rule(),
961            OverHalfSuperseded
962        );
963        assert_eq!(
964            InferenceMethod::MultiSourceConsensus.staleness_rule(),
965            FewerThanTwoRemain
966        );
967        assert_eq!(
968            InferenceMethod::EntityCount.staleness_rule(),
969            ParentCountChanges
970        );
971        assert_eq!(
972            InferenceMethod::ConflictReconciliation.staleness_rule(),
973            AnyParentSupersededOrNewConflict
974        );
975        assert_eq!(
976            InferenceMethod::CitationLink.staleness_rule(),
977            EitherEndpointSuperseded
978        );
979    }
980}