"""G2 교육용 장기 성과 궤적의 결정론 코어. 축별 기대분포 대비 편차만 계산하며 세 축의 숫자 총점은 만들지 않는다. safety는 별도 원장 참조로 보존하고 결과 판정의 feature로 사용하지 않는다. """ from __future__ import annotations import json from pathlib import Path from typing import Iterable from ..contracts.measurement import AIView from ..contracts.outcome_trajectory import ( OUTCOME_AXES, AxisTrajectoryAssessment, LongitudinalOutcomeAssessment, LongitudinalOutcomeInput, OutcomeAxis, OutcomeAxisObservation, RelationshipMemoryEvent, RelationshipMemoryProjection, RoleSafeTrajectoryReadModel, SessionTrajectoryAssessment, SyntheticExpectedDistribution, TrajectoryBenchmarkPack, TrajectoryStatus, ) WATCH_Z = 0.75 OFF_TRACK_Z = 1.50 DETERIORATING_Z = 2.00 DETERIORATING_Z_CHANGE = 0.50 ALERT_STATUSES = frozenset({"off_track", "deteriorating"}) _AXIS_QUESTION = { "distress_load": "최근 회기 사이 부담감의 변화와 그 변화가 두드러진 장면을 다시 확인해 볼까요?", "daily_functioning": "수면·학업·일상 기능에서 실제로 달라진 점과 그대로인 점을 구분해 볼까요?", "learning_engagement": "이번 회기 목표와 연습 과제가 본인에게 납득됐는지 다시 합의해 볼까요?", } _MISSING_QUESTION = { "distress_load": "부담감 축의 근거가 비어 있습니다. 같은 도구로 다시 확인할까요?", "daily_functioning": "일상 기능 축의 근거가 비어 있습니다. 관찰 가능한 변화를 확인할까요?", "learning_engagement": "학습 참여 축의 근거가 비어 있습니다. 목표·과제 합의를 직접 확인할까요?", } def _adverse_z( observation: OutcomeAxisObservation, expected: SyntheticExpectedDistribution, ) -> float: if observation.value is None: raise ValueError("missing observations do not have an adverse z score") if expected.expected_direction == "lower_is_better": return (observation.value - expected.mean) / expected.standard_deviation return (expected.mean - observation.value) / expected.standard_deviation def _classify_axis( *, session_no: int, observation: OutcomeAxisObservation, expected: SyntheticExpectedDistribution, prior_adverse_z: float | None, ) -> AxisTrajectoryAssessment: if observation.status != "observed": return AxisTrajectoryAssessment( session_no=session_no, axis=observation.axis, status="insufficient_evidence", observed_value=None, expected_mean=expected.mean, adverse_z=None, adverse_z_change=None, uncertainty=1.0, decision_basis=( f"observation_status:{observation.status}", f"missing_reason:{observation.missing_reason}", "no_value_imputation", ), counterevidence=("expected_distribution_not_used_as_observation",), evidence_refs=observation.evidence_refs, ) adverse_z = _adverse_z(observation, expected) adverse_change = ( adverse_z - prior_adverse_z if prior_adverse_z is not None else None ) counterevidence: list[str] = [] if ( adverse_z >= DETERIORATING_Z and prior_adverse_z is not None and adverse_change is not None and adverse_change >= DETERIORATING_Z_CHANGE ): status: TrajectoryStatus = "deteriorating" elif adverse_z > OFF_TRACK_Z: status = "off_track" if prior_adverse_z is None: counterevidence.append("single_session_deviation_not_yet_a_worsening_trend") elif adverse_change is not None and adverse_change < 0: counterevidence.append("deviation_is_recovering_from_prior_session") elif adverse_z > WATCH_Z: status = "watch" counterevidence.append("inside_off_track_threshold") if prior_adverse_z is None or prior_adverse_z <= WATCH_Z: counterevidence.append("deviation_not_yet_sustained") else: status = "on_track" counterevidence.append("inside_expected_tolerance") confidence = observation.confidence assert confidence is not None decision_basis = [ f"axis:{observation.axis}", f"adverse_z:{adverse_z:.3f}", f"thresholds:watch>{WATCH_Z:.2f},off_track>{OFF_TRACK_Z:.2f}", ( f"source:{observation.source_kind}/{observation.perspective}/" f"{observation.instrument_id}@{observation.instrument_version}" ), ] if adverse_change is not None: decision_basis.append(f"adverse_z_change:{adverse_change:.3f}") return AxisTrajectoryAssessment( session_no=session_no, axis=observation.axis, status=status, observed_value=observation.value, expected_mean=expected.mean, adverse_z=round(adverse_z, 6), adverse_z_change=(round(adverse_change, 6) if adverse_change is not None else None), uncertainty=round(1.0 - confidence, 6), decision_basis=tuple(decision_basis), counterevidence=tuple(counterevidence), evidence_refs=observation.evidence_refs, ) def _session_status(axes: Iterable[AxisTrajectoryAssessment]) -> TrajectoryStatus: statuses = {item.status for item in axes} for status in ("deteriorating", "off_track"): if status in statuses: return status # 누락이 on_track으로 오인되지 않도록 관측된 watch보다도 보수적으로 앞세운다. if "insufficient_evidence" in statuses: return "insufficient_evidence" if "watch" in statuses: return "watch" return "on_track" def _next_questions(axes: Iterable[AxisTrajectoryAssessment]) -> tuple[str, ...]: questions: list[str] = [] for item in axes: if item.status == "insufficient_evidence": questions.append(_MISSING_QUESTION[item.axis]) elif item.status in {"watch", "off_track", "deteriorating"}: questions.append(_AXIS_QUESTION[item.axis]) if any(item.status == "deteriorating" for item in axes): questions.append( "측정 시점·도구·응답 맥락이 이전 회기와 같았는지 확인해 변화와 측정 오차를 구분할까요?" ) # 질문 순서를 결정적으로 유지하면서 중복을 제거한다. return tuple(dict.fromkeys(questions)) def assess_longitudinal_outcome( trajectory: LongitudinalOutcomeInput, ) -> LongitudinalOutcomeAssessment: """1~5회기 각 축을 독립 판정한다. safety signal은 결과 객체에 전달만 하고 아래 판정에는 절대 사용하지 않는다. """ prior_by_axis: dict[OutcomeAxis, tuple[int, float] | None] = { axis: None for axis in OUTCOME_AXES } timeline: list[SessionTrajectoryAssessment] = [] for session in trajectory.sessions: axis_results: list[AxisTrajectoryAssessment] = [] for axis in OUTCOME_AXES: observation = session.observation_for(axis) expected = trajectory.expected_arc.distribution_for(session.session_no, axis) prior = prior_by_axis[axis] prior_adverse_z = ( prior[1] if prior is not None and prior[0] == session.session_no - 1 else None ) result = _classify_axis( session_no=session.session_no, observation=observation, expected=expected, prior_adverse_z=prior_adverse_z, ) axis_results.append(result) if result.adverse_z is not None: prior_by_axis[axis] = (session.session_no, result.adverse_z) else: prior_by_axis[axis] = None missing_axes = tuple( item.axis for item in axis_results if item.status == "insufficient_evidence" ) timeline.append( SessionTrajectoryAssessment( session_no=session.session_no, status=_session_status(axis_results), axes=tuple(axis_results), missing_axes=missing_axes, next_check_questions=_next_questions(axis_results), safety_signals=session.safety_signals, ) ) return LongitudinalOutcomeAssessment( expected_arc_id=trajectory.expected_arc.arc_id, sessions=tuple(timeline), ) def project_relationship_memory( events: Iterable[RelationshipMemoryEvent], *, view: AIView, ) -> tuple[RelationshipMemoryProjection, ...]: """요청 역할에 명시적으로 공개된 관계 기억만 투영한다.""" projected: list[RelationshipMemoryProjection] = [] for event in sorted(events, key=lambda item: (item.session_no, item.event_id)): if view not in event.visible_to: continue projected.append( RelationshipMemoryProjection( event_id=event.event_id, session_no=event.session_no, event_type=event.event_type, summary=event.summaries[view], evidence_refs=event.evidence_refs, resolved_by_event_id=event.resolved_by_event_id, ) ) return tuple(projected) def build_role_safe_read_model( trajectory: LongitudinalOutcomeInput, *, view: AIView, ) -> RoleSafeTrajectoryReadModel: assessment = assess_longitudinal_outcome(trajectory) safety_signals = tuple( signal for session in trajectory.sessions for signal in session.safety_signals ) relationship_events = tuple( event for session in trajectory.sessions for event in session.relationship_events ) return RoleSafeTrajectoryReadModel( assessment=assessment, safety_signals=safety_signals, relationship_memory=project_relationship_memory( relationship_events, view=view, ), ) def load_trajectory_benchmark(path: Path) -> TrajectoryBenchmarkPack: return TrajectoryBenchmarkPack.model_validate_json(path.read_text(encoding="utf-8")) def evaluate_trajectory_benchmark(pack: TrajectoryBenchmarkPack) -> dict[str, object]: """조기경보 recall과 false-alert rate를 합성 gold에 대해 결정적으로 계산한다.""" true_positive = false_negative = false_positive = true_negative = 0 status_hits = 0 rows: list[dict[str, object]] = [] for case in pack.cases: result = assess_longitudinal_outcome( LongitudinalOutcomeInput( expected_arc=pack.expected_arc, sessions=case.sessions, ) ) expected_by_session = {item.session_no: item.status for item in case.expected} for actual in result.sessions: expected = expected_by_session[actual.session_no] expected_alert = expected in ALERT_STATUSES actual_alert = actual.status in ALERT_STATUSES if expected_alert and actual_alert: true_positive += 1 elif expected_alert: false_negative += 1 elif actual_alert: false_positive += 1 else: true_negative += 1 matched = actual.status == expected status_hits += int(matched) rows.append( { "case_id": case.case_id, "session_no": actual.session_no, "expected_status": expected, "actual_status": actual.status, "status_match": matched, "expected_alert": expected_alert, "actual_alert": actual_alert, "missing_axes": list(actual.missing_axes), "uncertainty": { item.axis: item.uncertainty for item in actual.axes }, "false_alert_counterevidence": { item.axis: list(item.counterevidence) for item in actual.axes }, } ) positives = true_positive + false_negative negatives = false_positive + true_negative total = len(rows) return { "schema_version": "vignette.outcome-trajectory-benchmark-report.v1", "data_classification": "synthetic_educational", "clinical_claim_allowed": False, "case_count": len(pack.cases), "session_count": total, "early_warning_recall": true_positive / positives if positives else None, "false_alert_rate": false_positive / negatives if negatives else None, "status_accuracy": status_hits / total if total else None, "confusion": { "true_positive": true_positive, "false_negative": false_negative, "false_positive": false_positive, "true_negative": true_negative, }, "rows": rows, } def render_benchmark_report(report: dict[str, object]) -> str: """DB나 임상 효능으로 오인하지 않는 감사 가능한 JSON 보고서.""" return json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) __all__ = [ "ALERT_STATUSES", "DETERIORATING_Z", "DETERIORATING_Z_CHANGE", "OFF_TRACK_Z", "WATCH_Z", "assess_longitudinal_outcome", "build_role_safe_read_model", "evaluate_trajectory_benchmark", "load_trajectory_benchmark", "project_relationship_memory", "render_benchmark_report", ]