주기 실회기 검증과 G7 종료계약 보강

This commit is contained in:
Yun Chan 2026-08-09 23:37:19 +09:00
parent 83590e9ef7
commit 7b4955c3fc
23 changed files with 2916 additions and 117 deletions

View file

@ -32,7 +32,7 @@ class G7EvidenceProvenance(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def require_preregistered_analysis(self) -> "G7EvidenceProvenance": def require_preregistered_analysis(self) -> "G7EvidenceProvenance":
if self.registered_at > self.held_out_labels_opened_at: if self.registered_at >= self.held_out_labels_opened_at:
raise ValueError("analysis protocol must precede held-out label access") raise ValueError("analysis protocol must precede held-out label access")
return self return self
@ -62,7 +62,7 @@ class G7PowerPlan(BaseModel):
required_held_out_participants: int = Field(ge=1) required_held_out_participants: int = Field(ge=1)
required_held_out_sessions: int = Field(ge=1) required_held_out_sessions: int = Field(ge=1)
required_paired_axis_observations: int = Field(ge=3) required_paired_axis_observations: int = Field(ge=3)
alpha: float = Field(gt=0.0, le=0.05) alpha: Literal[0.05] = 0.05
target_power: float = Field(ge=0.8, lt=1.0) target_power: float = Field(ge=0.8, lt=1.0)
minimally_detectable_gain: float = Field(gt=0.0, le=1.0) minimally_detectable_gain: float = Field(gt=0.0, le=1.0)
planned_bootstrap_samples: Literal[10000] = 10000 planned_bootstrap_samples: Literal[10000] = 10000
@ -91,10 +91,7 @@ class G7HumanAxisLabel(BaseModel):
labeler_key: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$") labeler_key: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$")
score: float = Field(ge=0.0, le=1.0) score: float = Field(ge=0.0, le=1.0)
category: str | None = Field( category: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$")
default=None,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$",
)
class G7ReliabilityClaim(BaseModel): class G7ReliabilityClaim(BaseModel):
@ -103,11 +100,7 @@ class G7ReliabilityClaim(BaseModel):
method: Literal["ICC(A,1)"] = "ICC(A,1)" method: Literal["ICC(A,1)"] = "ICC(A,1)"
labeler_keys: tuple[str, ...] = Field(min_length=2) labeler_keys: tuple[str, ...] = Field(min_length=2)
reported_icc: float = Field(ge=-1.0, le=1.0) reported_icc: float = Field(ge=-1.0, le=1.0)
reported_categorical_kappa: float | None = Field( reported_categorical_kappa: float = Field(ge=-1.0, le=1.0)
default=None,
ge=-1.0,
le=1.0,
)
report_sha256: Sha256 = Field(pattern=r"^[a-f0-9]{64}$") report_sha256: Sha256 = Field(pattern=r"^[a-f0-9]{64}$")
@model_validator(mode="after") @model_validator(mode="after")
@ -146,9 +139,6 @@ class G7PairedAxisObservation(BaseModel):
labeler_keys = [item.labeler_key for item in self.labels] labeler_keys = [item.labeler_key for item in self.labels]
if len(set(labeler_keys)) != len(labeler_keys): if len(set(labeler_keys)) != len(labeler_keys):
raise ValueError("observation labeler keys must be unique") raise ValueError("observation labeler keys must be unique")
has_category = [item.category is not None for item in self.labels]
if any(has_category) and not all(has_category):
raise ValueError("categorical labels must be complete within an observation")
return self return self
@ -227,7 +217,6 @@ class G7HumanVoiceGainEvidencePack(BaseModel):
participant_by_session: dict[str, str] = {} participant_by_session: dict[str, str] = {}
axes_by_session: dict[str, set[AllianceAxis]] = {} axes_by_session: dict[str, set[AllianceAxis]] = {}
categorical_modes: set[bool] = set()
for observation in self.observations: for observation in self.observations:
if split_by_participant.get(observation.participant_key) != "held_out": if split_by_participant.get(observation.participant_key) != "held_out":
raise ValueError("evaluation observations must use held-out participants") raise ValueError("evaluation observations must use held-out participants")
@ -243,16 +232,8 @@ class G7HumanVoiceGainEvidencePack(BaseModel):
row_labelers = {item.labeler_key for item in observation.labels} row_labelers = {item.labeler_key for item in observation.labels}
if row_labelers != reliability_panel: if row_labelers != reliability_panel:
raise ValueError("every row must use the declared reliability panel") raise ValueError("every row must use the declared reliability panel")
categorical_modes.add(observation.labels[0].category is not None)
required_axes: set[AllianceAxis] = {"goal", "task", "bond"} required_axes: set[AllianceAxis] = {"goal", "task", "bond"}
if any(axes != required_axes for axes in axes_by_session.values()): if any(axes != required_axes for axes in axes_by_session.values()):
raise ValueError("every held-out session must cover goal, task, and bond") raise ValueError("every held-out session must cover goal, task, and bond")
if len(categorical_modes) != 1:
raise ValueError("categorical labels must be all-present or all-absent")
has_categories = True in categorical_modes
if has_categories != (
self.reliability.reported_categorical_kappa is not None
):
raise ValueError("categorical labels and reported kappa must appear together")
return self return self

View file

