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