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 산출물은 커밋에서 제외했다.
This commit is contained in:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -0,0 +1,410 @@
"""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",
]