use thiserror::Error;
use crate::confidence::Confidence;
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum InferenceMethod {
DirectLookup,
MajorityVote,
CitationLink,
AnalogyInference,
PatternSummarize,
ArchitecturalChain,
DominanceAnalysis,
EntityCount,
IntervalCalc,
FeedbackConsolidation,
QualitativeInference,
ProvenanceChain,
MultiSourceConsensus,
ConflictReconciliation,
}
impl InferenceMethod {
#[must_use]
pub fn symbol_name(self) -> &'static str {
match self {
Self::DirectLookup => "@direct_lookup",
Self::MajorityVote => "@majority_vote",
Self::CitationLink => "@citation_link",
Self::AnalogyInference => "@analogy_inference",
Self::PatternSummarize => "@pattern_summarize",
Self::ArchitecturalChain => "@architectural_chain",
Self::DominanceAnalysis => "@dominance_analysis",
Self::EntityCount => "@entity_count",
Self::IntervalCalc => "@interval_calc",
Self::FeedbackConsolidation => "@feedback_consolidation",
Self::QualitativeInference => "@qualitative_inference",
Self::ProvenanceChain => "@provenance_chain",
Self::MultiSourceConsensus => "@multi_source_consensus",
Self::ConflictReconciliation => "@conflict_reconciliation",
}
}
#[must_use]
pub fn from_symbol_name(name: &str) -> Option<Self> {
let bare = name.strip_prefix('@').unwrap_or(name);
Some(match bare {
"direct_lookup" => Self::DirectLookup,
"majority_vote" => Self::MajorityVote,
"citation_link" => Self::CitationLink,
"analogy_inference" => Self::AnalogyInference,
"pattern_summarize" => Self::PatternSummarize,
"architectural_chain" => Self::ArchitecturalChain,
"dominance_analysis" => Self::DominanceAnalysis,
"entity_count" => Self::EntityCount,
"interval_calc" => Self::IntervalCalc,
"feedback_consolidation" => Self::FeedbackConsolidation,
"qualitative_inference" => Self::QualitativeInference,
"provenance_chain" => Self::ProvenanceChain,
"multi_source_consensus" => Self::MultiSourceConsensus,
"conflict_reconciliation" => Self::ConflictReconciliation,
_ => return None,
})
}
#[must_use]
pub fn parent_count_rule(self) -> ParentCountRule {
match self {
Self::DirectLookup => ParentCountRule::Exactly(1),
Self::MajorityVote => ParentCountRule::AtLeastOdd(3),
Self::CitationLink | Self::AnalogyInference | Self::IntervalCalc => {
ParentCountRule::Exactly(2)
}
Self::PatternSummarize
| Self::ArchitecturalChain
| Self::DominanceAnalysis
| Self::ProvenanceChain
| Self::MultiSourceConsensus
| Self::ConflictReconciliation => ParentCountRule::AtLeast(2),
Self::EntityCount | Self::FeedbackConsolidation | Self::QualitativeInference => {
ParentCountRule::AtLeast(1)
}
}
}
#[must_use]
pub fn staleness_rule(self) -> StalenessRule {
match self {
Self::DirectLookup
| Self::MajorityVote
| Self::ArchitecturalChain
| Self::DominanceAnalysis
| Self::FeedbackConsolidation
| Self::QualitativeInference
| Self::ProvenanceChain => StalenessRule::AnyParentSuperseded,
Self::CitationLink | Self::AnalogyInference | Self::IntervalCalc => {
StalenessRule::EitherEndpointSuperseded
}
Self::PatternSummarize => StalenessRule::OverHalfSuperseded,
Self::EntityCount => StalenessRule::ParentCountChanges,
Self::MultiSourceConsensus => StalenessRule::FewerThanTwoRemain,
Self::ConflictReconciliation => StalenessRule::AnyParentSupersededOrNewConflict,
}
}
#[allow(clippy::too_many_lines, clippy::match_same_arms)]
pub fn compute(self, parents: &[Confidence]) -> Result<Confidence, InferenceMethodError> {
self.parent_count_rule().validate(self, parents.len())?;
match self {
Self::DirectLookup => Ok(parents[0]),
Self::MajorityVote => Ok(min_conf(parents)),
Self::CitationLink => {
let m = min_conf(parents);
Ok(scale_rational(m, 9, 10))
}
Self::AnalogyInference => {
let prod = product_conf(self, parents)?;
Ok(scale_rational(prod, 7, 10))
}
Self::PatternSummarize => {
let g = geomean_conf(parents)?;
Ok(scale_rational(g, 4, 5))
}
Self::ArchitecturalChain => product_conf(self, parents),
Self::DominanceAnalysis => {
let m = min_conf(parents);
Ok(scale_rational(m, 3, 5))
}
Self::EntityCount => {
let m = min_conf(parents);
Ok(scale_rational(m, 4, 5))
}
Self::IntervalCalc => {
let m = min_conf(parents);
Ok(scale_rational(m, 9, 10))
}
Self::FeedbackConsolidation => {
let m = min_conf(parents);
Ok(scale_rational(m, 17, 20))
}
Self::QualitativeInference => {
let m = min_conf(parents);
Ok(scale_rational(m, 1, 2))
}
Self::ProvenanceChain => product_conf(self, parents),
Self::MultiSourceConsensus => {
if parents.len() > 8 {
return Err(InferenceMethodError::TooManyParents {
method: self,
limit: 8,
got: parents.len(),
});
}
let complements: Vec<Confidence> = parents
.iter()
.map(|c| Confidence::from_u16(u16::MAX - c.as_u16()))
.collect();
let prod_complement = product_conf(self, &complements)?;
Ok(Confidence::from_u16(u16::MAX - prod_complement.as_u16()))
}
Self::ConflictReconciliation => {
let m = max_conf(parents);
Ok(scale_rational(m, 4, 5))
}
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ParentCountRule {
Exactly(usize),
AtLeast(usize),
AtLeastOdd(usize),
}
impl ParentCountRule {
fn validate(self, method: InferenceMethod, n: usize) -> Result<(), InferenceMethodError> {
let ok = match self {
Self::Exactly(k) => n == k,
Self::AtLeast(k) => n >= k,
Self::AtLeastOdd(k) => n >= k && n % 2 == 1,
};
if ok {
Ok(())
} else {
Err(InferenceMethodError::WrongParentCount {
method,
rule: self,
got: n,
})
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum StalenessRule {
AnyParentSuperseded,
EitherEndpointSuperseded,
OverHalfSuperseded,
ParentCountChanges,
FewerThanTwoRemain,
AnyParentSupersededOrNewConflict,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum InferenceMethodError {
#[error("method {method:?} requires {rule:?} parents, got {got}")]
WrongParentCount {
method: InferenceMethod,
rule: ParentCountRule,
got: usize,
},
#[error("method {method:?} supports at most {limit} parents, got {got}")]
TooManyParents {
method: InferenceMethod,
limit: usize,
got: usize,
},
}
fn min_conf(parents: &[Confidence]) -> Confidence {
parents.iter().min().copied().unwrap_or(Confidence::ZERO)
}
fn max_conf(parents: &[Confidence]) -> Confidence {
parents.iter().max().copied().unwrap_or(Confidence::ZERO)
}
fn mul_conf(a: Confidence, b: Confidence) -> Confidence {
let a64 = u64::from(a.as_u16());
let b64 = u64::from(b.as_u16());
let max = u64::from(u16::MAX);
let raw = (a64 * b64 + max / 2) / max;
#[allow(clippy::cast_possible_truncation)]
Confidence::from_u16(raw as u16)
}
fn product_conf(
method: InferenceMethod,
parents: &[Confidence],
) -> Result<Confidence, InferenceMethodError> {
if parents.len() > 8 {
return Err(InferenceMethodError::TooManyParents {
method,
limit: 8,
got: parents.len(),
});
}
let mut acc = Confidence::ONE;
for p in parents {
acc = mul_conf(acc, *p);
}
Ok(acc)
}
fn scale_rational(c: Confidence, num: u64, den: u64) -> Confidence {
debug_assert!(num <= den);
let v = u64::from(c.as_u16());
let raw = (v * num + den / 2) / den;
#[allow(clippy::cast_possible_truncation)]
Confidence::from_u16(raw as u16)
}
fn geomean_conf(parents: &[Confidence]) -> Result<Confidence, InferenceMethodError> {
if parents.len() > 8 {
return Err(InferenceMethodError::TooManyParents {
method: InferenceMethod::PatternSummarize,
limit: 8,
got: parents.len(),
});
}
if parents.iter().any(|c| c.as_u16() == 0) {
return Ok(Confidence::ZERO);
}
let n = parents.len();
let mut prod: u128 = 1;
for p in parents {
prod *= u128::from(p.as_u16());
}
#[allow(clippy::cast_possible_truncation)]
let root = integer_nth_root_u128(prod, n as u32) as u16;
Ok(Confidence::from_u16(root))
}
fn integer_nth_root_u128(x: u128, n: u32) -> u128 {
if x < 2 {
return x;
}
if n <= 1 {
return x;
}
let bits = 128 - x.leading_zeros();
let shift = (bits / n).min(127);
let mut y: u128 = 1u128 << shift;
while y.checked_pow(n).is_none_or(|v| v < x) {
y = y.saturating_mul(2);
if y >= 1u128 << 127 {
break;
}
}
loop {
let y_pow = checked_pow_u128(y, n - 1);
if y_pow == 0 {
break;
}
let y_new = ((u128::from(n) - 1) * y + x / y_pow) / u128::from(n);
if y_new >= y {
break;
}
y = y_new;
}
while y > 0 && y.checked_pow(n).is_none_or(|v| v > x) {
y -= 1;
}
y
}
fn checked_pow_u128(y: u128, n: u32) -> u128 {
y.checked_pow(n).unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn c(f: f32) -> Confidence {
Confidence::try_from_f32(f).expect("in range")
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn approx(a: Confidence, expected_f: f32) {
let expected = (f32::from(u16::MAX) * expected_f).round() as i32;
let actual = i32::from(a.as_u16());
assert!(
(actual - expected).abs() <= 1,
"expected ≈{expected_f} (u16={expected}), got {} (u16={actual})",
a.as_f32()
);
}
#[test]
fn every_variant_roundtrips_through_symbol_name() {
let variants = [
InferenceMethod::DirectLookup,
InferenceMethod::MajorityVote,
InferenceMethod::CitationLink,
InferenceMethod::AnalogyInference,
InferenceMethod::PatternSummarize,
InferenceMethod::ArchitecturalChain,
InferenceMethod::DominanceAnalysis,
InferenceMethod::EntityCount,
InferenceMethod::IntervalCalc,
InferenceMethod::FeedbackConsolidation,
InferenceMethod::QualitativeInference,
InferenceMethod::ProvenanceChain,
InferenceMethod::MultiSourceConsensus,
InferenceMethod::ConflictReconciliation,
];
assert_eq!(variants.len(), 14);
for m in variants {
let name = m.symbol_name();
let back = InferenceMethod::from_symbol_name(name).expect("known");
assert_eq!(m, back);
}
}
#[test]
fn from_symbol_name_accepts_without_at_prefix() {
assert_eq!(
InferenceMethod::from_symbol_name("direct_lookup"),
Some(InferenceMethod::DirectLookup)
);
assert_eq!(
InferenceMethod::from_symbol_name("@direct_lookup"),
Some(InferenceMethod::DirectLookup)
);
}
#[test]
fn unknown_name_returns_none() {
assert!(InferenceMethod::from_symbol_name("@my_custom_method").is_none());
}
#[test]
fn direct_lookup_requires_exactly_one() {
let method = InferenceMethod::DirectLookup;
assert!(method.compute(&[c(0.5)]).is_ok());
assert!(matches!(
method.compute(&[]).unwrap_err(),
InferenceMethodError::WrongParentCount { .. }
));
assert!(matches!(
method.compute(&[c(0.5), c(0.6)]).unwrap_err(),
InferenceMethodError::WrongParentCount { .. }
));
}
#[test]
fn majority_vote_rejects_even_n() {
let method = InferenceMethod::MajorityVote;
assert!(method.compute(&[c(0.5), c(0.6), c(0.7)]).is_ok());
assert!(matches!(
method.compute(&[c(0.5), c(0.6)]).unwrap_err(),
InferenceMethodError::WrongParentCount { .. }
));
assert!(matches!(
method.compute(&[c(0.5)]).unwrap_err(),
InferenceMethodError::WrongParentCount { .. }
));
}
#[test]
fn exactly_two_methods_reject_other_counts() {
for m in [
InferenceMethod::CitationLink,
InferenceMethod::AnalogyInference,
InferenceMethod::IntervalCalc,
] {
assert!(m.compute(&[c(0.5), c(0.6)]).is_ok());
assert!(matches!(
m.compute(&[c(0.5)]).unwrap_err(),
InferenceMethodError::WrongParentCount { .. }
));
assert!(matches!(
m.compute(&[c(0.5), c(0.6), c(0.7)]).unwrap_err(),
InferenceMethodError::WrongParentCount { .. }
));
}
}
#[test]
fn direct_lookup_is_identity() {
let out = InferenceMethod::DirectLookup
.compute(&[c(0.75)])
.expect("ok");
approx(out, 0.75);
}
#[test]
fn citation_link_scales_by_0_9() {
let out = InferenceMethod::CitationLink
.compute(&[c(1.0), c(1.0)])
.expect("ok");
approx(out, 0.9);
}
#[test]
fn citation_link_uses_min() {
let out = InferenceMethod::CitationLink
.compute(&[c(0.8), c(0.5)])
.expect("ok");
approx(out, 0.5 * 0.9);
}
#[test]
fn analogy_inference_scales_product_by_0_7() {
let out = InferenceMethod::AnalogyInference
.compute(&[c(1.0), c(1.0)])
.expect("ok");
approx(out, 0.7);
let out = InferenceMethod::AnalogyInference
.compute(&[c(0.5), c(0.5)])
.expect("ok");
approx(out, 0.25 * 0.7);
}
#[test]
fn architectural_chain_is_raw_product() {
let out = InferenceMethod::ArchitecturalChain
.compute(&[c(0.5), c(0.5), c(0.5)])
.expect("ok");
approx(out, 0.125);
}
#[test]
fn dominance_analysis_scales_min_by_0_6() {
let out = InferenceMethod::DominanceAnalysis
.compute(&[c(0.9), c(0.5)])
.expect("ok");
approx(out, 0.5 * 0.6);
}
#[test]
fn entity_count_scales_min_by_0_8() {
let out = InferenceMethod::EntityCount.compute(&[c(0.7)]).expect("ok");
approx(out, 0.7 * 0.8);
}
#[test]
fn interval_calc_scales_min_by_0_9() {
let out = InferenceMethod::IntervalCalc
.compute(&[c(0.8), c(0.6)])
.expect("ok");
approx(out, 0.6 * 0.9);
}
#[test]
fn feedback_consolidation_scales_min_by_0_85() {
let out = InferenceMethod::FeedbackConsolidation
.compute(&[c(0.6)])
.expect("ok");
approx(out, 0.6 * 0.85);
}
#[test]
fn qualitative_inference_scales_min_by_0_5() {
let out = InferenceMethod::QualitativeInference
.compute(&[c(0.8)])
.expect("ok");
approx(out, 0.8 * 0.5);
}
#[test]
fn provenance_chain_is_raw_product() {
let out = InferenceMethod::ProvenanceChain
.compute(&[c(0.9), c(0.8), c(0.7)])
.expect("ok");
approx(out, 0.9 * 0.8 * 0.7);
}
#[test]
fn multi_source_consensus_noisy_or_raises_confidence() {
let out = InferenceMethod::MultiSourceConsensus
.compute(&[c(0.5), c(0.5)])
.expect("ok");
approx(out, 0.75);
}
#[test]
fn multi_source_consensus_saturates_at_one_with_strong_parents() {
let out = InferenceMethod::MultiSourceConsensus
.compute(&[c(1.0), c(1.0)])
.expect("ok");
approx(out, 1.0);
}
#[test]
fn conflict_reconciliation_scales_max_by_0_8() {
let out = InferenceMethod::ConflictReconciliation
.compute(&[c(0.3), c(0.9)])
.expect("ok");
approx(out, 0.9 * 0.8);
}
#[test]
fn pattern_summarize_geomean_of_uniform_is_identity_scaled() {
let out = InferenceMethod::PatternSummarize
.compute(&[c(0.5), c(0.5), c(0.5), c(0.5)])
.expect("ok");
approx(out, 0.4);
}
#[test]
fn pattern_summarize_of_two_is_sqrt_product_scaled() {
let out = InferenceMethod::PatternSummarize
.compute(&[c(0.25), c(1.0)])
.expect("ok");
approx(out, 0.4);
}
#[test]
fn every_method_output_stays_in_range_for_saturated_input() {
let parents_for = |m: InferenceMethod| -> Vec<Confidence> {
let k = match m.parent_count_rule() {
ParentCountRule::Exactly(k)
| ParentCountRule::AtLeast(k)
| ParentCountRule::AtLeastOdd(k) => k,
};
vec![c(1.0); k]
};
let variants = [
InferenceMethod::DirectLookup,
InferenceMethod::MajorityVote,
InferenceMethod::CitationLink,
InferenceMethod::AnalogyInference,
InferenceMethod::PatternSummarize,
InferenceMethod::ArchitecturalChain,
InferenceMethod::DominanceAnalysis,
InferenceMethod::EntityCount,
InferenceMethod::IntervalCalc,
InferenceMethod::FeedbackConsolidation,
InferenceMethod::QualitativeInference,
InferenceMethod::ProvenanceChain,
InferenceMethod::MultiSourceConsensus,
InferenceMethod::ConflictReconciliation,
];
for m in variants {
let parents = parents_for(m);
let out = m.compute(&parents).expect("compute");
let v = out.as_f32();
assert!(
(0.0..=1.0).contains(&v),
"method {m:?} output {v} out of range"
);
}
}
#[test]
fn too_many_parents_errors_for_product_methods() {
let nine = vec![c(0.5); 9];
for m in [
InferenceMethod::AnalogyInference,
InferenceMethod::ArchitecturalChain,
InferenceMethod::ProvenanceChain,
InferenceMethod::MultiSourceConsensus,
InferenceMethod::PatternSummarize,
] {
let parents = match m.parent_count_rule() {
ParentCountRule::Exactly(2) => continue, _ => nine.clone(),
};
let err = m.compute(&parents).expect_err("overflow");
let InferenceMethodError::TooManyParents { method, .. } = err else {
panic!("expected TooManyParents, got {err:?}");
};
assert_eq!(method, m);
}
}
#[test]
fn integer_nth_root_matches_known_values() {
assert_eq!(integer_nth_root_u128(0, 2), 0);
assert_eq!(integer_nth_root_u128(1, 2), 1);
assert_eq!(integer_nth_root_u128(4, 2), 2);
assert_eq!(integer_nth_root_u128(9, 2), 3);
assert_eq!(integer_nth_root_u128(27, 3), 3);
assert_eq!(integer_nth_root_u128(1000, 3), 10);
assert_eq!(integer_nth_root_u128(10, 2), 3);
assert_eq!(integer_nth_root_u128(28, 3), 3);
}
#[test]
fn staleness_rules_match_spec() {
use StalenessRule::*;
assert_eq!(
InferenceMethod::DirectLookup.staleness_rule(),
AnyParentSuperseded
);
assert_eq!(
InferenceMethod::PatternSummarize.staleness_rule(),
OverHalfSuperseded
);
assert_eq!(
InferenceMethod::MultiSourceConsensus.staleness_rule(),
FewerThanTwoRemain
);
assert_eq!(
InferenceMethod::EntityCount.staleness_rule(),
ParentCountChanges
);
assert_eq!(
InferenceMethod::ConflictReconciliation.staleness_rule(),
AnyParentSupersededOrNewConflict
);
assert_eq!(
InferenceMethod::CitationLink.staleness_rule(),
EitherEndpointSuperseded
);
}
}