vignette/apps/api/app/contracts/calibration_transfer.py
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

564 lines
23 KiB
Python

"""G5 Calibration Mirror & Transfer의 버전 고정 순수 도메인 계약.
외부 평가 공개 전에 잠근 자기예측과 독립 관찰을 역량별로만 대조한다.
전이는 익숙한 문장 재현과 분리하고, 합성 subgroup 차이는 표본 근거가
충분할 때만 drift 신호로 남긴다. 단일 총점이나 임상 주장은 허용하지 않는다.
"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
from .measurement import (
MeasurementPerspective,
SOURCE_PERSPECTIVE_COMPATIBILITY,
SourceKind,
)
CalibrationBias = Literal[
"overconfident", "underconfident", "aligned", "insufficient_evidence"
]
ImprovementStatus = Literal["improved", "not_improved", "insufficient_evidence"]
PerformanceStatus = Literal["passed", "failed", "insufficient_evidence"]
RelationshipStyle = Literal[
"collaborative", "withdrawn", "confrontational", "ambivalent"
]
DriftStatus = Literal["stable", "drift_flagged", "insufficient_evidence"]
ActualTransferStatus = Literal["verified", "not_verified", "insufficient_evidence"]
class SelfPredictionRevision(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
prediction_id: str = Field(pattern=r"^oas-g5-prediction-[a-z0-9-]+$")
competency_id: str = Field(pattern=r"^competency\.[a-z0-9_.-]+$")
practice_block_id: str = Field(pattern=r"^oas-g5-block-[a-z0-9-]+$")
scenario_variant_id: str = Field(min_length=1, max_length=180)
phrase_family_id: str = Field(min_length=1, max_length=180)
predicted_success_probability: float = Field(ge=0.0, le=1.0)
confidence: float = Field(ge=0.0, le=1.0)
revision_no: int = Field(ge=1)
supersedes_prediction_id: str | None = None
recorded_sequence: int = Field(ge=1)
revision_reason: str = Field(min_length=1, max_length=300)
@model_validator(mode="after")
def require_revision_link(self) -> "SelfPredictionRevision":
if self.revision_no == 1 and self.supersedes_prediction_id is not None:
raise ValueError("first self-prediction revision cannot supersede another")
if self.revision_no > 1 and self.supersedes_prediction_id is None:
raise ValueError(
"later self-prediction revision must supersede its predecessor"
)
if self.supersedes_prediction_id == self.prediction_id:
raise ValueError("self-prediction revision cannot supersede itself")
return self
class LockedSelfPredictionHistory(BaseModel):
"""외부 관찰 공개 시퀀스 이전에만 수정 가능한 append-only 자기예측."""
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
lock_id: str = Field(pattern=r"^oas-g5-lock-[a-z0-9-]+$")
revisions: tuple[SelfPredictionRevision, ...] = Field(min_length=1)
locked_sequence: int = Field(ge=1)
external_reveal_sequence: int | None = Field(default=None, ge=1)
@model_validator(mode="after")
def enforce_append_only_pre_reveal_history(self) -> "LockedSelfPredictionHistory":
first = self.revisions[0]
expected_revisions = list(range(1, len(self.revisions) + 1))
if [item.revision_no for item in self.revisions] != expected_revisions:
raise ValueError("self-prediction revisions must be contiguous and ordered")
sequences = [item.recorded_sequence for item in self.revisions]
if sequences != sorted(set(sequences)):
raise ValueError(
"self-prediction revision sequences must be unique and ordered"
)
for index, item in enumerate(self.revisions):
if (
item.competency_id != first.competency_id
or item.practice_block_id != first.practice_block_id
or item.scenario_variant_id != first.scenario_variant_id
or item.phrase_family_id != first.phrase_family_id
):
raise ValueError(
"one prediction history must keep one assessment target"
)
if (
index
and item.supersedes_prediction_id
!= self.revisions[index - 1].prediction_id
):
raise ValueError(
"prediction revision must supersede the immediate predecessor"
)
if self.locked_sequence < sequences[-1]:
raise ValueError("prediction lock cannot precede its latest revision")
if self.external_reveal_sequence is not None:
if self.external_reveal_sequence <= self.locked_sequence:
raise ValueError(
"external evaluation must be revealed after prediction lock"
)
if any(
item.recorded_sequence >= self.external_reveal_sequence
for item in self.revisions
):
raise ValueError(
"self-prediction cannot be revised after external reveal"
)
return self
@property
def locked_prediction(self) -> SelfPredictionRevision:
return self.revisions[-1]
class IndependentPerformanceObservation(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
observation_id: str = Field(pattern=r"^oas-g5-observation-[a-z0-9-]+$")
competency_id: str = Field(pattern=r"^competency\.[a-z0-9_.-]+$")
practice_block_id: str = Field(pattern=r"^oas-g5-block-[a-z0-9-]+$")
scenario_variant_id: str = Field(min_length=1, max_length=180)
phrase_family_id: str = Field(min_length=1, max_length=180)
status: PerformanceStatus
source_kind: SourceKind
perspective: MeasurementPerspective
model_run_id: UUID | None = None
uncertainty: float = Field(ge=0.0, le=1.0)
evidence_refs: tuple[str, ...] = ()
counterevidence: tuple[str, ...] = ()
revealed_sequence: int = Field(ge=1)
@model_validator(mode="after")
def preserve_independent_observation_truth(
self,
) -> "IndependentPerformanceObservation":
if self.perspective not in SOURCE_PERSPECTIVE_COMPATIBILITY[self.source_kind]:
raise ValueError(
"performance observation mixes source and perspective layers"
)
if (
self.source_kind in {"model_inferred", "agent_reported"}
and self.model_run_id is None
):
raise ValueError("model/agent observation requires model_run_id")
if self.status == "insufficient_evidence":
if self.uncertainty != 1.0 or self.evidence_refs:
raise ValueError(
"insufficient performance evidence must remain evidence-free"
)
elif not self.evidence_refs:
raise ValueError("ready performance observation requires evidence")
if self.status == "failed" and not self.counterevidence:
raise ValueError("failed performance observation requires counterevidence")
return self
class CalibrationBlockInput(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
block_sequence: int = Field(ge=1)
prediction_history: LockedSelfPredictionHistory
observation: IndependentPerformanceObservation
@model_validator(mode="after")
def align_prediction_and_observation(self) -> "CalibrationBlockInput":
prediction = self.prediction_history.locked_prediction
observed = self.observation
for field in (
"competency_id",
"practice_block_id",
"scenario_variant_id",
"phrase_family_id",
):
if getattr(prediction, field) != getattr(observed, field):
raise ValueError(f"calibration block mismatches {field}")
if (
self.prediction_history.external_reveal_sequence
!= observed.revealed_sequence
):
raise ValueError("prediction history must record the exact external reveal")
return self
class CalibrationPair(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
practice_block_id: str
prediction_id: str
observation_id: str
predicted_success_probability: float = Field(ge=0.0, le=1.0)
observed_success: bool
signed_error: float = Field(ge=-1.0, le=1.0)
absolute_error: float = Field(ge=0.0, le=1.0)
confidence: float = Field(ge=0.0, le=1.0)
evidence_refs: tuple[str, ...] = Field(min_length=1)
class ConfidenceInterval(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
method: Literal["normal_95_bounded", "wilson_95"]
lower: float = Field(ge=0.0, le=1.0)
upper: float = Field(ge=0.0, le=1.0)
@model_validator(mode="after")
def require_order(self) -> "ConfidenceInterval":
if self.lower > self.upper:
raise ValueError("confidence interval lower bound exceeds upper bound")
return self
class CompetencyCalibrationAssessment(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
competency_id: str
pair_count: int = Field(ge=0)
mean_absolute_error: float | None = Field(default=None, ge=0.0, le=1.0)
mean_signed_error: float | None = Field(default=None, ge=-1.0, le=1.0)
error_interval: ConfidenceInterval | None = None
bias: CalibrationBias
baseline_error: float | None = Field(default=None, ge=0.0, le=1.0)
recent_error: float | None = Field(default=None, ge=0.0, le=1.0)
improvement: ImprovementStatus
pairs: tuple[CalibrationPair, ...]
excluded_block_ids: tuple[str, ...] = ()
counterevidence: tuple[str, ...] = ()
@model_validator(mode="after")
def keep_insufficient_calibration_scoreless(
self,
) -> "CompetencyCalibrationAssessment":
if self.pair_count != len(self.pairs):
raise ValueError("calibration pair_count must match pair evidence")
if self.bias == "insufficient_evidence":
if any(
item is not None
for item in (
self.mean_absolute_error,
self.mean_signed_error,
self.error_interval,
)
):
raise ValueError("insufficient calibration must remain scoreless")
elif self.mean_absolute_error is None or self.mean_signed_error is None:
raise ValueError("classified calibration requires error estimates")
return self
class MetacognitivePrescription(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
competency_id: str
bias: CalibrationBias
practice_mode: Literal[
"counterevidence_forecast",
"evidence_recall",
"uncertainty_range",
"collect_more_evidence",
]
instruction_ko: str = Field(min_length=10, max_length=500)
completion_evidence: tuple[str, ...] = Field(min_length=1)
class TransferVariation(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
context_variant: str = Field(min_length=1, max_length=120)
relationship_style: RelationshipStyle
difficulty_level: int = Field(ge=1, le=5)
expression_variant: str = Field(min_length=1, max_length=120)
synthetic_subgroup: str = Field(pattern=r"^synthetic-[a-z0-9-]+$")
scenario_family_id: str = Field(min_length=1, max_length=180)
phrase_family_id: str = Field(min_length=1, max_length=180)
class TransferTrial(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
trial_id: str = Field(pattern=r"^oas-g5-transfer-[a-z0-9-]+$")
competency_id: str = Field(pattern=r"^competency\.[a-z0-9_.-]+$")
scenario_variant_id: str = Field(min_length=1, max_length=180)
scenario_novelty: Literal["unseen_transfer"] = "unseen_transfer"
variation: TransferVariation
status: PerformanceStatus
uncertainty: float = Field(ge=0.0, le=1.0)
evidence_refs: tuple[str, ...] = ()
counterevidence: tuple[str, ...] = ()
@model_validator(mode="after")
def preserve_transfer_evidence(self) -> "TransferTrial":
if self.status == "insufficient_evidence":
if self.uncertainty != 1.0 or self.evidence_refs:
raise ValueError(
"insufficient transfer trial must remain evidence-free"
)
elif not self.evidence_refs:
raise ValueError("ready transfer trial requires evidence")
if self.status == "failed" and not self.counterevidence:
raise ValueError("failed transfer trial requires counterevidence")
return self
class NormalizedEvaluatorLabels(BaseModel):
"""원문 없이 실제 회기 판정에 사용한 정규화 evaluator 라벨."""
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
technique_codes: tuple[str, ...] = ()
client_state_codes: tuple[str, ...] = ()
appropriateness: tuple[Literal["pos", "neutral", "warn"], ...] = ()
intent_deviation_dimensions: tuple[str, ...] = ()
evaluator_error_count: int = Field(ge=0)
class ActualTransferExecution(BaseModel):
"""서버 원장에서 재구성한 한 번의 실제 transfer 연습 실행."""
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
execution_event_id: UUID
original_transfer_trial_record_id: UUID
practice_session_id: UUID
competency_id: str = Field(pattern=r"^competency\.[a-z0-9_.-]+$")
scenario_variant_id: str = Field(min_length=1, max_length=180)
scenario_novelty: Literal["unseen_transfer"] = "unseen_transfer"
variation: TransferVariation
status: PerformanceStatus
uncertainty: float = Field(ge=0.0, le=1.0)
evidence_turn_ids: tuple[UUID, ...] = ()
normalized_evaluator_labels: NormalizedEvaluatorLabels
counterevidence: tuple[str, ...] = ()
model_run_id: UUID
source_kind: Literal["model_inferred"] = "model_inferred"
perspective: Literal["independent_observer"] = "independent_observer"
instrument_id: Literal["unseen-transfer-g5"] = "unseen-transfer-g5"
instrument_version: Literal["1.0.0"] = "1.0.0"
observer_version: Literal["calibration-actual-transfer-observer-v1"] = (
"calibration-actual-transfer-observer-v1"
)
training_phrase_collision: bool = False
created_at: datetime
@model_validator(mode="after")
def preserve_actual_execution_truth(self) -> "ActualTransferExecution":
if self.status == "insufficient_evidence":
if self.uncertainty != 1.0 or self.evidence_turn_ids:
raise ValueError(
"insufficient actual execution must remain evidence-free"
)
elif not self.evidence_turn_ids:
raise ValueError("observed actual execution requires durable turn UUIDs")
if self.status == "failed" and not self.counterevidence:
raise ValueError("failed actual execution requires counterevidence")
if len(set(self.evidence_turn_ids)) != len(self.evidence_turn_ids):
raise ValueError("actual execution turn evidence must be unique")
return self
class TransferSuiteInput(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
suite_id: str = Field(pattern=r"^oas-g5-suite-[a-z0-9-]+$")
training_phrase_family_ids: tuple[str, ...] = Field(min_length=1)
trials: tuple[TransferTrial, ...] = Field(min_length=1)
@model_validator(mode="after")
def require_unique_trials(self) -> "TransferSuiteInput":
ids = [item.trial_id for item in self.trials]
if len(set(ids)) != len(ids):
raise ValueError("transfer trial ids must be unique")
return self
class TransferAssessment(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
competency_id: str
trial_count: int = Field(ge=0)
observed_trial_count: int = Field(ge=0)
success_rate: float | None = Field(default=None, ge=0.0, le=1.0)
success_interval: ConfidenceInterval | None = None
coverage: dict[str, int]
eligible: bool
transfer_verified: bool
blockers: tuple[str, ...]
evidence_refs: tuple[str, ...]
counterevidence: tuple[str, ...]
@model_validator(mode="after")
def enforce_transfer_gate(self) -> "TransferAssessment":
if self.transfer_verified and (
not self.eligible
or self.success_rate is None
or self.success_rate < 0.85
or not self.evidence_refs
):
raise ValueError(
"transfer verification requires eligible evidence at target rate"
)
if not self.observed_trial_count and (
self.success_rate is not None or self.success_interval is not None
):
raise ValueError("unobserved transfer must remain scoreless")
return self
class ActualTransferAssessment(BaseModel):
"""실제 완료 회기만으로 계산한 역량별 transfer 상태."""
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
evidence_source: Literal["actual_practice_execution"] = "actual_practice_execution"
competency_id: str = Field(pattern=r"^competency\.[a-z0-9_.-]+$")
execution_count: int = Field(ge=0)
independent_execution_count: int = Field(ge=0)
observed_execution_count: int = Field(ge=0)
success_rate: float | None = Field(default=None, ge=0.0, le=1.0)
success_interval: ConfidenceInterval | None = None
coverage: dict[str, int]
phrase_family_collision_count: int = Field(ge=0)
eligible: bool
actual_transfer_status: ActualTransferStatus
blockers: tuple[str, ...]
source_execution_event_ids: tuple[UUID, ...]
evidence_turn_ids: tuple[UUID, ...]
@model_validator(mode="after")
def enforce_actual_transfer_gate(self) -> "ActualTransferAssessment":
if self.independent_execution_count > self.execution_count:
raise ValueError("independent actual execution count exceeds total")
if self.observed_execution_count > self.independent_execution_count:
raise ValueError("observed actual execution count exceeds independent count")
if self.actual_transfer_status == "verified" and (
not self.eligible
or self.success_rate is None
or self.success_rate < 0.85
or not self.evidence_turn_ids
):
raise ValueError(
"actual transfer verification requires eligible durable evidence"
)
if self.actual_transfer_status == "not_verified" and not self.eligible:
raise ValueError("classified actual transfer requires eligible evidence")
if self.actual_transfer_status == "insufficient_evidence" and self.eligible:
raise ValueError("eligible actual transfer cannot remain insufficient")
if not self.observed_execution_count and (
self.success_rate is not None or self.success_interval is not None
):
raise ValueError("unobserved actual transfer must remain scoreless")
return self
class SyntheticSubgroupResult(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
subgroup: str
observed_count: int = Field(ge=0)
success_rate: float | None = Field(default=None, ge=0.0, le=1.0)
interval: ConfidenceInterval | None = None
class SubgroupDriftReport(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
competency_id: str
status: DriftStatus
max_rate_gap: float | None = Field(default=None, ge=0.0, le=1.0)
compared_subgroups: tuple[str, ...]
subgroup_results: tuple[SyntheticSubgroupResult, ...]
threshold: float = Field(default=0.2, ge=0.0, le=1.0)
notice_ko: str = Field(min_length=10, max_length=500)
@model_validator(mode="after")
def keep_underpowered_drift_scoreless(self) -> "SubgroupDriftReport":
if self.status == "insufficient_evidence" and self.max_rate_gap is not None:
raise ValueError("underpowered subgroup report cannot claim a rate gap")
if self.status != "insufficient_evidence" and self.max_rate_gap is None:
raise ValueError("classified subgroup report requires an observed gap")
return self
class CalibrationTransferBenchmarkExpectation(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
calibration_improved: dict[str, bool] = Field(default_factory=dict)
transfer_verified: dict[str, bool] = Field(default_factory=dict)
drift_status: dict[str, DriftStatus] = Field(default_factory=dict)
class CalibrationTransferBenchmarkCase(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
case_id: str = Field(pattern=r"^oas-g5-bench-[0-9]{3}$")
title_ko: str = Field(min_length=1, max_length=200)
tags: tuple[str, ...] = Field(min_length=1)
calibration_blocks: tuple[CalibrationBlockInput, ...] = ()
transfer_suite: TransferSuiteInput | None = None
expected: CalibrationTransferBenchmarkExpectation
class CalibrationTransferBenchmarkPack(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
schema_version: Literal["vignette.calibration-transfer-benchmark.v1"]
version: Literal["1.0.0"]
data_classification: Literal["synthetic_educational"]
clinical_claim_allowed: Literal[False]
cases: tuple[CalibrationTransferBenchmarkCase, ...] = Field(min_length=4)
@model_validator(mode="after")
def require_adversarial_coverage(self) -> "CalibrationTransferBenchmarkPack":
ids = [item.case_id for item in self.cases]
if len(set(ids)) != len(ids):
raise ValueError("calibration benchmark case ids must be unique")
tags = {tag for item in self.cases for tag in item.tags}
required = {
"post_reveal_contamination",
"memorized_phrase_transfer",
"calibration_improvement",
"synthetic_subgroup_drift",
}
if not required.issubset(tags):
raise ValueError(
"calibration benchmark lacks required adversarial coverage"
)
return self
__all__ = [
"ActualTransferAssessment",
"ActualTransferExecution",
"ActualTransferStatus",
"CalibrationBias",
"CalibrationBlockInput",
"CalibrationPair",
"CalibrationTransferBenchmarkCase",
"CalibrationTransferBenchmarkExpectation",
"CalibrationTransferBenchmarkPack",
"CompetencyCalibrationAssessment",
"ConfidenceInterval",
"DriftStatus",
"ImprovementStatus",
"IndependentPerformanceObservation",
"LockedSelfPredictionHistory",
"MetacognitivePrescription",
"NormalizedEvaluatorLabels",
"PerformanceStatus",
"RelationshipStyle",
"SelfPredictionRevision",
"SubgroupDriftReport",
"SyntheticSubgroupResult",
"TransferAssessment",
"TransferSuiteInput",
"TransferTrial",
"TransferVariation",
]