"""Holdout evaluation and promotion gate checking for fine-tuned models. Evaluates against frozen holdout and production artifact. A model is promoted only when correctness gates pass — not merely when adjudication rate falls. """ from __future__ import annotations import enum from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any from uuid import UUID, uuid4 class PromotionDecision(str, enum.Enum): """Decision on whether to promote a fine-tuned model.""" PROMOTE = "promote" REJECT = "reject" NEEDS_REVIEW = "needs_review" @dataclass class EvaluationResult: """Results of evaluating a fine-tuned model against holdout data.""" evaluation_id: UUID training_run_id: UUID model_version: str evaluated_at: datetime # Correctness metrics (what matters for promotion) entity_f1: float = 0.0 entity_precision: float = 0.0 entity_recall: float = 0.0 event_f1: float = 0.0 relation_f1: float = 0.0 fact_exact_match: float = 0.0 # Calibration metrics calibration_ece: float = 0.0 brier_score: float = 0.0 # Comparison with production model production_entity_f1: float = 0.0 production_event_f1: float = 0.0 entity_f1_delta: float = 0.0 event_f1_delta: float = 0.0 # Adjudication impact (reported but not a gate) adjudication_rate_before: float = 0.0 adjudication_rate_after: float = 0.0 adjudication_rate_delta: float = 0.0 # Holdout details holdout_size: int = 0 holdout_version: str = "" @classmethod def create( cls, training_run_id: UUID, model_version: str, **kwargs: Any, ) -> EvaluationResult: return cls( evaluation_id=uuid4(), training_run_id=training_run_id, model_version=model_version, evaluated_at=datetime.now(timezone.utc), **kwargs, ) def passes_correctness_gates( self, min_entity_f1_delta: float = 0.0, min_event_f1_delta: float = -0.02, # Allow tiny regression on events max_calibration_ece: float = 0.08, ) -> bool: """Check if correctness gates pass. Note: adjudication rate reduction is NOT a promotion gate. A model must pass field-level correctness regardless of adjudication impact. """ # Entity F1 must not regress if self.entity_f1_delta < min_entity_f1_delta: return False # Event F1 must not regress significantly if self.event_f1_delta < min_event_f1_delta: return False # Calibration must remain acceptable if self.calibration_ece > max_calibration_ece: return False return True def promotion_decision(self) -> PromotionDecision: """Determine promotion decision based on gates.""" if not self.passes_correctness_gates(): return PromotionDecision.REJECT # If adjudication rate actually increases, flag for review if self.adjudication_rate_delta > 0.05: return PromotionDecision.NEEDS_REVIEW return PromotionDecision.PROMOTE def to_dict(self) -> dict[str, Any]: return { "evaluation_id": str(self.evaluation_id), "model_version": self.model_version, "entity_f1": self.entity_f1, "event_f1": self.event_f1, "relation_f1": self.relation_f1, "calibration_ece": self.calibration_ece, "entity_f1_delta": self.entity_f1_delta, "event_f1_delta": self.event_f1_delta, "adjudication_rate_delta": self.adjudication_rate_delta, "passes_correctness_gates": self.passes_correctness_gates(), "promotion_decision": self.promotion_decision().value, } @dataclass class ModelCard: """Model card for a trained specialist model artifact. Contains training range, dataset version, intended use, limitations, and evaluation results as required by Requirement 17.6. """ card_id: UUID model_version: str base_model: str training_run_id: UUID created_at: datetime # Training details training_range: str = "" dataset_version: str = "" schema_version: str = "" total_training_examples: int = 0 # Intended use intended_use: str = "Entity and event extraction for financial documents" entity_types: list[str] = field(default_factory=list) # Limitations limitations: list[str] = field(default_factory=lambda: [ "Trained on English-language financial documents only", "Requires recalibration when new entity types are added", "Performance may degrade on document types not in training set", ]) # Evaluation evaluation_results: EvaluationResult | None = None # Registry promoted: bool = False promoted_at: datetime | None = None deprecated: bool = False deprecated_at: datetime | None = None @classmethod def create( cls, model_version: str, base_model: str, training_run_id: UUID, **kwargs: Any, ) -> ModelCard: return cls( card_id=uuid4(), model_version=model_version, base_model=base_model, training_run_id=training_run_id, created_at=datetime.now(timezone.utc), **kwargs, ) def promote(self) -> None: """Mark this model as promoted to production.""" self.promoted = True self.promoted_at = datetime.now(timezone.utc) def deprecate(self) -> None: """Mark this model as deprecated.""" self.deprecated = True self.deprecated_at = datetime.now(timezone.utc) def to_dict(self) -> dict[str, Any]: return { "card_id": str(self.card_id), "model_version": self.model_version, "base_model": self.base_model, "training_range": self.training_range, "dataset_version": self.dataset_version, "schema_version": self.schema_version, "total_training_examples": self.total_training_examples, "intended_use": self.intended_use, "entity_types": self.entity_types, "limitations": self.limitations, "promoted": self.promoted, "deprecated": self.deprecated, }