#!/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()