gradatum-core 0.4.3

Shared primitives: errors, IDs, types
Documentation
//! Provenance trust scores.
//!
//! En v0.4.x : trust **écrit/stocké, non consommé par le scoring**.
//! La consommation (decay par provenance) est réservée à une version ultérieure.
//!
//! ## Design
//!
//! - `TRUST_SCORES` : 4 sources statiques avec score fixe.
//! - `TrustLookup` : trait étroit synchrone évitant la dépendance circulaire
//!   `gradatum-core → gradatum-index`. Implémenté par `SqliteIndex` dans `gradatum-index`.
//! - `compute_distill_trust` : câblé pour la distillation (F-22), sans caller live en v0.4.x.
//!
//! ## Sources différées (hors scope v0.4.0)
//!
//! - `"human-validated"` (0.90) → Job::Validate, F-43 v0.5.0
//! - `"distilled"` → calculé par Job::Distill, F-22 v0.4.3

use ulid::Ulid;

/// Sources statiques avec leur score de confiance fixe.
///
/// Sources différées hors scope v0.4.0 : `"human-validated"` (F-43 v0.5.0)
/// et `"distilled"` (F-22 v0.4.3).
pub const TRUST_SCORES: &[(&str, f32)] = &[
    ("human-decision", 0.95),
    ("qa-event", 0.75),
    ("agent-log", 0.50),
    ("web-scraped", 0.35),
];

/// Trait minimal de lecture du score trust d'une note.
///
/// Trait étroit synchrone — évite la dépendance circulaire `gradatum-core → gradatum-index`.
/// `SqliteIndex` l'implémente dans `gradatum-index`.
///
/// Retourne `None` si la note est absente ou si son trust n'a pas été positionné.
pub trait TrustLookup {
    /// Retourne le score trust de la note identifiée par `id`, ou `None` si inconnu.
    fn get_trust(&self, id: &Ulid) -> Option<f32>;
}

/// Résout la provenance depuis un `section_hint` optionnel.
///
/// Si `section_hint ∈ TRUST_SCORES` → retourne `section_hint` tel quel.
/// Sinon (ou si absent) → retourne `"agent-log"` (défaut conservateur v0.4.0).
///
/// # Exemples
///
/// ```
/// use gradatum_core::provenance::resolve_provenance;
/// assert_eq!(resolve_provenance(Some("human-decision")), "human-decision");
/// assert_eq!(resolve_provenance(Some("qa-event")),       "qa-event");
/// assert_eq!(resolve_provenance(None),                   "agent-log");
/// assert_eq!(resolve_provenance(Some("unknown")),        "agent-log");
/// ```
pub fn resolve_provenance(section_hint: Option<&str>) -> &'static str {
    match section_hint {
        Some(hint) => TRUST_SCORES
            .iter()
            .find(|(k, _)| *k == hint)
            .map(|(k, _)| *k)
            .unwrap_or("agent-log"),
        None => "agent-log",
    }
}

/// Retourne le score trust statique pour une provenance connue, ou `None` si inconnue.
///
/// # Exemples
///
/// ```
/// use gradatum_core::provenance::trust_for;
/// assert_eq!(trust_for("human-decision"), Some(0.95));
/// assert_eq!(trust_for("agent-log"),      Some(0.50));
/// assert_eq!(trust_for("unknown"),         None);
/// ```
pub fn trust_for(provenance: &str) -> Option<f32> {
    TRUST_SCORES
        .iter()
        .find(|(k, _)| *k == provenance)
        .map(|(_, v)| *v)
}

