"""Promotion evaluator for NuExtract document-class decisions. Determines whether NuExtract should be promoted for specific document classes based on incremental value gates. NuExtract is only promoted where it demonstrably beats GLiNER2 + deterministic parsing. Requirement: 6.6 """ from __future__ import annotations import logging from services.intelligence_pipeline_v3.nuextract.models import ( IncrementalValueReport, PromotionGate, ) logger = logging.getLogger(__name__) class PromotionEvaluator: """Evaluates whether NuExtract should be promoted for a document class. Uses the configured gate thresholds to make promotion decisions: - F1 improvement must exceed minimum threshold - Latency must stay within maximum bounds - Memory must stay within maximum bounds - Sample count must meet minimum for statistical confidence Parameters ---------- gate Promotion gate thresholds. """ def __init__(self, gate: PromotionGate | None = None) -> None: self._gate = gate or PromotionGate() @property def gate(self) -> PromotionGate: """Return the current promotion gate configuration.""" return self._gate def evaluate(self, report: IncrementalValueReport) -> bool: """Evaluate whether NuExtract should be promoted for this document type. Parameters ---------- report Incremental value report for a specific document type. Returns ------- bool True if NuExtract passes all gate thresholds. """ reasons = self.get_rejection_reasons(report) promoted = len(reasons) == 0 if promoted: logger.info( "NuExtract PROMOTED for %s: delta=%.4f, latency=%.1fms, memory=%.1fMB", report.document_type, report.delta, report.nuextract_latency_ms, report.nuextract_memory_mb, ) else: logger.info( "NuExtract NOT promoted for %s: %s", report.document_type, "; ".join(reasons), ) return promoted def get_rejection_reasons(self, report: IncrementalValueReport) -> list[str]: """Return list of reasons why promotion would be rejected. Parameters ---------- report Incremental value report for a specific document type. Returns ------- list[str] Empty list if promotion passes; otherwise reasons for rejection. """ reasons: list[str] = [] # Check minimum sample count if report.sample_count < self._gate.min_sample_count: reasons.append( f"Insufficient samples: {report.sample_count} < {self._gate.min_sample_count}" ) # Check F1 improvement if report.delta < self._gate.min_f1_improvement: reasons.append( f"F1 improvement too small: {report.delta:.4f} < {self._gate.min_f1_improvement:.4f}" ) # Check latency if report.nuextract_latency_ms > self._gate.max_latency_ms: reasons.append( f"Latency exceeds gate: {report.nuextract_latency_ms:.1f}ms > {self._gate.max_latency_ms:.1f}ms" ) # Check memory if report.nuextract_memory_mb > self._gate.max_memory_mb: reasons.append( f"Memory exceeds gate: {report.nuextract_memory_mb:.1f}MB > {self._gate.max_memory_mb:.1f}MB" ) return reasons