vignette/scripts/smoke-outcome-trajectory-alerts.py
Yun Chan 16e791e044 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 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

477 lines
20 KiB
Python

#!/usr/bin/env python3
"""Prove G2 warning and noisy-control trajectories on real PostgreSQL rows.
The smoke creates two synthetic educational five-session cases. It submits each
session's three axes through the production route/store measurement producer, then
reads the resulting learner/teacher projections and audits the immutable ledger.
No source score is copied into the evidence JSON and no clinical claim is made.
Run only against an expendable development database. Unique fixture rows remain
because the measurement and trajectory ledgers are intentionally append-only.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Any
from uuid import UUID, uuid4
REPO_ROOT = Path(__file__).resolve().parents[1]
API_ROOT = REPO_ROOT / "apps" / "api"
API_ENV = API_ROOT / ".env"
def _load_api_env() -> None:
if not API_ENV.exists():
return
for raw_line in API_ENV.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
_load_api_env()
if str(API_ROOT) not in sys.path:
sys.path.insert(0, str(API_ROOT))
from app import db, persona_repository, session_persistence # noqa: E402
from app.deps import Principal, Role # noqa: E402
from app.routes import outcome_trajectories # noqa: E402
from app.routes.outcome_trajectories import ( # noqa: E402
OutcomeAxisValues,
OutcomeObservationSubmissionRequest,
)
from app.services import memory, state_machine # noqa: E402
COHORT = "g2-trajectory-alert-smoke"
AXES = ("distress_load", "daily_functioning", "learning_engagement")
ALERT_STATUSES = {"off_track", "deteriorating"}
INSTRUMENT_ID = "vignette-session-outcome-checkin"
INSTRUMENT_VERSION = "1.0.0"
# These values are synthetic educational fixture inputs. They intentionally stay
# inside the executable fixture and are never copied to the persisted evidence JSON.
WORSENING_PROFILE = (
{"distress_load": 0.70, "daily_functioning": 0.30, "learning_engagement": 0.40},
{"distress_load": 0.62, "daily_functioning": 0.40, "learning_engagement": 0.48},
{"distress_load": 0.72, "daily_functioning": 0.50, "learning_engagement": 0.56},
{"distress_load": 0.70, "daily_functioning": 0.60, "learning_engagement": 0.64},
{"distress_load": 0.68, "daily_functioning": 0.68, "learning_engagement": 0.72},
)
NOISY_CONTROL_PROFILE = (
{"distress_load": 0.70, "daily_functioning": 0.30, "learning_engagement": 0.40},
{"distress_load": 0.62, "daily_functioning": 0.40, "learning_engagement": 0.48},
{"distress_load": 0.64, "daily_functioning": 0.50, "learning_engagement": 0.56},
{"distress_load": 0.46, "daily_functioning": 0.60, "learning_engagement": 0.64},
{"distress_load": 0.38, "daily_functioning": 0.68, "learning_engagement": 0.72},
)
EXPECTED_WORSENING = (
"on_track",
"on_track",
"off_track",
"deteriorating",
"deteriorating",
)
EXPECTED_CONTROL = ("on_track", "on_track", "watch", "on_track", "on_track")
class SmokeError(RuntimeError):
pass
def _principal(user_id: UUID, role: Role) -> Principal:
return Principal(
user_id=str(user_id),
role=role,
cohort_ids=[COHORT],
consent_at=1.0,
profile_completed_at=1.0,
)
async def _seed_users(*user_roles: tuple[UUID, str]) -> None:
async with db.acquire(role="admin") as conn:
for user_id, role in user_roles:
await conn.execute(
"""
INSERT INTO app.app_user (
user_id, external_id, email, display_name, role, cohort,
consent_at, profile_completed_at, terms_agreed_at,
privacy_agreed_at
) VALUES ($1,$2,$3,$4,$5,$6,now(),now(),now(),now())
""",
user_id,
f"dev:e2e:g2-trajectory-alert:{user_id}",
f"{user_id}@g2-trajectory-alert.invalid",
f"G2 synthetic {role} fixture",
role,
COHORT,
)
async def _create_ended_session(learner: Principal) -> Any:
catalog = await persona_repository.get_approved_persona("P1")
if catalog is None:
raise SmokeError("approved P1 persona is unavailable")
session = await session_persistence.create_session(
learner_id=learner.user_id,
card=catalog.card,
theory_mode="humanistic",
state=state_machine.init_state(params=catalog.card.openness_params()),
persona_id=catalog.persona_id,
persona_version=catalog.version,
goal_stages=["라포", "탐색"],
)
if session is None:
raise SmokeError("durable session creation fell back or failed")
carry = memory.make_carry_over(
state=session.state,
session_id=session.session_id,
case_id=session.case_id,
session_no=session.session_no,
masked_turns=[],
open_threads=[],
)
if not await session_persistence.end_session(session, carry):
raise SmokeError(f"S{session.session_no} durable end failed")
return session
def _axis(session: dict[str, Any], axis: str) -> dict[str, Any]:
for item in session.get("axes") or []:
if item.get("axis") == axis:
return item
raise SmokeError(f"S{session.get('session_no')} omitted {axis}")
def _assert_projection(
payload: dict[str, Any],
*,
expected_statuses: tuple[str, ...],
control: bool,
) -> None:
expected_arc = payload.get("expected_arc") or {}
assessment = payload.get("assessment") or {}
sessions = assessment.get("sessions") or []
statuses = tuple(item.get("status") for item in sessions)
if statuses != expected_statuses:
raise SmokeError(f"trajectory statuses differ: {statuses!r}")
if expected_arc.get("data_classification") != "synthetic_educational":
raise SmokeError("expected arc lost synthetic educational classification")
if expected_arc.get("clinical_claim_allowed") is not False:
raise SmokeError("expected arc allowed a clinical claim")
if assessment.get("data_classification") != "synthetic_educational":
raise SmokeError("assessment lost synthetic educational classification")
if assessment.get("clinical_claim_allowed") is not False:
raise SmokeError("assessment allowed a clinical claim")
if len(payload.get("observations") or []) != 15:
raise SmokeError("five-session projection did not expose 15 axis observations")
if len(payload.get("source_fingerprint") or "") != 64:
raise SmokeError("projection omitted its source fingerprint")
if control:
promoted = ALERT_STATUSES.intersection(statuses)
if promoted:
raise SmokeError(f"one-off noise was promoted to an alert: {promoted}")
noisy_axis = _axis(sessions[2], "distress_load")
counterevidence = set(noisy_axis.get("counterevidence") or [])
required = {"inside_off_track_threshold", "deviation_not_yet_sustained"}
if not required.issubset(counterevidence):
raise SmokeError(
"noisy control omitted false-alert counterevidence: "
f"{sorted(counterevidence)}"
)
else:
deteriorating_axis = _axis(sessions[3], "distress_load")
if deteriorating_axis.get("status") != "deteriorating":
raise SmokeError("S4 distress did not become deteriorating")
if deteriorating_axis.get("adverse_z_change") is None:
raise SmokeError("deterioration omitted longitudinal change evidence")
if not sessions[3].get("next_check_questions"):
raise SmokeError("deterioration omitted next-check questions")
async def _run_case(
*,
learner: Principal,
teacher: Principal,
profile: tuple[dict[str, float], ...],
expected_statuses: tuple[str, ...],
control: bool,
) -> dict[str, Any]:
session_ids: list[str] = []
submitted_measurement_ids: list[str] = []
case_id = ""
for expected_no, scores in enumerate(profile, start=1):
session = await _create_ended_session(learner)
if session.session_no != expected_no:
raise SmokeError(
f"session continuity differs: {session.session_no} != {expected_no}"
)
if case_id and session.case_id != case_id:
raise SmokeError("five sessions did not stay in one case")
case_id = session.case_id
session_ids.append(session.session_id)
created = await outcome_trajectories.create_outcome_observations(
UUID(session.session_id),
OutcomeObservationSubmissionRequest(
submission_id=uuid4(),
scores=OutcomeAxisValues(**scores),
confidences=OutcomeAxisValues(**{axis: 0.9 for axis in AXES}),
),
learner,
)
submitted_measurement_ids.extend(
str(item) for item in created.submitted_measurement_ids
)
learner_payload = (
await outcome_trajectories.get_outcome_trajectory(
UUID(session_ids[-1]), learner
)
).model_dump(mode="json")
teacher_payload = (
await outcome_trajectories.get_outcome_trajectory(
UUID(session_ids[-1]), teacher
)
).model_dump(mode="json")
_assert_projection(
learner_payload, expected_statuses=expected_statuses, control=control
)
_assert_projection(
teacher_payload, expected_statuses=expected_statuses, control=control
)
learner_statuses = [
item["status"] for item in learner_payload["assessment"]["sessions"]
]
teacher_statuses = [
item["status"] for item in teacher_payload["assessment"]["sessions"]
]
if learner_statuses != teacher_statuses:
raise SmokeError("learner and teacher projections disagree")
if learner_payload["revision_id"] != teacher_payload["revision_id"]:
raise SmokeError("role-safe reads did not project the same latest revision")
return {
"case_id": case_id,
"session_ids": session_ids,
"submitted_measurement_ids": submitted_measurement_ids,
"revision_id": learner_payload["revision_id"],
"revision_no": learner_payload["revision_no"],
"source_fingerprint": learner_payload["source_fingerprint"],
"session_statuses": learner_statuses,
"learner_teacher_projection_equal": True,
"next_question_count": len(learner_payload.get("next_questions") or []),
"alert_promotion_count": len(ALERT_STATUSES.intersection(learner_statuses)),
}
async def _postgres_proof(case_ids: list[str]) -> dict[str, Any]:
async with db.acquire(role="admin") as conn:
measurement = await conn.fetchrow(
"""
SELECT count(*)::int AS row_count,
count(DISTINCT me.measurement_id)::int AS distinct_measurements,
count(DISTINCT me.session_id)::int AS session_count,
array_agg(DISTINCT me.source_kind::text ORDER BY me.source_kind::text)
AS source_kinds,
array_agg(DISTINCT me.perspective::text ORDER BY me.perspective::text)
AS perspectives,
array_agg(DISTINCT me.instrument_id ORDER BY me.instrument_id)
AS instrument_ids,
array_agg(DISTINCT me.instrument_version ORDER BY me.instrument_version)
AS instrument_versions,
bool_and((me.metadata ->> 'clinical_claim_allowed')::boolean = false)
AS all_nonclinical,
bool_and(me.metadata ? 'submission_id' AND me.metadata ? 'submission_hash')
AS all_have_submission_provenance
FROM app.measurement_event me
JOIN app.sessions s ON s.id = me.session_id
WHERE s.case_id = ANY($1::uuid[])
AND me.construct = 'session_outcome'
""",
[UUID(item) for item in case_ids],
)
revisions = await conn.fetch(
"""
SELECT case_id, count(*)::int AS revision_count,
min(revision_no)::int AS first_revision,
max(revision_no)::int AS last_revision,
count(DISTINCT source_fingerprint)::int AS fingerprint_count,
count(*) FILTER (WHERE supersedes_revision_id IS NOT NULL)::int
AS superseding_revision_count,
array_agg(DISTINCT computed_role ORDER BY computed_role)
AS computed_roles
FROM app.outcome_trajectory_revision
WHERE case_id = ANY($1::uuid[])
GROUP BY case_id
ORDER BY case_id
""",
[UUID(item) for item in case_ids],
)
latest_observations = await conn.fetchrow(
"""
WITH latest AS (
SELECT DISTINCT ON (case_id) revision_id, case_id
FROM app.outcome_trajectory_revision
WHERE case_id = ANY($1::uuid[])
ORDER BY case_id, revision_no DESC
)
SELECT count(*)::int AS row_count,
count(DISTINCT oto.measurement_id)::int AS distinct_measurements,
count(DISTINCT oto.session_id)::int AS session_count,
count(DISTINCT oto.axis)::int AS axis_count,
bool_and(oto.status = 'observed') AS all_observed,
bool_and(oto.source_kind = 'learner_reported') AS source_preserved,
bool_and(oto.perspective = 'learner_self_report')
AS perspective_preserved
FROM latest
JOIN app.outcome_trajectory_observation oto
ON oto.revision_id = latest.revision_id
""",
[UUID(item) for item in case_ids],
)
all_linked = await conn.fetchval(
"""
SELECT count(DISTINCT oto.measurement_id)::int
FROM app.outcome_trajectory_observation oto
JOIN app.outcome_trajectory_revision otr
ON otr.revision_id = oto.revision_id
WHERE otr.case_id = ANY($1::uuid[])
AND oto.measurement_id IS NOT NULL
""",
[UUID(item) for item in case_ids],
)
measurement_payload = dict(measurement or {})
latest_payload = dict(latest_observations or {})
if measurement_payload.get("row_count") != 30:
raise SmokeError(f"measurement ledger count differs: {measurement_payload}")
if measurement_payload.get("session_count") != 10:
raise SmokeError("measurement ledger did not cover ten real sessions")
if measurement_payload.get("source_kinds") != ["learner_reported"]:
raise SmokeError("measurement producer source kind drifted")
if measurement_payload.get("perspectives") != ["learner_self_report"]:
raise SmokeError("measurement producer perspective drifted")
if measurement_payload.get("instrument_ids") != [INSTRUMENT_ID]:
raise SmokeError("measurement producer instrument id drifted")
if measurement_payload.get("instrument_versions") != [INSTRUMENT_VERSION]:
raise SmokeError("measurement producer instrument version drifted")
if not measurement_payload.get("all_nonclinical"):
raise SmokeError("a producer row allowed clinical claims")
if not measurement_payload.get("all_have_submission_provenance"):
raise SmokeError("a producer row omitted submission provenance")
revision_payloads = [dict(row) for row in revisions]
if len(revision_payloads) != 2:
raise SmokeError("trajectory revision ledger omitted a case")
for row in revision_payloads:
if row["revision_count"] != 5 or row["first_revision"] != 1:
raise SmokeError(f"revision chain is incomplete: {row}")
if row["last_revision"] != 5 or row["superseding_revision_count"] != 4:
raise SmokeError(f"revision supersession chain is invalid: {row}")
if row["fingerprint_count"] != 5 or row["computed_roles"] != ["learner"]:
raise SmokeError(f"revision provenance drifted: {row}")
if latest_payload.get("row_count") != 30:
raise SmokeError("latest revisions do not contain 30 immutable snapshots")
if latest_payload.get("distinct_measurements") != 30 or all_linked != 30:
raise SmokeError("measurement-to-observation provenance is incomplete")
if latest_payload.get("session_count") != 10 or latest_payload.get("axis_count") != 3:
raise SmokeError("latest observation snapshots lost session/axis coverage")
if not all(
latest_payload.get(key)
for key in ("all_observed", "source_preserved", "perspective_preserved")
):
raise SmokeError("observation snapshot provenance drifted")
return {
"measurement_event_count": measurement_payload["row_count"],
"distinct_measurement_count": measurement_payload["distinct_measurements"],
"session_count": measurement_payload["session_count"],
"source_kinds": measurement_payload["source_kinds"],
"perspectives": measurement_payload["perspectives"],
"instrument_ids": measurement_payload["instrument_ids"],
"instrument_versions": measurement_payload["instrument_versions"],
"all_nonclinical": measurement_payload["all_nonclinical"],
"all_have_submission_provenance": measurement_payload[
"all_have_submission_provenance"
],
"revision_chains": revision_payloads,
"latest_observation_snapshot_count": latest_payload["row_count"],
"all_measurements_linked_to_observation_ledger": all_linked == 30,
"producer_store_evaluator_boundary_proved": True,
}
async def run() -> dict[str, Any]:
await db.init_pool()
try:
await persona_repository.materialize_seed_personas()
worsening_learner_id = uuid4()
control_learner_id = uuid4()
teacher_id = uuid4()
await _seed_users(
(worsening_learner_id, "learner"),
(control_learner_id, "learner"),
(teacher_id, "instructor"),
)
teacher = _principal(teacher_id, Role.TEACHER)
worsening = await _run_case(
learner=_principal(worsening_learner_id, Role.LEARNER),
teacher=teacher,
profile=WORSENING_PROFILE,
expected_statuses=EXPECTED_WORSENING,
control=False,
)
control = await _run_case(
learner=_principal(control_learner_id, Role.LEARNER),
teacher=teacher,
profile=NOISY_CONTROL_PROFILE,
expected_statuses=EXPECTED_CONTROL,
control=True,
)
postgres = await _postgres_proof([worsening["case_id"], control["case_id"]])
return {
"ok": True,
"fixture_classification": "synthetic_educational",
"clinical_claim_allowed": False,
"notice_ko": "교육용 합성 fixture 검증이며 임상적 효과·예후를 주장하지 않는다.",
"fixture_policy": (
"retained unique dev:e2e rows in expendable development DB; "
"append-only ledger"
),
"entrypoint": (
"outcome_trajectories.create_outcome_observations -> "
"outcome_trajectory_store measurement producer -> evaluator"
),
"direct_measurement_or_trajectory_insert_used": False,
"source_values_in_evidence_json": False,
"worsening_case": worsening,
"noisy_one_off_control": control,
"postgres": postgres,
}
finally:
await db.close_pool()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", default="")
args = parser.parse_args()
result = asyncio.run(run())
output = json.dumps(result, ensure_ascii=False, indent=2, default=str)
if args.out:
path = Path(args.out)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(output + "\n", encoding="utf-8")
print(output)
if __name__ == "__main__":
main()