"""G3 Rupture & Repair Lab의 버전 고정 순수 도메인 계약. 균열 유형, 탐지 근거, 상담자 복구 행동, 내담자 후속 반응을 서로 분리한다. 단일 총점으로 합산하지 않으며 safety 신호는 균열 판정의 입력 feature가 아니다. """ from __future__ import annotations from typing import Literal from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, model_validator from .measurement import ( MeasurementPerspective, SOURCE_PERSPECTIVE_COMPATIBILITY, SourceKind, ) RUPTURE_TYPES = ( "withdrawal", "confrontation", "goal_mismatch", "task_mismatch", "empathic_miss", "cultural_miss", "boundary_tension", "premature_advice", "over_disclosure", ) RuptureType = Literal[ "withdrawal", "confrontation", "goal_mismatch", "task_mismatch", "empathic_miss", "cultural_miss", "boundary_tension", "premature_advice", "over_disclosure", ] RUPTURE_LIFECYCLE_STATES = ( "onset", "recognized", "repair_attempted", "missed", "partial", "resolved", ) RuptureLifecycleState = Literal[ "onset", "recognized", "repair_attempted", "missed", "partial", "resolved", ] FinalRuptureStatus = Literal[ "missed", "partial", "resolved", "not_applicable", "insufficient_evidence", ] REPAIR_BEHAVIORS = ( "noticing", "naming", "curiosity", "impact_acknowledgement", "goal_reagreement", "task_reagreement", "follow_up_check", ) RepairBehavior = Literal[ "noticing", "naming", "curiosity", "impact_acknowledgement", "goal_reagreement", "task_reagreement", "follow_up_check", ] ClientRepairResponse = Literal[ "rejecting", "withdrawn", "compliance_only", "mixed", "engaged", "explicit_alignment", ] SignalLoop = Literal["fast", "deep"] SignalStatus = Literal["detected", "not_detected", "error"] ReconciliationDisposition = Literal[ "not_applicable", "confirmed", "superseded_resolved", "superseded_partial", "dismissed", ] class RuptureEvidenceRef(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) ref_id: str = Field(min_length=1, max_length=180) turn_index: int = Field(ge=0) speaker: Literal["learner", "client", "observer", "runtime"] class SafetySignalReference(BaseModel): """균열/복구 판정과 합산하지 않는 기존 safety 원장 포인터.""" model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) safety_event_id: str = Field(min_length=1, max_length=180) risk_level: Literal["low", "moderate", "high", "imminent"] escalated: bool evidence_refs: tuple[RuptureEvidenceRef, ...] = Field(min_length=1) class RuptureDetectionSignal(BaseModel): """fast/deep observer가 만든 독립 탐지 가설.""" model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) signal_id: str = Field(min_length=1, max_length=180) loop: SignalLoop status: SignalStatus rupture_type: RuptureType | None = None confidence: float | None = Field(default=None, ge=0.0, le=1.0) uncertainty: float = Field(ge=0.0, le=1.0) observed_at_turn: int = Field(ge=0) source_kind: SourceKind perspective: MeasurementPerspective model_run_id: UUID | None = None evidence_refs: tuple[RuptureEvidenceRef, ...] = () counterevidence: tuple[str, ...] = () error_code: str | None = Field(default=None, max_length=120) @model_validator(mode="after") def enforce_detection_truth(self) -> "RuptureDetectionSignal": allowed = SOURCE_PERSPECTIVE_COMPATIBILITY[self.source_kind] if self.perspective not in allowed: raise ValueError("rupture detection 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 rupture detection requires model_run_id") if self.status == "detected": if self.rupture_type is None or self.confidence is None or not self.evidence_refs: raise ValueError("detected rupture requires type, confidence, and evidence") if self.error_code: raise ValueError("detected rupture cannot carry error_code") elif self.status == "not_detected": if self.rupture_type is not None or self.confidence is not None: raise ValueError("not_detected rupture must remain type/scoreless") if self.error_code: raise ValueError("not_detected rupture cannot carry error_code") else: if self.rupture_type is not None or self.confidence is not None: raise ValueError("error rupture signal must remain type/scoreless") if not self.error_code: raise ValueError("error rupture signal requires error_code") if len({item.ref_id for item in self.evidence_refs}) != len(self.evidence_refs): raise ValueError("rupture evidence refs must be unique") return self class FastLoopWarning(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) warning_id: str = Field(min_length=1, max_length=180) signal_id: str = Field(min_length=1, max_length=180) provisional_status: Literal["missed", "partial"] emitted_at_turn: int = Field(ge=0) class RepairAttemptObservation(BaseModel): """문구가 아니라 관찰된 복구 행동과 내담자 후속 반응을 보존한다.""" model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) attempt_id: str = Field(min_length=1, max_length=180) turn_index: int = Field(ge=0) behaviors: tuple[RepairBehavior, ...] = () client_response: ClientRepairResponse evidence_refs: tuple[RuptureEvidenceRef, ...] = Field(min_length=1) response_evidence_refs: tuple[RuptureEvidenceRef, ...] = Field(min_length=1) counterevidence: tuple[str, ...] = () uncertainty: float = Field(ge=0.0, le=1.0) utterance_template_id: str | None = Field(default=None, max_length=180) @model_validator(mode="after") def preserve_attempt_evidence(self) -> "RepairAttemptObservation": if len(set(self.behaviors)) != len(self.behaviors): raise ValueError("repair behaviors must be unique") refs = (*self.evidence_refs, *self.response_evidence_refs) if len({item.ref_id for item in refs}) != len(refs): raise ValueError("repair attempt evidence refs must be unique") if any(item.turn_index > self.turn_index for item in self.evidence_refs): raise ValueError("repair behavior evidence cannot follow the attempt") if any(item.turn_index <= self.turn_index for item in self.response_evidence_refs): raise ValueError("client response evidence must follow the attempt") return self class RuptureEpisodeInput(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) episode_id: str = Field(pattern=r"^oas-g3-episode-[a-z0-9-]+$") detection_signals: tuple[RuptureDetectionSignal, ...] = Field(min_length=1) fast_warning: FastLoopWarning | None = None recognized_at_turn: int | None = Field(default=None, ge=0) recognition_evidence_refs: tuple[RuptureEvidenceRef, ...] = () repair_attempts: tuple[RepairAttemptObservation, ...] = () safety_signals: tuple[SafetySignalReference, ...] = () @model_validator(mode="after") def require_ordered_episode(self) -> "RuptureEpisodeInput": signal_ids = [item.signal_id for item in self.detection_signals] if len(set(signal_ids)) != len(signal_ids): raise ValueError("rupture signal ids must be unique") if self.fast_warning: linked = next( (item for item in self.detection_signals if item.signal_id == self.fast_warning.signal_id), None, ) if linked is None or linked.loop != "fast" or linked.status != "detected": raise ValueError("fast warning must reference a detected fast-loop signal") if self.fast_warning.emitted_at_turn < linked.observed_at_turn: raise ValueError("fast warning cannot precede its signal") if self.recognized_at_turn is None and self.recognition_evidence_refs: raise ValueError("recognition evidence requires recognized_at_turn") if self.recognized_at_turn is not None and not self.recognition_evidence_refs: raise ValueError("recognized rupture requires recognition evidence") attempts = [item.attempt_id for item in self.repair_attempts] if len(set(attempts)) != len(attempts): raise ValueError("repair attempt ids must be unique") attempt_turns = [item.turn_index for item in self.repair_attempts] if attempt_turns != sorted(attempt_turns): raise ValueError("repair attempts must be ordered by turn") if self.repair_attempts and self.recognized_at_turn is None: raise ValueError("repair attempts require rupture recognition") if self.recognized_at_turn is not None and any( item.turn_index < self.recognized_at_turn for item in self.repair_attempts ): raise ValueError("repair attempt cannot precede rupture recognition") return self class RuptureLedgerEntry(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) sequence_no: int = Field(ge=1) event_name: Literal[ "rupture.detected", "rupture.recognized", "rupture.missed", "repair.attempted", "repair.partial", "repair.resolved", "repair.missed", "rupture.reconciled", ] from_state: RuptureLifecycleState | None = None to_state: RuptureLifecycleState evidence_refs: tuple[RuptureEvidenceRef, ...] counterevidence: tuple[str, ...] = () uncertainty: float = Field(ge=0.0, le=1.0) source_ref_id: str = Field(min_length=1, max_length=180) reconciles_event_id: str | None = Field(default=None, max_length=180) class RepairAttemptAssessment(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) attempt_id: str outcome: Literal["missed", "partial", "resolved"] observed_behaviors: tuple[RepairBehavior, ...] required_behaviors: tuple[RepairBehavior, ...] missing_behaviors: tuple[RepairBehavior, ...] client_response: ClientRepairResponse evidence_refs: tuple[RuptureEvidenceRef, ...] counterevidence: tuple[str, ...] uncertainty: float = Field(ge=0.0, le=1.0) class FastDeepReconciliation(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) warning_id: str | None = None disposition: ReconciliationDisposition provisional_status: Literal["missed", "partial"] | None = None deep_status: FinalRuptureStatus evidence_refs: tuple[RuptureEvidenceRef, ...] = () reason: str = Field(min_length=1, max_length=500) class RuptureEpisodeAssessment(BaseModel): """점수 합산 없이 유형·상태·근거·반증을 나란히 반환한다.""" model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) schema_version: Literal["vignette.rupture-repair-assessment.v1"] = ( "vignette.rupture-repair-assessment.v1" ) episode_id: str assessment_status: Literal["ready", "error"] = "ready" detected: bool rupture_type: RuptureType | None final_status: FinalRuptureStatus confidence: float | None = Field(default=None, ge=0.0, le=1.0) uncertainty: float = Field(ge=0.0, le=1.0) evidence_refs: tuple[RuptureEvidenceRef, ...] counterevidence: tuple[str, ...] repair_attempts: tuple[RepairAttemptAssessment, ...] ledger: tuple[RuptureLedgerEntry, ...] reconciliation: FastDeepReconciliation safety_signals: tuple[SafetySignalReference, ...] @model_validator(mode="after") def keep_negative_assessment_scoreless(self) -> "RuptureEpisodeAssessment": if self.assessment_status == "error": if self.detected or self.rupture_type is not None or self.confidence is not None: raise ValueError("error assessment must remain detection/type/scoreless") if self.final_status != "insufficient_evidence": raise ValueError("error assessment requires insufficient_evidence status") if self.repair_attempts or self.ledger: raise ValueError("error assessment cannot fabricate lifecycle events") return self if not self.detected: if self.rupture_type is not None or self.confidence is not None: raise ValueError("not-detected assessment must remain type/scoreless") if self.final_status != "not_applicable": raise ValueError("not-detected assessment has no repair status") if self.repair_attempts or self.ledger: raise ValueError("not-detected assessment cannot fabricate lifecycle events") elif self.rupture_type is None or self.confidence is None: raise ValueError("detected assessment requires type and confidence") return self class RuptureBenchmarkExpectation(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) detected: bool rupture_type: RuptureType | None final_status: FinalRuptureStatus @model_validator(mode="after") def keep_expectation_consistent(self) -> "RuptureBenchmarkExpectation": if self.detected and self.rupture_type is None: raise ValueError("detected benchmark expectation requires rupture_type") if not self.detected and ( self.rupture_type is not None or self.final_status != "not_applicable" ): raise ValueError("negative benchmark expectation must be type/repair scoreless") return self class RuptureBenchmarkCase(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) case_id: str = Field(pattern=r"^oas-g3-bench-[0-9]{3}$") title_ko: str = Field(min_length=1, max_length=200) episode: RuptureEpisodeInput expected: RuptureBenchmarkExpectation critical: bool = False tags: tuple[str, ...] = () forbidden_claims: tuple[str, ...] = Field(min_length=1) class RuptureBenchmarkPack(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) schema_version: Literal["vignette.rupture-repair-benchmark.v1"] = ( "vignette.rupture-repair-benchmark.v1" ) data_classification: Literal["synthetic_educational"] = "synthetic_educational" clinical_claim_allowed: Literal[False] = False version: str = Field(pattern=r"^[0-9]+\.[0-9]+\.[0-9]+$") cases: tuple[RuptureBenchmarkCase, ...] = Field(min_length=1) @model_validator(mode="after") def require_adversarial_coverage(self) -> "RuptureBenchmarkPack": case_ids = [item.case_id for item in self.cases] if len(set(case_ids)) != len(case_ids): raise ValueError("rupture benchmark case ids must be unique") covered = { item.expected.rupture_type for item in self.cases if item.expected.detected } if covered != set(RUPTURE_TYPES): raise ValueError("rupture benchmark must cover every rupture type") if not any("judge_gaming" in item.tags for item in self.cases): raise ValueError("rupture benchmark requires judge_gaming cases") if not any("memorized_phrase_trap" in item.tags for item in self.cases): raise ValueError("rupture benchmark requires memorized phrase variants") template_expectations: dict[str, set[FinalRuptureStatus]] = {} for case in self.cases: for attempt in case.episode.repair_attempts: if attempt.utterance_template_id: template_expectations.setdefault(attempt.utterance_template_id, set()).add( case.expected.final_status ) if not any(len(statuses) > 1 for statuses in template_expectations.values()): raise ValueError("a memorized template must have different contextual outcomes") return self __all__ = [ "ClientRepairResponse", "FastDeepReconciliation", "FastLoopWarning", "FinalRuptureStatus", "REPAIR_BEHAVIORS", "RUPTURE_LIFECYCLE_STATES", "RUPTURE_TYPES", "RepairAttemptAssessment", "RepairAttemptObservation", "RepairBehavior", "RuptureBenchmarkCase", "RuptureBenchmarkExpectation", "RuptureBenchmarkPack", "RuptureDetectionSignal", "RuptureEpisodeAssessment", "RuptureEpisodeInput", "RuptureEvidenceRef", "RuptureLedgerEntry", "RuptureLifecycleState", "RuptureType", "SafetySignalReference", ]