vignette/apps/api/app/services/calibration_transfer.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

636 lines
24 KiB
Python

"""G5 역량별 자기보정·미지 사례 전이·합성 subgroup drift 코어."""
from __future__ import annotations
import json
import math
from collections import defaultdict
from pathlib import Path
from statistics import mean, stdev
from typing import Iterable
from ..contracts.calibration_transfer import (
ActualTransferAssessment,
ActualTransferExecution,
CalibrationBlockInput,
CalibrationPair,
CalibrationTransferBenchmarkPack,
CompetencyCalibrationAssessment,
ConfidenceInterval,
MetacognitivePrescription,
SubgroupDriftReport,
SyntheticSubgroupResult,
TransferAssessment,
TransferSuiteInput,
TransferTrial,
)
MIN_CALIBRATION_PAIRS = 3
MIN_IMPROVEMENT_PAIRS = 4
MIN_TRANSFER_TRIALS = 4
MIN_ACTUAL_TRANSFER_EXECUTIONS = 4
MIN_SUBGROUP_SAMPLES = 3
TRANSFER_TARGET_RATE = 0.85
DRIFT_GAP_THRESHOLD = 0.20
def _bounded_normal_interval(values: list[float]) -> ConfidenceInterval:
if len(values) < 2:
center = values[0]
return ConfidenceInterval(
method="normal_95_bounded", lower=center, upper=center
)
margin = 1.96 * stdev(values) / math.sqrt(len(values))
center = mean(values)
return ConfidenceInterval(
method="normal_95_bounded",
lower=max(0.0, center - margin),
upper=min(1.0, center + margin),
)
def _wilson_interval(successes: int, total: int) -> ConfidenceInterval:
if total <= 0:
raise ValueError("Wilson interval requires observed trials")
z = 1.96
proportion = successes / total
denominator = 1 + z * z / total
center = (proportion + z * z / (2 * total)) / denominator
margin = (
z
* math.sqrt(proportion * (1 - proportion) / total + z * z / (4 * total * total))
/ denominator
)
return ConfidenceInterval(
method="wilson_95",
lower=max(0.0, center - margin),
upper=min(1.0, center + margin),
)
def assess_calibration(
blocks: Iterable[CalibrationBlockInput],
) -> tuple[CompetencyCalibrationAssessment, ...]:
"""잠긴 예측과 독립 관찰을 역량별로 대조한다. 총점은 만들지 않는다."""
grouped: dict[str, list[CalibrationBlockInput]] = defaultdict(list)
seen_blocks: set[str] = set()
for block in sorted(blocks, key=lambda item: item.block_sequence):
block_id = block.observation.practice_block_id
if block_id in seen_blocks:
raise ValueError(f"duplicate calibration practice block: {block_id}")
seen_blocks.add(block_id)
grouped[block.observation.competency_id].append(block)
output: list[CompetencyCalibrationAssessment] = []
for competency_id in sorted(grouped):
pairs: list[CalibrationPair] = []
excluded: list[str] = []
for block in grouped[competency_id]:
observation = block.observation
if observation.status == "insufficient_evidence":
excluded.append(observation.practice_block_id)
continue
prediction = block.prediction_history.locked_prediction
observed_success = observation.status == "passed"
signed_error = prediction.predicted_success_probability - float(
observed_success
)
pairs.append(
CalibrationPair(
practice_block_id=observation.practice_block_id,
prediction_id=prediction.prediction_id,
observation_id=observation.observation_id,
predicted_success_probability=(
prediction.predicted_success_probability
),
observed_success=observed_success,
signed_error=signed_error,
absolute_error=abs(signed_error),
confidence=prediction.confidence,
evidence_refs=observation.evidence_refs,
)
)
if len(pairs) < MIN_CALIBRATION_PAIRS:
output.append(
CompetencyCalibrationAssessment(
competency_id=competency_id,
pair_count=len(pairs),
bias="insufficient_evidence",
improvement="insufficient_evidence",
pairs=tuple(pairs),
excluded_block_ids=tuple(excluded),
counterevidence=(
f"calibration_pairs_below_minimum:{len(pairs)}/{MIN_CALIBRATION_PAIRS}",
),
)
)
continue
absolute_errors = [item.absolute_error for item in pairs]
signed_errors = [item.signed_error for item in pairs]
mean_absolute_error = mean(absolute_errors)
mean_signed_error = mean(signed_errors)
if mean_signed_error > 0.10:
bias = "overconfident"
elif mean_signed_error < -0.10:
bias = "underconfident"
else:
bias = "aligned"
baseline_error = None
recent_error = None
improvement = "insufficient_evidence"
counterevidence: list[str] = []
if len(pairs) >= MIN_IMPROVEMENT_PAIRS:
midpoint = len(pairs) // 2
baseline_error = mean(item.absolute_error for item in pairs[:midpoint])
recent_error = mean(item.absolute_error for item in pairs[midpoint:])
improvement = (
"improved" if recent_error <= baseline_error - 0.05 else "not_improved"
)
if improvement == "not_improved":
counterevidence.append("recent_calibration_error_did_not_decrease")
else:
counterevidence.append(
f"improvement_pairs_below_minimum:{len(pairs)}/{MIN_IMPROVEMENT_PAIRS}"
)
output.append(
CompetencyCalibrationAssessment(
competency_id=competency_id,
pair_count=len(pairs),
mean_absolute_error=mean_absolute_error,
mean_signed_error=mean_signed_error,
error_interval=_bounded_normal_interval(absolute_errors),
bias=bias,
baseline_error=baseline_error,
recent_error=recent_error,
improvement=improvement,
pairs=tuple(pairs),
excluded_block_ids=tuple(excluded),
counterevidence=tuple(counterevidence),
)
)
return tuple(output)
def prescribe_metacognitive_practice(
assessment: CompetencyCalibrationAssessment,
) -> MetacognitivePrescription:
if assessment.bias == "overconfident":
return MetacognitivePrescription(
competency_id=assessment.competency_id,
bias=assessment.bias,
practice_mode="counterevidence_forecast",
instruction_ko=(
"성공을 예측하기 전에 실패할 수 있는 장면 근거 두 가지를 먼저 적고, "
"그 근거를 반영해 성공 확률 범위를 다시 잠가라."
),
completion_evidence=(
"two_counterevidence_refs",
"revised_probability_range_before_reveal",
),
)
if assessment.bias == "underconfident":
return MetacognitivePrescription(
competency_id=assessment.competency_id,
bias=assessment.bias,
practice_mode="evidence_recall",
instruction_ko=(
"성공한 미지 장면의 행동 근거와 내담자 반응을 각각 하나씩 회상한 뒤, "
"그 근거만으로 다음 성공 확률을 잠가라."
),
completion_evidence=(
"learner_behavior_evidence_ref",
"client_response_evidence_ref",
),
)
if assessment.bias == "aligned":
return MetacognitivePrescription(
competency_id=assessment.competency_id,
bias=assessment.bias,
practice_mode="uncertainty_range",
instruction_ko=(
"단일 확신값 대신 성공 가능 범위와 그 범위를 넓히는 불확실성 근거를 "
"먼저 기록하고 외부평가 공개 전 잠가라."
),
completion_evidence=("probability_interval", "uncertainty_evidence_ref"),
)
return MetacognitivePrescription(
competency_id=assessment.competency_id,
bias=assessment.bias,
practice_mode="collect_more_evidence",
instruction_ko=(
"현재는 자기보정 판정에 필요한 독립 관찰이 부족하다. 같은 역량의 새 장면을 "
"최소 세 번 수행하고 각 예측을 외부평가 전에 잠가라."
),
completion_evidence=(
"three_locked_predictions",
"three_independent_observations",
),
)
def _coverage(trials: list[TransferTrial]) -> dict[str, int]:
return {
"contexts": len({item.variation.context_variant for item in trials}),
"relationship_styles": len(
{item.variation.relationship_style for item in trials}
),
"difficulty_levels": len({item.variation.difficulty_level for item in trials}),
"expression_variants": len(
{item.variation.expression_variant for item in trials}
),
"scenario_families": len(
{item.variation.scenario_family_id for item in trials}
),
"synthetic_subgroups": len(
{item.variation.synthetic_subgroup for item in trials}
),
}
def assess_transfer(
suite: TransferSuiteInput,
) -> tuple[TransferAssessment, ...]:
grouped: dict[str, list[TransferTrial]] = defaultdict(list)
for trial in suite.trials:
grouped[trial.competency_id].append(trial)
output: list[TransferAssessment] = []
training_phrases = set(suite.training_phrase_family_ids)
for competency_id in sorted(grouped):
trials = grouped[competency_id]
observed = [item for item in trials if item.status != "insufficient_evidence"]
successes = sum(item.status == "passed" for item in observed)
success_rate = successes / len(observed) if observed else None
coverage = _coverage(observed)
blockers: list[str] = []
if len(observed) < MIN_TRANSFER_TRIALS:
blockers.append(
f"observed_trials_below_minimum:{len(observed)}/{MIN_TRANSFER_TRIALS}"
)
for dimension in (
"contexts",
"relationship_styles",
"difficulty_levels",
"expression_variants",
"scenario_families",
):
if coverage[dimension] < 2:
blockers.append(f"transfer_coverage_missing:{dimension}")
reused = sorted(
{
item.variation.phrase_family_id
for item in observed
if item.variation.phrase_family_id in training_phrases
}
)
if reused:
blockers.append("memorized_training_phrase_reused")
if any(item.uncertainty > 0.5 for item in observed):
blockers.append("transfer_trial_uncertainty_above_boundary")
eligible = not blockers
transfer_verified = bool(
eligible
and success_rate is not None
and success_rate >= TRANSFER_TARGET_RATE
)
if eligible and not transfer_verified:
blockers.append(
f"transfer_success_rate_below_target:{success_rate:.3f}/{TRANSFER_TARGET_RATE:.2f}"
)
output.append(
TransferAssessment(
competency_id=competency_id,
trial_count=len(trials),
observed_trial_count=len(observed),
success_rate=success_rate,
success_interval=(
_wilson_interval(successes, len(observed)) if observed else None
),
coverage=coverage,
eligible=eligible,
transfer_verified=transfer_verified,
blockers=tuple(blockers),
evidence_refs=tuple(
dict.fromkeys(
ref for item in observed for ref in item.evidence_refs
)
),
counterevidence=tuple(
dict.fromkeys(
ref for item in observed for ref in item.counterevidence
)
),
)
)
return tuple(output)
def _actual_coverage(
executions: list[ActualTransferExecution],
) -> dict[str, int]:
return {
"contexts": len({item.variation.context_variant for item in executions}),
"relationship_styles": len(
{item.variation.relationship_style for item in executions}
),
"difficulty_levels": len(
{item.variation.difficulty_level for item in executions}
),
"expression_variants": len(
{item.variation.expression_variant for item in executions}
),
"scenario_families": len(
{item.variation.scenario_family_id for item in executions}
),
"synthetic_subgroups": len(
{item.variation.synthetic_subgroup for item in executions}
),
"phrase_families": len(
{item.variation.phrase_family_id for item in executions}
),
}
def assess_actual_transfer_executions(
executions: Iterable[ActualTransferExecution],
) -> tuple[ActualTransferAssessment, ...]:
"""실제 회기 원장을 역량별로 집계한다.
같은 phrase family의 반복은 최신 실행 하나만 독립 표본으로 인정한다. 합성
benchmark 결과와 섞지 않으며, 훈련 phrase 충돌은 보존하되 전이 분류를 막는다.
"""
grouped: dict[str, list[ActualTransferExecution]] = defaultdict(list)
for execution in executions:
grouped[execution.competency_id].append(execution)
output: list[ActualTransferAssessment] = []
for competency_id in sorted(grouped):
all_items = sorted(
grouped[competency_id],
key=lambda item: (item.created_at, str(item.execution_event_id)),
)
latest_by_phrase: dict[str, ActualTransferExecution] = {}
for item in all_items:
latest_by_phrase[item.variation.phrase_family_id] = item
independent = sorted(
latest_by_phrase.values(),
key=lambda item: (item.created_at, str(item.execution_event_id)),
)
observed = [
item
for item in independent
if item.status != "insufficient_evidence"
and not item.training_phrase_collision
]
coverage = _actual_coverage(independent)
blockers: list[str] = []
if len(independent) < MIN_ACTUAL_TRANSFER_EXECUTIONS:
blockers.append(
"actual_independent_phrase_families_below_minimum:"
f"{len(independent)}/{MIN_ACTUAL_TRANSFER_EXECUTIONS}"
)
if len(observed) < MIN_ACTUAL_TRANSFER_EXECUTIONS:
blockers.append(
"actual_observed_executions_below_minimum:"
f"{len(observed)}/{MIN_ACTUAL_TRANSFER_EXECUTIONS}"
)
for dimension in (
"contexts",
"relationship_styles",
"difficulty_levels",
"expression_variants",
"scenario_families",
):
if coverage[dimension] < 2:
blockers.append(f"actual_transfer_coverage_missing:{dimension}")
if any(item.training_phrase_collision for item in all_items):
blockers.append("training_phrase_family_reused")
if any(item.uncertainty > 0.5 for item in observed):
blockers.append("actual_transfer_uncertainty_above_boundary")
successes = sum(item.status == "passed" for item in observed)
success_rate = successes / len(observed) if observed else None
eligible = not blockers
if not eligible:
status = "insufficient_evidence"
elif success_rate is not None and success_rate >= TRANSFER_TARGET_RATE:
status = "verified"
else:
status = "not_verified"
blockers.append(
"actual_transfer_success_rate_below_target:"
f"{success_rate:.3f}/{TRANSFER_TARGET_RATE:.2f}"
)
output.append(
ActualTransferAssessment(
competency_id=competency_id,
execution_count=len(all_items),
independent_execution_count=len(independent),
observed_execution_count=len(observed),
success_rate=success_rate,
success_interval=(
_wilson_interval(successes, len(observed)) if observed else None
),
coverage=coverage,
phrase_family_collision_count=len(all_items) - len(independent),
eligible=eligible,
actual_transfer_status=status,
blockers=tuple(blockers),
source_execution_event_ids=tuple(
item.execution_event_id for item in all_items
),
evidence_turn_ids=tuple(
dict.fromkeys(
turn_id for item in observed for turn_id in item.evidence_turn_ids
)
),
)
)
return tuple(output)
def assess_synthetic_subgroup_drift(
suite: TransferSuiteInput,
) -> tuple[SubgroupDriftReport, ...]:
by_competency: dict[str, list[TransferTrial]] = defaultdict(list)
for trial in suite.trials:
by_competency[trial.competency_id].append(trial)
reports: list[SubgroupDriftReport] = []
for competency_id in sorted(by_competency):
by_group: dict[str, list[TransferTrial]] = defaultdict(list)
for trial in by_competency[competency_id]:
if trial.status != "insufficient_evidence":
by_group[trial.variation.synthetic_subgroup].append(trial)
results: list[SyntheticSubgroupResult] = []
eligible_rates: dict[str, float] = {}
for subgroup in sorted(by_group):
items = by_group[subgroup]
successes = sum(item.status == "passed" for item in items)
rate = successes / len(items) if items else None
results.append(
SyntheticSubgroupResult(
subgroup=subgroup,
observed_count=len(items),
success_rate=rate,
interval=(
_wilson_interval(successes, len(items)) if items else None
),
)
)
if len(items) >= MIN_SUBGROUP_SAMPLES and rate is not None:
eligible_rates[subgroup] = rate
if len(eligible_rates) < 2:
reports.append(
SubgroupDriftReport(
competency_id=competency_id,
status="insufficient_evidence",
compared_subgroups=tuple(sorted(eligible_rates)),
subgroup_results=tuple(results),
threshold=DRIFT_GAP_THRESHOLD,
notice_ko=(
"교육용 합성 subgroup별 관측이 충분하지 않아 평가 드리프트를 "
"판정하지 않는다. 실제 인구집단 성능 주장이 아니다."
),
)
)
continue
gap = max(eligible_rates.values()) - min(eligible_rates.values())
reports.append(
SubgroupDriftReport(
competency_id=competency_id,
status=("drift_flagged" if gap > DRIFT_GAP_THRESHOLD else "stable"),
max_rate_gap=gap,
compared_subgroups=tuple(sorted(eligible_rates)),
subgroup_results=tuple(results),
threshold=DRIFT_GAP_THRESHOLD,
notice_ko=(
"이 차이는 교육용 합성 시나리오의 평가 민감도 신호이며 실제 인구집단의 "
"능력·위험·임상 결과 차이를 뜻하지 않는다."
),
)
)
return tuple(reports)
def load_calibration_transfer_benchmark(
path: str | Path,
) -> CalibrationTransferBenchmarkPack:
return CalibrationTransferBenchmarkPack.model_validate_json(
Path(path).read_text(encoding="utf-8")
)
def evaluate_calibration_transfer_benchmark(
pack: CalibrationTransferBenchmarkPack,
) -> dict[str, object]:
case_results: list[dict[str, object]] = []
expected_count = 0
correct_count = 0
contamination_rejections = 0
memorized_false_verifications = 0
for case in pack.cases:
calibration = {
item.competency_id: item
for item in assess_calibration(case.calibration_blocks)
}
transfer = (
{item.competency_id: item for item in assess_transfer(case.transfer_suite)}
if case.transfer_suite
else {}
)
drift = (
{
item.competency_id: item
for item in assess_synthetic_subgroup_drift(case.transfer_suite)
}
if case.transfer_suite
else {}
)
checks: list[bool] = []
for competency_id, expected in case.expected.calibration_improved.items():
actual = calibration[competency_id].improvement == "improved"
checks.append(actual == expected)
for competency_id, expected in case.expected.transfer_verified.items():
actual = transfer[competency_id].transfer_verified
checks.append(actual == expected)
for competency_id, expected in case.expected.drift_status.items():
actual = drift[competency_id].status
checks.append(actual == expected)
expected_count += len(checks)
correct_count += sum(checks)
if "post_reveal_contamination" in case.tags:
contamination_rejections += 1
if "memorized_phrase_transfer" in case.tags and any(
item.transfer_verified for item in transfer.values()
):
memorized_false_verifications += 1
case_results.append(
{
"case_id": case.case_id,
"checks": checks,
"calibration": {
key: value.model_dump(mode="json")
for key, value in calibration.items()
},
"transfer": {
key: value.model_dump(mode="json")
for key, value in transfer.items()
},
"drift": {
key: value.model_dump(mode="json") for key, value in drift.items()
},
}
)
return {
"schema_version": "vignette.calibration-transfer-benchmark-report.v1",
"benchmark_version": pack.version,
"data_classification": pack.data_classification,
"clinical_claim_allowed": pack.clinical_claim_allowed,
"expectation_accuracy": (
correct_count / expected_count if expected_count else 1.0
),
"post_reveal_contamination_rejections": contamination_rejections,
"memorized_phrase_false_verifications": memorized_false_verifications,
"cases": case_results,
}
def render_calibration_transfer_benchmark_report(report: dict[str, object]) -> str:
return json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
__all__ = [
"DRIFT_GAP_THRESHOLD",
"MIN_CALIBRATION_PAIRS",
"MIN_IMPROVEMENT_PAIRS",
"MIN_SUBGROUP_SAMPLES",
"MIN_TRANSFER_TRIALS",
"MIN_ACTUAL_TRANSFER_EXECUTIONS",
"TRANSFER_TARGET_RATE",
"assess_calibration",
"assess_actual_transfer_executions",
"assess_synthetic_subgroup_drift",
"assess_transfer",
"evaluate_calibration_transfer_benchmark",
"load_calibration_transfer_benchmark",
"prescribe_metacognitive_practice",
"render_calibration_transfer_benchmark_report",
]