/// Trust distillé = mean(trust des sources connues) × confidence, clamp [0, 1].
///
/// Retourne `0.5` (neutre) si aucune source n'est connue de l'index.
///
/// Câblé pour F-22 (Job::Distill v0.4.3) — sans caller live en v0.4.0.
///
/// # Paramètres
///
/// - `sources` : identifiants ULID des notes sources.
/// - `index` : implémentation du trait [`TrustLookup`] pour lire les scores.
/// - `confidence` : confiance du modèle F-42 (0.0–1.0).
///
/// # Comportement
///
/// - Si `sources` est vide ou qu'aucune source n'a de score connu → retourne `0.5`.
/// - Sinon : `clamp(mean(scores) * confidence, 0.0, 1.0)`.
pub fn compute_distill_trust(sources: &[Ulid], index: &dyn TrustLookup, confidence: f32) -> f32 {
    let trusts: Vec<f32> = sources
        .iter()
        .filter_map(|id| index.get_trust(id))
        .collect();

    // Neutre 0.5 si aucune source connue de l'index — pas de multiplication par confidence
    // (0.5 représente l'absence de signal, pas un score à pondérer).
    if trusts.is_empty() {
        return 0.5;
    }

    let mean = trusts.iter().sum::<f32>() / trusts.len() as f32;
    (mean * confidence).clamp(0.0, 1.0)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;

    struct FakeIndex(HashMap<Ulid, f32>);

    impl TrustLookup for FakeIndex {
        fn get_trust(&self, id: &Ulid) -> Option<f32> {
            self.0.get(id).copied()
        }
    }

    #[test]
    fn trust_scores_known_values() {
        assert_eq!(trust_for("human-decision"), Some(0.95));
        assert_eq!(trust_for("qa-event"), Some(0.75));
        assert_eq!(trust_for("agent-log"), Some(0.50));
        assert_eq!(trust_for("web-scraped"), Some(0.35));
        assert_eq!(trust_for("unknown"), None);
        assert_eq!(trust_for("human-validated"), None); // F-43 v0.5.0 — différé
        assert_eq!(trust_for("distilled"), None); // F-22 v0.4.3 — différé
    }

    #[test]
    fn distill_empty_sources_is_neutral() {
        let idx = FakeIndex(HashMap::new());
        assert_eq!(compute_distill_trust(&[], &idx, 0.87), 0.5);
    }

    #[test]
    fn distill_empty_sources_confidence_zero_is_neutral() {
        // Même avec confidence=0, sources vides → 0.5 (mean de l'ensemble vide).
        let idx = FakeIndex(HashMap::new());
        assert_eq!(compute_distill_trust(&[], &idx, 0.0), 0.5);
    }

    #[test]
    fn distill_mean_times_confidence() {
        let (a, b) = (Ulid::new(), Ulid::new());
        let mut m = HashMap::new();
        m.insert(a, 0.95);
        m.insert(b, 0.75);
        let idx = FakeIndex(m);
        let got = compute_distill_trust(&[a, b], &idx, 0.80);
        // mean(0.95, 0.75) = 0.85 ; 0.85 * 0.80 = 0.68
        assert!(
            (got - 0.85_f32 * 0.80_f32).abs() < 1e-6,
            "attendu ≈ 0.68, obtenu {got}"
        );
    }

    #[test]
    fn distill_clamp_high() {
        // confidence=1.0, score très élevé → pas de dépassement 1.0.
        let id = Ulid::new();
        let mut m = HashMap::new();
        m.insert(id, 1.0_f32);
        let idx = FakeIndex(m);
        let got = compute_distill_trust(&[id], &idx, 1.0);
        assert!(got <= 1.0, "clamp haut violé : {got}");
        assert!((got - 1.0_f32).abs() < 1e-6);
    }

    #[test]
    fn distill_clamp_low() {
        // confidence=0.0 → tout clampé à 0.0 (sauf sources vides = 0.5 ).
        let id = Ulid::new();
        let mut m = HashMap::new();
        m.insert(id, 0.95_f32);
        let idx = FakeIndex(m);
        let got = compute_distill_trust(&[id], &idx, 0.0);
        assert!((got - 0.0_f32).abs() < 1e-6, "clamp bas violé : {got}");
    }

    #[test]
    fn distill_unknown_sources_counted_as_missing_not_zero() {
        // Les sources sans trust connu sont ignorées (filter_map), pas comptées comme 0.
        // Donc si toutes les sources sont inconnues → neutre 0.5.
        let (a, b) = (Ulid::new(), Ulid::new());
        let idx = FakeIndex(HashMap::new()); // aucune source connue
        let got = compute_distill_trust(&[a, b], &idx, 0.9);
        assert_eq!(got, 0.5, "toutes sources inconnues → neutre 0.5");
    }
}