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,371 @@
"""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",
]