tga 10.0.0

Developer productivity analytics — git commit collection, classification, and reporting
Documentation
//! One commit's verdict as the eval harness evaluates it (#111).
//!
//! Why: `tga eval sample` and `tga eval repredict` must reach the same verdict
//! for the same commit under the same config, or a re-predicted sample would
//! measure a different classifier than a freshly drawn one. Both aim at what
//! `tga classify` would store under that config, so they share one policy.
//! What: [`resolve_verdicts`] runs the traced rule engine on each commit's
//! message and merge flag — the inputs `tga classify` gives the cascade. A
//! stored verdict from a tier that is never re-run offline (manual, LLM,
//! external source, repo fallback) is carried only when the config's cascade
//! would still reach that tier ([`CarryPolicy`]); otherwise the re-derived
//! verdict wins and the stored one counts as superseded.
//! Test: `tests/eval_harness.rs::sample_then_score_end_to_end`,
//! `tests/eval_harness.rs::repredict_keeps_rows_and_follows_the_new_rules`,
//! `tests/eval_harness.rs::repredict_carries_a_stored_verdict_only_when_its_tier_is_reached`,
//! `tests/eval_harness.rs::repredict_never_carries_an_llm_verdict_for_a_merge`.

use super::population::CommitRow;
use crate::classify::{ClassificationEngine, TraceTier, TracedVerdict};
use crate::core::config::Config;

/// A commit's verdict as the harness evaluates it.
pub(crate) struct Resolved {
    pub tier: TraceTier,
    pub rule_id: String,
    pub category: String,
    pub confidence: f64,
    /// The verdict was carried from the database, not re-derived.
    pub carried: bool,
    /// A stored non-rule verdict existed but the config no longer reaches
    /// its tier, so the re-derived verdict replaced it.
    pub superseded: bool,
}

/// Which non-rule tiers the config's `tga classify` cascade would reach.
///
/// Why: #111 review — a stored LLM or external verdict must not survive a
/// config that no longer runs that tier. What: mirrors
/// `ClassificationPipeline`: the LLM tier runs when an `llm:` section exists or
/// `classification.use_llm` is set, on the verdicts `llm_fallback_scope`
/// selects (#111: at or below `llm_fallback_threshold`, or only unanswered
/// ones), never for a merge commit (#111), and only for a stored category
/// inside a custom-only rules set (#131); external sources run when any is
/// configured and
/// `no_external` is off. The stored verdict does not say which source produced
/// it, so any configured source keeps it. A stored repo fallback is never
/// carried: `tga classify` never applies one.
/// Test: `tests/eval_harness.rs::repredict_carries_a_stored_verdict_only_when_its_tier_is_reached`,
/// `tests/eval_harness.rs::repredict_never_carries_an_llm_verdict_for_a_merge`.
pub(crate) struct CarryPolicy {
    use_llm: bool,
    llm_threshold: f64,
    llm_scope: crate::core::config::LlmFallbackScope,
    /// #131: the LLM's category set when the rules restrict it.
    llm_categories: Option<Vec<String>>,
    external: bool,
}

impl CarryPolicy {
    /// Read the policy from `config`.
    ///
    /// # Errors
    ///
    /// Returns an error if a configured rules file fails to load.
    pub(crate) fn from_config(config: &Config) -> crate::classify::Result<Self> {
        let c = config.classification.as_ref();
        let llm_categories = crate::classify::ClassificationPipeline::new(config.clone())
            .llm_categories()?
            .map(|cats| cats.into_iter().map(|c| c.name).collect());
        Ok(Self {
            llm_categories,
            use_llm: config.llm.is_some() || c.is_some_and(|c| c.use_llm),
            llm_threshold: c.map_or(0.65, |c| c.llm_fallback_threshold),
            llm_scope: c.map(|c| c.llm_fallback_scope).unwrap_or_default(),
            external: c.is_some_and(|c| !c.no_external && !c.sources.is_empty()),
        })
    }

