diff --git a/docs/qwen-voice-register.md b/docs/qwen-voice-register.md index 4b23a36..bd5ec72 100644 --- a/docs/qwen-voice-register.md +++ b/docs/qwen-voice-register.md @@ -56,7 +56,7 @@ python tools/audio/generate_voice.py --catalog SourceAssets/Audio/Voice/voice-ca python tools/audio/generate_voice.py --catalog SourceAssets/Audio/Voice/voice-catalog.json --profile SourceAssets/Audio/Voice/qwen-profile.json --validate-only --manifest SourceAssets/Audio/Voice/generated/20261004T161231511126Z_aa20f404/manifest.json ``` -초기 RED는 생성기 모듈이 없어 종료 코드 1이었습니다. 최종 GREEN에서는 동작 검증 테스트 16개가 종료 코드 0으로 통과했습니다. 잘못된 카탈로그, 현재 대본·프로필과 부분 검사 범위, 실패하거나 비정상적인 WAV, 누락·손상 WAV, 대본·설정 변경, 해시 변조, 이전 원본 보존, 리샘플링, 다른 게임 파일 덮어쓰기 거부, 긴 숫자 출력 차단, 특정 클립만 새 회차로 생성하는 동작, 전부 재사용할 때 모델 로딩 없이 현재 패키지·장치와 원본 생성 환경을 연결하는 동작을 검사합니다. 회차 입력 snapshot 보존·손상 검사와 검증 전용 경로의 비로딩 검사도 포함합니다. +초기 RED는 생성기 모듈이 없어 종료 코드 1이었습니다. 최종 GREEN에서는 동작 검증 테스트 18개가 종료 코드 0으로 통과했습니다. 잘못된 카탈로그, 현재 대본·프로필과 부분 검사 범위, 실패하거나 비정상적인 WAV, 누락·손상 WAV, 대본·설정 변경, 해시 변조, 이전 원본 보존, 리샘플링, 다른 게임 파일 덮어쓰기 거부, 긴 숫자 출력 차단, 특정 클립만 새 회차로 생성하는 동작, 전부 재사용할 때 모델 로딩 없이 현재 패키지·장치와 원본 생성 환경을 연결하는 동작을 검사합니다. 회차 입력 snapshot 보존·손상 검사, 원본 manifest SHA-256 무결성, 검증 전용 경로의 비로딩 검사도 포함합니다. 첫 표본 생성·확대 생성·검사 전용 명령은 각각 종료 코드 0이었습니다. `--validate-only`는 모델을 로딩하거나 다운로드하지 않습니다. ID를 생략하면 현재 T2 필수 세 클립 전체를 검사하고, `--ids`를 지정하면 선택 범위를 결과에 기록합니다. 생성한 열두 클립을 모두 지정한 파일·이력 검사는 종료 코드 0이었지만 count_05의 별도 음질 문제는 합격으로 처리하지 않았습니다. 잘못된 ID `missing`은 해당 ID를 포함한 오류와 종료 코드 1을 반환했습니다. 마지막 `pip check`는 종료 코드 0으로 충돌이 없었습니다. @@ -79,3 +79,13 @@ snapshot은 새 회차 폴더를 만들 때 한 번 저장하고 공유 입력 새 회차 `20261004T171918683576Z_7a8cbd60`는 세 필수 음성을 전부 재사용하고 실제 입력 snapshot과 manifest를 기록했으며 CLI 종료 0이었습니다. 기존 네 회차와 새 회차의 검증 전용 명령은 각각 명시된 생성 범위에 대해 종료 0이었습니다. 이때 모델 loader·장치 명령을 차단했고 torch·qwen_tts·huggingface_hub가 import되지 않았음을 별도 JSON 증거에 기록했습니다. 원본을 건드리지 않는 검사 복사본에서 snapshot 누락·손상·해시 불일치, manifest 해시 불일치, 현재 프로필 불일치의 실제 CLI 다섯 건은 각각 종료 1을 반환했습니다. 승인된 세 게임 WAV와 기본 프로필은 변경하지 않았습니다. 증거 경로는 `artifacts/verification/FirstSlice/Voice/InputSnapshots/`의 `red-tests.log`, `green-tests.log`, `legacy-migration.log`, `reuse-new-snapshot-run.log`, `validate-only-proof.json` 및 `.log`, `negative-cli-proof.json` 및 `.log`입니다. 이는 입력·파일·이력 검증 증거이며 미검수 음성의 청각 합격을 뜻하지 않습니다. + +## 원본 manifest 무결성 검증 보완 + +재사용 환경의 `sourceGenerations[].manifestSha256`를 단순히 기록하는 데 그치지 않고, 가리키는 원본 manifest 파일의 실제 SHA-256과 대조합니다. 참조 경로와 runId가 일치하는지, 복사한 생성 환경이 원본 환경과 같은지도 확인합니다. 따라서 WAV·대본·프로필은 그대로 둔 채 원본의 장치 이름 등 환경 기록만 변조해도 검증이 실패합니다. + +sidecar 전환 당시 `input-snapshot-migration.json`의 네 `runs[].originalManifestSha256`는 변경하지 않은 원본 manifest 전체 파일을 가리킵니다. 현재 실제 네 파일과 기록된 SHA가 모두 일치함을 재확인했으며, 해당 legacy 회차를 읽을 때 이 해시도 검증합니다. 전환 때 점검한 `preservedArtifactSha256`의 공유 카탈로그·프로필 같은 항목은 그 시점의 보존 증거이므로 앞으로의 정상적인 입력 변경을 금지하는 고정값으로 사용하지 않습니다. 현재 입력 대조는 별도의 기존 catalog/profile 검사로 수행합니다. + +변경 전 원본 환경의 device만 `CORRUPTED_DEVICE_RECORD`로 바꾼 검사 복사본은 실제 `--validate-only`가 종료 0을 반환하는 결함을 재현했습니다. 두 새 회귀 검사는 RED 종료 1이었습니다. 보완 후 같은 검사 복사본의 실제 CLI는 원본 manifest SHA 불일치를 기록하고 종료 1을 반환했습니다. 원본 자료 다섯 회차는 각각 기록된 생성 범위를 검증하여 모두 종료 0이었고 전체 Python 18개 검사가 통과했습니다. 모델 loader와 장치 명령은 차단했고 torch·qwen_tts·huggingface_hub가 import되지 않았음을 기록했습니다. 실제 원본 manifest·입력·WAV·승인 이력은 변경하거나 재생성하지 않았습니다. + +증거는 `artifacts/verification/FirstSlice/Voice/OriginIntegrity/`의 `red-tests.log`, `red-cli-proof.json` 및 `.log`, `green-tests.log`, `green-cli-proof.json` 및 `.log`에 있습니다. diff --git a/tools/audio/generate_voice.py b/tools/audio/generate_voice.py index dbaf3a2..f80025a 100644 --- a/tools/audio/generate_voice.py +++ b/tools/audio/generate_voice.py @@ -109,6 +109,23 @@ def load_run_inputs(manifest, root): validate_inputs(catalog, profile) if profile['modelRevision'] != manifest.get('modelRevision'): raise ValueError('input snapshot model revision differs from manifest') + migration_path = Path(root) / 'input-snapshot-migration.json' + if migration_path.exists(): + migrated = [record for record in load_json(migration_path)['runs'] if record['runId'] == run_id] + if migrated and (len(migrated) != 1 or file_hash(run_dir / 'manifest.json') != migrated[0]['originalManifestSha256']): + raise ValueError(f'{run_id}: immutable legacy manifest SHA-256 mismatch') + for reference in manifest.get('environment', {}).get('sourceGenerations', []): + source_id = reference['runId'] + if not isinstance(source_id, str) or not re.fullmatch(r'[A-Za-z0-9_]+', source_id): + raise ValueError('invalid source generation reference runId') + if reference['manifest'] != f'generated/{source_id}/manifest.json': + raise ValueError('source generation reference must identify its own run manifest') + source_path = Path(root) / reference['manifest'] + if file_hash(source_path) != reference['manifestSha256']: + raise ValueError(f'{source_id}: recorded source manifest SHA-256 mismatch') + source = load_json(source_path) + if source.get('runId') != source_id or source.get('environment') != reference.get('environment'): + raise ValueError(f'{source_id}: source generation environment reference differs') clips = {clip['id']: clip for clip in catalog['clips']} for entry in manifest.get('clips', []): clip = clips.get(entry.get('id')) diff --git a/tools/audio/test_generate_voice.py b/tools/audio/test_generate_voice.py index a7b465b..55171a9 100644 --- a/tools/audio/test_generate_voice.py +++ b/tools/audio/test_generate_voice.py @@ -223,6 +223,39 @@ class VoiceHistoryTests(unittest.TestCase): self.assertEqual([], voice.validate_manifest(legacy, self.catalog, self.profile, self.root)) self.assertEqual(saved_manifest, (self.root / 'generated' / manifest['runId'] / 'manifest.json').read_bytes()) + def test_reuse_rejects_origin_manifest_device_changed_after_sha_recording(self): + first = self.run_generation() + entries = voice.reusable_entries(self.root, self.catalog, self.profile) + with mock.patch.object(voice.importlib.metadata, 'version', return_value='test-version'), \ + mock.patch.object(voice.subprocess, 'run', return_value=mock.Mock(stdout='Test GPU\n')): + environment = voice.reuse_environment(self.profile, entries, self.root) + second = voice.generate(self.catalog, self.profile, self.root, environment=environment) + self.assertEqual([], voice.validate_manifest(second, self.catalog, self.profile, self.root)) + changed_reference = copy.deepcopy(second) + changed_reference['environment']['sourceGenerations'][0]['manifestSha256'] = '0' * 64 + self.assertTrue(voice.validate_manifest(changed_reference, self.catalog, self.profile, self.root)) + changed_environment = copy.deepcopy(second) + changed_environment['environment']['sourceGenerations'][0]['environment']['device'] = 'CORRUPTED_DEVICE_RECORD' + self.assertTrue(voice.validate_manifest(changed_environment, self.catalog, self.profile, self.root)) + origin = self.root / 'generated' / first['runId'] / 'manifest.json' + changed = voice.load_json(origin) + changed['environment']['device'] = 'CORRUPTED_DEVICE_RECORD' + voice.write_json(origin, changed) + self.assertTrue(voice.validate_manifest(second, self.catalog, self.profile, self.root)) + + def test_legacy_migration_sha_seals_manifest_without_freezing_mutable_inputs(self): + manifest = self.run_generation() + path = self.root / 'generated' / manifest['runId'] / 'manifest.json' + voice.write_json(self.root / 'input-snapshot-migration.json', { + 'schemaVersion': 1, 'runs': [{'runId': manifest['runId'], 'originalManifestSha256': voice.file_hash(path)}], + 'preservedArtifactSha256': {'qwen-profile.json': 'old mutable file hash'}}) + voice.write_json(self.root / 'qwen-profile.json', {'seed': 43}) + self.assertEqual([], voice.validate_manifest(manifest, self.catalog, self.profile, self.root)) + changed = voice.load_json(path) + changed['environment']['device'] = 'CORRUPTED_DEVICE_RECORD' + voice.write_json(path, changed) + self.assertTrue(voice.validate_manifest(changed, self.catalog, self.profile, self.root)) + if __name__ == '__main__': unittest.main()