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}