    /// Whether the cascade reaches `stored` given the re-derived verdict `t`
    /// for a commit whose merge flag is `is_merge`.
    fn reaches(
        &self,
        stored: TraceTier,
        stored_category: &str,
        t: &TracedVerdict,
        is_merge: bool,
    ) -> bool {
        match stored {
            TraceTier::Manual => true,
            // #111: `tga classify` never sends a merge to the LLM.
            TraceTier::Llm if is_merge => false,
            // #111: the same predicate `tga classify` routes with.
            TraceTier::Llm => {
                // #131: `tga classify` now drops an LLM answer outside the
                // configured set, so a stored one is superseded.
                let in_set = self
                    .llm_categories
                    .as_ref()
                    .is_none_or(|cats| cats.iter().any(|n| n == stored_category));
                self.use_llm
                    && in_set
                    && crate::classify::pipeline_llm::llm_eligible(
                        self.llm_scope,
                        &t.verdict,
                        self.llm_threshold,
                    )
            }
            // #111 review: `tga classify` never applies a repo fallback
            // (`apply_repo_category_fallback` has no production caller), so a
            // stored one is never reproduced.
            TraceTier::RepoCategory => false,
            TraceTier::ExternalSource => self.external,
            _ => false,
        }
    }
}

/// Map a stored `method` decided outside the rule engine to its trace.
fn stored_override(method: &str, traced: TraceTier) -> Option<(TraceTier, &'static str)> {
    match method {
        "manual" => Some((TraceTier::Manual, "manual_override")),
        "llm_fallback" => Some((TraceTier::Llm, "llm")),
        "repo_category_fallback" => Some((TraceTier::RepoCategory, "repo_category")),
        // The engine's own JIRA-project and issue-type tiers also store
        // `external_source`; only a verdict the engine did not reproduce came
        // from the pipeline's external resolver.
        "external_source" if !matches!(traced, TraceTier::JiraProject | TraceTier::IssueType) => {
            Some((TraceTier::ExternalSource, "external_source"))
        }
        _ => None,
    }
}

/// Resolve each commit's verdict, in input order, and count drift.
///
/// Why: see the module doc. What: classifies `(message, is_merge)` with
/// [`ClassificationEngine::classify_batch_traced`]; a commit whose stored
/// `method` names a tier outside the rule engine keeps its stored verdict when
/// `policy` says the cascade reaches that tier. The second value counts
/// rule-engine commits whose stored category differs from the re-derived one.
/// Test: see the module doc.
pub(crate) fn resolve_verdicts(
    engine: &ClassificationEngine,
    policy: &CarryPolicy,
    commits: &[&CommitRow],
) -> (Vec<Resolved>, u64) {
    let pairs: Vec<(&str, bool)> = commits
        .iter()
        .map(|c| (c.message.as_str(), c.is_merge))
        .collect();
    let traced = engine.classify_batch_traced(&pairs);
    let mut drifted = 0u64;
    let resolved = commits
        .iter()
        .zip(traced)
        .map(|(c, t)| {
            let mut superseded = false;
            if let Some((cat, conf, method)) = &c.stored {
                match stored_override(method, t.trace.tier) {
                    // #111: carry only a tier the config's cascade reaches.
                    Some((tier, rule)) if policy.reaches(tier, cat, &t, c.is_merge) => {
                        return Resolved {
                            tier,
                            rule_id: rule.to_string(),
                            category: cat.clone(),
                            confidence: *conf,
                            carried: true,
                            superseded: false,
                        };
                    }
                    Some(_) => superseded = true,
                    None if cat != &t.verdict.category => drifted += 1,
                    None => {}
                }
            }
            Resolved {
                tier: t.trace.tier,
                rule_id: t.trace.rule_id,
                category: t.verdict.category,
                confidence: t.verdict.confidence,
                carried: false,
                superseded,
            }
        })
        .collect();
    (resolved, drifted)
}