feat: Intelligence Pipeline v3 — full implementation
Multi-stage evidence-grounded inference architecture replacing the monolithic 9B model extraction pipeline. CPU-first specialist services handle routine extraction while the 9B vLLM model is preserved for semantic adjudication of ambiguous cases. Key components: - Capability-aware inference gateway (OpenAI-compatible + Ollama) - Endpoint registry with DB migrations and REST API - Sentence-aware document segmenter (property tests) - Deterministic financial parsing with offset integrity - Symbol resolution with ambiguity detection - Specialist service (GLiNER2, dynamic batching, K8s deployment) - Company-specific sentiment (FinBERT, calibration) - Retrieval-based novelty and duplicate detection - Confidence calibration pipeline - Deterministic routing engine (property tests) - 9B adjudication layer with VRAM gating - Stock-specific impact model (features, labels, baseline, trained) - Pipeline orchestrator (state machine, queues, leases, feature flags) - Bounded parallelism (async workers, semaphore, load shedding) - Observability (tracing, metrics, alerts) - Compatibility adapter (v3→v2 golden mapping tests) - Shadow/canary promotion framework - Active learning and fine-tuning pipeline Test results: 1,161 tests pass, ruff lint clean. All 282 spec tasks completed.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
"""Fine-tuning module for specialist extractor models.
|
||||
|
||||
Manages training pipelines, holdout evaluation, score recalibration,
|
||||
and promotion gates. A model is promoted only when correctness gates
|
||||
pass, not merely when adjudication rate falls.
|
||||
"""
|
||||
|
||||
from services.intelligence_pipeline_v3.fine_tuning.evaluation import (
|
||||
EvaluationResult,
|
||||
ModelCard,
|
||||
PromotionDecision,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.fine_tuning.trainer import (
|
||||
TrainingConfig,
|
||||
TrainingRun,
|
||||
TrainingStatus,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EvaluationResult",
|
||||
"ModelCard",
|
||||
"PromotionDecision",
|
||||
"TrainingConfig",
|
||||
"TrainingRun",
|
||||
"TrainingStatus",
|
||||
]
|
||||
@@ -0,0 +1,210 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Training pipeline for specialist extractor fine-tuning.
|
||||
|
||||
Manages training runs on the Stonks Oracle schema, tracks artifacts,
|
||||
and produces evaluation-ready models for holdout testing.
|
||||
"""
|
||||
|
||||
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 TrainingStatus(str, enum.Enum):
|
||||
"""Status of a training run."""
|
||||
|
||||
PENDING = "pending"
|
||||
PREPARING_DATA = "preparing_data"
|
||||
TRAINING = "training"
|
||||
EVALUATING = "evaluating"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrainingConfig:
|
||||
"""Configuration for specialist model fine-tuning."""
|
||||
|
||||
base_model: str = "GLiNER2-large"
|
||||
schema_version: str = "1.0"
|
||||
dataset_version: str = ""
|
||||
training_range: str = "" # e.g., "2024-01 to 2024-06"
|
||||
|
||||
# Training parameters
|
||||
learning_rate: float = 2e-5
|
||||
batch_size: int = 16
|
||||
max_epochs: int = 10
|
||||
warmup_steps: int = 100
|
||||
weight_decay: float = 0.01
|
||||
|
||||
# Data split
|
||||
train_ratio: float = 0.8
|
||||
validation_ratio: float = 0.1
|
||||
holdout_ratio: float = 0.1 # Frozen holdout — never used in training
|
||||
|
||||
# Entity types to fine-tune
|
||||
entity_types: list[str] = field(default_factory=lambda: [
|
||||
"company", "person", "event", "financial_metric",
|
||||
"date", "money", "percentage", "ticker",
|
||||
])
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrainingRun:
|
||||
"""A single training run for the specialist extractor."""
|
||||
|
||||
run_id: UUID
|
||||
config: TrainingConfig
|
||||
status: TrainingStatus = TrainingStatus.PENDING
|
||||
started_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
|
||||
# Training metrics
|
||||
train_loss: float = 0.0
|
||||
validation_loss: float = 0.0
|
||||
best_epoch: int = 0
|
||||
total_examples: int = 0
|
||||
|
||||
# Artifact tracking
|
||||
artifact_path: str = ""
|
||||
model_version: str = ""
|
||||
parent_model_version: str = ""
|
||||
|
||||
# Metadata
|
||||
notes: str = ""
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def create(cls, config: TrainingConfig) -> TrainingRun:
|
||||
return cls(
|
||||
run_id=uuid4(),
|
||||
config=config,
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Begin training."""
|
||||
self.status = TrainingStatus.PREPARING_DATA
|
||||
self.started_at = datetime.now(timezone.utc)
|
||||
|
||||
def begin_training(self) -> None:
|
||||
"""Transition to active training."""
|
||||
self.status = TrainingStatus.TRAINING
|
||||
|
||||
def begin_evaluation(self) -> None:
|
||||
"""Transition to evaluation phase."""
|
||||
self.status = TrainingStatus.EVALUATING
|
||||
|
||||
def complete(
|
||||
self,
|
||||
artifact_path: str,
|
||||
model_version: str,
|
||||
train_loss: float = 0.0,
|
||||
validation_loss: float = 0.0,
|
||||
best_epoch: int = 0,
|
||||
) -> None:
|
||||
"""Mark training as complete with artifact metadata."""
|
||||
self.status = TrainingStatus.COMPLETED
|
||||
self.completed_at = datetime.now(timezone.utc)
|
||||
self.artifact_path = artifact_path
|
||||
self.model_version = model_version
|
||||
self.train_loss = train_loss
|
||||
self.validation_loss = validation_loss
|
||||
self.best_epoch = best_epoch
|
||||
|
||||
def fail(self, error: str) -> None:
|
||||
"""Mark training as failed."""
|
||||
self.status = TrainingStatus.FAILED
|
||||
self.completed_at = datetime.now(timezone.utc)
|
||||
self.errors.append(error)
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> float | None:
|
||||
if self.started_at and self.completed_at:
|
||||
return (self.completed_at - self.started_at).total_seconds()
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"run_id": str(self.run_id),
|
||||
"status": self.status.value,
|
||||
"base_model": self.config.base_model,
|
||||
"schema_version": self.config.schema_version,
|
||||
"dataset_version": self.config.dataset_version,
|
||||
"model_version": self.model_version,
|
||||
"artifact_path": self.artifact_path,
|
||||
"train_loss": self.train_loss,
|
||||
"validation_loss": self.validation_loss,
|
||||
"best_epoch": self.best_epoch,
|
||||
"duration_seconds": self.duration_seconds,
|
||||
}
|
||||
Reference in New Issue
Block a user