"""HeadTrainingRun — persisted lifecycle of a head-training batch (#114). A persisted run row (not transient frontend state) so the run SURVIVES navigation and the admin card can show live + historical status. Training is idempotent (it upserts tag_head rows), so a SIGKILL'd run is harmless — a maintenance recovery sweep flips a stalled `running` row to `error`, and the next run re-trains. State machine: running → ready / error. """ from datetime import datetime from typing import Any from sqlalchemy import DateTime, Integer, String, Text, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from .base import Base class HeadTrainingRun(Base): __tablename__ = "head_training_run" id: Mapped[int] = mapped_column(Integer, primary_key=True) # Training parameters: {min_positives, neg_ratio, precision_target, ...}. params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) status: Mapped[str] = mapped_column( String(16), nullable=False, default="running", index=True ) # running | ready | error started_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) finished_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) # How many concepts got a (re)trained head vs were skipped (too few labels). n_trained: Mapped[int | None] = mapped_column(Integer, nullable=True) n_skipped: Mapped[int | None] = mapped_column(Integer, nullable=True) error: Mapped[str | None] = mapped_column(Text, nullable=True) # Last time the task made progress — the recovery sweep tells a live run # from a SIGKILL'd one by this. last_progress_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True )