vignette/apps/api/app/contracts/deliberate_practice.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

639 lines
24 KiB
Python

"""G4 Deliberate Practice Engine의 버전 고정 순수 도메인 계약.
코칭 장면을 하나의 관찰 가능한 행동으로 분해한 실행형 처방, 시도 근거,
역량 그래프와 전이 게이트를 정의한다. 단일 총점이나 보상 점수로 숙련을
승격하지 않으며, 익숙한 장면의 성공과 미지 사례 전이를 물리적으로 구분한다.
"""
from __future__ import annotations
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
from .measurement import (
MeasurementPerspective,
SOURCE_PERSPECTIVE_COMPATIBILITY,
SourceKind,
)
PRACTICE_MODES = (
"replay",
"branch",
"constrained_response",
"voice_retry",
"difficulty_ladder",
)
PracticeMode = Literal[
"replay",
"branch",
"constrained_response",
"voice_retry",
"difficulty_ladder",
]
COMPETENCY_BANDS = (
"unassessed",
"fragile",
"developing",
"consistent_local",
"transfer_verified",
)
CompetencyBand = Literal[
"unassessed",
"fragile",
"developing",
"consistent_local",
"transfer_verified",
]
CriterionStatus = Literal["observed", "not_observed", "error"]
AttemptOutcome = Literal["passed", "needs_retry", "insufficient_evidence"]
PracticeProgress = Literal["practicing", "transfer_pending", "mastered"]
ScenarioNovelty = Literal["familiar", "unseen_transfer"]
ClientPracticeResponse = Literal[
"rejecting",
"withdrawn",
"compliance_only",
"mixed",
"engaged",
"explicit_alignment",
]
EvidenceKind = Literal[
"scene_context",
"learner_behavior",
"client_response",
"evaluator_decision",
"voice_feature",
]
class PracticeEvidenceRef(BaseModel):
"""원문을 복제하지 않는 장면·행동·반응 원장 포인터."""
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
ref_id: str = Field(min_length=1, max_length=180)
scene_id: str = Field(min_length=1, max_length=180)
turn_index: int = Field(ge=0)
actor: Literal["learner", "client", "observer", "runtime"]
kind: EvidenceKind
class ReplayActivity(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
mode: Literal["replay"] = "replay"
launch_intent: Literal["practice.replay.launch"] = "practice.replay.launch"
scenario_variant_id: str = Field(min_length=1, max_length=180)
scenario_novelty: ScenarioNovelty = "familiar"
difficulty_level: int = Field(ge=1, le=5)
pause_at_evidence_ref: str = Field(min_length=1, max_length=180)
class BranchActivity(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
mode: Literal["branch"] = "branch"
launch_intent: Literal["practice.branch.launch"] = "practice.branch.launch"
scenario_variant_id: str = Field(min_length=1, max_length=180)
scenario_novelty: ScenarioNovelty
difficulty_level: int = Field(ge=1, le=5)
branch_options: tuple[str, ...] = Field(min_length=2, max_length=5)
client_responses_hidden: Literal[True] = True
@model_validator(mode="after")
def require_distinct_branches(self) -> "BranchActivity":
if len(set(self.branch_options)) != len(self.branch_options):
raise ValueError("branch options must be unique")
return self
class ConstrainedResponseActivity(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
mode: Literal["constrained_response"] = "constrained_response"
launch_intent: Literal["practice.constrained-response.launch"] = (
"practice.constrained-response.launch"
)
scenario_variant_id: str = Field(min_length=1, max_length=180)
scenario_novelty: ScenarioNovelty
difficulty_level: int = Field(ge=1, le=5)
max_words: int = Field(ge=5, le=80)
required_moves: tuple[str, ...] = Field(min_length=1, max_length=4)
class VoiceRetryActivity(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
mode: Literal["voice_retry"] = "voice_retry"
launch_intent: Literal["practice.voice-retry.launch"] = (
"practice.voice-retry.launch"
)
scenario_variant_id: str = Field(min_length=1, max_length=180)
scenario_novelty: ScenarioNovelty
difficulty_level: int = Field(ge=1, le=5)
max_seconds: int = Field(ge=5, le=120)
acoustic_focus: tuple[str, ...] = Field(min_length=1, max_length=4)
class DifficultyStep(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
level: int = Field(ge=1, le=5)
variation: str = Field(min_length=1, max_length=240)
scenario_variant_id: str = Field(min_length=1, max_length=180)
scenario_novelty: ScenarioNovelty
class DifficultyLadderActivity(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
mode: Literal["difficulty_ladder"] = "difficulty_ladder"
launch_intent: Literal["practice.difficulty-ladder.launch"] = (
"practice.difficulty-ladder.launch"
)
scenario_variant_id: str = Field(min_length=1, max_length=180)
scenario_novelty: ScenarioNovelty
difficulty_level: int = Field(ge=1, le=5)
steps: tuple[DifficultyStep, ...] = Field(min_length=2, max_length=5)
@model_validator(mode="after")
def require_ordered_ladder_with_transfer(self) -> "DifficultyLadderActivity":
levels = [item.level for item in self.steps]
if levels != sorted(set(levels)):
raise ValueError("difficulty ladder levels must be unique and ascending")
if not any(item.scenario_novelty == "unseen_transfer" for item in self.steps):
raise ValueError("difficulty ladder must end in an unseen transfer step")
return self
PracticeActivity = Annotated[
ReplayActivity
| BranchActivity
| ConstrainedResponseActivity
| VoiceRetryActivity
| DifficultyLadderActivity,
Field(discriminator="mode"),
]
class PracticeTargetSpec(BaseModel):
"""한 역량의 한 관찰 행동만 소유하는 원자적 연습 명세."""
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
prescription_id: str = Field(pattern=r"^oas-g4-practice-[a-z0-9-]+$")
competency_id: str = Field(pattern=r"^competency\.[a-z0-9_.-]+$")
criterion_id: str = Field(pattern=r"^criterion\.[a-z0-9_.-]+$")
observable_behavior: str = Field(min_length=10, max_length=500)
activity: PracticeActivity
class CoachingCard(BaseModel):
"""실행 가능한 재연습이 없는 코칭 카드를 구조적으로 거부한다."""
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
card_id: str = Field(pattern=r"^oas-g4-card-[a-z0-9-]+$")
scene_id: str = Field(min_length=1, max_length=180)
coach_claim: str = Field(min_length=10, max_length=800)
evidence_refs: tuple[PracticeEvidenceRef, ...] = Field(min_length=1)
source_refs: tuple[str, ...] = Field(min_length=1)
uncertainty: float = Field(ge=0.0, le=1.0)
counterevidence: tuple[str, ...] = ()
targets: tuple[PracticeTargetSpec, ...] = Field(min_length=1, max_length=3)
@model_validator(mode="after")
def require_atomic_actionable_targets(self) -> "CoachingCard":
if any(item.scene_id != self.scene_id for item in self.evidence_refs):
raise ValueError("coaching card evidence must belong to its scene")
prescription_ids = [item.prescription_id for item in self.targets]
if len(set(prescription_ids)) != len(prescription_ids):
raise ValueError("coaching card prescription ids must be unique")
target_keys = [(item.competency_id, item.criterion_id) for item in self.targets]
if len(set(target_keys)) != len(target_keys):
raise ValueError("coaching card targets must be atomic and unique")
return self
class PracticePrescription(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
schema_version: Literal["vignette.practice-prescription.v1"] = (
"vignette.practice-prescription.v1"
)
event_name: Literal["practice.prescribed"] = "practice.prescribed"
prescription_id: str
coaching_card_id: str
scene_id: str
competency_id: str
criterion_id: str
observable_behavior: str
activity: PracticeActivity
can_launch: Literal[True] = True
evidence_refs: tuple[PracticeEvidenceRef, ...] = Field(min_length=1)
source_refs: tuple[str, ...] = Field(min_length=1)
uncertainty: float = Field(ge=0.0, le=1.0)
counterevidence: tuple[str, ...] = ()
class CriterionObservation(BaseModel):
"""시도의 관찰 결과. 오류와 미관찰을 성공값으로 보간하지 않는다."""
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
criterion_id: str = Field(pattern=r"^criterion\.[a-z0-9_.-]+$")
status: CriterionStatus
source_kind: SourceKind
perspective: MeasurementPerspective
model_run_id: UUID | None = None
evidence_refs: tuple[PracticeEvidenceRef, ...] = ()
counterevidence: tuple[str, ...] = ()
uncertainty: float = Field(ge=0.0, le=1.0)
error_code: str | None = Field(default=None, max_length=120)
@model_validator(mode="after")
def preserve_observation_truth(self) -> "CriterionObservation":
if self.perspective not in SOURCE_PERSPECTIVE_COMPATIBILITY[self.source_kind]:
raise ValueError("practice observation mixes source and perspective layers")
if (
self.source_kind in {"model_inferred", "agent_reported"}
and self.model_run_id is None
):
raise ValueError("model/agent practice observation requires model_run_id")
if self.status == "observed":
if not self.evidence_refs:
raise ValueError("observed practice criterion requires evidence")
if self.error_code:
raise ValueError("observed practice criterion cannot carry error_code")
elif self.status == "not_observed":
if not self.counterevidence:
raise ValueError(
"not-observed practice criterion requires counterevidence"
)
if self.error_code:
raise ValueError(
"not-observed practice criterion cannot carry error_code"
)
else:
if not self.error_code or self.uncertainty != 1.0:
raise ValueError(
"error practice criterion requires error_code and maximum uncertainty"
)
if len({(item.ref_id, item.kind) for item in self.evidence_refs}) != len(
self.evidence_refs
):
raise ValueError("practice criterion evidence refs must be unique")
return self
class PracticeAttemptObservation(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
attempt_id: str = Field(pattern=r"^oas-g4-attempt-[a-z0-9-]+$")
prescription_id: str = Field(pattern=r"^oas-g4-practice-[a-z0-9-]+$")
competency_id: str = Field(pattern=r"^competency\.[a-z0-9_.-]+$")
sequence_no: int = Field(ge=1)
scenario_variant_id: str = Field(min_length=1, max_length=180)
scenario_novelty: ScenarioNovelty
difficulty_level: int = Field(ge=1, le=5)
criterion: CriterionObservation
client_response: ClientPracticeResponse | None = None
evidence_refs: tuple[PracticeEvidenceRef, ...] = ()
uncertainty: float = Field(ge=0.0, le=1.0)
counterevidence: tuple[str, ...] = ()
utterance_template_id: str | None = Field(default=None, max_length=180)
learner_claimed_success: bool = False
error_code: str | None = Field(default=None, max_length=120)
@model_validator(mode="after")
def require_behavior_and_impact_evidence(self) -> "PracticeAttemptObservation":
refs = (*self.evidence_refs, *self.criterion.evidence_refs)
if len({(item.ref_id, item.kind) for item in refs}) != len(refs):
raise ValueError("practice attempt evidence refs must be unique")
if self.criterion.status == "error":
if (
not self.error_code
or self.client_response is not None
or self.uncertainty != 1.0
):
raise ValueError(
"error practice attempt must remain impact-free with maximum uncertainty"
)
return self
if self.error_code:
raise ValueError("ready practice attempt cannot carry error_code")
kinds = {item.kind for item in refs}
if "learner_behavior" not in kinds or "client_response" not in kinds:
raise ValueError(
"ready practice attempt requires learner behavior and client response evidence"
)
if self.client_response is None:
raise ValueError("ready practice attempt requires observed client response")
return self
class PracticeEpisodeInput(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
episode_id: str = Field(pattern=r"^oas-g4-episode-[a-z0-9-]+$")
prescription_id: str = Field(pattern=r"^oas-g4-practice-[a-z0-9-]+$")
attempts: tuple[PracticeAttemptObservation, ...] = Field(min_length=1)
@model_validator(mode="after")
def require_ordered_attempt_history(self) -> "PracticeEpisodeInput":
if any(item.prescription_id != self.prescription_id for item in self.attempts):
raise ValueError(
"practice episode attempts must reference one prescription"
)
sequences = [item.sequence_no for item in self.attempts]
if sequences != list(range(1, len(sequences) + 1)):
raise ValueError("practice attempts must have contiguous sequence numbers")
ids = [item.attempt_id for item in self.attempts]
if len(set(ids)) != len(ids):
raise ValueError("practice attempt ids must be unique")
return self
class PracticeAttemptAssessment(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
attempt_id: str
outcome: AttemptOutcome
criterion_status: CriterionStatus
client_response: ClientPracticeResponse | None
scenario_novelty: ScenarioNovelty
scenario_variant_id: str
difficulty_level: int = Field(ge=1, le=5)
utterance_template_id: str | None
uncertainty: float = Field(ge=0.0, le=1.0)
evidence_refs: tuple[PracticeEvidenceRef, ...]
counterevidence: tuple[str, ...]
class BeforeAfterComparison(BaseModel):
"""총점 차이가 아니라 동일 기준의 전후 관찰과 근거를 보존한다."""
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
criterion_id: str
before_attempt_id: str
after_attempt_id: str
change: Literal["improved", "unchanged", "regressed", "inconclusive"]
before_status: CriterionStatus
after_status: CriterionStatus
before_evidence_refs: tuple[PracticeEvidenceRef, ...]
after_evidence_refs: tuple[PracticeEvidenceRef, ...]
uncertainty: float = Field(ge=0.0, le=1.0)
counterevidence: tuple[str, ...]
class PracticeEpisodeAssessment(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
schema_version: Literal["vignette.practice-episode-assessment.v1"] = (
"vignette.practice-episode-assessment.v1"
)
event_names: tuple[
Literal["practice.attempted", "practice.mastered", "transfer.verified"], ...
]
episode_id: str
prescription_id: str
competency_id: str
attempts: tuple[PracticeAttemptAssessment, ...]
comparison: BeforeAfterComparison
prior_familiar_demonstrations: int = Field(default=0, ge=0)
progress: PracticeProgress
mastery_allowed: bool
mastery_blockers: tuple[str, ...]
uncertainty: float = Field(ge=0.0, le=1.0)
evidence_refs: tuple[PracticeEvidenceRef, ...]
counterevidence: tuple[str, ...]
@model_validator(mode="after")
def enforce_transfer_before_mastery(self) -> "PracticeEpisodeAssessment":
if self.progress == "mastered":
if not self.mastery_allowed or "transfer.verified" not in self.event_names:
raise ValueError("mastery requires an explicit transfer.verified event")
has_familiar_basis = self.prior_familiar_demonstrations > 0 or any(
item.outcome == "passed" and item.scenario_novelty == "familiar"
for item in self.attempts
)
if not has_familiar_basis:
raise ValueError(
"mastery requires current or prior familiar demonstration evidence"
)
if not any(
item.outcome == "passed" and item.scenario_novelty == "unseen_transfer"
for item in self.attempts
):
raise ValueError("mastery requires passed unseen transfer evidence")
elif self.mastery_allowed:
raise ValueError("non-mastered practice cannot allow mastery")
return self
class CompetencyDefinition(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
competency_id: str = Field(pattern=r"^competency\.[a-z0-9_.-]+$")
label_ko: str = Field(min_length=1, max_length=120)
description: str = Field(min_length=10, max_length=500)
prerequisite_ids: tuple[str, ...] = ()
class CompetencyState(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
competency_id: str = Field(pattern=r"^competency\.[a-z0-9_.-]+$")
band: CompetencyBand
forgetting_risk: float = Field(ge=0.0, le=1.0)
uncertainty: float = Field(ge=0.0, le=1.0)
attempt_count: int = Field(ge=0)
familiar_demonstrations: int = Field(ge=0)
unseen_transfer_demonstrations: int = Field(ge=0)
highest_familiar_difficulty: int = Field(ge=0, le=5)
evidence_refs: tuple[PracticeEvidenceRef, ...] = ()
counterevidence: tuple[str, ...] = ()
@model_validator(mode="after")
def prevent_unverified_mastery(self) -> "CompetencyState":
if (
self.familiar_demonstrations + self.unseen_transfer_demonstrations
> self.attempt_count
):
raise ValueError("competency demonstrations cannot exceed attempt count")
if self.band == "unassessed" and self.attempt_count:
raise ValueError("attempted competency cannot remain unassessed")
if self.band == "consistent_local" and self.familiar_demonstrations < 1:
raise ValueError(
"consistent_local requires familiar demonstration evidence"
)
if self.band == "transfer_verified":
if self.unseen_transfer_demonstrations < 1 or not self.evidence_refs:
raise ValueError("transfer_verified requires unseen transfer evidence")
elif self.unseen_transfer_demonstrations:
raise ValueError(
"unseen transfer demonstration must promote transfer_verified"
)
return self
class CompetencyGraph(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
schema_version: Literal["vignette.competency-graph.v1"] = (
"vignette.competency-graph.v1"
)
definitions: tuple[CompetencyDefinition, ...] = Field(min_length=1)
states: tuple[CompetencyState, ...] = Field(min_length=1)
@model_validator(mode="after")
def require_complete_acyclic_graph(self) -> "CompetencyGraph":
definitions = {item.competency_id: item for item in self.definitions}
states = {item.competency_id: item for item in self.states}
if len(definitions) != len(self.definitions) or len(states) != len(self.states):
raise ValueError("competency graph ids must be unique")
if definitions.keys() != states.keys():
raise ValueError(
"competency graph requires exactly one state per definition"
)
if any(
prerequisite not in definitions
for item in self.definitions
for prerequisite in item.prerequisite_ids
):
raise ValueError("competency prerequisite must exist in graph")
visiting: set[str] = set()
visited: set[str] = set()
def visit(competency_id: str) -> None:
if competency_id in visiting:
raise ValueError("competency graph prerequisites must be acyclic")
if competency_id in visited:
return
visiting.add(competency_id)
for prerequisite in definitions[competency_id].prerequisite_ids:
visit(prerequisite)
visiting.remove(competency_id)
visited.add(competency_id)
for competency_id in definitions:
visit(competency_id)
return self
class CurriculumDecision(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
schema_version: Literal["vignette.curriculum-decision.v1"] = (
"vignette.curriculum-decision.v1"
)
selected_prescription_id: str
competency_id: str
competency_band: CompetencyBand
forgetting_risk: float = Field(ge=0.0, le=1.0)
mode: PracticeMode
selection_basis: tuple[str, ...] = Field(min_length=2)
deferred_prescription_ids: tuple[str, ...]
blocked_prescription_reasons: tuple[str, ...]
class PracticeBenchmarkExpectation(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
episode_progress: tuple[PracticeProgress, ...]
final_competency_id: str
final_band: CompetencyBand
selected_prescription_id: str
class PracticeBenchmarkCase(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
case_id: str = Field(pattern=r"^oas-g4-bench-[0-9]{3}$")
title_ko: str = Field(min_length=1, max_length=200)
coaching_cards: tuple[CoachingCard, ...] = Field(min_length=1)
graph: CompetencyGraph
episodes: tuple[PracticeEpisodeInput, ...] = ()
expected: PracticeBenchmarkExpectation
tags: tuple[str, ...] = ()
forbidden_claims: tuple[str, ...] = Field(min_length=1)
class PracticeBenchmarkPack(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())
schema_version: Literal["vignette.deliberate-practice-benchmark.v1"] = (
"vignette.deliberate-practice-benchmark.v1"
)
data_classification: Literal["synthetic_educational"] = "synthetic_educational"
clinical_claim_allowed: Literal[False] = False
version: str = Field(pattern=r"^[0-9]+\.[0-9]+\.[0-9]+$")
cases: tuple[PracticeBenchmarkCase, ...] = Field(min_length=1)
@model_validator(mode="after")
def require_adversarial_and_mode_coverage(self) -> "PracticeBenchmarkPack":
case_ids = [item.case_id for item in self.cases]
if len(set(case_ids)) != len(case_ids):
raise ValueError("practice benchmark case ids must be unique")
modes = {
target.activity.mode
for case in self.cases
for card in case.coaching_cards
for target in card.targets
}
if modes != set(PRACTICE_MODES):
raise ValueError("practice benchmark must cover every practice mode")
required_tags = {
"reward_hacking",
"easy_repeat_hacking",
"memorized_phrase_hacking",
"unseen_transfer_gate",
}
tags = {tag for case in self.cases for tag in case.tags}
if not required_tags.issubset(tags):
raise ValueError("practice benchmark lacks required adversarial coverage")
return self
__all__ = [
"AttemptOutcome",
"BeforeAfterComparison",
"BranchActivity",
"COMPETENCY_BANDS",
"ClientPracticeResponse",
"CoachingCard",
"CompetencyBand",
"CompetencyDefinition",
"CompetencyGraph",
"CompetencyState",
"ConstrainedResponseActivity",
"CriterionObservation",
"CriterionStatus",
"CurriculumDecision",
"DifficultyLadderActivity",
"DifficultyStep",
"EvidenceKind",
"PRACTICE_MODES",
"PracticeActivity",
"PracticeAttemptAssessment",
"PracticeAttemptObservation",
"PracticeBenchmarkCase",
"PracticeBenchmarkExpectation",
"PracticeBenchmarkPack",
"PracticeEpisodeAssessment",
"PracticeEpisodeInput",
"PracticeEvidenceRef",
"PracticeMode",
"PracticePrescription",
"PracticeProgress",
"PracticeTargetSpec",
"ReplayActivity",
"ScenarioNovelty",
"VoiceRetryActivity",
]