@ -51,7 +51,9 @@ class VoiceGainEvidenceResult(BaseModel):
passed: bool passed: bool
clinical_claim_allowed: Literal[False] = False clinical_claim_allowed: Literal[False] = False
held_out_participants: int held_out_participants: int
total_held_out_sessions: int
held_out_sessions: int held_out_sessions: int
total_axis_observations: int
paired_axis_observations: int paired_axis_observations: int
intention_to_evaluate_imputations: int intention_to_evaluate_imputations: int
text_only_one_minus_mae: float text_only_one_minus_mae: float
@ -62,7 +64,7 @@ class VoiceGainEvidenceResult(BaseModel):
confidence_level: float confidence_level: float
bootstrap_samples: int bootstrap_samples: int
recomputed_icc: float recomputed_icc: float
recomputed_categorical_kappa: float | None recomputed_categorical_kappa: float
checks: tuple[VoiceGainEvidenceCheck, ...] checks: tuple[VoiceGainEvidenceCheck, ...]
failure_reasons: tuple[str, ...] failure_reasons: tuple[str, ...]
@ -221,22 +223,23 @@ def evaluate_human_voice_gain(
labels_by_key = {item.labeler_key: item for item in observation.labels} labels_by_key = {item.labeler_key: item for item in observation.labels}
ordered_labels = [labels_by_key[labeler_key] for labeler_key in panel] ordered_labels = [labels_by_key[labeler_key] for labeler_key in panel]
ratings.append([item.score for item in ordered_labels]) ratings.append([item.score for item in ordered_labels])
if ordered_labels[0].category is not None: categories.append([item.category for item in ordered_labels])
categories.append([str(item.category) for item in ordered_labels])
reference = sum(item.score for item in ordered_labels) / len(ordered_labels) reference = sum(item.score for item in ordered_labels) / len(ordered_labels)
if observation.text_only_status == "observed": text_observed = observation.text_only_status == "observed"
voice_observed = observation.voice_enabled_status == "observed"
if text_observed and voice_observed:
assert observation.text_only_score is not None assert observation.text_only_score is not None
text_error = abs(observation.text_only_score - reference) text_error = abs(observation.text_only_score - reference)
else:
text_error = 1.0
imputation_count += 1
if observation.voice_enabled_status == "observed":
assert observation.voice_enabled_score is not None assert observation.voice_enabled_score is not None
voice_error = abs(observation.voice_enabled_score - reference) voice_error = abs(observation.voice_enabled_score - reference)
else: else:
# 한 조건만 결측이어도 두 조건을 모두 최대 오류로 대치한다. baseline-only
# 결측이 candidate gain을 인위적으로 키우는 비대칭을 차단하면서도
# intention-to-evaluate 행은 분석에서 유지한다.
text_error = 1.0
voice_error = 1.0 voice_error = 1.0
imputation_count += 1 imputation_count += int(not text_observed) + int(not voice_observed)
errors_by_participant[observation.participant_key].append( errors_by_participant[observation.participant_key].append(
(text_error, voice_error) (text_error, voice_error)
) )
@ -258,11 +261,28 @@ def evaluate_human_voice_gain(
seed=thresholds.seed, seed=thresholds.seed,
) )
recomputed_icc = _icc_absolute_agreement_single(ratings) recomputed_icc = _icc_absolute_agreement_single(ratings)
recomputed_kappa = _fleiss_kappa(categories) if categories else None recomputed_kappa = _fleiss_kappa(categories)
held_out_participants = len(errors_by_participant) held_out_participants = len(errors_by_participant)
held_out_sessions = len({item.session_key for item in pack.observations}) all_session_keys = {item.session_key for item in pack.observations}
observation_count = len(pack.observations) complete_axes_by_session: dict[str, set[str]] = defaultdict(set)
for observation in pack.observations:
if (
observation.text_only_status == "observed"
and observation.voice_enabled_status == "observed"
):
complete_axes_by_session[observation.session_key].add(observation.axis)
required_axes = {"goal", "task", "bond"}
held_out_sessions = sum(
axes == required_axes for axes in complete_axes_by_session.values()
)
total_observation_count = len(pack.observations)
paired_observation_count = sum(
1
for observation in pack.observations
if observation.text_only_status == "observed"
and observation.voice_enabled_status == "observed"
)
checks: list[VoiceGainEvidenceCheck] = [] checks: list[VoiceGainEvidenceCheck] = []
_check( _check(
checks, checks,
@ -281,8 +301,8 @@ def evaluate_human_voice_gain(
_check( _check(
checks, checks,
"production_observation_floor", "production_observation_floor",
observation_count >= thresholds.min_paired_axis_observations, paired_observation_count >= thresholds.min_paired_axis_observations,
observation_count, paired_observation_count,
thresholds.min_paired_axis_observations, thresholds.min_paired_axis_observations,
) )
_check( _check(
@ -318,8 +338,8 @@ def evaluate_human_voice_gain(
_check( _check(
checks, checks,
"power_plan_observations_achieved", "power_plan_observations_achieved",
observation_count >= pack.power_plan.required_paired_axis_observations, paired_observation_count >= pack.power_plan.required_paired_axis_observations,
observation_count, paired_observation_count,
pack.power_plan.required_paired_axis_observations, pack.power_plan.required_paired_axis_observations,
) )
_check( _check(
@ -355,8 +375,6 @@ def evaluate_human_voice_gain(
recomputed_icc, recomputed_icc,
pack.reliability.reported_icc, pack.reliability.reported_icc,
) )
if recomputed_kappa is not None:
assert pack.reliability.reported_categorical_kappa is not None
_check( _check(
checks, checks,
"recomputed_categorical_kappa", "recomputed_categorical_kappa",
@ -390,8 +408,10 @@ def evaluate_human_voice_gain(
passed=not failures, passed=not failures,
clinical_claim_allowed=False, clinical_claim_allowed=False,
held_out_participants=held_out_participants, held_out_participants=held_out_participants,
total_held_out_sessions=len(all_session_keys),
held_out_sessions=held_out_sessions, held_out_sessions=held_out_sessions,
paired_axis_observations=observation_count, total_axis_observations=total_observation_count,
paired_axis_observations=paired_observation_count,
intention_to_evaluate_imputations=imputation_count, intention_to_evaluate_imputations=imputation_count,
text_only_one_minus_mae=text_accuracy, text_only_one_minus_mae=text_accuracy,
voice_enabled_one_minus_mae=voice_accuracy, voice_enabled_one_minus_mae=voice_accuracy,

View file

@ -179,8 +179,34 @@ class G7HumanVoiceGainEvidenceTests(unittest.TestCase):
self.assertEqual(result.intention_to_evaluate_imputations, 1) self.assertEqual(result.intention_to_evaluate_imputations, 1)
expected_voice_accuracy = 1.0 - ((1.0 + (0.05 * 5)) / 6) expected_voice_accuracy = 1.0 - ((1.0 + (0.05 * 5)) / 6)
self.assertAlmostEqual(result.voice_enabled_one_minus_mae, expected_voice_accuracy) self.assertAlmostEqual(result.voice_enabled_one_minus_mae, expected_voice_accuracy)
self.assertEqual(result.paired_axis_observations, 5)
self.assertEqual(result.held_out_sessions, 1)
self.assertIn("production_observation_floor", result.failure_reasons)
self.assertIn("production_session_floor", result.failure_reasons)
self.assertFalse(result.passed) self.assertFalse(result.passed)
def test_baseline_only_missing_cannot_inflate_candidate_gain(self) -> None:
complete = G7HumanVoiceGainEvidencePack.model_validate(_valid_payload())
complete_result = evaluate_human_voice_gain(
complete,
thresholds=_test_thresholds(),
)
payload = _valid_payload()
observations = payload["observations"]
assert isinstance(observations, list)
observations[0]["text_only_status"] = "missing"
observations[0]["text_only_score"] = None
missing = G7HumanVoiceGainEvidencePack.model_validate(payload)
missing_result = evaluate_human_voice_gain(
missing,
thresholds=_test_thresholds(),
)
self.assertLess(missing_result.paired_gain, complete_result.paired_gain)
self.assertEqual(missing_result.intention_to_evaluate_imputations, 1)
self.assertEqual(missing_result.paired_axis_observations, 5)
def test_duplicate_or_calibration_observation_is_rejected(self) -> None: def test_duplicate_or_calibration_observation_is_rejected(self) -> None:
duplicate = _valid_payload() duplicate = _valid_payload()
duplicate_rows = duplicate["observations"] duplicate_rows = duplicate["observations"]
@ -236,6 +262,64 @@ class G7HumanVoiceGainEvidenceTests(unittest.TestCase):
self.assertFalse(result.passed) self.assertFalse(result.passed)
self.assertIn("reported_icc_matches_rows", result.failure_reasons) self.assertIn("reported_icc_matches_rows", result.failure_reasons)
def test_categorical_kappa_is_mandatory_and_recomputed(self) -> None:
missing_report = _valid_payload()
reliability = missing_report["reliability"]
assert isinstance(reliability, dict)
reliability.pop("reported_categorical_kappa")
with self.assertRaises(ValidationError):
G7HumanVoiceGainEvidencePack.model_validate(missing_report)
missing_category = _valid_payload()
observations = missing_category["observations"]
assert isinstance(observations, list)
labels = observations[0]["labels"]
assert isinstance(labels, list)
labels[0].pop("category")
with self.assertRaises(ValidationError):
G7HumanVoiceGainEvidencePack.model_validate(missing_category)
mismatched = _valid_payload()
reliability = mismatched["reliability"]
assert isinstance(reliability, dict)
reliability["reported_categorical_kappa"] = 0.8
pack = G7HumanVoiceGainEvidencePack.model_validate(mismatched)
result = evaluate_human_voice_gain(pack, thresholds=_test_thresholds())
self.assertIn("reported_kappa_matches_rows", result.failure_reasons)
weak = _valid_payload()
observations = weak["observations"]
assert isinstance(observations, list)
for observation in observations:
labels = observation["labels"]
assert isinstance(labels, list)
labels[0]["category"] = "low"
labels[1]["category"] = "high"
reliability = weak["reliability"]
assert isinstance(reliability, dict)
reliability["reported_categorical_kappa"] = -1.0
weak_pack = G7HumanVoiceGainEvidencePack.model_validate(weak)
weak_result = evaluate_human_voice_gain(
weak_pack,
thresholds=_test_thresholds(),
)
self.assertIn("recomputed_categorical_kappa", weak_result.failure_reasons)
def test_analysis_registration_must_strictly_precede_held_out_access(self) -> None:
payload = _valid_payload()
provenance = payload["provenance"]
assert isinstance(provenance, dict)
provenance["registered_at"] = provenance["held_out_labels_opened_at"]
with self.assertRaisesRegex(ValidationError, "must precede"):
G7HumanVoiceGainEvidencePack.model_validate(payload)
wrong_alpha = _valid_payload()
power_plan = wrong_alpha["power_plan"]
assert isinstance(power_plan, dict)
power_plan["alpha"] = 0.01
with self.assertRaises(ValidationError):
G7HumanVoiceGainEvidencePack.model_validate(wrong_alpha)
def test_custom_thresholds_require_explicit_test_factory(self) -> None: def test_custom_thresholds_require_explicit_test_factory(self) -> None:
pack = G7HumanVoiceGainEvidencePack.model_validate(_valid_payload()) pack = G7HumanVoiceGainEvidencePack.model_validate(_valid_payload())
with self.assertRaisesRegex(ValueError, "test-only"): with self.assertRaisesRegex(ValueError, "test-only"):

View file

@ -333,6 +333,7 @@ test.describe("dev dashboard static command center", () => {
for (const viewport of [ for (const viewport of [
{ width: 1440, height: 900 }, { width: 1440, height: 900 },
{ width: 390, height: 844 }, { width: 390, height: 844 },
{ width: 320, height: 568 },
]) { ]) {
await page.setViewportSize(viewport); await page.setViewportSize(viewport);
await openDashboard(page, `http://localhost:${staticServer.port}/docs/dev_dashboard.html`); await openDashboard(page, `http://localhost:${staticServer.port}/docs/dev_dashboard.html`);

View file

@ -0,0 +1,426 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import {
expect,
test,
type APIResponse,
type Page,
} from "@playwright/test";
import {
completeAlliancePreCheckpoint,
useRealApi,
} from "./support";
interface ReturnedPracticeFixture {
schema_version: "vignette.returned-practice-browser-fixture.v1";
login: {
email: string;
role: "learner";
display_name: string;
cohort_ids: string[];
};
source_session_id: string;
practice_session_id: string;
deliberate: {
prescription_id: string;
criterion_id: string;
novelty: "familiar" | "unseen_transfer";
mode:
| "replay"
| "branch"
| "constrained_response"
| "voice_retry"
| "difficulty_ladder";
};
transfer: {
prescription_id: string;
suite_id: string;
trial_id: string;
criterion_id: string;
novelty: "unseen_transfer";
mode:
| "counterevidence_forecast"
| "evidence_recall"
| "uncertainty_range"
| "collect_more_evidence";
};
}
interface SessionDetail {
session_id: string;
persona_code: string;
}
interface AuthMe {
user_id: string;
}
interface DeliberateSubmission {
idempotent_replay: boolean;
}
interface DeliberateReadModel {
episodes: Array<{
session_id?: string | null;
attempts?: unknown[];
}>;
}
interface TransferSubmission {
idempotent_replay: boolean;
assessment: {
execution_count: number;
independent_execution_count: number;
};
}
interface TransferReadModel {
actual_executions: Array<{
original_transfer_trial_record_id: string;
practice_session_id: string;
}>;
}
const LIVE_GATE = process.env.E2E_PERIODIC_LEARNER_REAL_CLOSED_LOOP === "1";
const FIXTURE_PATH = process.env.E2E_RETURNED_PRACTICE_FIXTURE ?? "";
const RESULT_PATH = process.env.E2E_PERIODIC_LEARNER_RESULT ?? "";
function loadFixture(): ReturnedPracticeFixture {
if (!FIXTURE_PATH) {
throw new Error("E2E_RETURNED_PRACTICE_FIXTURE is required");
}
return JSON.parse(
readFileSync(path.resolve(FIXTURE_PATH), "utf8"),
) as ReturnedPracticeFixture;
}
async function expectOk(response: APIResponse) {
expect(response.ok(), await response.text()).toBeTruthy();
}
async function signInExistingFixture(
page: Page,
fixture: ReturnedPracticeFixture,
): Promise<string> {
const login = await page.request.post("/api/auth/dev-login", {
data: fixture.login,
});
await expectOk(login);
const me = await page.request.get("/api/auth/me");
await expectOk(me);
return ((await me.json()) as AuthMe).user_id;
}
function launchSearch(
fixture: ReturnedPracticeFixture,
kind: "deliberate" | "transfer",
): string {
const source = kind === "deliberate" ? fixture.deliberate : fixture.transfer;
const search = new URLSearchParams({
launch: kind,
prescription: source.prescription_id,
source_session: fixture.source_session_id,
criterion: source.criterion_id,
novelty: source.novelty,
mode: source.mode,
});
if (kind === "transfer") {
search.set("suite", fixture.transfer.suite_id);
search.set("trial", fixture.transfer.trial_id);
}
return search.toString();
}
function runtimeAttemptCount(
readModel: DeliberateReadModel,
practiceSessionId: string,
): number {
return readModel.episodes
.filter((episode) => episode.session_id === practiceSessionId)
.reduce((count, episode) => count + (episode.attempts?.length ?? 0), 0);
}
function actualExecutionCount(
readModel: TransferReadModel,
fixture: ReturnedPracticeFixture,
practiceSessionId: string,
): number {
return readModel.actual_executions.filter(
(execution) =>
execution.original_transfer_trial_record_id === fixture.transfer.trial_id &&
execution.practice_session_id === practiceSessionId,
).length;
}
async function readPracticeCount(page: Page, practiceSessionId: string) {
const response = await page.request.get("/api/practice/learners/me");
await expectOk(response);
return runtimeAttemptCount(
(await response.json()) as DeliberateReadModel,
practiceSessionId,
);
}
async function readTransferCount(
page: Page,
fixture: ReturnedPracticeFixture,
practiceSessionId: string,
) {
const response = await page.request.get("/api/calibration/learners/me");
await expectOk(response);
return actualExecutionCount(
(await response.json()) as TransferReadModel,
fixture,
practiceSessionId,
);
}
async function openEvidenceCard(
page: Page,
fixture: ReturnedPracticeFixture,
practiceSessionId: string,
kind: "deliberate" | "transfer",
) {
await page.goto(
`/learn/session/${practiceSessionId}/review?${launchSearch(fixture, kind)}`,
);
const insightsTab = page.locator("#sr-tab-insights");
await expect(insightsTab).toBeVisible({ timeout: 30_000 });
if ((await insightsTab.getAttribute("aria-selected")) !== "true") {
await insightsTab.click();
}
const selector =
kind === "deliberate" ? ".dp-runtime-observation" : ".ct-actual-transfer";
const card = page.locator("#sr-panel-insights").locator(selector);
await expect(card).toBeVisible({ timeout: 30_000 });
return card;
}
function writeResult(result: Record<string, unknown>) {
if (!RESULT_PATH) {
throw new Error("E2E_PERIODIC_LEARNER_RESULT is required");
}
const resolved = path.resolve(RESULT_PATH);
mkdirSync(path.dirname(resolved), { recursive: true });
writeFileSync(resolved, `${JSON.stringify(result, null, 2)}\n`, "utf8");
}
test.describe("periodic same-learner real closed loop", () => {
test.skip(!LIVE_GATE, "Explicit disposable periodic gate only");
test("@single-run home recommendation, SSE session, review, G4/G5 POST and reload stay one learner", async ({
page,
}) => {
test.setTimeout(20 * 60_000);
const fixture = loadFixture();
expect(fixture.schema_version).toBe(
"vignette.returned-practice-browser-fixture.v1",
);
await useRealApi(page);
const learnerId = await signInExistingFixture(page, fixture);
const preparedSession = await page.request.get(
`/api/sessions/${fixture.practice_session_id}`,
);
await expectOk(preparedSession);
const practicePersona = ((await preparedSession.json()) as SessionDetail)
.persona_code;
expect(practicePersona).toBeTruthy();
await page.goto("/learn");
await expect(
page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("다음 연습 추천", { exact: true })).toBeVisible();
await expect(page.getByText("리뷰 확인을 우선합니다.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "리뷰 확인하기" }).click();
await expect(page).toHaveURL(/\/learn\/history$/);
await page.goto(`/learn/practice?${launchSearch(fixture, "deliberate")}`);
await expect(
page.getByRole("heading", { name: /처방을 이어받았습니다/ }),
).toBeVisible({ timeout: 30_000 });
const escapedPersona = practicePersona.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const persona = page.getByRole("option", {
name: new RegExp(escapedPersona),
});
await persona.click();
await expect(persona).toHaveAttribute("aria-selected", "true");
await page.getByRole("button", { name: "새 회기 시작" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${escapedPersona}\\?`));
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
const activeUrl = new URL(page.url());
const practiceSessionId = activeUrl.pathname.split("/").filter(Boolean).at(-1);
expect(practiceSessionId).toBeTruthy();
expect(practiceSessionId).not.toBe(practicePersona);
const learnerText =
"그 말을 꺼내기까지 많이 외롭고 조심스러웠던 것 같아요. 제가 이해한 마음이 맞을까요?";
const streamResponse = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
response.url().endsWith(`/api/sessions/${practiceSessionId}/stream`),
{ timeout: 6 * 60_000 },
);
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
const stream = await streamResponse;
await expectOk(stream);
expect(stream.headers()["content-type"] ?? "").toContain("text/event-stream");
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible();
await expect(page.locator(".sx-utt.is-client").last()).toBeVisible({
timeout: 6 * 60_000,
});
await page.getByRole("button", { name: "회기 종료" }).click();
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();
await expect(page).toHaveURL(
new RegExp(`/learn/session/${practiceSessionId}/review\\?`),
);
await expect
.poll(
async () => {
const review = await page.request.get(
`/api/sessions/${practiceSessionId}/review`,
);
if (!review.ok()) return false;
return Boolean(((await review.json()) as { reviewReady?: boolean }).reviewReady);
},
{ timeout: 6 * 60_000, intervals: [1_000, 2_000, 5_000] },
)
.toBe(true);
expect(await readPracticeCount(page, practiceSessionId!)).toBe(0);
let deliberateCard = await openEvidenceCard(
page,
fixture,
practiceSessionId!,
"deliberate",
);
const g4PostUrl = `/api/practice/${encodeURIComponent(
fixture.deliberate.prescription_id,
)}/attempts/from-session/${practiceSessionId}`;
const g4FirstPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(g4PostUrl),
{ timeout: 5 * 60_000 },
);
await deliberateCard
.getByRole("button", { name: "이번 회기를 독립 관찰로 반영" })
.click();
const g4First = await g4FirstPost;
await expectOk(g4First);
expect(((await g4First.json()) as DeliberateSubmission).idempotent_replay).toBe(
false,
);
const g4FirstCount = await readPracticeCount(page, practiceSessionId!);
expect(g4FirstCount).toBe(1);
await page.reload();
deliberateCard = await openEvidenceCard(
page,
fixture,
practiceSessionId!,
"deliberate",
);
const g4ReplayPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(g4PostUrl),
{ timeout: 5 * 60_000 },
);
await deliberateCard
.getByRole("button", { name: "반영 상태 다시 확인" })
.click();
const g4Replay = await g4ReplayPost;
await expectOk(g4Replay);
expect(((await g4Replay.json()) as DeliberateSubmission).idempotent_replay).toBe(
true,
);
const g4ReplayCount = await readPracticeCount(page, practiceSessionId!);
expect(g4ReplayCount).toBe(1);
expect(await readTransferCount(page, fixture, practiceSessionId!)).toBe(0);
let transferCard = await openEvidenceCard(
page,
fixture,
practiceSessionId!,
"transfer",
);
const g5PostUrl = "/api/calibration/transfer-executions";
const g5FirstPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(g5PostUrl),
{ timeout: 5 * 60_000 },
);
await transferCard
.getByRole("button", { name: "이 회기를 전이 근거로 확인" })
.click();
const g5First = await g5FirstPost;
await expectOk(g5First);
const g5FirstBody = (await g5First.json()) as TransferSubmission;
expect(g5FirstBody.idempotent_replay).toBe(false);
expect(g5FirstBody.assessment.execution_count).toBe(1);
expect(g5FirstBody.assessment.independent_execution_count).toBe(1);
const g5FirstCount = await readTransferCount(
page,
fixture,
practiceSessionId!,
);
expect(g5FirstCount).toBe(1);
await page.reload();
transferCard = await openEvidenceCard(
page,
fixture,
practiceSessionId!,
"transfer",
);
const g5ReplayPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(g5PostUrl),
{ timeout: 5 * 60_000 },
);
await transferCard
.getByRole("button", { name: "같은 회기 기록 다시 확인" })
.click();
const g5Replay = await g5ReplayPost;
await expectOk(g5Replay);
expect(((await g5Replay.json()) as TransferSubmission).idempotent_replay).toBe(
true,
);
const g5ReplayCount = await readTransferCount(
page,
fixture,
practiceSessionId!,
);
expect(g5ReplayCount).toBe(1);
const meAfter = await page.request.get("/api/auth/me");
await expectOk(meAfter);
expect(((await meAfter.json()) as AuthMe).user_id).toBe(learnerId);
writeResult({
schema_version: "vignette.periodic-learner-real-closed-loop.v1",
learner_id: learnerId,
source_session_id: fixture.source_session_id,
practice_session_id: practiceSessionId,
same_learner: true,
recommendation_verified: true,
session_created_in_browser: true,
sse_turn_verified: true,
review_ready: true,
g4: {
initial_count: 0,
first_count: g4FirstCount,
replay_count: g4ReplayCount,
replay_idempotent: true,
},
g5: {
initial_count: 0,
first_count: g5FirstCount,
replay_count: g5ReplayCount,
replay_idempotent: true,
},
});
});
});

View file

@ -1020,6 +1020,20 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
); );
await expectReadableButtonContrast(page, "학습 대상 선택"); await expectReadableButtonContrast(page, "학습 대상 선택");
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
if (testInfo.project.name.includes("mobile")) {
await page.setViewportSize({ width: 320, height: 568 });
await page.evaluate(() => new Promise(requestAnimationFrame));
await expectInsideInitialViewport(
page,
"[data-learner-primary-action]",
"320px 학습자 홈 핵심 행동",
);
await expectMinimumHitTargets(page, ".lh-tabs [role='tab']", "320px 학습 홈 탭");
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "home-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await capture(page, testInfo.project.name, "home"); await capture(page, testInfo.project.name, "home");
await primaryAction.click(); await primaryAction.click();
@ -1061,6 +1075,7 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await page.getByRole("button", { name: "보내기" }).click(); await page.getByRole("button", { name: "보내기" }).click();
await expect(page.locator(".sx-utt").filter({ hasText: LEARNER_TEXT })).toBeVisible(); await expect(page.locator(".sx-utt").filter({ hasText: LEARNER_TEXT })).toBeVisible();
await expect(page.locator(".sx-utt").filter({ hasText: CLIENT_REPLY })).toBeVisible(); await expect(page.locator(".sx-utt").filter({ hasText: CLIENT_REPLY })).toBeVisible();
await expect(page.locator(".sx-page")).not.toContainText("[NAME]");
if (testInfo.project.name.includes("mobile")) { if (testInfo.project.name.includes("mobile")) {
await expect( await expect(
page.getByRole("region", { name: "현재 회기 요약" }), page.getByRole("region", { name: "현재 회기 요약" }),
@ -1145,7 +1160,22 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await expect(practiceCard).toContainText("미지 사례 전이 아직 미검증"); await expect(practiceCard).toContainText("미지 사례 전이 아직 미검증");
await expect(practiceCard).not.toContainText("criterion.reflect-and-check"); await expect(practiceCard).not.toContainText("criterion.reflect-and-check");
await expect(practiceCard).not.toContainText("unseen_transfer_not_verified"); await expect(practiceCard).not.toContainText("unseen_transfer_not_verified");
await expect(page.locator(".sr-root")).not.toContainText("[NAME]");
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
if (testInfo.project.name.includes("mobile")) {
await page.setViewportSize({ width: 320, height: 568 });
await page.evaluate(() => new Promise(requestAnimationFrame));
await practiceCard.scrollIntoViewIfNeeded();
await expectMinimumHitTargets(
page,
".dp-launch-ticket__cta",
"320px 처방 연습 시작 행동",
);
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "review-prescription-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await practiceCard.scrollIntoViewIfNeeded(); await practiceCard.scrollIntoViewIfNeeded();
await capture(page, testInfo.project.name, "review-prescription"); await capture(page, testInfo.project.name, "review-prescription");
@ -1185,6 +1215,16 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await expect(page.locator(".lh-root")).toContainText("원본 회기 참조"); await expect(page.locator(".lh-root")).toContainText("원본 회기 참조");
await expect(page.locator(".lh-root")).not.toContainText("criterion.reflect-and-check"); await expect(page.locator(".lh-root")).not.toContainText("criterion.reflect-and-check");
await expect(page.locator(".lh-root")).not.toContainText(PRIMARY_SESSION_ID.slice(0, 8)); await expect(page.locator(".lh-root")).not.toContainText(PRIMARY_SESSION_ID.slice(0, 8));
await expect(page.locator(".lh-root")).not.toContainText("[NAME]");
if (testInfo.project.name.includes("mobile")) {
await page.setViewportSize({ width: 320, height: 568 });
await page.evaluate(() => new Promise(requestAnimationFrame));
await expectMinimumHitTargets(page, ".lh-actions .vg-btn", "320px 재연습 대상 행동");
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "repractice-home-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await page.getByRole("button", { name: "새 회기 시작" }).click(); await page.getByRole("button", { name: "새 회기 시작" }).click();
await expect( await expect(
page.getByRole("heading", { name: "처방 연습 · 장면 다시 보기" }), page.getByRole("heading", { name: "처방 연습 · 장면 다시 보기" }),
@ -1209,6 +1249,24 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
"처방 재연습 시작 행동", "처방 재연습 시작 행동",
); );
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
if (testInfo.project.name.includes("mobile")) {
await page.setViewportSize({ width: 320, height: 568 });
await page.evaluate(() => new Promise(requestAnimationFrame));
await expectInsideInitialViewport(
page,
".sx-prestart__actions .vg-btn",
"320px 처방 재연습 시작 행동",
);
await expectMinimumHitTargets(
page,
".sx-prestart__actions .vg-btn",
"320px 처방 재연습 시작 행동",
);
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "repractice-prestart-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await capture(page, testInfo.project.name, "repractice-prestart"); await capture(page, testInfo.project.name, "repractice-prestart");
await page.getByRole("button", { name: "회기 시작" }).click(); await page.getByRole("button", { name: "회기 시작" }).click();
@ -1297,6 +1355,22 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await expect.poll(() => fixture.practiceReadCount).toBeGreaterThan( await expect.poll(() => fixture.practiceReadCount).toBeGreaterThan(
readsBeforeSuccess, readsBeforeSuccess,
); );
if (testInfo.project.name.includes("mobile")) {
await page.setViewportSize({ width: 320, height: 568 });
await page.evaluate(() => new Promise(requestAnimationFrame));
await observation.scrollIntoViewIfNeeded();
await expectMinimumHitTargets(
page,
".dp-runtime-observation button",
"320px 서버 관찰 행동",
);
await expect(observation).not.toContainText(RETRY_SESSION_ID.slice(0, 8));
await expect(observation).not.toContainText("[NAME]");
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "repractice-observation-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await observation.scrollIntoViewIfNeeded(); await observation.scrollIntoViewIfNeeded();
await capture(page, testInfo.project.name, "repractice-observation"); await capture(page, testInfo.project.name, "repractice-observation");

View file

@ -206,13 +206,13 @@ async function expectMainControlsUnclipped(page: Page) {
const controls = [ const controls = [
{ selector: ".sx-compose textarea", parent: ".sx-compose" }, { selector: ".sx-compose textarea", parent: ".sx-compose" },
{ selector: ".sx-compose .vg-btn", parent: ".sx-compose" }, { selector: ".sx-compose .vg-btn", parent: ".sx-compose" },
{ selector: ".sx-controlbar .sx-mic", parent: ".sx-controlbar" }, { selector: ".sx-controlbar .sx-mic", parent: ".sx-controlbar", minTouchSize: 44 },
{ selector: ".sx-controlbar .sx-segmented", parent: ".sx-controlbar" }, { selector: ".sx-controlbar .sx-segmented", parent: ".sx-controlbar" },
{ selector: ".sx-controlbar .sx-pause", parent: ".sx-controlbar" }, { selector: ".sx-controlbar .sx-pause", parent: ".sx-controlbar", minTouchSize: 44 },
{ selector: ".sx-controlbar .sx-end-button", parent: ".sx-controlbar" }, { selector: ".sx-controlbar .sx-end-button", parent: ".sx-controlbar", minTouchSize: 44 },
]; ];
return controls.map(({ selector, parent }) => { return controls.map(({ selector, parent, minTouchSize }) => {
const el = document.querySelector<HTMLElement>(selector); const el = document.querySelector<HTMLElement>(selector);
const parentEl = document.querySelector<HTMLElement>(parent); const parentEl = document.querySelector<HTMLElement>(parent);
if (!el || !parentEl) return { selector, ok: false, reason: "missing" }; if (!el || !parentEl) return { selector, ok: false, reason: "missing" };
@ -236,11 +236,21 @@ async function expectMainControlsUnclipped(page: Page) {
rect.left >= parentRect.left - 1 && rect.left >= parentRect.left - 1 &&
rect.right <= parentRect.right + 1 && rect.right <= parentRect.right + 1 &&
rect.bottom <= parentRect.bottom + 1; rect.bottom <= parentRect.bottom + 1;
const touchTargetOk =
minTouchSize === undefined || (rect.width >= minTouchSize && rect.height >= minTouchSize);
return { return {
selector, selector,
ok: visible && insideParent && !textClipped, ok: visible && insideParent && !textClipped && touchTargetOk,
reason: !visible ? "not-visible" : !insideParent ? "outside-parent" : textClipped ? "text-clipped" : "", reason: !visible
? "not-visible"
: !insideParent
? "outside-parent"
: textClipped
? "text-clipped"
: !touchTargetOk
? "touch-target-under-44px"
: "",
rect: { rect: {
top: Math.round(rect.top), top: Math.round(rect.top),
right: Math.round(rect.right), right: Math.round(rect.right),

View file

@ -2386,7 +2386,7 @@
flex: none; flex: none;
} }
.sx-end-button { .sx-end-button {
min-height: 40px; min-height: 44px;
padding: 9px 13px; padding: 9px 13px;
border: 1px solid color-mix(in srgb, var(--crit-solid) 34%, var(--border-subtle)); border: 1px solid color-mix(in srgb, var(--crit-solid) 34%, var(--border-subtle));
border-radius: var(--radius); border-radius: var(--radius);
@ -4210,6 +4210,20 @@
} }
} }
/* 처방 재연습은 카드 안에 과제 종류·성공 기준·출처를 다시 명시한다. 320×568처럼
세로가 아주 짧은 화면에서 상단 단계 헤더까지 반복하면 유일한 행동인 "회기 시작"
화면 아래로 밀린다. 구간만 중복 헤더를 접고 여백을 줄여 과제 맥락과 CTA를
함께 보이게 한다. 일반 회기 준비 화면과 390×844 이상 레이아웃은 그대로 유지한다. */
@media (max-width: 460px) and (max-height: 620px) {
.vg-main:has(.sx-page--prestart .sx-prestart--prescribed) {
padding-top: var(--sp-2);
padding-bottom: var(--sp-3);
}
.sx-page--prestart:has(.sx-prestart--prescribed) .sx-head {
display: none;
}
}
@media (max-width: 1180px) and (max-height: 820px) { @media (max-width: 1180px) and (max-height: 820px) {
.sx-sessionbar { .sx-sessionbar {
min-height: 38px; min-height: 38px;
@ -4355,19 +4369,18 @@
.sx-page--active .sx-mic-block__ms { .sx-page--active .sx-mic-block__ms {
display: none; display: none;
} }
/* 아주 좁거나 낮은 뷰포트: 세로 공간 확보를 위해 40px 낮춘다 /* 아주 좁거나 낮은 뷰포트에서도 주요 회기 제어는 44px 터치 하한을 지킨다. */
(390x720 여유 있는 폭에서는 44px 유지). 가로 터치 폭은 유지. */
.sx-page--active .sx-mic { .sx-page--active .sx-mic {
width: 40px; width: 44px;
height: 40px; height: 44px;
} }
.sx-page--active .sx-pause { .sx-page--active .sx-pause {
width: 44px; width: 44px;
height: 40px; height: 44px;
} }
.sx-page--active .sx-end-button { .sx-page--active .sx-end-button {
min-width: 104px; min-width: 104px;
height: 40px; height: 44px;
padding: 0 10px; padding: 0 10px;
} }
} }

View file

@ -453,6 +453,10 @@ G7 내부 소스 계약은 완료됐고 `scripts/check-g7-external-proof.py`는
`apps/api/requirements.txt`와 명시적 Python 3.11 모두 `psutil==6.1.1`로 고정됐다. `apps/api/requirements.txt`와 명시적 Python 3.11 모두 `psutil==6.1.1`로 고정됐다.
- 현재 운영 STT 모델은 이 호스트에서 실측된 CPU int8 `small`로 고정한다. cuDNN 9가 설치되고 별도 성능·정확도 - 현재 운영 STT 모델은 이 호스트에서 실측된 CPU int8 `small`로 고정한다. cuDNN 9가 설치되고 별도 성능·정확도
gate를 통과하기 전까지 launcher·API runtime metadata·50분 runner/checker expected model을 모두 `small`로 유지한다. gate를 통과하기 전까지 launcher·API runtime metadata·50분 runner/checker expected model을 모두 `small`로 유지한다.
- human voice-gain pack은 category와 categorical κ를 필수로 포함하고, preregistration이 held-out 공개보다 앞서야 하며,
양 조건이 모두 관측된 50회기/150축만 paired 표본으로 집계한다. 한쪽 결측이면 양쪽 모두 최대오류 ITT로 처리한다.
`scripts/check-g7-human-voice-gain.py --input <pack.json>`을 먼저 통과하지 못하면 production runner는 마이크를
열기 전에 exit 2로 끝난다. 스키마는 `--print-schema`로 출력한다. 관련 API+script 계약은 97/97 통과했다.
- 무마이크 rehearsal 산출물은 - 무마이크 rehearsal 산출물은
`D:\workspace\vignette-runtime-evidence\g7-rehearsal-b34623f3db05-20260809T134105Z`에 있다. `D:\workspace\vignette-runtime-evidence\g7-rehearsal-b34623f3db05-20260809T134105Z`에 있다.
voice `61af2e98…009c`, runtime `c6e8670e…4454`, topology `89f1cb86…a251`이며 세 leg 모두 passed, voice `61af2e98…009c`, runtime `c6e8670e…4454`, topology `89f1cb86…a251`이며 세 leg 모두 passed,
@ -673,9 +677,14 @@ node.exe .\node_modules\@playwright\test\cli.js test e2e/session-layout.spec.ts
--project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1 --reporter=line --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1 --reporter=line
``` ```
전체 Playwright inventory는 626 tests / 44 files다. 실행 환경/API/DB를 정확히 맞추지 않고 fixture failure를 전체 Playwright inventory는 627 tests / 45 files다. 실행 환경/API/DB를 정확히 맞추지 않고 fixture failure를
제품 failure로 오인하지 않는다. 같은 blocker가 두 번 반복되면 전체 재시도 대신 원인·증거·수정 계획을 먼저 보고한다. 제품 failure로 오인하지 않는다. 같은 blocker가 두 번 반복되면 전체 재시도 대신 원인·증거·수정 계획을 먼저 보고한다.
주기 실회기 검증은 `scripts/run-periodic-learner-e2e.py`가 소유한다. NAS 112건 중 실 API/DB는 22건이고
route fixture는 90건이므로 전체 숫자를 실제 회기 폐루프로 부르면 안 된다. 새 runner는 clean HEAD/tree에서만
전용 engine/DB/API/Web을 만들고 같은 학습자의 SSE→review→G4/G5 0→1→1과 returned-practice desktop/mobile 4건을
검증한 뒤 sentinel resource·PID·listener·temp 잔여 0을 영수증으로 남긴다. 공개 8001/55432/9099와 NAS는 금지다.
## 9. 핵심 변경 파일 ## 9. 핵심 변경 파일
- `scripts/launch-nas-preview-g8-helpers.py` · `scripts/test_launch_nas_preview_g8_helpers.py` (신규, Gate6 계약) - `scripts/launch-nas-preview-g8-helpers.py` · `scripts/test_launch_nas_preview_g8_helpers.py` (신규, Gate6 계약)

View file

@ -110,6 +110,9 @@
(2) **독립 blind human voice-gain pack** — held-out 30명 외 calibration split 참가자 포함 총 최소 31명 / (2) **독립 blind human voice-gain pack** — held-out 30명 외 calibration split 참가자 포함 총 최소 31명 /
held-out 50회기 / 150 paired axis / 독립 평가자 2인 / held-out 50회기 / 150 paired axis / 독립 평가자 2인 /
ICC(A,1) ≥ 0.75 · κ ≥ 0.70 · gain ≥ 0.01 · participant-cluster bootstrap 10,000회 95% CI lower > 0. ICC(A,1) ≥ 0.75 · κ ≥ 0.70 · gain ≥ 0.01 · participant-cluster bootstrap 10,000회 95% CI lower > 0.
runner는 이 pack의 category·κ·preregistration·paired completeness·결측 ITT를 3,120초 캡처 전에 검증하며,
부적합하면 마이크를 열지 않고 exit 2로 끝낸다. 독립 검수는
`python -X utf8 -B scripts/check-g7-human-voice-gain.py --input <pack.json>`이고 `--print-schema`로 계약을 확인한다.
**공개 선행 조건 완료(2026-08-09):** detached-clean `b34623f3…`·tree `32cc85c7…`에서 OpenAPI 126, **공개 선행 조건 완료(2026-08-09):** detached-clean `b34623f3…`·tree `32cc85c7…`에서 OpenAPI 126,
`/admin/voice-runtime`, exact local voice provider/model과 queue 4를 제공한다. fresh launcher receipt `/admin/voice-runtime`, exact local voice provider/model과 queue 4를 제공한다. fresh launcher receipt
`9f8d1941…a21bf`가 passed이고 두 Scheduled Task도 동일 source에 pin돼 result 0이다. Cloudflare의 Python `9f8d1941…a21bf`가 passed이고 두 Scheduled Task도 동일 source에 pin돼 result 0이다. Cloudflare의 Python
@ -120,7 +123,7 @@
`≥3000s`를 checker가 강제한다. Windows artifact는 detached-clean HEAD/tree, runner/collector/checker SHA, `≥3000s`를 checker가 강제한다. Windows artifact는 detached-clean HEAD/tree, runner/collector/checker SHA,
exact `psutil==6.1.1`을 매 sample 전후 pin한다. fresh launcher는 legacy API와 exact-config cloudflared를 bounded exact `psutil==6.1.1`을 매 sample 전후 pin한다. fresh launcher는 legacy API와 exact-config cloudflared를 bounded
교체하고 새 PID/start/exe/command SHA/실제 cwd의 raw command-line 없는 receipt를 만든다. G7 runner/checker/topology 교체하고 새 PID/start/exe/command SHA/실제 cwd의 raw command-line 없는 receipt를 만든다. G7 runner/checker/topology
87/87과 runtime sampler 4/4를 통과했다. `--rehearse`는 인증 WSS ready/ping/close 1000, human pack 계약 97/97과 기존 runtime sampler 4/4를 통과했다. `--rehearse`는 인증 WSS ready/ping/close 1000,
runtime 7 samples, Windows topology 7 samples를 모두 통과했고 마이크 capture false·UUID/email literal 0이다. runtime 7 samples, Windows topology 7 samples를 모두 통과했고 마이크 capture false·UUID/email literal 0이다.
fail-closed 경계(동의 없음·pack 없음·3,120초 미만·host/Origin/scheme 불일치)는 CLI로 실증했다. 상세: fail-closed 경계(동의 없음·pack 없음·3,120초 미만·host/Origin/scheme 불일치)는 CLI로 실증했다. 상세:
`ops/outcome-os-g7-external-proof-readiness-2026-08-07.md`. `ops/outcome-os-g7-external-proof-readiness-2026-08-07.md`.
@ -136,7 +139,9 @@
> dump/restore를 수행하지 않으므로 매 execute 직전 fresh custom dump SHA/size/TOC와 DB identity를 별도 결속한다. > dump/restore를 수행하지 않으므로 매 execute 직전 fresh custom dump SHA/size/TOC와 DB identity를 별도 결속한다.
> 자동 rollback은 image/Compose/active-state만 복구하며 적용 migration/data restore는 별도 owner 승인이 필요하다. > 자동 rollback은 image/Compose/active-state만 복구하며 적용 migration/data restore는 별도 owner 승인이 필요하다.
> 과거 `6030a677…c611`의 localhost 108/108·평문 origin UUID 24건 실패와 후속 verified rollback은 이력으로 보존한다. > 과거 `6030a677…c611`의 localhost 108/108·평문 origin UUID 24건 실패와 후속 verified rollback은 이력으로 보존한다.
> 매일 04:30 KST `vignette-e2e` 자동화는 ACTIVE다. > 매시간 `vignette-e2e` heartbeat 자동화는 ACTIVE다. 공개/NAS 화면과 health는 읽기 전용으로 보여주고,
> 소스 material milestone에서만 전용 disposable 회기 E2E를 실행한다. runner의 clean HEAD/tree·로컬 npipe Docker·
> loopback 동적 포트·sentinel·exact cleanup 계약과 unit 10/10은 완료됐고, 첫 실제 GREEN receipt는 clean commit 뒤 남았다.
> 증거: [배포 증거](./ops/nas-preview-deployment-evidence-2026-08-07.md), [기계 판독 증거](./ops/evidence/nas-preview-current-deploy-2026-08-07.json), [브라우저 증거](./ops/evidence/nas-preview-live-turn-2026-08-07.png). 이는 G7 물리 마이크·독립 사람 평가 증거를 대체하지 않는다. > 증거: [배포 증거](./ops/nas-preview-deployment-evidence-2026-08-07.md), [기계 판독 증거](./ops/evidence/nas-preview-current-deploy-2026-08-07.json), [브라우저 증거](./ops/evidence/nas-preview-live-turn-2026-08-07.png). 이는 G7 물리 마이크·독립 사람 평가 증거를 대체하지 않는다.
- [ ] **claude_cli ↔ Anthropic API live 동일성** — provider 라우팅·모델 탐색·설정 저장 경로는 구현 완료. 남은 게이트는 연구팀/기관 `ANTHROPIC_API_KEY`를 게이트웨이 호스트에 주입한 뒤 같은 프롬프트의 live 응답·계량·오류 표면화를 비교하는 것이다. - [ ] **claude_cli ↔ Anthropic API live 동일성** — provider 라우팅·모델 탐색·설정 저장 경로는 구현 완료. 남은 게이트는 연구팀/기관 `ANTHROPIC_API_KEY`를 게이트웨이 호스트에 주입한 뒤 같은 프롬프트의 live 응답·계량·오류 표면화를 비교하는 것이다.

View file

@ -272,7 +272,14 @@
.mstrip{flex-wrap:nowrap;overflow-x:auto;padding-bottom:4px;scrollbar-width:thin;} .mstrip{flex-wrap:nowrap;overflow-x:auto;padding-bottom:4px;scrollbar-width:thin;}
.mchip{flex:0 0 auto;} .mchip{flex:0 0 auto;}
} }
@media (max-width:460px){ .kpis{grid-template-columns:1fr;} h1{font-size:22px;} .stamp{display:none;} } @media (max-width:460px){
.kpis{grid-template-columns:1fr;}
h1{font-size:22px;}
.stamp{display:none;}
.dg-note{overflow-wrap:anywhere;}
.ocols{grid-template-columns:minmax(0,1fr);}
.ocol,.ostack,.ocard{min-width:0;max-width:100%;width:auto;}
}
@media (prefers-reduced-motion:reduce){ *{transition:none!important;} } @media (prefers-reduced-motion:reduce){ *{transition:none!important;} }
</style> </style>
</head> </head>
@ -658,6 +665,7 @@
<p class="dg-note">190차 적용(2026-08-09): exact include 52개를 clean commit <code>5221f79e…1c69</code>으로 고정하고 tree <code>e4f15001…308b</code>, 2회 동일 archive <code>1109bf86…0f4b</code>를 결속했다. fresh NAS dump <code>6f4b95a7…b529f</code>(1,015,222 bytes·TOC 1,752/TABLE DATA 129) 뒤 execute는 API 921·types·typecheck·build·insecure-context 6/6·candidate DB 회기 후반까지 통과했지만 <b>110/112</b>에서 승격 전에 fail-closed했다. 실패 2건은 Vite entry <code>/src/main.tsx</code>만 차단하던 boot 진단 테스트가 production hashed entry <code>/assets/index-*.js</code>를 차단하지 못한 환경 계약 누락이었다. 두 entry를 함께 차단하도록 고쳐 Vite source 2/2와 production preview 2/2를 통과했다. NAS mutation·rollback·DB migration은 0이며, 테스트 수정의 새 clean commit을 재결속한 한 번의 execute가 남았다.</p> <p class="dg-note">190차 적용(2026-08-09): exact include 52개를 clean commit <code>5221f79e…1c69</code>으로 고정하고 tree <code>e4f15001…308b</code>, 2회 동일 archive <code>1109bf86…0f4b</code>를 결속했다. fresh NAS dump <code>6f4b95a7…b529f</code>(1,015,222 bytes·TOC 1,752/TABLE DATA 129) 뒤 execute는 API 921·types·typecheck·build·insecure-context 6/6·candidate DB 회기 후반까지 통과했지만 <b>110/112</b>에서 승격 전에 fail-closed했다. 실패 2건은 Vite entry <code>/src/main.tsx</code>만 차단하던 boot 진단 테스트가 production hashed entry <code>/assets/index-*.js</code>를 차단하지 못한 환경 계약 누락이었다. 두 entry를 함께 차단하도록 고쳐 Vite source 2/2와 production preview 2/2를 통과했다. NAS mutation·rollback·DB migration은 0이며, 테스트 수정의 새 clean commit을 재결속한 한 번의 execute가 남았다.</p>
<p class="dg-note">191차 적용(2026-08-09): 후속 clean commit <code>21461ab3…6fde</code>·tree <code>5c5f12a8…524e</code>·archive <code>20d49694…fa1a</code>는 candidate <b>112/112</b>를 통과하고 NAS 새 API/Web <code>d5021950…</code>/<code>376a3aa8…</code>를 올렸다. 실제 NAS-origin postdeploy G4 desktop/mobile 2건이 110/112에서 실패하자 active-state commit 전에 이전 <code>52e0e816…</code>/<code>6fdbb646…</code>로 자동 rollback했고 health·auth 401·OpenAPI 126·G0~G8 route·image ID를 재검증해 rollback <b>verified</b>로 종료했다. exact-image 평문 origin에서 문장 fingerprint의 <code>crypto.subtle.digest</code> 의존이 요청 전 예외를 내는 원인을 확인했다. 원문 외부 전송 없이 portable SHA-256 fallback을 추가하고 표준 digest를 payload에 exact 고정해 secure localhost 2/2와 Tailnet insecure origin 2/2를 통과했다. 새 clean commit의 전체 candidate+NAS-origin 0 failure 전까지 G8은 runtime REVALIDATION이다.</p> <p class="dg-note">191차 적용(2026-08-09): 후속 clean commit <code>21461ab3…6fde</code>·tree <code>5c5f12a8…524e</code>·archive <code>20d49694…fa1a</code>는 candidate <b>112/112</b>를 통과하고 NAS 새 API/Web <code>d5021950…</code>/<code>376a3aa8…</code>를 올렸다. 실제 NAS-origin postdeploy G4 desktop/mobile 2건이 110/112에서 실패하자 active-state commit 전에 이전 <code>52e0e816…</code>/<code>6fdbb646…</code>로 자동 rollback했고 health·auth 401·OpenAPI 126·G0~G8 route·image ID를 재검증해 rollback <b>verified</b>로 종료했다. exact-image 평문 origin에서 문장 fingerprint의 <code>crypto.subtle.digest</code> 의존이 요청 전 예외를 내는 원인을 확인했다. 원문 외부 전송 없이 portable SHA-256 fallback을 추가하고 표준 digest를 payload에 exact 고정해 secure localhost 2/2와 Tailnet insecure origin 2/2를 통과했다. 새 clean commit의 전체 candidate+NAS-origin 0 failure 전까지 G8은 runtime REVALIDATION이다.</p>
<p class="dg-note">192차 적용(2026-08-09): G8 clean-head 최종 승격을 완료했다. source HEAD <code>61a41d1f…6af</code>·tree <code>87dec55d…3b77</code>에서 2회 동일하게 만든 archive <code>4d15d055d74f…119d4d</code>를 active release로 고정했고 candidate session E2E <b>112/112</b>와 실제 <code>http://100.116.83.60:8088</code> 평문 NAS-origin browser review <b>112/112</b>(11 specs, desktop/mobile/single-run)를 모두 통과했다. 실행 이미지는 API <code>d5021950…e4b1</code>·Web <code>9796c092…4b36</code>이며 health는 <code>status=ok·db=true·engine=true</code>, 비인증 auth 401, OpenAPI 126을 재확인했다. execute 직전 fresh custom dump <code>36ec8748…24db8</code>(1,097,100 bytes, TOC 1,738 / TABLE DATA 129)를 보존했고 이전 active <code>6030a677…c611</code>과 이전 이미지는 rollback 기준으로 남겼다. 앞선 fail-closed·verified rollback 이력은 삭제하지 않는다. 이 증거로 G8을 <b>DONE</b>으로 승격하며 남은 Outcome OS gate는 G7 external proof다.</p> <p class="dg-note">192차 적용(2026-08-09): G8 clean-head 최종 승격을 완료했다. source HEAD <code>61a41d1f…6af</code>·tree <code>87dec55d…3b77</code>에서 2회 동일하게 만든 archive <code>4d15d055d74f…119d4d</code>를 active release로 고정했고 candidate session E2E <b>112/112</b>와 실제 <code>http://100.116.83.60:8088</code> 평문 NAS-origin browser review <b>112/112</b>(11 specs, desktop/mobile/single-run)를 모두 통과했다. 실행 이미지는 API <code>d5021950…e4b1</code>·Web <code>9796c092…4b36</code>이며 health는 <code>status=ok·db=true·engine=true</code>, 비인증 auth 401, OpenAPI 126을 재확인했다. execute 직전 fresh custom dump <code>36ec8748…24db8</code>(1,097,100 bytes, TOC 1,738 / TABLE DATA 129)를 보존했고 이전 active <code>6030a677…c611</code>과 이전 이미지는 rollback 기준으로 남겼다. 앞선 fail-closed·verified rollback 이력은 삭제하지 않는다. 이 증거로 G8을 <b>DONE</b>으로 승격하며 남은 Outcome OS gate는 G7 external proof다.</p>
<p class="dg-note">193차 적용(2026-08-09): 학생 화면의 320×568 신뢰성 게이트를 닫았다. 처방 재연습 prestart CTA를 첫 화면에 유지하고, active 회기의 마이크·일시정지·종료를 모든 밀도 구간에서 최소 44px로 고정했으며, 대시보드의 긴 privacy token과 owner grid가 320px 문서를 384px까지 넓히던 overflow를 제거했다. typecheck, session-layout desktop/mobile <b>8/8</b>, dev-dashboard desktop/mobile <b>10/10</b>, 자기주도 focused <b>16/16</b>을 통과했다. 동시에 NAS 112건을 실 API/DB 22와 route fixture 90으로 정직하게 분리하고, clean HEAD/tree·전용 disposable stack·동일 학습자 SSE→review→G4/G5 0→1→1·exact cleanup을 강제하는 주기 runner를 추가했다. unit <b>10/10</b>과 Compose preflight는 통과했으며 첫 실제 GREEN receipt는 이 변경의 clean commit 뒤 실행한다. G7 human pack은 category·κ≥0.70·preregistration·paired completeness·결측 ITT를 캡처 전에 검증해 부적합 pack이면 마이크를 열지 않는다.</p>
<div class="dg-principles" aria-label="디자인 생성 가드레일"> <div class="dg-principles" aria-label="디자인 생성 가드레일">
<div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div> <div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div>
<div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div> <div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div>
@ -912,7 +920,7 @@
</article> </article>
<article class="scard" data-status="doing" data-cat="Outcome OS·음성" data-owner="0"> <article class="scard" data-status="doing" data-cat="Outcome OS·음성" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-doing">G7 GATE · INTERNAL DONE · EXTERNAL PROOF</span><span class="scard-mid"><span class="scard-title">Multimodal Alliance — 음성·비언어 동맹 신호</span><span class="scard-sum">clean 공개 runtime·인증 WSS 무마이크 rehearsal까지 닫았고, 동의 마이크·사람 평가 증거를 기다린다.</span></span><span class="caret" aria-hidden="true"></span></button> <button class="scard-head" aria-expanded="false"><span class="chip c-doing">G7 GATE · INTERNAL DONE · EXTERNAL PROOF</span><span class="scard-mid"><span class="scard-title">Multimodal Alliance — 음성·비언어 동맹 신호</span><span class="scard-sum">clean 공개 runtime·인증 WSS 무마이크 rehearsal까지 닫았고, 동의 마이크·사람 평가 증거를 기다린다.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv k-good"><b>내부 구현·공개 선행조건 DONE</b><p>consent→단일 clock→독립 text/voice/fusion→철회·tombstone, streaming interim/final·word timestamp·provider event, HMAC word pseudonym, 열린 stream의 1초 동의 재검사·abort, 텍스트 보존·음성 재연결 UX를 연결했다. 운영 기본 provider는 노트북 상주 <code>local_whisper</code>/<code>melotts</code>이고 외부 Deepgram/OpenAI adapter는 fallback으로 보존한다. 격리 synthetic PCM soak는 59/59를 통과했다. Windows host topology는 detached-clean commit/tree·도구 SHA·<code>psutil</code> version과 PID/start/exe·command SHA/cwd·process/TCP high-water를 fail-closed로 수집하고, 3,120초 capture의 공통 3,000초를 canonical exit와 결속한다. 공개 API/cloudflared는 clean <code>b34623f3…</code>·tree <code>32cc85c7…</code>로 fresh 승격돼 OpenAPI 126·<code>/admin/voice-runtime</code>·<code>local_whisper/small</code>·<code>melotts/melotts-korean</code>·WS queue 4를 제공한다. receipt <code>9f8d1941…a21bf</code>가 passed이고 두 source-pinned task도 result 0이다. current six-suite 87/87·runtime sampler 4/4를 통과했다.</p></div><div class="kv k-warn"><b>외부 종료 GATE</b><p>30초 무마이크 rehearsal은 authenticated public WSS ready/ping/close1000, runtime 7 samples, Windows topology 7 samples를 모두 통과했고 <code>physical_capture=false</code>·UUID/email literal 0이다. 남은 것은 사용자가 선택한 장치와 실행 직전 명시 동의를 받은 물리 마이크 3,120초 양방향 soak, 같은 public host·공통 3,000초 시간창의 worker/Uvicorn queue와 process/TCP high-water, 최소 31명·held-out 50회기·150축·blind evaluator 2인의 독립 human voice-gain pack이다. 네 artifact가 <code>scripts/check-g7-external-proof.py</code> exit 0을 만들기 전에는 메인 상태를 DONE으로 바꾸지 않는다.</p></div></div> <div class="scard-body"><div class="kv k-good"><b>내부 구현·공개 선행조건 DONE</b><p>consent→단일 clock→독립 text/voice/fusion→철회·tombstone, streaming interim/final·word timestamp·provider event, HMAC word pseudonym, 열린 stream의 1초 동의 재검사·abort, 텍스트 보존·음성 재연결 UX를 연결했다. 운영 기본 provider는 노트북 상주 <code>local_whisper</code>/<code>melotts</code>이고 외부 Deepgram/OpenAI adapter는 fallback으로 보존한다. 격리 synthetic PCM soak는 59/59를 통과했다. Windows host topology는 detached-clean commit/tree·도구 SHA·<code>psutil</code> version과 PID/start/exe·command SHA/cwd·process/TCP high-water를 fail-closed로 수집하고, 3,120초 capture의 공통 3,000초를 canonical exit와 결속한다. 공개 API/cloudflared는 clean <code>b34623f3…</code>·tree <code>32cc85c7…</code>로 fresh 승격돼 OpenAPI 126·<code>/admin/voice-runtime</code>·<code>local_whisper/small</code>·<code>melotts/melotts-korean</code>·WS queue 4를 제공한다. receipt <code>9f8d1941…a21bf</code>가 passed이고 두 source-pinned task도 result 0이다. human pack은 category·κ·preregistration·paired completeness·결측 ITT를 캡처 전에 검증하며 관련 계약 97/97·runtime sampler 4/4를 통과했다.</p></div><div class="kv k-warn"><b>외부 종료 GATE</b><p>30초 무마이크 rehearsal은 authenticated public WSS ready/ping/close1000, runtime 7 samples, Windows topology 7 samples를 모두 통과했고 <code>physical_capture=false</code>·UUID/email literal 0이다. 남은 것은 사용자가 선택한 장치와 실행 직전 명시 동의를 받은 물리 마이크 3,120초 양방향 soak, 같은 public host·공통 3,000초 시간창의 worker/Uvicorn queue와 process/TCP high-water, 최소 31명·held-out 50회기·150축·blind evaluator 2인의 독립 human voice-gain pack이다. pack은 <code>check-g7-human-voice-gain.py</code>를 먼저 통과해야 하며 부적합하면 마이크를 열기 전에 exit 2다. 네 artifact가 <code>scripts/check-g7-external-proof.py</code> exit 0을 만들기 전에는 메인 상태를 DONE으로 바꾸지 않는다.</p></div></div>
</article> </article>
<article class="scard" data-status="done" data-cat="Outcome OS·에이전틱" data-owner="0"> <article class="scard" data-status="done" data-cat="Outcome OS·에이전틱" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-done">G8 DONE · CLEAN-HEAD NAS VERIFIED</span><span class="scard-mid"><span class="scard-title">Autonomous Content &amp; Continuous Improvement</span><span class="scard-sum">agentic worker·human gate·실제 rollback과 current clean source의 NAS-origin 112/112를 모두 닫았다.</span></span><span class="caret" aria-hidden="true"></span></button> <button class="scard-head" aria-expanded="false"><span class="chip c-done">G8 DONE · CLEAN-HEAD NAS VERIFIED</span><span class="scard-mid"><span class="scard-title">Autonomous Content &amp; Continuous Improvement</span><span class="scard-sum">agentic worker·human gate·실제 rollback과 current clean source의 NAS-origin 112/112를 모두 닫았다.</span></span><span class="caret" aria-hidden="true"></span></button>
@ -1068,7 +1076,7 @@
<tbody> <tbody>
<tr><td>Web typecheck</td><td><code>npm run typecheck</code></td><td>Passed</td></tr> <tr><td>Web typecheck</td><td><code>npm run typecheck</code></td><td>Passed</td></tr>
<tr><td>Design SSOT / auth visual</td><td><code>npm run check:design-ssot</code> / <code>npx playwright test e2e/auth-visual.spec.ts --project=chromium-single-run --reporter=line</code> / <code>npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --reporter=line</code></td><td>SSOT checker passed; login/onboarding light-dark desktop-mobile 1 passed; 14 core screens × 7 widths visual gate 14 passed.</td></tr> <tr><td>Design SSOT / auth visual</td><td><code>npm run check:design-ssot</code> / <code>npx playwright test e2e/auth-visual.spec.ts --project=chromium-single-run --reporter=line</code> / <code>npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --reporter=line</code></td><td>SSOT checker passed; login/onboarding light-dark desktop-mobile 1 passed; 14 core screens × 7 widths visual gate 14 passed.</td></tr>
<tr><td>Full Playwright E2E baseline</td><td><code>npm run e2e:parallel</code> / <code>npm run e2e:single-run</code> / <code>npm run e2e:list</code></td><td>2026-08-09 현재 수집은 <b>626 tests / 44 files</b>다. 이 숫자는 수집량이며 현 작업트리 전체 GREEN과 동일하지 않다. G8 clean-head release gate는 candidate 112/112와 실제 NAS-origin 112/112를 통과했다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다.</td></tr> <tr><td>Full Playwright E2E baseline</td><td><code>npm run e2e:parallel</code> / <code>npm run e2e:single-run</code> / <code>npm run e2e:list</code></td><td>2026-08-09 현재 수집은 <b>627 tests / 45 files</b>다. 이 숫자는 수집량이며 현 작업트리 전체 GREEN과 동일하지 않다. G8 clean-head release gate는 candidate 112/112와 실제 NAS-origin 112/112를 통과했다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다.</td></tr>
<tr><td>Refactor governance P1~P8</td><td><code>ruff check app</code> / <code>pytest -q app</code> / <code>pytest -q engine_gateway</code> / <code>npm run typecheck</code> / <code>npm run check:api-types</code> / <code>npm run check:design-ssot</code> / <code>npm run check:dead-code</code> / <code>npm run check:duplication</code> / <code>npm run build</code> / <code>npm audit --audit-level=high</code> / full Playwright</td><td>Backend 400 passed, gateway 29 passed, web gates/build/audit passed, vulnerabilities 0, production duplication 1 clone/15 lines/0.03%, Playwright 215/215 passed. 상세 근거는 <code>ops/refactor-governance-2026-07-15.md</code>.</td></tr> <tr><td>Refactor governance P1~P8</td><td><code>ruff check app</code> / <code>pytest -q app</code> / <code>pytest -q engine_gateway</code> / <code>npm run typecheck</code> / <code>npm run check:api-types</code> / <code>npm run check:design-ssot</code> / <code>npm run check:dead-code</code> / <code>npm run check:duplication</code> / <code>npm run build</code> / <code>npm audit --audit-level=high</code> / full Playwright</td><td>Backend 400 passed, gateway 29 passed, web gates/build/audit passed, vulnerabilities 0, production duplication 1 clone/15 lines/0.03%, Playwright 215/215 passed. 상세 근거는 <code>ops/refactor-governance-2026-07-15.md</code>.</td></tr>
<tr><td>API typegen SSOT</td><td><code>npm run check:api-types</code></td><td>Passed; FastAPI OpenAPI → <code>src/lib/api.gen.ts</code> stale check</td></tr> <tr><td>API typegen SSOT</td><td><code>npm run check:api-types</code></td><td>Passed; FastAPI OpenAPI → <code>src/lib/api.gen.ts</code> stale check</td></tr>
<tr><td>Outcome &amp; Alliance OS G0</td><td><code>py -3.11 -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_measurement_contract.py apps/api/app/test_runtime_schema_ssot.py -q</code> / <code>scripts/check-measurement-ledger.sql</code> / measurement·API contract checks / web typecheck / DB-backed <code>session-persistence</code> focused E2E 3종</td><td>G0 contract/schema 11 passed, 기존 backend 100 passed, auth 39 passed. Python→JSON Schema→TypeScript→PostgreSQL enum·필수필드 계약이 일치하고 8개 deterministic benchmark가 검증됐다. Live PostgreSQL에서 learner/client/evaluator 가시 행 1/1/2, 교차 누수 0, append-only guard 2를 확인했다. 학습자 턴→교수자 대시보드, 워크시트 검수, 종료 deep 평가→durable 리뷰 E2E는 각각 1 passed. G0/AOS-001~004 완료.</td></tr> <tr><td>Outcome &amp; Alliance OS G0</td><td><code>py -3.11 -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_measurement_contract.py apps/api/app/test_runtime_schema_ssot.py -q</code> / <code>scripts/check-measurement-ledger.sql</code> / measurement·API contract checks / web typecheck / DB-backed <code>session-persistence</code> focused E2E 3종</td><td>G0 contract/schema 11 passed, 기존 backend 100 passed, auth 39 passed. Python→JSON Schema→TypeScript→PostgreSQL enum·필수필드 계약이 일치하고 8개 deterministic benchmark가 검증됐다. Live PostgreSQL에서 learner/client/evaluator 가시 행 1/1/2, 교차 누수 0, append-only guard 2를 확인했다. 학습자 턴→교수자 대시보드, 워크시트 검수, 종료 deep 평가→durable 리뷰 E2E는 각각 1 passed. G0/AOS-001~004 완료.</td></tr>
@ -1145,7 +1153,7 @@
<tr><td>Docker image smoke</td><td><code>docker build -f apps/api/Dockerfile .</code> / <code>docker run ... python -c "import app.main"</code> / <code>docker build -f apps/web/Dockerfile apps/web</code></td><td>API/Web image build and API import smoke pass after packaging cleanup. Web build uses npm lockfile and ignores host <code>node_modules</code>; API image excludes local <code>.env</code> files.</td></tr> <tr><td>Docker image smoke</td><td><code>docker build -f apps/api/Dockerfile .</code> / <code>docker run ... python -c "import app.main"</code> / <code>docker build -f apps/web/Dockerfile apps/web</code></td><td>API/Web image build and API import smoke pass after packaging cleanup. Web build uses npm lockfile and ignores host <code>node_modules</code>; API image excludes local <code>.env</code> files.</td></tr>
<tr><td>Deploy preflight</td><td><code>python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets</code> / DB mode with local <code>DATABASE_URL</code></td><td>Passed: exact-pinned API requirements, live coaching <code>data/kb</code> source pack, env template keys, and DB readiness (<code>current_user=vignette</code>). DB mode can additionally check app-role DSN with <code>--require-app-role</code> and now verifies session read-model columns including <code>app.turns</code> voice metadata columns(<code>audio_ref</code>/<code>silence_ms</code>/<code>speech_rate</code>/<code>barge_in</code>/<code>provider_events</code>) plus worksheet review columns.</td></tr> <tr><td>Deploy preflight</td><td><code>python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets</code> / DB mode with local <code>DATABASE_URL</code></td><td>Passed: exact-pinned API requirements, live coaching <code>data/kb</code> source pack, env template keys, and DB readiness (<code>current_user=vignette</code>). DB mode can additionally check app-role DSN with <code>--require-app-role</code> and now verifies session read-model columns including <code>app.turns</code> voice metadata columns(<code>audio_ref</code>/<code>silence_ms</code>/<code>speech_rate</code>/<code>barge_in</code>/<code>provider_events</code>) plus worksheet review columns.</td></tr>
<tr><td>Fresh compose smoke</td><td><code>docker compose -p vignette-packaging-smoke -f infra/docker-compose.yml up -d --build</code> + proxy <code>/api/health</code></td><td>Passed with dummy production-safe env: API healthy, DB healthy, web/proxy up, <code>http://localhost:18080/api/health</code> 200 with <code>db:true</code>, <code>engine:true</code>, <code>engine_mode:"claude_cli"</code>. Smoke volumes/network removed after run.</td></tr> <tr><td>Fresh compose smoke</td><td><code>docker compose -p vignette-packaging-smoke -f infra/docker-compose.yml up -d --build</code> + proxy <code>/api/health</code></td><td>Passed with dummy production-safe env: API healthy, DB healthy, web/proxy up, <code>http://localhost:18080/api/health</code> 200 with <code>db:true</code>, <code>engine:true</code>, <code>engine_mode:"claude_cli"</code>. Smoke volumes/network removed after run.</td></tr>
<tr><td>격리 NAS 프리뷰 · 회기 E2E 자동화</td><td><code>http://100.116.83.60:8088</code> / <code>vignette-e2e</code>일 04:30 KST / <a href="./ops/nas-preview-deployment-evidence-2026-08-07.md">배포 증거</a> / <a href="./ops/evidence/nas-preview-current-deploy-2026-08-07.json">기계 판독 증거</a></td><td>current clean source HEAD <code>61a41d1f…6af</code>·tree <code>87dec55d…3b77</code>의 archive <code>4d15d055…119d4d</code>를 전용 Compose 프로젝트·포트·네트워크·named volume에 승격했다. candidate session E2E 112/112와 실제 NAS 평문 origin browser review 112/112, health ok·db/engine true, OpenAPI 126·auth 401을 통과했고 fresh dump와 이전 active/images를 보존했다. 자동화는 ACTIVE이며 material milestone+release gate·NAS preflight+배포 SHA 변경 때만 프리뷰를 갱신한다. G7 외부 mic/provider/human 증거는 별도다.</td></tr> <tr><td>격리 NAS 프리뷰 · 회기 E2E 자동화</td><td><code>http://100.116.83.60:8088</code> / <code>vignette-e2e</code>시간 heartbeat / <a href="./ops/nas-preview-deployment-evidence-2026-08-07.md">배포 증거</a> / <a href="./ops/evidence/nas-preview-current-deploy-2026-08-07.json">기계 판독 증거</a></td><td>current clean source HEAD <code>61a41d1f…6af</code>·tree <code>87dec55d…3b77</code>의 archive <code>4d15d055…119d4d</code>를 전용 Compose 프로젝트·포트·네트워크·named volume에 승격했다. candidate 112/112와 실제 NAS-origin 112/112는 통과했지만 영수증은 실 API/DB 22건과 route fixture 90건을 분리한다. 자동화는 공개/NAS 화면과 health를 읽기 전용으로 매시간 보여준다. material milestone의 동일 학습자 SSE→review→G4/G5 actual 폐루프는 clean HEAD/tree·전용 engine/DB/API/Web·보호 포트 거부·sentinel·exact cleanup runner가 소유하며 unit 10/10은 통과했다. 첫 실제 GREEN receipt는 clean commit 뒤 남았다. release gate·NAS preflight·배포 SHA 변경 때만 프리뷰를 갱신한다. G7 외부 mic/provider/human 증거는 별도다.</td></tr>
<tr><td>G7 외부 증거 3-artifact 동시 시간창 오케스트레이터</td><td><code>scripts/run-g7-external-proof-window.py</code> · <a href="./ops/outcome-os-g7-external-proof-readiness-2026-08-07.md">준비 문서</a></td><td>soak·runtime·topology 세 캡처를 같은 host의 공통 3,000초 시간창으로 실행하고 checker까지 잇는다. 공개 배포·프로세스·source pin은 clean commit <code>b34623f3…</code>와 fresh receipt <code>9f8d1941…</code>로 완료했다. Cloudflare가 Python 기본 User-Agent를 403으로 거부하는 경계도 브라우저 호환 header와 회귀 4/4로 고정했다. 30초 rehearsal은 세 leg 7 samples를 모두 통과했으며 마이크를 열지 않았다. <b>남은 선행 조건:</b> 사용자의 물리 장치 선택과 명시 동의, 독립 human voice-gain pack이다.</td></tr> <tr><td>G7 외부 증거 3-artifact 동시 시간창 오케스트레이터</td><td><code>scripts/run-g7-external-proof-window.py</code> · <a href="./ops/outcome-os-g7-external-proof-readiness-2026-08-07.md">준비 문서</a></td><td>soak·runtime·topology 세 캡처를 같은 host의 공통 3,000초 시간창으로 실행하고 checker까지 잇는다. 공개 배포·프로세스·source pin은 clean commit <code>b34623f3…</code>와 fresh receipt <code>9f8d1941…</code>로 완료했다. Cloudflare가 Python 기본 User-Agent를 403으로 거부하는 경계도 브라우저 호환 header와 회귀 4/4로 고정했다. 30초 rehearsal은 세 leg 7 samples를 모두 통과했으며 마이크를 열지 않았다. <b>남은 선행 조건:</b> 사용자의 물리 장치 선택과 명시 동의, 독립 human voice-gain pack이다.</td></tr>
<tr><td>G7 external GATE · 최종 감사 정정</td><td><code>run-g7-external-proof-window.py</code><code>check-g7-external-proof.py</code></td><td><b>상태는 external GATE 유지.</b> runner 프로세스 exit는 canonical checker <code>exit 0</code>·<code>gate_closed=true</code>에 결속되고, browser Origin과 API transport host는 별도 allowlist로 검증된다. 세 artifact 교집합은 <code>≥3000s</code> + 120초 margin이고 Windows proof는 detached-clean HEAD/tree·tool SHA·<code>psutil==6.1.1</code>을 pin한다. clean-source 배포와 무마이크 rehearsal은 완료했다. 남은 것은 물리 마이크 3,120초, 동시 runtime/topology, 독립 human pack을 합친 4-artifact 실증뿐이다.</td></tr> <tr><td>G7 external GATE · 최종 감사 정정</td><td><code>run-g7-external-proof-window.py</code><code>check-g7-external-proof.py</code></td><td><b>상태는 external GATE 유지.</b> runner 프로세스 exit는 canonical checker <code>exit 0</code>·<code>gate_closed=true</code>에 결속되고, browser Origin과 API transport host는 별도 allowlist로 검증된다. 세 artifact 교집합은 <code>≥3000s</code> + 120초 margin이고 Windows proof는 detached-clean HEAD/tree·tool SHA·<code>psutil==6.1.1</code>을 pin한다. clean-source 배포와 무마이크 rehearsal은 완료했다. 남은 것은 물리 마이크 3,120초, 동시 runtime/topology, 독립 human pack을 합친 4-artifact 실증뿐이다.</td></tr>
<tr><td>G8 DONE · clean-head NAS 기준선</td><td><code>vignette-preview-20260807</code> · <code>run-outcome-os-release-agent.py</code></td><td><b>상태는 DONE.</b> active archive <code>4d15d055…119d4d</code>, source HEAD/tree <code>61a41d1f…6af</code>/<code>87dec55d…3b77</code>, API/Web <code>d5021950…e4b1</code>/<code>9796c092…4b36</code> exact running. candidate 112/112와 actual NAS-origin 112/112, health ok·db/engine true·auth 401·OpenAPI 126을 통과했다. fresh dump <code>36ec8748…24db8</code> 1,097,100 bytes, TOC 1,738 / TABLE DATA 129와 previous active <code>6030a677…c611</code>을 보존했다. release agent의 DB restore 비소유·image/Compose/active-state rollback 경계는 유지한다.</td></tr> <tr><td>G8 DONE · clean-head NAS 기준선</td><td><code>vignette-preview-20260807</code> · <code>run-outcome-os-release-agent.py</code></td><td><b>상태는 DONE.</b> active archive <code>4d15d055…119d4d</code>, source HEAD/tree <code>61a41d1f…6af</code>/<code>87dec55d…3b77</code>, API/Web <code>d5021950…e4b1</code>/<code>9796c092…4b36</code> exact running. candidate 112/112와 actual NAS-origin 112/112, health ok·db/engine true·auth 401·OpenAPI 126을 통과했다. fresh dump <code>36ec8748…24db8</code> 1,097,100 bytes, TOC 1,738 / TABLE DATA 129와 previous active <code>6030a677…c611</code>을 보존했다. release agent의 DB restore 비소유·image/Compose/active-state rollback 경계는 유지한다.</td></tr>

View file

@ -20,7 +20,7 @@ Vignette 저장소의 모든 검증 수단(백엔드 단위 테스트, 웹 타
| API 타입 생성 체크 | `apps/web` | `npm run check:api-types` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass | | API 타입 생성 체크 | `apps/web` | `npm run check:api-types` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
| 웹 타입체크 | `apps/web` | `npm run typecheck` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass | | 웹 타입체크 | `apps/web` | `npm run typecheck` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
| 웹 빌드 | `apps/web` | `npm run build` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass | | 웹 빌드 | `apps/web` | `npm run build` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
| Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 현재 수집 626 tests / 44 files · 현 작업트리 전체 GREEN 미검증 | | Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 현재 수집 627 tests / 45 files · 현 작업트리 전체 GREEN 미검증 |
핵심 원칙: **단위 테스트(pytest)와 타입체크/빌드는 외부 서비스 없이 단독 실행된다.** 핵심 원칙: **단위 테스트(pytest)와 타입체크/빌드는 외부 서비스 없이 단독 실행된다.**
**E2E만 풀스택(DB+API+웹+브라우저)을 요구한다.** 아래 각 절에서 근거와 절차를 설명한다. **E2E만 풀스택(DB+API+웹+브라우저)을 요구한다.** 아래 각 절에서 근거와 절차를 설명한다.
@ -271,10 +271,17 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API
### 3.6 실측 테스트 개수 (현재) ### 3.6 실측 테스트 개수 (현재)
2026-08-09 `npx playwright test --list` 기준 **현재 수집 626 tests / 44 files**다. 2026-08-09 `npx playwright test --list` 기준 **현재 수집 627 tests / 45 files**다.
이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 626개 완주는 아직 증거가 없으며, 이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 627개 완주는 아직 증거가 없으며,
과거 전체 GREEN 기록과 이번 focused/release gate 결과를 구분해 적는다. 과거 전체 GREEN 기록과 이번 focused/release gate 결과를 구분해 적는다.
NAS-origin 112건은 전부 실 API/DB 회기가 아니다. release receipt는 `session-layout`·`session-persistence`
22건을 `real_api_db_specs`, 나머지 route fixture 90건을 `route_fixture_specs`로 분리한다.
학생 동일 계정의 홈 추천→브라우저 회기 생성→실제 SSE→review ready→G4/G5 POST·reload→0→1→1은
`scripts/run-periodic-learner-e2e.py --execute`가 전용 disposable engine/DB/API/Web에서만 실행한다.
이 runner는 clean HEAD/tree, 로컬 npipe Docker, loopback 동적 포트, sentinel과 exact cleanup을 강제하며
공개 8001·DB 55432·engine 9099와 NAS를 구조적으로 거부한다. 첫 GREEN receipt 전에는 스케줄 등록하지 않는다.
- **병렬 시나리오**: 166 tests (desktop 83 + mobile 83) - **병렬 시나리오**: 166 tests (desktop 83 + mobile 83)
- **`@single-run` 직렬 시나리오**: 49 tests (DB 영속화·세션 MVP·음성 성공경로·인증 시각 테마·회기말 평가 저장·교수자 명시 재평가 저장·교수자 턴 재평가 저장·교수자 UI 평가 재시도·교수자 사용자별 분석·source pack sync·이론모드 저장·동의 철회 후 voice 차단·브라우저 stream PII 마스킹·음성 transcript 저장 실패 UI 표면화 등) - **`@single-run` 직렬 시나리오**: 49 tests (DB 영속화·세션 MVP·음성 성공경로·인증 시각 테마·회기말 평가 저장·교수자 명시 재평가 저장·교수자 턴 재평가 저장·교수자 UI 평가 재시도·교수자 사용자별 분석·source pack sync·이론모드 저장·동의 철회 후 voice 차단·브라우저 stream PII 마스킹·음성 transcript 저장 실패 UI 표면화 등)
- 2026-08-06 Outcome & Alliance OS G0 검증: `test_measurement_contract.py` + runtime schema SSOT **11 passed**, - 2026-08-06 Outcome & Alliance OS G0 검증: `test_measurement_contract.py` + runtime schema SSOT **11 passed**,
@ -432,6 +439,10 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API
ready/ping/close1000, runtime 7 samples, Windows topology 7 samples를 모두 통과했고 `physical_capture=false`, ready/ping/close1000, runtime 7 samples, Windows topology 7 samples를 모두 통과했고 `physical_capture=false`,
UUID/email literal 0이다. current G7 six-suite는 87/87, runtime sampler는 4/4다. 이 리허설은 실제 마이크 UUID/email literal 0이다. current G7 six-suite는 87/87, runtime sampler는 4/4다. 이 리허설은 실제 마이크
3,120초·공통 3,000초 high-water·독립 human pack·canonical checker exit 0을 대체하지 않는다. 3,120초·공통 3,000초 high-water·독립 human pack·canonical checker exit 0을 대체하지 않는다.
- 2026-08-09 G7 human pack preflight: production runner는 캡처 전에 category·categorical κ≥0.70,
preregistration 선행, 50회기/150축 complete pairing, 양 조건 결측 ITT를 검증한다. 독립 검수는
`python -X utf8 -B scripts/check-g7-human-voice-gain.py --input <pack.json>`이며 `--print-schema`도 지원한다.
부적합 pack은 마이크를 열기 전에 exit 2다. API evaluator 11/11 + G7 script 86/86, 합계 97/97 통과했다.
- 2026-08-07 G7/G8 시각 QA: 데스크톱·Pixel 5에서 focused **16/16**, typecheck·build·cosmetic-filter를 - 2026-08-07 G7/G8 시각 QA: 데스크톱·Pixel 5에서 focused **16/16**, typecheck·build·cosmetic-filter를
통과했다. G8 승인 차단 이유를 `title` 의존에서 상시 문구·`aria-describedby`·semantic form/Enter 제출로 통과했다. G8 승인 차단 이유를 `title` 의존에서 상시 문구·`aria-describedby`·semantic form/Enter 제출로
바꾸고, G7 이벤트 최소 24×24px와 시간축 키보드 포커스를 수치 회귀로 고정했다. 상세는 바꾸고, G7 이벤트 최소 24×24px와 시간축 키보드 포커스를 수치 회귀로 고정했다. 상세는

View file

@ -90,8 +90,8 @@
PID/start/exe/command SHA/cwd safe receipt를 만든다. current six-suite 87/87과 runtime sampler 4/4를 통과했다. PID/start/exe/command SHA/cwd safe receipt를 만든다. current six-suite 87/87과 runtime sampler 4/4를 통과했다.
무마이크 rehearsal은 완료됐지만 물리 마이크·human pack을 대체하지 않는다. 무마이크 rehearsal은 완료됐지만 물리 마이크·human pack을 대체하지 않는다.
격리 NAS 프리뷰 `http://100.116.83.60:8088`은 전용 Compose 프로젝트·포트·네트워크·볼륨에 배포했고, 실제 브라우저 회기와 격리 NAS 프리뷰 `http://100.116.83.60:8088`은 전용 Compose 프로젝트·포트·네트워크·볼륨에 배포했고, 실제 브라우저 회기와
review API 저장 축어록 2턴을 확인했다. 기존 프로젝트 중단·재생성 명령은 실행하지 않았다. 매일 04:30 KST `Vignette 회기 E2E 정기 검증` review API 저장 축어록 2턴을 확인했다. 기존 프로젝트 중단·재생성 명령은 실행하지 않았다. 매시간 `Vignette 앱 상태·회기 E2E 정기 검증`
(automation id `vignette-e2e`)은 ACTIVE이며 material milestone+release gate·NAS preflight+배포 SHA 변경 때만 프리뷰를 갱신한다. 2026-08-07 SHA `6030a677af7e87cbfabc422b553d108d53414fd3c446548734a13b036d35c611`의 localhost 108/108과 평문 origin UUID 24건 실패는 역사 기준선으로 보존한다. 실제 receipt-bound rollback은 같은 프리뷰에서 별도 helper로 두 번 실행해 종료했다([런북](./nas-preview-g8-rollback-proof-runbook.md), [기계 판독 증거](./evidence/nas-preview-g8-actual-rollback-2026-08-07.json)). 현재는 source HEAD/tree `61a41d1f…6af`/`87dec55d…3b77`의 active archive `4d15d055…119d4d`, exact API/Web `d5021950…e4b1`/`9796c092…4b36`이 실행 중이다. candidate 112/112와 실제 NAS-origin 112/112, health ok·db/engine true·auth 401·OpenAPI 126을 통과했고 fresh dump `36ec8748…24db8` 1,097,100 bytes·TOC 1,738/TABLE DATA 129와 previous `6030a677…c611`을 보존했다. 첫 예약 실행 이력은 대기 상태다. 증거: [배포 증거](./nas-preview-deployment-evidence-2026-08-07.md), heartbeat(automation id `vignette-e2e`)는 ACTIVE이며 공개/NAS 화면·health는 읽기 전용으로 보여주고, material milestone에서만 전용 disposable 회기 E2E를 실행한다. NAS 112건은 실 API/DB 22와 route fixture 90으로 영수증에서 분리한다. 동일 학습자 SSE→review→G4/G5 actual 폐루프 runner의 clean HEAD/tree·보호 포트 거부·sentinel·exact cleanup과 unit 10/10은 완료됐고 첫 실제 GREEN receipt는 clean commit 뒤 남았다. release gate·NAS preflight·배포 SHA 변경 때만 프리뷰를 갱신한다. 2026-08-07 SHA `6030a677af7e87cbfabc422b553d108d53414fd3c446548734a13b036d35c611`의 localhost 108/108과 평문 origin UUID 24건 실패는 역사 기준선으로 보존한다. 실제 receipt-bound rollback은 같은 프리뷰에서 별도 helper로 두 번 실행해 종료했다([런북](./nas-preview-g8-rollback-proof-runbook.md), [기계 판독 증거](./evidence/nas-preview-g8-actual-rollback-2026-08-07.json)). 현재는 source HEAD/tree `61a41d1f…6af`/`87dec55d…3b77`의 active archive `4d15d055…119d4d`, exact API/Web `d5021950…e4b1`/`9796c092…4b36`이 실행 중이다. candidate 112/112와 실제 NAS-origin 112/112, health ok·db/engine true·auth 401·OpenAPI 126을 통과했고 fresh dump `36ec8748…24db8` 1,097,100 bytes·TOC 1,738/TABLE DATA 129와 previous `6030a677…c611`을 보존했다. 증거: [배포 증거](./nas-preview-deployment-evidence-2026-08-07.md),
[브라우저 증거](./evidence/nas-preview-live-turn-2026-08-07.png). 명시 동의 물리 마이크와 독립 human voice-gain 증거는 아직 없으므로 G7은 external GATE로 유지한다. [브라우저 증거](./evidence/nas-preview-live-turn-2026-08-07.png). 명시 동의 물리 마이크와 독립 human voice-gain 증거는 아직 없으므로 G7은 external GATE로 유지한다.
- [ ] **claude_cli ↔ Anthropic API live 동일성** — provider 라우팅·Anthropic `/v1/models` 탐색·지원 추론 강도·관리자 fail-closed 저장 경로는 구현 완료. 남은 범위는 연구팀/기관 `ANTHROPIC_API_KEY`를 게이트웨이 호스트에 주입한 live 응답·계량·오류 표면화 비교다. Claude CLI·Codex CLI(Terra/Medium)·Agy CLI(Gemini 3.6 Flash/High)는 로컬 live probe를 통과했다. - [ ] **claude_cli ↔ Anthropic API live 동일성** — provider 라우팅·Anthropic `/v1/models` 탐색·지원 추론 강도·관리자 fail-closed 저장 경로는 구현 완료. 남은 범위는 연구팀/기관 `ANTHROPIC_API_KEY`를 게이트웨이 호스트에 주입한 live 응답·계량·오류 표면화 비교다. Claude CLI·Codex CLI(Terra/Medium)·Agy CLI(Gemini 3.6 Flash/High)는 로컬 live probe를 통과했다.
- [ ] **stable-source 재부팅 후 watchdog smoke** — watchdog·로그온 boot는 detached-clean `b34623f3…` stable - [ ] **stable-source 재부팅 후 watchdog smoke** — watchdog·로그온 boot는 detached-clean `b34623f3…` stable

View file

@ -28,9 +28,11 @@ G7은 **내부 구현 DONE · external GATE**다. 이번 변경은 물리 마이
SHA, `psutil==6.1.1`, 새 API/cloudflared PID/start/exe·command SHA/실제 cwd를 매 sample 전후 검증한다. SHA, `psutil==6.1.1`, 새 API/cloudflared PID/start/exe·command SHA/실제 cwd를 매 sample 전후 검증한다.
checker는 voice/runtime/topology 세 artifact의 **공통 시간창이 3,000초 이상**인지 재계산한다. checker는 voice/runtime/topology 세 artifact의 **공통 시간창이 3,000초 이상**인지 재계산한다.
- 독립 human-labeled held-out voice-gain 계약은 raw audio·transcript·synthetic pack을 거부하고, - 독립 human-labeled held-out voice-gain 계약은 raw audio·transcript·synthetic pack을 거부하고,
사전등록·동의·participant split·model artifact·blind independent labeler provenance를 요구한다. held-out 공개 전에 끝난 사전등록·동의·participant split·model artifact·blind independent labeler provenance를 요구한다.
production gate는 30명/50회기/150 paired axis, ICC(A,1) 0.75, 선택적 κ 0.70, gain 0.01, production gate는 최소 31명(held-out 30명 외 calibration split 포함), 양 조건이 모두 관측된 완전 paired
participant-cluster bootstrap 10,000회의 95% CI lower `> 0`을 재계산한다. 50회기/150축, ICC(A,1) 0.75, **필수 categorical κ 0.70**, gain 0.01,
participant-cluster bootstrap 10,000회의 95% CI lower `> 0`을 재계산한다. 한 조건이라도 결측이면 양 조건을
모두 최대오류로 처리해 baseline-only 결측이 candidate gain을 부풀리지 못하게 한다.
- `scripts/check-g7-external-proof.py`는 같은 public host와 겹치는 시간창의 public soak, - `scripts/check-g7-external-proof.py`는 같은 public host와 겹치는 시간창의 public soak,
process-local runtime sampling, pinned topology sampling, independent human-gain pack 네 가지를 process-local runtime sampling, pinned topology sampling, independent human-gain pack 네 가지를
모두 통과시켜야 성공한다. G7 release gate는 이 checker 증거가 있을 때만 모두 통과시켜야 성공한다. G7 release gate는 이 checker 증거가 있을 때만
@ -39,8 +41,8 @@ G7은 **내부 구현 DONE · external GATE**다. 이번 변경은 물리 마이
## 검증 ## 검증
- API 전체 `921 passed`, gateway `58 passed`, API voice 통합 `71 passed`. - API 전체 `921 passed`, gateway `58 passed`, API voice 통합 `71 passed`.
- runner/checker/topology `86 passed`, public launcher/sidecar `80 passed`, G7 통합 `166 passed`; - current G7 scripts `86/86`, API human evaluator `11/11`;
Ruff·`py_compile`·PowerShell 5.1 parser·`git diff --check` 통과. Ruff·`py_compile`·`git diff --check` 통과.
- Web typecheck와 API type contract 통과. - Web typecheck와 API type contract 통과.
- `voice-success.spec.ts` chromium single-run `2/2` 통과. 동의 원장 응답을 의도적으로 지연한 - `voice-success.spec.ts` chromium single-run `2/2` 통과. 동의 원장 응답을 의도적으로 지연한
동안 `getUserMedia=0`, voice WebSocket `0`을 확인했고, 성공 뒤 각각 정확히 1회였다. 동안 `getUserMedia=0`, voice WebSocket `0`을 확인했고, 성공 뒤 각각 정확히 1회였다.
@ -70,8 +72,17 @@ DONE으로 표시하지 않는다.
앞의 1~3번은 **같은 public transport host의 공통 3,000초 시간창**이어야 하는데, 지금까지는 운영자가 세 명령을 따로 앞의 1~3번은 **같은 public transport host의 공통 3,000초 시간창**이어야 하는데, 지금까지는 운영자가 세 명령을 따로
띄우고 시계를 손으로 맞춰야 했다. 50분짜리 실행에서 한 번 어긋나면 처음부터 다시 해야 한다. 띄우고 시계를 손으로 맞춰야 했다. 50분짜리 실행에서 한 번 어긋나면 처음부터 다시 해야 한다.
`scripts/run-g7-external-proof-window.py`가 세 캡처를 **동시에** 시작하고, 전부 끝나면 human pack을 `scripts/run-g7-external-proof-window.py`는 production human pack을 **마이크를 열기 전에 먼저 완전 검증**하고,
더해 checker까지 그대로 돌린다. 통과한 경우에만 세 캡처를 동시에 시작한다. 종료 뒤 canonical checker가 같은 pack과 세 runtime artifact를 다시 검증한다.
runtime/topology interval 기본값은 child collector 상한과 같은 60초이며, 60초 초과는 시작 전에 차단한다.
사람 데이터 운영자는 52분 실행과 독립적으로 pack을 먼저 검증할 수 있다. `--print-schema`는 authoritative JSON Schema를
출력하고, 오류는 JSON pointer와 유형만 반환해 participant/labeler key·라벨 값·PII를 반사하지 않는다.
```powershell
& $py -X utf8 -B scripts/check-g7-human-voice-gain.py --print-schema
& $py -X utf8 -B scripts/check-g7-human-voice-gain.py --input <pack.json>
```
```powershell ```powershell
# 실제 실행 (물리 마이크 50분 + 사람 pack 필요) # 실제 실행 (물리 마이크 50분 + 사람 pack 필요)
@ -98,7 +109,9 @@ DONE으로 표시하지 않는다.
|---|---| |---|---|
| 동의·장치 없이 운영 실행 | `physical_microphone_consent_required`, exit 2 | | 동의·장치 없이 운영 실행 | `physical_microphone_consent_required`, exit 2 |
| human pack 없이 운영 실행 | `human_voice_gain_pack_required`, exit 2 | | human pack 없이 운영 실행 | `human_voice_gain_pack_required`, exit 2 |
| malformed·underpowered·κ 미달·불완전 paired human pack | 물리 캡처 전에 human-pack 오류, exit 2 |
| 3,120초 미만 시간창 | `production_window_too_short`, exit 2 | | 3,120초 미만 시간창 | `production_window_too_short`, exit 2 |
| runtime/topology interval 60초 초과 | child collector 실행 전 interval 오류, exit 2 |
| transport host/scheme 불일치 | `hosts_must_match` 또는 scheme 오류, exit 2 | | transport host/scheme 불일치 | `hosts_must_match` 또는 scheme 오류, exit 2 |
| 허용되지 않은 browser Origin | query/원문을 반사하지 않는 Origin 오류, exit 2 | | 허용되지 않은 browser Origin | query/원문을 반사하지 않는 Origin 오류, exit 2 |
@ -106,33 +119,28 @@ DONE으로 표시하지 않는다.
열지 않고, 인자·경로·산출 파일까지 실제로 검증한다. 보고서의 `gate_closed`는 rehearse에서 **항상 열지 않고, 인자·경로·산출 파일까지 실제로 검증한다. 보고서의 `gate_closed`는 rehearse에서 **항상
false**다. 실제 50분 실행 전에 이걸로 먼저 실패를 뽑아내라. false**다. 실제 50분 실행 전에 이걸로 먼저 실패를 뽑아내라.
### ⚠ 선행 조건 — 공개 런타임을 먼저 올려야 한다 (2026-08-08 발견) ### 공개 선행 조건 — 2026-08-09 완료
**마이크와 사람을 다 준비해도 오늘 실행하면 50분을 버리고 artifact 2에서 실패한다.** 공개 API/cloudflared는 detached-clean `b34623f3…`·tree `32cc85c7…`에서 fresh 승격됐다. 공개 OpenAPI 126,
`/admin/voice-runtime`, `local_whisper/small`, `melotts/melotts-korean`, Uvicorn WS queue 4와 receipt
`9f8d1941…a21bf` passed를 확인했다. watchdog·로그온 task도 같은 stable root에 source pin돼 명시 실행 결과 0이다.
공개 런타임은 **119 paths 구버전**이고 `/admin/voice-runtime`이 **배포돼 있지 않다**. 30초 `--rehearse`는 authenticated WSS ready/ping/close1000, runtime 7 samples, Windows topology 7 samples를
`capture-g7-runtime-evidence.py`는 정확히 그 경로만 부르므로 artifact 2를 만들 수 없다. 모두 통과했다. evidence는 `physical_capture=false`이며 UUID/email literal 0이다. 이 리허설은 실제 물리 마이크와
현재 소스에는 있다(`apps/api/app/routes/admin.py:1699`, NAS 프리뷰 기준 126 paths). human pack을 대체하지 않는다.
```
공개 OpenAPI 119 paths · voice 경로 = /users/me/voice-presets, /voice/health, /voice/speech
has /admin/voice-runtime → False
```
그래서 오케스트레이터는 시작 전에 이걸 검사하고 `admin_voice_runtime_not_deployed`로 즉시 멈춘다.
실측: 공개 런타임 대상 `--rehearse` 실행이 exit 2로 몇 초 만에 차단됐다.
**판정을 미인증 HTTP 상태로 하지 않는다.** Cloudflare 앞단이 자동화 클라이언트에게 존재하지 않는 **판정을 미인증 HTTP 상태로 하지 않는다.** Cloudflare 앞단이 자동화 클라이언트에게 존재하지 않는
경로까지 포함해 **모든 경로를 403(error code 1010)** 으로 돌려주기 때문에, 상태 코드로는 "배포 누락"과 경로까지 포함해 **모든 경로를 403(error code 1010)** 으로 돌려주기 때문에, 상태 코드로는 "배포 누락"과
"edge 차단"을 구분할 수 없다. 처음엔 404/401 판정으로 짰다가 이 사실을 확인하고 **OpenAPI spec의 "edge 차단"을 구분할 수 없다. 처음엔 404/401 판정으로 짰다가 이 사실을 확인하고 **OpenAPI spec의
`paths`만 authoritative하게 쓰도록** 고쳤다. 회귀에 그 이유를 테스트로 남겼다. `paths`만 authoritative하게 쓰도록** 고쳤다. 회귀에 그 이유를 테스트로 남겼다.
따라서 G7 실행 순서는 이렇다. 따라서 남은 G7 실행 순서는 이렇다.
1. current source를 공개 런타임에 승격한다(비-secure origin `crypto.randomUUID` 수정도 여기 포함된다). 1. 독립 연구팀이 실제 human pack을 작성하고 standalone validator를 통과시킨다.
2. `--rehearse`로 배관을 확인한다. 2. 사용자가 정확한 마이크 장치를 고르고 3,120초 물리 캡처에 명시 동의한다.
3. 동의 하 물리 마이크 50분 + human pack으로 실제 실행한다. 3. production runner로 human pack을 재검증한 뒤 mic/runtime/topology 동시 창을 실행한다.
4. canonical checker exit 0과 `gate_closed=true`를 확인한다.
기대 provider 기본값은 2026-08-08 결정에 맞춰 `local_whisper/small` / `melotts/melotts-korean`이다 기대 provider 기본값은 2026-08-08 결정에 맞춰 `local_whisper/small` / `melotts/melotts-korean`이다
(근거: `../decisions/local-voice-stack.md`). runner 회귀는 37/37, G7 통합은 166/166이다. production exit 0은 (근거: `../decisions/local-voice-stack.md`). current G7 scripts는 86/86, API human evaluator는 11/11이다. production exit 0은
`checker_returncode == 0 && gate_closed is true`에 결속되며 rehearse는 성공해도 gate를 닫지 않는다. `checker_returncode == 0 && gate_closed is true`에 결속되며 rehearse는 성공해도 gate를 닫지 않는다.

View file

@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Validate and evaluate one independent human-held-out G7 voice-gain pack.
The command emits only aggregate metrics and PII-safe JSON pointers. It never
echoes input paths, participant/labeler keys, labels, or raw validation values.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Iterable
from pydantic import ValidationError
REPO_ROOT = Path(__file__).resolve().parents[1]
API_ROOT = REPO_ROOT / "apps/api"
if str(API_ROOT) not in sys.path:
sys.path.insert(0, str(API_ROOT))
from app.contracts.g7_external_evidence import ( # noqa: E402
G7HumanVoiceGainEvidencePack,
)
from app.services.g7_voice_gain_evidence import ( # noqa: E402
evaluate_human_voice_gain,
)
def _json_pointer(location: tuple[int | str, ...]) -> str:
if not location:
return "/"
parts = []
for item in location:
value = str(item).replace("~", "~0").replace("/", "~1")
parts.append(value)
return "/" + "/".join(parts)
def _base_report() -> dict[str, Any]:
return {
"schema_version": "vignette.g7-human-voice-gain-check.v1",
"passed": False,
"clinical_claim_allowed": False,
"privacy_boundary": {
"input_path_logged": False,
"participant_keys_logged": False,
"labeler_keys_logged": False,
"labels_logged": False,
"raw_validation_values_logged": False,
},
"validation_errors": [],
"result": {},
}
def validate_payload(payload: object) -> dict[str, Any]:
report = _base_report()
try:
pack = G7HumanVoiceGainEvidencePack.model_validate(payload)
except ValidationError as exc:
report["validation_errors"] = [
{
"pointer": _json_pointer(tuple(item["loc"])),
"type": item["type"],
}
for item in exc.errors(
include_url=False,
include_context=False,
include_input=False,
)
]
return report
try:
result = evaluate_human_voice_gain(pack)
except Exception as exc:
report["validation_errors"] = [
{"pointer": "/", "type": f"evaluation:{type(exc).__name__}"}
]
return report
report["passed"] = result.passed
report["result"] = result.model_dump(mode="json")
return report
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(description=__doc__)
mode = result.add_mutually_exclusive_group(required=True)
mode.add_argument("--input", type=Path, help="deidentified human pack JSON")
mode.add_argument(
"--print-schema",
action="store_true",
help="print the authoritative JSON Schema and exit",
)
return result
def main(argv: Iterable[str] | None = None) -> int:
args = parser().parse_args(list(argv) if argv is not None else None)
if args.print_schema:
print(
json.dumps(
G7HumanVoiceGainEvidencePack.model_json_schema(),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
)
return 0
try:
payload = json.loads(args.input.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
report = _base_report()
report["validation_errors"] = [
{"pointer": "/", "type": f"input:{type(exc).__name__}"}
]
else:
report = validate_payload(payload)
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
return 0 if report["passed"] is True else 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -42,6 +42,16 @@ from typing import Any, Callable, Iterable, Sequence
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = REPO_ROOT / "scripts" SCRIPTS = REPO_ROOT / "scripts"
API_ROOT = REPO_ROOT / "apps/api"
if str(API_ROOT) not in sys.path:
sys.path.insert(0, str(API_ROOT))
from app.contracts.g7_external_evidence import ( # noqa: E402
G7HumanVoiceGainEvidencePack,
)
from app.services.g7_voice_gain_evidence import ( # noqa: E402
evaluate_human_voice_gain,
)
SOAK_SCRIPT = SCRIPTS / "soak-public-voice-websocket.py" SOAK_SCRIPT = SCRIPTS / "soak-public-voice-websocket.py"
RUNTIME_SCRIPT = SCRIPTS / "capture-g7-runtime-evidence.py" RUNTIME_SCRIPT = SCRIPTS / "capture-g7-runtime-evidence.py"
@ -63,6 +73,8 @@ MIN_PRODUCTION_SECONDS = (
MIN_REQUIRED_OVERLAP_SECONDS + CAPTURE_START_SKEW_MARGIN_SECONDS MIN_REQUIRED_OVERLAP_SECONDS + CAPTURE_START_SKEW_MARGIN_SECONDS
) )
RUNTIME_SAMPLE_MARGIN = 1 RUNTIME_SAMPLE_MARGIN = 1
MAX_CHILD_INTERVAL_SECONDS = 60.0
DEFAULT_SAMPLE_INTERVAL_SECONDS = 60.0
class WindowError(RuntimeError): class WindowError(RuntimeError):
@ -172,6 +184,23 @@ def sample_plan(duration_seconds: float, interval_seconds: float) -> int:
return math.ceil(duration_seconds / interval_seconds) + RUNTIME_SAMPLE_MARGIN return math.ceil(duration_seconds / interval_seconds) + RUNTIME_SAMPLE_MARGIN
def validate_human_voice_gain_pack(path: Path) -> None:
"""52분 캡처를 열기 전에 사람 pack의 production gate를 완전히 계산한다."""
try:
payload = json.loads(path.read_text(encoding="utf-8"))
pack = G7HumanVoiceGainEvidencePack.model_validate(payload)
result = evaluate_human_voice_gain(pack)
except (OSError, UnicodeError, json.JSONDecodeError):
raise WindowError("human_voice_gain_pack_unreadable") from None
except Exception:
# validation 원문에는 입력값이 포함될 수 있으므로 오류 code만 낸다.
raise WindowError("human_voice_gain_pack_invalid") from None
if not result.passed:
reasons = ",".join(result.failure_reasons)
raise WindowError(f"human_voice_gain_pack_failed:{reasons}")
def build_soak_leg(args: argparse.Namespace, output: Path) -> Leg: def build_soak_leg(args: argparse.Namespace, output: Path) -> Leg:
argv = [ argv = [
sys.executable, sys.executable,
@ -533,8 +562,16 @@ def build_parser() -> argparse.ArgumentParser:
default=MIN_PRODUCTION_SECONDS, default=MIN_PRODUCTION_SECONDS,
help="실제 캡처는 최소 3120초; 유효 artifact 교집합 기준은 3000초", help="실제 캡처는 최소 3120초; 유효 artifact 교집합 기준은 3000초",
) )
parser.add_argument("--runtime-interval-seconds", type=float, default=100.0) parser.add_argument(
parser.add_argument("--topology-interval-seconds", type=float, default=100.0) "--runtime-interval-seconds",
type=float,
default=DEFAULT_SAMPLE_INTERVAL_SECONDS,
)
parser.add_argument(
"--topology-interval-seconds",
type=float,
default=DEFAULT_SAMPLE_INTERVAL_SECONDS,
)
parser.add_argument("--human-voice-gain", type=Path) parser.add_argument("--human-voice-gain", type=Path)
parser.add_argument("--out-dir", type=Path, required=True) parser.add_argument("--out-dir", type=Path, required=True)
parser.add_argument( parser.add_argument(
@ -550,6 +587,12 @@ def validate(args: argparse.Namespace) -> None:
raise WindowError("production_window_too_short") raise WindowError("production_window_too_short")
if not args.rehearse and args.human_voice_gain is None: if not args.rehearse and args.human_voice_gain is None:
raise WindowError("human_voice_gain_pack_required") raise WindowError("human_voice_gain_pack_required")
for value, code in (
(args.runtime_interval_seconds, "runtime_interval_out_of_bounds"),
(args.topology_interval_seconds, "topology_interval_out_of_bounds"),
):
if not math.isfinite(value) or not 0.05 <= value <= MAX_CHILD_INTERVAL_SECONDS:
raise WindowError(code)
if args.topology_mode not in ("linux-compose", "windows-host"): if args.topology_mode not in ("linux-compose", "windows-host"):
raise WindowError("topology_mode_invalid") raise WindowError("topology_mode_invalid")
if args.topology_mode == "linux-compose": if args.topology_mode == "linux-compose":
@ -589,6 +632,9 @@ def main(argv: Iterable[str] | None = None) -> int:
args = build_parser().parse_args(list(argv) if argv is not None else None) args = build_parser().parse_args(list(argv) if argv is not None else None)
try: try:
validate(args) validate(args)
if not args.rehearse:
assert args.human_voice_gain is not None
validate_human_voice_gain_pack(args.human_voice_gain)
args.out_dir.mkdir(parents=True, exist_ok=True) args.out_dir.mkdir(parents=True, exist_ok=True)
legs = plan_legs(args, args.out_dir) legs = plan_legs(args, args.out_dir)
except WindowError as exc: except WindowError as exc:

View file

@ -88,6 +88,19 @@ POSTDEPLOY_NAS_E2E_SPECS = (
"e2e/multimodal-alliance.spec.ts", "e2e/multimodal-alliance.spec.ts",
"e2e/continuous-improvement-admin.spec.ts", "e2e/continuous-improvement-admin.spec.ts",
) )
# Only these specs use the configured NAS API/DB instead of replacing the
# product API with Playwright route fixtures. Keep the split explicit in the
# receipt so the 112-test browser total cannot be mistaken for 112 live
# session/database proofs.
POSTDEPLOY_NAS_REAL_API_E2E_SPECS = (
"e2e/session-layout.spec.ts",
"e2e/session-persistence.spec.ts",
)
POSTDEPLOY_NAS_ROUTE_FIXTURE_E2E_SPECS = tuple(
spec
for spec in POSTDEPLOY_NAS_E2E_SPECS
if spec not in POSTDEPLOY_NAS_REAL_API_E2E_SPECS
)
POSTDEPLOY_SOURCE_ONLY_E2E_SPECS = ("e2e/insecure-context-uuid.spec.ts",) POSTDEPLOY_SOURCE_ONLY_E2E_SPECS = ("e2e/insecure-context-uuid.spec.ts",)
POSTDEPLOY_DISPOSABLE_DB_E2E_SPECS = ( POSTDEPLOY_DISPOSABLE_DB_E2E_SPECS = (
"e2e/returned-practice-db-closed-loop.spec.ts", "e2e/returned-practice-db-closed-loop.spec.ts",
@ -1572,8 +1585,13 @@ class ReleaseAgent:
"base_url": self.config.target.base_url, "base_url": self.config.target.base_url,
"specs": list(POSTDEPLOY_NAS_E2E_SPECS), "specs": list(POSTDEPLOY_NAS_E2E_SPECS),
"projects": list(RELEASE_BROWSER_PROJECTS), "projects": list(RELEASE_BROWSER_PROJECTS),
"runtime_scope": {
"real_api_db_specs": list(POSTDEPLOY_NAS_REAL_API_E2E_SPECS),
"route_fixture_specs": list(POSTDEPLOY_NAS_ROUTE_FIXTURE_E2E_SPECS),
},
"source_only_candidate_specs": list(POSTDEPLOY_SOURCE_ONLY_E2E_SPECS), "source_only_candidate_specs": list(POSTDEPLOY_SOURCE_ONLY_E2E_SPECS),
"separate_disposable_db_specs": list(POSTDEPLOY_DISPOSABLE_DB_E2E_SPECS), "separate_disposable_db_specs": list(POSTDEPLOY_DISPOSABLE_DB_E2E_SPECS),
"separate_disposable_db_status": "not_run_by_release_agent",
"uuid_runtime_route_specs": [ "uuid_runtime_route_specs": [
"e2e/session-persistence.spec.ts", "e2e/session-persistence.spec.ts",
"e2e/self-directed-learning-loop.spec.ts", "e2e/self-directed-learning-loop.spec.ts",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,95 @@
from __future__ import annotations
import contextlib
import importlib.util
import io
import json
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
API_ROOT = REPO_ROOT / "apps/api"
if str(API_ROOT) not in sys.path:
sys.path.insert(0, str(API_ROOT))
from app.test_g7_voice_gain_evidence import _valid_payload # noqa: E402
from scripts.test_g7_external_proof import human_pack # noqa: E402
SCRIPT_PATH = Path(__file__).with_name("check-g7-human-voice-gain.py")
SPEC = importlib.util.spec_from_file_location("check_g7_human_voice_gain", SCRIPT_PATH)
assert SPEC is not None and SPEC.loader is not None
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
class G7HumanVoiceGainCliTests(unittest.TestCase):
def test_production_pack_passes_with_aggregate_output_only(self) -> None:
report = MODULE.validate_payload(human_pack())
self.assertTrue(report["passed"])
self.assertEqual([], report["validation_errors"])
self.assertEqual(30, report["result"]["held_out_participants"])
serialized = json.dumps(report, ensure_ascii=False)
self.assertNotIn("held-000", serialized)
self.assertNotIn("labeler-001", serialized)
self.assertFalse(report["privacy_boundary"]["participant_keys_logged"])
def test_underpowered_pack_fails_with_gate_names_but_no_identifiers(self) -> None:
report = MODULE.validate_payload(_valid_payload())
self.assertFalse(report["passed"])
self.assertIn(
"production_participant_floor",
report["result"]["failure_reasons"],
)
serialized = json.dumps(report, ensure_ascii=False)
self.assertNotIn("held-out-001", serialized)
self.assertNotIn("labeler-001", serialized)
def test_invalid_rows_emit_only_json_pointer_and_error_type(self) -> None:
payload = _valid_payload()
observations = payload["observations"]
assert isinstance(observations, list)
labels = observations[0]["labels"]
assert isinstance(labels, list)
labels[0]["category"] = "person@example.test"
report = MODULE.validate_payload(payload)
self.assertFalse(report["passed"])
self.assertEqual({}, report["result"])
self.assertTrue(report["validation_errors"])
self.assertEqual(
"/observations/0/labels/0/category",
report["validation_errors"][0]["pointer"],
)
serialized = json.dumps(report, ensure_ascii=False)
self.assertNotIn("person@example.test", serialized)
self.assertNotIn("held-out-001", serialized)
def test_cli_does_not_echo_input_path_and_schema_marks_kappa_required(self) -> None:
with tempfile.TemporaryDirectory() as directory:
secret_name = "person-at-example.test.json"
path = Path(directory) / secret_name
path.write_text(json.dumps(_valid_payload()), encoding="utf-8")
output = io.StringIO()
with contextlib.redirect_stdout(output):
exit_code = MODULE.main(["--input", str(path)])
self.assertEqual(1, exit_code)
self.assertNotIn(secret_name, output.getvalue())
schema = MODULE.G7HumanVoiceGainEvidencePack.model_json_schema()
reliability = schema["$defs"]["G7ReliabilityClaim"]
label = schema["$defs"]["G7HumanAxisLabel"]
self.assertIn("reported_categorical_kappa", reliability["required"])
self.assertIn("category", label["required"])
if __name__ == "__main__":
unittest.main()

View file

@ -44,6 +44,7 @@ def human_pack() -> dict[str, object]:
) )
observations = [] observations = []
axes = ("goal", "task", "bond") axes = ("goal", "task", "bond")
categories = ("low", "medium", "high")
for session_index in range(50): for session_index in range(50):
participant = f"held-{session_index % 30:03d}" participant = f"held-{session_index % 30:03d}"
for axis_index, axis in enumerate(axes): for axis_index, axis in enumerate(axes):
@ -60,8 +61,16 @@ def human_pack() -> dict[str, object]:
"voice_enabled_status": "observed", "voice_enabled_status": "observed",
"voice_enabled_score": target, "voice_enabled_score": target,
"labels": [ "labels": [
{"labeler_key": "labeler-001", "score": target}, {
{"labeler_key": "labeler-002", "score": target}, "labeler_key": "labeler-001",
"score": target,
"category": categories[axis_index],
},
{
"labeler_key": "labeler-002",
"score": target,
"category": categories[axis_index],
},
], ],
} }
) )
@ -108,6 +117,7 @@ def human_pack() -> dict[str, object]:
"reliability": { "reliability": {
"labeler_keys": ["labeler-001", "labeler-002"], "labeler_keys": ["labeler-001", "labeler-002"],
"reported_icc": 1.0, "reported_icc": 1.0,
"reported_categorical_kappa": 1.0,
"report_sha256": _sha(112), "report_sha256": _sha(112),
}, },
"observations": observations, "observations": observations,
@ -432,6 +442,25 @@ class G7ExternalProofTests(unittest.TestCase):
self.assertEqual([], errors) self.assertEqual([], errors)
self.assertTrue(gain["passed"]) self.assertTrue(gain["passed"])
self.assertGreaterEqual(gain["held_out_participants"], 30) self.assertGreaterEqual(gain["held_out_participants"], 30)
self.assertGreaterEqual(gain["recomputed_categorical_kappa"], 0.70)
def test_human_pack_cannot_bypass_the_required_categorical_kappa(self) -> None:
errors: list[str] = []
payload = human_pack()
reliability = payload["reliability"]
assert isinstance(reliability, dict)
reliability.pop("reported_categorical_kappa")
observations = payload["observations"]
assert isinstance(observations, list)
for observation in observations:
labels = observation["labels"]
for label in labels:
label.pop("category")
gain = self.checker.validate_human_gain(payload, errors)
self.assertEqual({}, gain)
self.assertIn("human_gain:invalid:ValidationError", errors)
def test_windows_host_topology_passes_without_weakening_compose(self) -> None: def test_windows_host_topology_passes_without_weakening_compose(self) -> None:
errors: list[str] = [] errors: list[str] = []

View file

@ -693,6 +693,15 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
set(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS) set(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS)
| set(self.agent_module.POSTDEPLOY_SOURCE_ONLY_E2E_SPECS), | set(self.agent_module.POSTDEPLOY_SOURCE_ONLY_E2E_SPECS),
) )
self.assertEqual(
set(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS),
set(self.agent_module.POSTDEPLOY_NAS_REAL_API_E2E_SPECS)
| set(self.agent_module.POSTDEPLOY_NAS_ROUTE_FIXTURE_E2E_SPECS),
)
self.assertFalse(
set(self.agent_module.POSTDEPLOY_NAS_REAL_API_E2E_SPECS)
& set(self.agent_module.POSTDEPLOY_NAS_ROUTE_FIXTURE_E2E_SPECS)
)
self.assertLess( self.assertLess(
runner.event_log.index("runner:postdeploy_browser_review"), runner.event_log.index("runner:postdeploy_browser_review"),
runner.event_log.index("deployment:commit_active_state"), runner.event_log.index("deployment:commit_active_state"),
@ -750,6 +759,22 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
["e2e/returned-practice-db-closed-loop.spec.ts"], ["e2e/returned-practice-db-closed-loop.spec.ts"],
evidence["browser_review_proof"]["separate_disposable_db_specs"], evidence["browser_review_proof"]["separate_disposable_db_specs"],
) )
self.assertEqual(
{
"real_api_db_specs": [
"e2e/session-layout.spec.ts",
"e2e/session-persistence.spec.ts",
],
"route_fixture_specs": list(
self.agent_module.POSTDEPLOY_NAS_ROUTE_FIXTURE_E2E_SPECS
),
},
evidence["browser_review_proof"]["runtime_scope"],
)
self.assertEqual(
"not_run_by_release_agent",
evidence["browser_review_proof"]["separate_disposable_db_status"],
)
self.assertEqual( self.assertEqual(
[ [
"docs/dev_dashboard.html", "docs/dev_dashboard.html",

View file

@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import importlib.util import importlib.util
import json
import sys import sys
import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@ -50,8 +52,8 @@ def args(**overrides):
"microphone_device": "", "microphone_device": "",
"confirm_physical_capture": False, "confirm_physical_capture": False,
"duration_seconds": 3_120.0, "duration_seconds": 3_120.0,
"runtime_interval_seconds": 100.0, "runtime_interval_seconds": 60.0,
"topology_interval_seconds": 100.0, "topology_interval_seconds": 60.0,
"human_voice_gain": Path("pack.json"), "human_voice_gain": Path("pack.json"),
"out_dir": Path("out"), "out_dir": Path("out"),
"rehearse": False, "rehearse": False,
@ -154,6 +156,8 @@ class HostAlignmentTest(unittest.TestCase):
self.assertEqual(MODULE.DEFAULT_BROWSER_ORIGIN, parsed.origin) self.assertEqual(MODULE.DEFAULT_BROWSER_ORIGIN, parsed.origin)
self.assertEqual(MODULE.DEFAULT_ADMIN_RUNTIME_URL, parsed.admin_runtime_url) self.assertEqual(MODULE.DEFAULT_ADMIN_RUNTIME_URL, parsed.admin_runtime_url)
self.assertEqual(3_120.0, parsed.duration_seconds) self.assertEqual(3_120.0, parsed.duration_seconds)
self.assertEqual(60.0, parsed.runtime_interval_seconds)
self.assertEqual(60.0, parsed.topology_interval_seconds)
self.assertIsNone(parsed.allowed_browser_origins) self.assertIsNone(parsed.allowed_browser_origins)
help_text = parser.format_help() help_text = parser.format_help()
for expected in ( for expected in (
@ -168,13 +172,13 @@ class HostAlignmentTest(unittest.TestCase):
class SamplePlanTest(unittest.TestCase): class SamplePlanTest(unittest.TestCase):
def test_samples_cover_the_whole_window(self) -> None: def test_samples_cover_the_whole_window(self) -> None:
self.assertEqual(MODULE.sample_plan(3_120.0, 100.0), 33) self.assertEqual(MODULE.sample_plan(3_120.0, 60.0), 53)
def test_fractional_interval_always_gets_a_terminal_sample(self) -> None: def test_fractional_interval_always_gets_a_terminal_sample(self) -> None:
self.assertEqual(MODULE.sample_plan(3_121.0, 100.0), 33) self.assertEqual(MODULE.sample_plan(3_121.0, 60.0), 54)
def test_invalid_plan_fails_closed(self) -> None: def test_invalid_plan_fails_closed(self) -> None:
for duration, interval in ((0, 100.0), (3_000.0, 0)): for duration, interval in ((0, 60.0), (3_000.0, 0)):
with self.subTest(duration=duration, interval=interval): with self.subTest(duration=duration, interval=interval):
with self.assertRaises(MODULE.WindowError): with self.assertRaises(MODULE.WindowError):
MODULE.sample_plan(duration, interval) MODULE.sample_plan(duration, interval)
@ -226,6 +230,44 @@ class ConsentGateTest(unittest.TestCase):
def test_rehearse_relaxes_only_the_two_human_inputs(self) -> None: def test_rehearse_relaxes_only_the_two_human_inputs(self) -> None:
MODULE.validate(args(rehearse=True, duration_seconds=30.0, human_voice_gain=None)) MODULE.validate(args(rehearse=True, duration_seconds=30.0, human_voice_gain=None))
def test_child_sampling_intervals_are_bounded_before_capture(self) -> None:
for field in ("runtime_interval_seconds", "topology_interval_seconds"):
with self.subTest(field=field):
with self.assertRaises(MODULE.WindowError) as ctx:
MODULE.validate(args(**{field: 60.001}))
self.assertEqual(
f"{field.removesuffix('_seconds')}_out_of_bounds",
str(ctx.exception),
)
def test_human_pack_is_fully_validated_before_capture(self) -> None:
from app.test_g7_voice_gain_evidence import _valid_payload
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
missing = root / "missing.json"
with self.assertRaises(MODULE.WindowError) as ctx:
MODULE.validate_human_voice_gain_pack(missing)
self.assertEqual("human_voice_gain_pack_unreadable", str(ctx.exception))
malformed = root / "malformed.json"
malformed.write_text("{}", encoding="utf-8")
with self.assertRaises(MODULE.WindowError) as ctx:
MODULE.validate_human_voice_gain_pack(malformed)
self.assertEqual("human_voice_gain_pack_invalid", str(ctx.exception))
underpowered = root / "underpowered.json"
underpowered.write_text(
json.dumps(_valid_payload()),
encoding="utf-8",
)
with self.assertRaises(MODULE.WindowError) as ctx:
MODULE.validate_human_voice_gain_pack(underpowered)
self.assertTrue(
str(ctx.exception).startswith("human_voice_gain_pack_failed:")
)
self.assertIn("production_participant_floor", str(ctx.exception))
class LegCompositionTest(unittest.TestCase): class LegCompositionTest(unittest.TestCase):
def test_expected_providers_default_to_the_decided_local_stack(self) -> None: def test_expected_providers_default_to_the_decided_local_stack(self) -> None:

View file

@ -0,0 +1,360 @@
from __future__ import annotations
import importlib.util
import json
import sys
import tempfile
import unittest
from pathlib import Path
from types import ModuleType
from unittest.mock import patch
SCRIPT = Path(__file__).with_name("run-periodic-learner-e2e.py")
def load_module() -> ModuleType:
spec = importlib.util.spec_from_file_location("run_periodic_learner_e2e", SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError("cannot load periodic learner runner")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
runner = load_module()
class FakeController:
def __init__(self, *, tracked_status: str = "") -> None:
self.tracked_status = tracked_status
self.commands: list[tuple[str, list[str]]] = []
self.starts: list[tuple[str, list[str]]] = []
self.stops: list[int] = []
def run(
self,
stage,
argv,
*,
cwd,
env=None,
timeout,
check=True,
):
del cwd, env, timeout, check
self.commands.append((stage, list(argv)))
stdout = ""
if stage == "source_head":
stdout = "1" * 40 + "\n"
elif stage == "source_tree":
stdout = "2" * 40 + "\n"
elif stage == "source_tracked_clean":
stdout = self.tracked_status
elif stage == "source_untracked_inventory":
stdout = "?? apps/api/engine.err.log.bak\n"
elif stage == "docker_context_show":
stdout = "desktop-linux\n"
elif stage == "docker_context_inspect":
stdout = json.dumps(
[
{
"Endpoints": {
"docker": {
"Host": "npipe:////./pipe/dockerDesktopLinuxEngine"
}
}
}
]
)
return runner.CommandResult(0, stdout, "", 0.01)
def start(
self,
stage,
argv,
*,
cwd,
env,
stdout_path,
stderr_path,
):
del cwd, env, stdout_path, stderr_path
self.starts.append((stage, list(argv)))
return runner.ProcessHandle(4242)
def stop_exact(self, handle, *, timeout):
del timeout
self.stops.append(handle.pid)
return True
def http_json(self, url, *, headers=None, timeout):
del url, headers, timeout
return {"ok": True, "status": "ok", "db": True, "engine": True}
def http_text(self, url, *, timeout):
del url, timeout
return '<div id="root"></div>'
def tcp_listening(self, host, port, *, timeout=0.25):
del host, port, timeout
return False
def sleep(self, seconds):
del seconds
def config(receipt_path: Path) -> runner.RunnerConfig:
return runner.RunnerConfig(
receipt_path=receipt_path,
python_exe="python.exe",
node_exe="node.exe",
docker_exe="docker.exe",
execute=True,
)
class SafetyContractTests(unittest.TestCase):
def test_rejects_every_protected_port_from_argv_and_runtime_env(self) -> None:
for port in sorted(runner.FORBIDDEN_PORTS):
with self.subTest(port=port):
with self.assertRaises(runner.GateError):
runner.assert_safe_invocation([f"http://127.0.0.1:{port}"], {})
with self.assertRaises(runner.GateError):
runner.assert_safe_invocation([], {"DATABASE_URL": f"postgresql://x@127.0.0.1:{port}/db"})
def test_rejects_public_and_nas_markers(self) -> None:
values = [
"https://vignette.chanpaca.net",
"https://api-vignette.chanpaca.net/health",
"postgresql://app@100.116.83.60:55433/vignette",
"vignette-preview-20260807",
"vignette-dev-db",
"/volume1/docker/vignette",
]
for value in values:
with self.subTest(value=value), self.assertRaises(runner.GateError):
runner.assert_safe_value("contract", value)
def test_rejects_inherited_runtime_targets_even_when_the_port_looks_local(self) -> None:
for key, value in (
("DATABASE_URL", "postgresql://app@127.0.0.1:55439/vignette"),
("ENGINE_URL", "http://127.0.0.1:9199"),
("PLAYWRIGHT_BASE_URL", "http://127.0.0.1:5199"),
("COMPOSE_PROJECT_NAME", "some-existing-project"),
):
with self.subTest(key=key), self.assertRaises(runner.GateError):
runner.assert_no_inherited_runtime_targets({key: value})
def test_runtime_identity_and_generated_environment_are_disposable(self) -> None:
source = runner.SourceIdentity("1" * 40, "2" * 40)
with patch.object(runner, "allocate_unique_ports", return_value=(18080, 18443, 15439, 19199)):
runtime = runner.build_runtime_identity(source)
runner.validate_runtime_identity(runtime)
values = runner.build_stack_environment(runtime)
self.assertEqual(len(set(runtime.ports)), 4)
self.assertTrue(runner.PROJECT_RE.fullmatch(runtime.project))
self.assertEqual(values["HTTP_PORT"], "18080")
self.assertEqual(values["DB_HOST_PORT"], "15439")
self.assertEqual(values["ENGINE_URL"], "http://host.docker.internal:19199")
self.assertNotIn("vignette.chanpaca.net", json.dumps(values))
self.assertFalse(set(runtime.ports) & runner.FORBIDDEN_PORTS)
def test_compose_override_labels_every_resource_and_loopback_binds_database(self) -> None:
with tempfile.TemporaryDirectory() as raw:
path = Path(raw) / "override.yml"
runner.write_compose_override(path)
text = path.read_text(encoding="utf-8")
self.assertEqual(text.count("com.vignette.periodic-e2e-sentinel"), 8)
self.assertIn('127.0.0.1:${DB_HOST_PORT}:5432', text)
self.assertIn("pgdata:", text)
self.assertIn("apiuploads:", text)
self.assertIn("caddydata:", text)
self.assertIn("vignette:", text)
def test_resolved_compose_contract_binds_only_loopback_and_internal_db(self) -> None:
runtime = runner.RuntimeIdentity(
"20260809T120000-abcdef12",
"vignette-periodic-11111111-abcdef12",
"periodic:" + "1" * 40 + ":" + "2" * 40 + ":run",
18080,
18443,
15439,
19199,
)
stack_env = {
"POSTGRES_DB": "vignette_periodic",
}
payload = {
"services": {
name: {
"labels": {
"com.vignette.periodic-e2e-sentinel": runtime.sentinel
},
"ports": [],
}
for name in ("db", "api", "web", "proxy")
},
"volumes": {
name: {
"labels": {
"com.vignette.periodic-e2e-sentinel": runtime.sentinel
}
}
for name in ("pgdata", "apiuploads", "caddydata")
},
"networks": {
"vignette": {
"labels": {
"com.vignette.periodic-e2e-sentinel": runtime.sentinel
}
}
},
}
payload["services"]["db"]["ports"] = [
{"host_ip": "127.0.0.1", "target": 5432, "published": "15439"}
]
payload["services"]["proxy"]["ports"] = [
{"host_ip": "127.0.0.1", "target": 80, "published": "18080"},
{"host_ip": "127.0.0.1", "target": 443, "published": "18443"},
]
payload["services"]["api"]["environment"] = {
"DATABASE_URL": "postgresql://app:secret@db:5432/vignette_periodic",
"ENGINE_URL": "http://host.docker.internal:19199",
"FRONTEND_BASE_URL": "http://127.0.0.1:18080",
"OAUTH_REDIRECT_URI": "http://127.0.0.1:18080/api/auth/callback",
}
class ConfigFake(FakeController):
def run(self, stage, argv, **kwargs):
result = super().run(stage, argv, **kwargs)
if stage == "resolved_compose_config":
return runner.CommandResult(0, json.dumps(payload), "", 0.01)
return result
fake = ConfigFake()
with tempfile.TemporaryDirectory() as raw:
periodic = runner.PeriodicRunner(config(Path(raw) / "receipt.json"), fake)
periodic.state.runtime = runtime
periodic.state.docker_context = "desktop-linux"
periodic.state.env_file = Path(raw) / "stack.env"
periodic.state.override_file = Path(raw) / "override.yml"
digest = periodic._validate_resolved_compose_config(stack_env)
self.assertEqual(len(digest), 64)
payload["services"]["proxy"]["ports"][0]["host_ip"] = "0.0.0.0"
with tempfile.TemporaryDirectory() as raw:
periodic = runner.PeriodicRunner(config(Path(raw) / "receipt.json"), ConfigFake())
periodic.state.runtime = runtime
periodic.state.docker_context = "desktop-linux"
periodic.state.env_file = Path(raw) / "stack.env"
periodic.state.override_file = Path(raw) / "override.yml"
with self.assertRaises(runner.GateError):
periodic._validate_resolved_compose_config(stack_env)
def test_dirty_source_fails_before_process_or_compose_mutation(self) -> None:
fake = FakeController(tracked_status=" M apps/web/src/App.tsx\n")
with tempfile.TemporaryDirectory() as raw:
receipt = Path(raw) / "receipt.json"
execution = runner.PeriodicRunner(config(receipt), fake).execute()
stored = json.loads(receipt.read_text(encoding="utf-8"))
self.assertEqual(execution["status"], "FAILED")
self.assertEqual(stored["status"], "FAILED")
self.assertIn("tracked worktree differs", execution["error"])
self.assertEqual(fake.starts, [])
self.assertFalse(any("compose" in command for _, command in fake.commands))
def test_docker_context_is_pinned_to_local_windows_named_pipe(self) -> None:
fake = FakeController()
periodic = runner.PeriodicRunner(config(Path("receipt.json")), fake)
proof = periodic._pin_local_docker_context()
self.assertEqual(proof, {"context": "desktop-linux", "transport": "npipe", "remote": False})
self.assertEqual(periodic.state.docker_context, "desktop-linux")
class RemoteDockerFake(FakeController):
def run(self, stage, argv, **kwargs):
result = super().run(stage, argv, **kwargs)
if stage == "docker_context_inspect":
return runner.CommandResult(
0,
json.dumps(
[
{
"Endpoints": {
"docker": {"Host": "tcp://127.0.0.1:2375"}
}
}
]
),
"",
0.01,
)
return result
with self.assertRaises(runner.GateError):
runner.PeriodicRunner(
config(Path("receipt.json")), RemoteDockerFake()
)._pin_local_docker_context()
def test_cleanup_targets_only_exact_project_and_proves_listener_zero(self) -> None:
fake = FakeController()
with tempfile.TemporaryDirectory() as raw:
periodic = runner.PeriodicRunner(config(Path(raw) / "receipt.json"), fake)
periodic.state.source = runner.SourceIdentity("1" * 40, "2" * 40)
periodic.state.runtime = runner.RuntimeIdentity(
"20260809T120000-abcdef12",
"vignette-periodic-11111111-abcdef12",
"periodic:" + "1" * 40 + ":" + "2" * 40 + ":run",
18080,
18443,
15439,
19199,
)
periodic.state.docker_context = "desktop-linux"
periodic.state.env_file = Path(raw) / "stack.env"
periodic.state.override_file = Path(raw) / "override.yml"
periodic.state.stack_attempted = True
periodic.state.engine = runner.ProcessHandle(4242)
proof = periodic._cleanup()
down = next(command for stage, command in fake.commands if stage == "compose_down")
self.assertIn("vignette-periodic-11111111-abcdef12", down)
self.assertIn("--remove-orphans", down)
self.assertIn("--volumes", down)
self.assertEqual(fake.stops, [4242])
self.assertEqual(proof["container_remainder"], 0)
self.assertEqual(proof["volume_remainder"], 0)
self.assertEqual(proof["network_remainder"], 0)
self.assertEqual(set(proof["listener_counts"].values()), {0})
self.assertTrue(periodic._cleanup_green(proof))
def test_untracked_runtime_source_is_rejected_but_log_backup_is_not(self) -> None:
fake = FakeController()
periodic = runner.PeriodicRunner(config(Path("receipt.json")), fake)
identity = periodic.source_identity(require_clean=True)
self.assertEqual(identity.head, "1" * 40)
class DangerousFake(FakeController):
def run(self, stage, argv, **kwargs):
result = super().run(stage, argv, **kwargs)
if stage == "source_untracked_inventory":
return runner.CommandResult(
0,
"?? apps/web/e2e/uncommitted-runtime.spec.ts\n",
"",
0.01,
)
return result
with self.assertRaises(runner.GateError):
runner.PeriodicRunner(
config(Path("receipt.json")), DangerousFake()
).source_identity(require_clean=True)
if __name__ == "__main__":
unittest.main()