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 산출물은 커밋에서 제외했다.
This commit is contained in:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -0,0 +1,660 @@
"""Deterministic hidden behavior opportunities for G3 rupture/repair practice.
The director does not script a learner-visible answer. It occasionally gives the
client agent one conditional, autonomy-preserving behavior cue. Taxonomy labels,
selection criteria, IDs, and provenance stay outside the model messages and are
available only as request metadata.
"""
from __future__ import annotations
import hashlib
import math
import re
from dataclasses import dataclass
from typing import Any, Mapping, Sequence
from uuid import UUID, uuid5
from .. import db
from ..contracts.outcome_trajectory import (
RELATIONSHIP_EVENT_TYPES,
TRAJECTORY_STATUSES,
)
from ..contracts.rupture_repair import RUPTURE_TYPES, RuptureType
SCENARIO_DIRECTOR_VERSION = "2.0.0"
SCENARIO_TAXONOMY_SOURCE = "contracts.rupture_repair.RUPTURE_TYPES"
SCENARIO_SELECTOR = "stored-context-gap-cycle-v2"
_SCENARIO_NAMESPACE = UUID("eef69e4d-7060-5c10-a7e1-8448569815c9")
_MIN_GAP = 4
_GAP_VARIANTS = (4, 5, 6)
_COPRIME_STEPS = (1, 2, 4, 5, 7, 8)
_COMPETENCY_ID_RE = re.compile(r"^competency\.[a-z0-9_.-]+$")
_WEAK_COMPETENCY_BANDS = {"fragile": 0, "developing": 1}
_COMPETENCY_RUPTURE_TYPES: dict[str, tuple[RuptureType, ...]] = {
"competency.empathic_reflection": ("empathic_miss",),
"competency.open_question": ("premature_advice",),
"competency.rupture_repair": ("withdrawal", "confrontation"),
"competency.collaborative_goal": ("goal_mismatch",),
}
_RELATIONSHIP_RUPTURE_TYPES: dict[str, tuple[RuptureType, ...]] = {
"rupture_withdrawal": ("withdrawal",),
"rupture_confrontation": ("confrontation",),
"unresolved_rupture": ("withdrawal", "confrontation"),
}
_TRAJECTORY_RUPTURE_TYPES: dict[str, tuple[RuptureType, ...]] = {
"watch": ("empathic_miss",),
"off_track": ("goal_mismatch", "task_mismatch"),
"deteriorating": ("withdrawal", "confrontation"),
}
_CASE_ARC_RUPTURE_TYPES: dict[int, tuple[RuptureType, ...]] = {
1: ("goal_mismatch", "task_mismatch"),
2: ("empathic_miss", "premature_advice"),
3: ("task_mismatch", "cultural_miss"),
4: ("boundary_tension", "withdrawal"),
5: ("over_disclosure", "confrontation"),
}
_RUPTURE_STATES = {
"onset",
"recognized",
"repair_attempted",
"missed",
"partial",
"resolved",
}
# The strings are deliberately conditional. They preserve client agency and do
# not instruct the model to reward, punish, trap, shame, or force the learner.
_BEHAVIOR_CUES: dict[RuptureType, tuple[str, ...]] = {
"withdrawal": (
"상담자의 말이 지금 받아들이기 벅차다면, 평소 말투 범위에서 답을 조금 짧게 하고 잠시 생각할 시간을 둔다. 충분히 안전하다고 느끼면 다시 말할 여지를 남긴다.",
"지금 대화에서 마음이 닫히는 느낌이 든다면, 억지로 동의하지 말고 한두 문장으로만 반응한다. 상담자가 여유를 주면 자신의 속도로 다시 이어 간다.",
),
"confrontation": (
"상담자의 해석이 자신의 경험과 분명히 다르다면, 공격하지 말고 무엇이 다른지 짧고 단호하게 자기 관점으로 말한다.",
"상담자의 말에 실제로 납득되지 않는 부분이 있다면 공손한 순응으로 덮지 말고, 사실과 느낌이 다른 지점을 현실적인 말투로 짚는다.",
),
"goal_mismatch": (
"지금 대화가 자신에게 중요한 문제와 멀어졌다고 느껴질 때만, 당장 다루고 싶은 주제가 무엇인지 자기 말로 분명히 제안한다.",
"상담자가 잡은 대화의 방향이 자신의 바람과 다르다면 무조건 따라가지 말고, 이번 시간에 얻고 싶은 것을 자연스럽게 다시 말한다.",
),
"task_mismatch": (
"제안받은 활동이나 진행 방식이 자신에게 맞지 않는다고 느껴질 때만, 어려운 이유와 더 편한 진행 방식을 솔직하게 말한다.",
"지금 요구받은 방식이 부담스럽거나 어색하다면 억지로 수행하지 말고, 가능한 속도나 다른 방법이 있는지 내담자답게 묻는다.",
),
"empathic_miss": (
"상담자의 말이 자신의 감정 핵심과 빗나갔다고 느껴질 때만, '그런 뜻이라기보다…'처럼 실제로 다른 느낌을 조심스럽게 바로잡는다.",
"이해받았다는 느낌이 들지 않는다면 맞장구로 넘기지 말고, 놓친 감정이나 의미를 한 가지 구체적으로 덧붙인다.",
),
"cultural_miss": (
"자신의 가족·세대·성별·지역·종교 등 배경이 단순하게 일반화됐다고 느낄 때만, 자기 경험은 어떻게 다른지 구체적으로 말한다.",
"상담자의 전제가 자신의 생활 맥락과 맞지 않는다면 상대를 몰아세우지 말고, 그 맥락에서 중요한 차이를 자기 경험 중심으로 짚는다.",
),
"boundary_tension": (
"상담 관계의 역할, 연락, 비밀보장, 시간 같은 경계가 실제로 모호하게 느껴질 때만, 추측해서 따르지 말고 궁금함이나 불편함을 질문한다.",
"상담자와 어디까지 이야기하거나 기대해도 되는지 헷갈린다면, 불안을 숨기지 말고 확인이 필요한 한 가지를 현실적으로 묻는다.",
),
"premature_advice": (
"충분히 이해받기 전에 해결책이 먼저 나왔다고 느껴질 때만, 그 방법이 지금은 어렵다는 점이나 먼저 더 들어줬으면 하는 부분을 말한다.",
"조언이 자신의 상황보다 앞서 간다고 느껴진다면 억지로 수락하지 말고, 왜 바로 실행하기 어려운지 한 가지 현실적인 이유를 설명한다.",
),
"over_disclosure": (
"상담자의 개인 이야기가 자신의 이야기보다 중심이 됐다고 느껴질 때만, 자연스러운 거리감을 보이거나 대화를 자신의 경험으로 조심스럽게 돌린다.",
"상담자의 자기 이야기가 부담스럽거나 비교당하는 느낌을 줄 때만, 형식적으로 위로하지 말고 자신이 지금 말하고 싶은 경험을 다시 꺼낸다.",
),
}
if set(_BEHAVIOR_CUES) != set(
RUPTURE_TYPES
): # pragma: no cover - import-time SSOT guard
raise RuntimeError(
"scenario director behavior cues must cover the G3 rupture taxonomy"
)
_SCENARIO_ID_RE = re.compile(r"\bg3-scenario-[0-9a-f]{32}\b", re.IGNORECASE)
_INTERNAL_LEAKAGE_MARKERS = (
"scenario_id",
"scenario director",
"scenario_director",
"provenance",
"taxonomy_source",
"taxonomy_type",
"director_version",
"context_fingerprint",
"trajectory_status",
"case_session_no",
"competency.",
"weakness",
"rupture_type",
"rupture type",
"rupture taxonomy",
"평가기준",
"채점기준",
"정답 라벨",
"내부 상태",
"effective_openness",
"rapport_credit",
"ideation_stage",
"state_before",
"state_after",
"resistance:",
"저항 수치",
"핵심신념",
"자동적 사고",
"진단 차원",
"이 턴의 자연스러운 반응 단서",
)
@dataclass(frozen=True, slots=True)
class StoredScenarioContext:
"""Role-safe categorical projection of stored learning and case ledgers.
The context deliberately carries no transcript, model rationale, rubric answer,
evidence sentence, or role-private relationship summary. ``available=False``
is a fail-closed sentinel and must never produce a scenario directive.
"""
available: bool
fingerprint: str
weak_competency_ids: tuple[str, ...] = ()
unresolved_rupture_types: tuple[RuptureType, ...] = ()
relationship_event_types: tuple[str, ...] = ()
trajectory_status: str | None = None
case_session_no: int | None = None
@classmethod
def unavailable(cls) -> "StoredScenarioContext":
return cls(available=False, fingerprint="")
@classmethod
def from_stored_signals(
cls,
*,
weak_competency_ids: Sequence[str] = (),
unresolved_rupture_types: Sequence[str] = (),
relationship_event_types: Sequence[str] = (),
trajectory_status: str | None = None,
case_session_no: int | None = None,
) -> "StoredScenarioContext":
competencies = tuple(sorted(set(weak_competency_ids)))
if any(not _COMPETENCY_ID_RE.fullmatch(item) for item in competencies):
raise ValueError("invalid competency id in stored scenario context")
unresolved_values = tuple(sorted(set(unresolved_rupture_types)))
if any(item not in RUPTURE_TYPES for item in unresolved_values):
raise ValueError("invalid rupture type in stored scenario context")
relationship_values = tuple(sorted(set(relationship_event_types)))
if any(item not in RELATIONSHIP_EVENT_TYPES for item in relationship_values):
raise ValueError("invalid relationship event in stored scenario context")
if trajectory_status is not None and trajectory_status not in TRAJECTORY_STATUSES:
raise ValueError("invalid trajectory status in stored scenario context")
if case_session_no is not None and case_session_no < 1:
raise ValueError("case session number must be positive")
canonical = (
"|".join(competencies),
"|".join(unresolved_values),
"|".join(relationship_values),
trajectory_status or "none",
str(case_session_no or 0),
)
fingerprint = hashlib.sha256("\x1f".join(canonical).encode("utf-8")).hexdigest()
return cls(
available=True,
fingerprint=fingerprint,
weak_competency_ids=competencies,
unresolved_rupture_types=unresolved_values, # type: ignore[arg-type]
relationship_event_types=relationship_values,
trajectory_status=trajectory_status,
case_session_no=case_session_no,
)
@dataclass(frozen=True, slots=True)
class ScenarioDirective:
"""Internal-only selection result; never serialize this object to learner APIs."""
scenario_id: str
rupture_type: RuptureType
behavior_cue: str
turn_seq: int
opportunity_index: int
context_fingerprint: str
def request_metadata(self) -> dict[str, Any]:
"""Return provenance without including the behavior prompt itself."""
return {
"scenario_id": self.scenario_id,
"taxonomy_type": self.rupture_type,
"context_fingerprint": self.context_fingerprint,
"provenance": {
"director": "g3-rupture-scenario-director",
"version": SCENARIO_DIRECTOR_VERSION,
"selector": SCENARIO_SELECTOR,
"taxonomy_source": SCENARIO_TAXONOMY_SOURCE,
},
}
def _digest(*parts: object) -> bytes:
payload = "\x1f".join(str(part) for part in parts).encode("utf-8")
return hashlib.sha256(payload).digest()
def _stable_schedule(
case_id: str, session_id: str, context_fingerprint: str
) -> tuple[int, int, int]:
seed = _digest(
SCENARIO_DIRECTOR_VERSION,
case_id,
session_id,
context_fingerprint,
)
gap = _GAP_VARIANTS[seed[0] % len(_GAP_VARIANTS)]
first_turn = 3 + (seed[1] % gap)
type_offset = seed[2] % len(RUPTURE_TYPES)
return gap, first_turn, type_offset
def _dedupe_types(values: Sequence[RuptureType]) -> tuple[RuptureType, ...]:
return tuple(dict.fromkeys(values))
def _types_for_competency(competency_id: str) -> tuple[RuptureType, ...]:
explicit = _COMPETENCY_RUPTURE_TYPES.get(competency_id)
if explicit is not None:
return explicit
token_map: tuple[tuple[str, tuple[RuptureType, ...]], ...] = (
("empath", ("empathic_miss",)),
("reflect", ("empathic_miss",)),
("goal", ("goal_mismatch",)),
("task", ("task_mismatch",)),
("cultur", ("cultural_miss",)),
("bound", ("boundary_tension",)),
("advice", ("premature_advice",)),
("disclos", ("over_disclosure",)),
("repair", ("withdrawal", "confrontation")),
)
lowered = competency_id.casefold()
for token, rupture_types in token_map:
if token in lowered:
return rupture_types
return ()
def _candidate_types(context: StoredScenarioContext) -> tuple[RuptureType, ...]:
# An unresolved rupture is the strongest continuity signal. Lower-priority
# context still participates in the fingerprint, so any ledger change creates
# a new deterministic schedule and scenario identity.
if context.unresolved_rupture_types:
return context.unresolved_rupture_types
competency_types = _dedupe_types(
tuple(
rupture_type
for competency_id in context.weak_competency_ids
for rupture_type in _types_for_competency(competency_id)
)
)
if competency_types:
return competency_types
relationship_types = _dedupe_types(
tuple(
rupture_type
for event_type in context.relationship_event_types
for rupture_type in _RELATIONSHIP_RUPTURE_TYPES.get(event_type, ())
)
)
if relationship_types:
return relationship_types
if context.trajectory_status is not None:
trajectory_types = _TRAJECTORY_RUPTURE_TYPES.get(context.trajectory_status, ())
if trajectory_types:
return trajectory_types
if context.case_session_no is not None:
arc_types = _CASE_ARC_RUPTURE_TYPES.get(
min(context.case_session_no, max(_CASE_ARC_RUPTURE_TYPES)),
(),
)
if arc_types:
return arc_types
return RUPTURE_TYPES # type: ignore[return-value]
def _stable_step(seed: bytes, candidate_count: int) -> int:
if candidate_count <= 1:
return 1
steps = tuple(
step for step in _COPRIME_STEPS if math.gcd(step, candidate_count) == 1
)
return steps[seed[0] % len(steps)]
def select_scenario_directive(
*,
case_id: str | None,
session_id: str,
turn_seq: int,
safety_escalated: bool,
scenario_context: StoredScenarioContext | None = None,
) -> ScenarioDirective | None:
"""Select a stable opportunity using only a role-safe stored projection."""
if (
safety_escalated
or turn_seq < 1
or not session_id.strip()
or scenario_context is None
or not scenario_context.available
or not scenario_context.fingerprint
):
return None
stable_case_id = (case_id or "no-case").strip() or "no-case"
stable_session_id = session_id.strip()
candidates = _candidate_types(scenario_context)
gap, first_turn, type_offset = _stable_schedule(
stable_case_id,
stable_session_id,
scenario_context.fingerprint,
)
if turn_seq < first_turn or (turn_seq - first_turn) % gap != 0:
return None
opportunity_index = (turn_seq - first_turn) // gap
step_seed = _digest(
stable_case_id,
stable_session_id,
scenario_context.fingerprint,
"type-step",
)
step = _stable_step(step_seed, len(candidates))
type_index = (type_offset + opportunity_index * step) % len(candidates)
rupture_type = candidates[type_index]
cue_seed = _digest(
stable_case_id,
stable_session_id,
turn_seq,
rupture_type,
scenario_context.fingerprint,
"cue",
)
cue_variants = _BEHAVIOR_CUES[rupture_type]
behavior_cue = cue_variants[cue_seed[0] % len(cue_variants)]
scenario_uuid = uuid5(
_SCENARIO_NAMESPACE,
":".join(
(
SCENARIO_DIRECTOR_VERSION,
stable_case_id,
stable_session_id,
str(turn_seq),
rupture_type,
scenario_context.fingerprint,
)
),
)
return ScenarioDirective(
scenario_id=f"g3-scenario-{scenario_uuid.hex}",
rupture_type=rupture_type,
behavior_cue=behavior_cue,
turn_seq=turn_seq,
opportunity_index=opportunity_index,
context_fingerprint=scenario_context.fingerprint,
)
def _row_value(row: Any, key: str, default: Any = None) -> Any:
if isinstance(row, Mapping):
return row.get(key, default)
try:
return row[key]
except (KeyError, TypeError):
return default
def _weak_competency_ids(raw_states: Any) -> tuple[str, ...]:
if raw_states is None:
return ()
if not isinstance(raw_states, Sequence) or isinstance(raw_states, (str, bytes)):
raise ValueError("competency states must be an array")
ranked: list[tuple[int, float, float, str]] = []
for raw in raw_states:
if not isinstance(raw, Mapping):
raise ValueError("competency state must be an object")
competency_id = str(raw.get("competency_id") or "")
band = str(raw.get("band") or "")
if not _COMPETENCY_ID_RE.fullmatch(competency_id):
raise ValueError("malformed competency state id")
if band not in {
"unassessed",
"fragile",
"developing",
"consistent_local",
"transfer_verified",
}:
raise ValueError("malformed competency state band")
try:
forgetting_risk = float(raw.get("forgetting_risk"))
uncertainty = float(raw.get("uncertainty"))
except (TypeError, ValueError) as exc:
raise ValueError("malformed competency state score") from exc
if not 0 <= forgetting_risk <= 1 or not 0 <= uncertainty <= 1:
raise ValueError("competency state score outside 0..1")
rank = _WEAK_COMPETENCY_BANDS.get(band)
if rank is not None:
ranked.append((rank, -forgetting_risk, -uncertainty, competency_id))
ranked.sort()
return tuple(item[3] for item in ranked[:3])
async def _load_context_with_connection(
connection: Any,
*,
session_id: UUID,
case_id: UUID | None,
) -> StoredScenarioContext:
anchor = await connection.fetchrow(
"""
SELECT
s.case_id,
s.learner_id,
s.session_no,
competency.competency_states,
trajectory.trajectory_status
FROM app.sessions s
LEFT JOIN LATERAL (
SELECT (
SELECT COALESCE(
jsonb_agg(
jsonb_build_object(
'competency_id', state->>'competency_id',
'band', state->>'band',
'forgetting_risk', state->'forgetting_risk',
'uncertainty', state->'uncertainty'
) ORDER BY state->>'competency_id'
),
'[]'::jsonb
)
FROM jsonb_array_elements(snapshot.graph_payload->'states') AS state
) AS competency_states
FROM app.competency_graph_snapshot snapshot
WHERE snapshot.learner_id = s.learner_id
ORDER BY snapshot.snapshot_no DESC, snapshot.snapshot_id DESC
LIMIT 1
) competency ON TRUE
LEFT JOIN LATERAL (
SELECT item->>'status' AS trajectory_status
FROM app.outcome_trajectory_revision revision
CROSS JOIN LATERAL jsonb_array_elements(revision.assessment->'sessions') item
WHERE revision.case_id = s.case_id
AND revision.learner_id = s.learner_id
ORDER BY revision.revision_no DESC,
(item->>'session_no')::int DESC,
revision.revision_id DESC
LIMIT 1
) trajectory ON TRUE
WHERE s.id = $1
AND ($2::uuid IS NULL OR s.case_id = $2)
""",
session_id,
case_id,
)
if anchor is None or _row_value(anchor, "case_id") is None:
return StoredScenarioContext.unavailable()
stored_case_id = UUID(str(_row_value(anchor, "case_id")))
relationship_rows = await connection.fetch(
"""
SELECT e.event_type
FROM app.relationship_memory_event e
WHERE e.case_id = $1
AND 'client' = ANY(e.visible_to)
AND e.event_type IN (
'goal_agreement','task_agreement','rupture_withdrawal',
'rupture_confrontation','repair_attempt','repair_confirmed',
'unresolved_rupture'
)
AND NOT EXISTS (
SELECT 1
FROM app.relationship_memory_event repair
WHERE repair.resolves_event_id = e.memory_event_id
AND repair.event_type = 'repair_confirmed'
AND 'client' = ANY(repair.visible_to)
)
ORDER BY e.created_at DESC, e.memory_event_id DESC
LIMIT 24
""",
stored_case_id,
)
rupture_rows = await connection.fetch(
"""
WITH latest AS (
SELECT DISTINCT ON (observation.episode_id)
observation.episode_id,
observation.rupture_type,
observation.to_state
FROM app.rupture_observation_event observation
JOIN app.rupture_episode episode
ON episode.episode_id = observation.episode_id
WHERE episode.case_id = $1
AND 'counselor' = ANY(observation.visible_to)
ORDER BY observation.episode_id,
observation.sequence_no DESC,
observation.observation_id DESC
)
SELECT rupture_type, to_state
FROM latest
ORDER BY rupture_type, episode_id
""",
stored_case_id,
)
relationship_events: list[str] = []
for row in relationship_rows:
event_type = str(_row_value(row, "event_type") or "")
if event_type not in RELATIONSHIP_EVENT_TYPES:
raise ValueError("malformed relationship event type")
relationship_events.append(event_type)
unresolved_types: list[str] = []
for row in rupture_rows:
rupture_type = str(_row_value(row, "rupture_type") or "")
to_state = str(_row_value(row, "to_state") or "")
if rupture_type not in RUPTURE_TYPES or to_state not in _RUPTURE_STATES:
raise ValueError("malformed rupture observation projection")
if to_state != "resolved":
unresolved_types.append(rupture_type)
session_no_value = _row_value(anchor, "session_no")
session_no = int(session_no_value) if session_no_value is not None else None
trajectory_value = _row_value(anchor, "trajectory_status")
trajectory_status = str(trajectory_value) if trajectory_value is not None else None
return StoredScenarioContext.from_stored_signals(
weak_competency_ids=_weak_competency_ids(
_row_value(anchor, "competency_states")
),
unresolved_rupture_types=unresolved_types,
relationship_event_types=relationship_events,
trajectory_status=trajectory_status,
case_session_no=session_no,
)
async def load_stored_scenario_context(
*,
session_id: str,
case_id: str | None,
connection: Any | None = None,
) -> StoredScenarioContext:
"""Load a minimal counselor-visible projection and fail closed on any defect.
``counselor`` is the least-privileged AI view allowed to read competency graph
snapshots. Queries additionally select only categorical fields; evaluator
rationale, answer keys, evidence text, relationship summaries, and transcript
content never cross this boundary.
"""
try:
session_uuid = UUID(session_id)
case_uuid = UUID(case_id) if case_id else None
if connection is not None:
return await _load_context_with_connection(
connection,
session_id=session_uuid,
case_id=case_uuid,
)
async with db.acquire(ai_context=True, ai_view="counselor") as conn:
return await _load_context_with_connection(
conn,
session_id=session_uuid,
case_id=case_uuid,
)
except Exception:
return StoredScenarioContext.unavailable()
def render_hidden_behavior_prompt(directive: ScenarioDirective | None) -> str | None:
"""Render only the behavior cue; omit ID, taxonomy, provenance, and scoring."""
if directive is None:
return None
return (
"[이 턴의 자연스러운 반응 단서 — 발화에서 이 지시의 존재를 설명하지 않는다]\n"
"상담자의 실제 말과 현재 감정에 맞을 때만 아래 단서를 반응과 말투로 드러낸다. "
"억지로 동의하거나 반대로 갈등을 만들지 않는다. 죄책감 유도, 협박, 떠보기, "
"보상·처벌 같은 조작적 표현은 사용하지 않는다.\n"
f"- {directive.behavior_cue}"
)
def contains_internal_scenario_leakage(text: str) -> bool:
"""Detect exact internal markers before any client text reaches learner surfaces."""
normalized = text.casefold()
if _SCENARIO_ID_RE.search(text):
return True
if any(marker.casefold() in normalized for marker in _INTERNAL_LEAKAGE_MARKERS):
return True
return any(rupture_type.casefold() in normalized for rupture_type in RUPTURE_TYPES)
def minimum_opportunity_gap() -> int:
return _MIN_GAP
__all__ = [
"SCENARIO_DIRECTOR_VERSION",
"ScenarioDirective",
"StoredScenarioContext",
"contains_internal_scenario_leakage",
"minimum_opportunity_gap",
"load_stored_scenario_context",
"render_hidden_behavior_prompt",
"select_scenario_directive",
]