"""G2 Longitudinal Outcome Trajectory의 순수 도메인 계약. 이 계약은 교육용 합성 사례의 1~5회기 진행 궤적을 다룬다. 임상 진단·치료 효과 예측 계약이 아니며, 실제 데이터 부재를 정상값이나 평균값으로 대체하지 않는다. """ from __future__ import annotations from typing import Literal from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, model_validator from .measurement import ( AIView, MeasurementPerspective, SOURCE_PERSPECTIVE_COMPATIBILITY, SourceKind, ) OUTCOME_AXES = ("distress_load", "daily_functioning", "learning_engagement") OutcomeAxis = Literal["distress_load", "daily_functioning", "learning_engagement"] TRAJECTORY_STATUSES = ( "on_track", "watch", "off_track", "deteriorating", "insufficient_evidence", ) TrajectoryStatus = Literal[ "on_track", "watch", "off_track", "deteriorating", "insufficient_evidence", ] OBSERVATION_STATUSES = ("observed", "missing", "error") ObservationStatus = Literal["observed", "missing", "error"] RELATIONSHIP_EVENT_TYPES = ( "goal_agreement", "task_agreement", "rupture_withdrawal", "rupture_confrontation", "repair_attempt", "repair_confirmed", "unresolved_rupture", ) RelationshipEventType = Literal[ "goal_agreement", "task_agreement", "rupture_withdrawal", "rupture_confrontation", "repair_attempt", "repair_confirmed", "unresolved_rupture", ] class SyntheticExpectedDistribution(BaseModel): """한 축·한 회기의 교육용 예상 분포. ``mean``은 관측값의 폴백이 아니다. 관측값이 없으면 비교 자체를 하지 않는다. """ model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) session_no: int = Field(ge=1, le=5) axis: OutcomeAxis mean: float = Field(ge=0.0, le=1.0) standard_deviation: float = Field(gt=0.0, le=0.5) lower_reference: float = Field(ge=0.0, le=1.0) upper_reference: float = Field(ge=0.0, le=1.0) sample_size: int = Field(ge=1) expected_direction: Literal["lower_is_better", "higher_is_better"] @model_validator(mode="after") def keep_reference_band_ordered(self) -> "SyntheticExpectedDistribution": if not self.lower_reference <= self.mean <= self.upper_reference: raise ValueError("expected mean must stay inside its reference band") return self class SyntheticExpectedArc(BaseModel): """임상 오표시를 구조적으로 막는 5회기 교육용 예상 궤적.""" model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) schema_version: Literal["vignette.synthetic-outcome-arc.v1"] = ( "vignette.synthetic-outcome-arc.v1" ) arc_id: str = Field(pattern=r"^oas-g2-arc-[0-9]{3}$") title_ko: str = Field(min_length=1, max_length=200) data_classification: Literal["synthetic_educational"] = "synthetic_educational" clinical_claim_allowed: Literal[False] = False provenance_note: str = Field(min_length=20, max_length=1000) distributions: tuple[SyntheticExpectedDistribution, ...] @model_validator(mode="after") def require_complete_five_session_distribution(self) -> "SyntheticExpectedArc": keys = {(item.session_no, item.axis) for item in self.distributions} expected = { (session_no, axis) for session_no in range(1, 6) for axis in OUTCOME_AXES } if keys != expected or len(self.distributions) != len(expected): raise ValueError("synthetic arc requires every outcome axis for sessions 1..5") for item in self.distributions: if item.axis == "distress_load" and item.expected_direction != "lower_is_better": raise ValueError("distress_load must use lower_is_better direction") if item.axis != "distress_load" and item.expected_direction != "higher_is_better": raise ValueError(f"{item.axis} must use higher_is_better direction") return self def distribution_for( self, session_no: int, axis: OutcomeAxis ) -> SyntheticExpectedDistribution: for item in self.distributions: if item.session_no == session_no and item.axis == axis: return item raise KeyError((session_no, axis)) class OutcomeAxisObservation(BaseModel): """실제 관측 여부를 보존하는 한 축의 회기 관측.""" model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) axis: OutcomeAxis status: ObservationStatus = "observed" value: float | None = Field(default=None, ge=0.0, le=1.0) confidence: float | None = Field(default=None, ge=0.0, le=1.0) source_kind: SourceKind perspective: MeasurementPerspective = "client_simulation" instrument_id: str = Field(min_length=1, max_length=120) instrument_version: str = Field(min_length=1, max_length=40) model_run_id: UUID | None = None evidence_refs: tuple[str, ...] = () missing_reason: str | None = Field(default=None, max_length=300) @model_validator(mode="after") def never_impute_missing_observations(self) -> "OutcomeAxisObservation": if self.status == "observed": if self.value is None or self.confidence is None: raise ValueError("observed outcomes require value and confidence") if self.missing_reason: raise ValueError("observed outcomes cannot carry missing_reason") if not self.evidence_refs: raise ValueError("observed outcomes require evidence_refs") else: if self.value is not None or self.confidence is not None: raise ValueError("missing/error outcomes must remain scoreless") if not self.missing_reason: raise ValueError("missing/error outcomes require missing_reason") if len(set(self.evidence_refs)) != len(self.evidence_refs): raise ValueError("outcome evidence_refs must be unique") allowed = SOURCE_PERSPECTIVE_COMPATIBILITY[self.source_kind] if self.perspective not in allowed: raise ValueError("outcome 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 outcome observations require model_run_id provenance") return self class SafetySignalReference(BaseModel): """성과 악화와 별도로 전달되는 기존 safety 원장 참조.""" model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) safety_event_id: str = Field(min_length=1, max_length=160) session_no: int = Field(ge=1, le=5) risk_level: Literal["low", "moderate", "high", "imminent"] escalated: bool evidence_refs: tuple[str, ...] = Field(min_length=1) class RelationshipMemoryEvent(BaseModel): """역할별 문장을 서로 섞지 않는 관계 기억 원본. 범용 ``metadata`` 필드를 의도적으로 두지 않는다. 각 역할은 자기 키의 summary만 받을 수 있어 내담자 내부 설정이나 평가자 정답 라벨이 상담자 뷰로 새지 않는다. """ model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) event_id: str = Field(min_length=1, max_length=160) session_no: int = Field(ge=1, le=5) event_type: RelationshipEventType visible_to: tuple[AIView, ...] = Field(min_length=1) summaries: dict[AIView, str] = Field(min_length=1) evidence_refs: tuple[str, ...] = Field(min_length=1) resolved_by_event_id: str | None = Field(default=None, max_length=160) @model_validator(mode="after") def enforce_role_safe_summary_keys(self) -> "RelationshipMemoryEvent": if len(set(self.visible_to)) != len(self.visible_to): raise ValueError("relationship visible_to must be unique") if set(self.summaries) != set(self.visible_to): raise ValueError("relationship summaries must exactly match visible_to roles") if any(not text.strip() for text in self.summaries.values()): raise ValueError("relationship summaries must not be blank") if len(set(self.evidence_refs)) != len(self.evidence_refs): raise ValueError("relationship evidence_refs must be unique") if self.resolved_by_event_id == self.event_id: raise ValueError("relationship event cannot resolve itself") return self class ObservedSessionOutcome(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) session_no: int = Field(ge=1, le=5) axes: tuple[OutcomeAxisObservation, ...] = Field(min_length=3, max_length=3) safety_signals: tuple[SafetySignalReference, ...] = () relationship_events: tuple[RelationshipMemoryEvent, ...] = () @model_validator(mode="after") def require_explicit_axis_presence(self) -> "ObservedSessionOutcome": axes = [item.axis for item in self.axes] if set(axes) != set(OUTCOME_AXES) or len(set(axes)) != len(OUTCOME_AXES): raise ValueError("every session must explicitly represent all outcome axes") if any(item.session_no != self.session_no for item in self.safety_signals): raise ValueError("safety references must belong to the observed session") if any(item.session_no != self.session_no for item in self.relationship_events): raise ValueError("relationship events must belong to the observed session") return self def observation_for(self, axis: OutcomeAxis) -> OutcomeAxisObservation: for item in self.axes: if item.axis == axis: return item raise KeyError(axis) class LongitudinalOutcomeInput(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) expected_arc: SyntheticExpectedArc sessions: tuple[ObservedSessionOutcome, ...] = Field(min_length=1, max_length=5) @model_validator(mode="after") def require_contiguous_early_sessions(self) -> "LongitudinalOutcomeInput": session_numbers = [item.session_no for item in self.sessions] if session_numbers != list(range(1, len(self.sessions) + 1)): raise ValueError("outcome sessions must be contiguous and ordered from session 1") safety_ids = [ signal.safety_event_id for session in self.sessions for signal in session.safety_signals ] if len(set(safety_ids)) != len(safety_ids): raise ValueError("safety event references must be unique across the arc") events = [ event for session in self.sessions for event in session.relationship_events ] event_by_id = {event.event_id: event for event in events} if len(event_by_id) != len(events): raise ValueError("relationship event ids must be unique across the arc") for event in events: if not event.resolved_by_event_id: continue resolution = event_by_id.get(event.resolved_by_event_id) if resolution is None: raise ValueError("relationship resolution reference must exist in the arc") if resolution.event_type != "repair_confirmed": raise ValueError("relationship resolution must point to repair_confirmed") if resolution.session_no < event.session_no: raise ValueError("relationship resolution cannot precede the rupture") return self class AxisTrajectoryAssessment(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) session_no: int = Field(ge=1, le=5) axis: OutcomeAxis status: TrajectoryStatus observed_value: float | None = Field(default=None, ge=0.0, le=1.0) expected_mean: float = Field(ge=0.0, le=1.0) adverse_z: float | None = None adverse_z_change: float | None = None uncertainty: float = Field(ge=0.0, le=1.0) decision_basis: tuple[str, ...] = Field(min_length=1) counterevidence: tuple[str, ...] = () evidence_refs: tuple[str, ...] = () @model_validator(mode="after") def keep_missing_assessment_scoreless(self) -> "AxisTrajectoryAssessment": if self.status == "insufficient_evidence": if self.observed_value is not None or self.adverse_z is not None: raise ValueError("insufficient evidence assessment must remain scoreless") if self.uncertainty != 1.0: raise ValueError("insufficient evidence must expose maximum uncertainty") elif self.observed_value is None or self.adverse_z is None: raise ValueError("classified trajectory axes require observed evidence") return self class SessionTrajectoryAssessment(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) session_no: int = Field(ge=1, le=5) status: TrajectoryStatus axes: tuple[AxisTrajectoryAssessment, ...] = Field(min_length=3, max_length=3) missing_axes: tuple[OutcomeAxis, ...] = () next_check_questions: tuple[str, ...] = () safety_signals: tuple[SafetySignalReference, ...] = () class LongitudinalOutcomeAssessment(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) schema_version: Literal["vignette.outcome-trajectory-assessment.v1"] = ( "vignette.outcome-trajectory-assessment.v1" ) expected_arc_id: str data_classification: Literal["synthetic_educational"] = "synthetic_educational" clinical_claim_allowed: Literal[False] = False sessions: tuple[SessionTrajectoryAssessment, ...] class RelationshipMemoryProjection(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) event_id: str session_no: int event_type: RelationshipEventType summary: str evidence_refs: tuple[str, ...] resolved_by_event_id: str | None = None class RoleSafeTrajectoryReadModel(BaseModel): """성과·safety·관계 기억을 합산하지 않고 나란히 전달하는 읽기 모델.""" model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) assessment: LongitudinalOutcomeAssessment safety_signals: tuple[SafetySignalReference, ...] relationship_memory: tuple[RelationshipMemoryProjection, ...] class TrajectoryBenchmarkExpectation(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) session_no: int = Field(ge=1, le=5) status: TrajectoryStatus class TrajectoryBenchmarkCase(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) case_id: str = Field(pattern=r"^oas-g2-bench-[0-9]{3}$") title_ko: str = Field(min_length=1, max_length=200) sessions: tuple[ObservedSessionOutcome, ...] = Field(min_length=5, max_length=5) expected: tuple[TrajectoryBenchmarkExpectation, ...] = Field( min_length=5, max_length=5 ) forbidden_claims: tuple[str, ...] = Field(min_length=1) @model_validator(mode="after") def require_complete_benchmark_timeline(self) -> "TrajectoryBenchmarkCase": session_numbers = [item.session_no for item in self.sessions] expectation_numbers = [item.session_no for item in self.expected] if session_numbers != [1, 2, 3, 4, 5]: raise ValueError("benchmark sessions must be ordered 1..5") if expectation_numbers != [1, 2, 3, 4, 5]: raise ValueError("benchmark expectations must be ordered 1..5") return self class TrajectoryBenchmarkPack(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) schema_version: Literal["vignette.outcome-trajectory-benchmark.v1"] = ( "vignette.outcome-trajectory-benchmark.v1" ) expected_arc: SyntheticExpectedArc cases: tuple[TrajectoryBenchmarkCase, ...] = Field(min_length=1) __all__ = [ "OBSERVATION_STATUSES", "OUTCOME_AXES", "RELATIONSHIP_EVENT_TYPES", "TRAJECTORY_STATUSES", "AxisTrajectoryAssessment", "LongitudinalOutcomeAssessment", "LongitudinalOutcomeInput", "ObservedSessionOutcome", "OutcomeAxis", "OutcomeAxisObservation", "RelationshipMemoryEvent", "RelationshipMemoryProjection", "RoleSafeTrajectoryReadModel", "SafetySignalReference", "SessionTrajectoryAssessment", "SyntheticExpectedArc", "SyntheticExpectedDistribution", "TrajectoryBenchmarkCase", "TrajectoryBenchmarkExpectation", "TrajectoryBenchmarkPack", "TrajectoryStatus", ]