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 산출물은 커밋에서 제외했다.
371 lines
13 KiB
Python
371 lines
13 KiB
Python
"""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",
|
|
]
|