Skip to content

Commit b0631d9

Browse files
committed
feat: expose resume evidence states
1 parent 3f3ee3b commit b0631d9

7 files changed

Lines changed: 257 additions & 5 deletions

.github/workflows/instavar-voice-contract.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
uses: actions/checkout@v5
1818
with:
1919
repository: instavar/instavar-voice-evaluation
20-
ref: 8c0fb66a592c73f801a289aabd242e03a6849115
20+
ref: 29c38cfd86b889abc8b79df063c817dd8f684903
2121
path: .instavar-voice-evaluation
2222
- uses: actions/setup-python@v6
2323
with:
@@ -32,6 +32,10 @@ jobs:
3232
env:
3333
PYTHONPATH: .instavar-voice-evaluation
3434
run: python -c "from inspect import signature; from instavar_voice_lab.content_faithfulness import CONTENT_FAITHFULNESS_SCHEMA_VERSION as v, build_content_faithfulness_report as b; assert v == 'instavar_voice_content_faithfulness/v1'; assert {'generation_plan', 'reference_catalog', 'reference_assignment_plan', 'speaker_references', 'instruction_ngram_size', 'minimum_instruction_ngram_hits'} <= set(signature(b).parameters)"
35+
- name: Verify resume evidence API
36+
env:
37+
PYTHONPATH: .instavar-voice-evaluation
38+
run: python -c "from inspect import signature; from instavar_voice_lab.resume import LIVE_CONDITIONING_SCHEMA_VERSION as v, build_resume_run_receipt as b, compare_resume_artifacts as c; assert v == '1.1.0'; assert 'identity_artifacts' in signature(b).parameters; assert 'output_path' in signature(c).parameters"
3539
- name: Validate historical run evidence
3640
run: python .instavar-voice-evaluation/main.py validate historical historical-run.json
3741
- name: Validate executable lifecycle backend

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,20 @@ Each atomic `resume_epoch_NNNNNN` contains adapter bytes, optimizer, scheduler,
252252
AMP scaler when enabled, Python, NumPy, Torch, and CUDA RNG state, completed
253253
epoch and step, plus CV early-stop history.
254254

255+
Future checkpoints also bind separate `optimizer-state.pt` and
256+
`scheduler-state.pt` files. Together with `adapter_model.safetensors`,
257+
`training-state.json`, and `runtime-state.pt`, they expose the five independent
258+
file roles required by Instavar Voice evaluator 0.45. The combined runtime file
259+
remains the guarded loader source, so older checkpoints without the decomposed
260+
copies still resume under their original sidecar authority.
261+
262+
`evaluator_lora_artifact_paths(...)` rechecks the sidecar-bound live bytes and
263+
rejects missing decomposed state, ambiguous model files, and cross-role
264+
hardlinks. This instrumentation does not upgrade existing training or quality
265+
evidence. A fresh run pair must bind live Base, lineage, controls, and initial
266+
state through schema 1.1 receipts before outcome inspection. See
267+
[`reports/resume-evaluator-045-instrumentation-2026-08-14.md`](reports/resume-evaluator-045-instrumentation-2026-08-14.md).
268+
255269
The selected directory must be the newest owned guarded checkpoint. Changed
256270
bytes, changed inputs, terminal symlinks, unowned numeric siblings, a completed
257271
`max_epoch`, or an already-triggered early-stop target fail before adapter or
@@ -746,7 +760,7 @@ Tools in this repo are Apache-2.0 licensed. CosyVoice itself is under the [CosyV
746760

747761
## Instavar Voice conformance
748762

749-
[`instavar-voice-capabilities.json`](instavar-voice-capabilities.json) records the validated PyTorch adapter and merged-weight vLLM paths, while keeping direct vLLM LoRA loading explicitly unsupported. It also freezes the shared objective and blinded-listening criteria that remain necessary before a perceptual promotion decision. CI validates the manifest against the pinned public [Instavar Voice evaluation contract](https://github.com/instavar/instavar-voice-evaluation). New lifecycle runs should use evaluator commit `8c0fb66a592c73f801a289aabd242e03a6849115` or a deliberately reviewed successor so POSIX stage timeouts clean the complete process group. This does not retroactively upgrade earlier run evidence.
763+
[`instavar-voice-capabilities.json`](instavar-voice-capabilities.json) records the validated PyTorch adapter and merged-weight vLLM paths, while keeping direct vLLM LoRA loading explicitly unsupported. It also freezes the shared objective and blinded-listening criteria that remain necessary before a perceptual promotion decision. CI validates the manifest against the pinned public [Instavar Voice evaluation contract](https://github.com/instavar/instavar-voice-evaluation). New lifecycle and resume-evidence runs should use evaluator commit `29c38cfd86b889abc8b79df063c817dd8f684903` or a deliberately reviewed successor so POSIX stage timeouts clean the complete process group and schema 1.1 receipts bind live conditioning artifacts. This does not retroactively upgrade earlier run evidence.
750764

751765
The lifecycle preserves invalid generations as explicit rows, then uses
752766
evaluator revision `8feadf7bbda75abe1c305c63e362c41b86451cda` to bind timing,

instavar-voice-capabilities.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@
4848
"level": "repository_declared",
4949
"ref": "README.md#guarded-epoch-boundary-continuation",
5050
"claim": "New single-process torch_ddp runs can publish content-bound epoch continuation packages with optimizer, scheduler, scaler, RNG, progress, and CV monitor state."
51+
},
52+
{
53+
"level": "repository_declared",
54+
"ref": "reports/resume-evaluator-045-instrumentation-2026-08-14.md",
55+
"claim": "Future checkpoints expose independent model, optimizer, scheduler, trainer, and RNG files for evaluator 0.45 while preserving legacy loading."
5156
}
5257
],
5358
"boundary": "Historical checkpoint preservation does not validate the new resume contract or establish merged-runtime equivalence."
@@ -445,6 +450,7 @@
445450
"The corrected inference_instruct2 run produced valid WAVs but universal requested-text WER failure and frequent post-hoc exact instruction overlap; test any frontend or instruction rewrite under a new frozen plan.",
446451
"The early-stop helper passed a two-rank control smoke, but a real multi-rank production training run remains unverified.",
447452
"Guarded single-process epoch continuation has dependency-free contract coverage but no real interrupted GPU comparison.",
453+
"Existing resume evidence predates evaluator 0.45 live-conditioning receipts; a fresh paired run is required for the stronger byte-exact claim tier.",
448454
"DeepSpeed, multi-rank, and multi-worker guarded continuation are rejected until rank-local and worker state plus collective publication are represented.",
449455
"Run a real PyTorch lifecycle package through managed backup and verified restore after completing model, dataset, speaker, and generated-output rights review.",
450456
"The 18-row split-boundary probe showed that the earlier request-seed association follows frontend request boundaries rather than text length alone. Test a per-utterance seed schedule and reset policy only after selecting a checkpoint that passes the content gate."
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Evaluator 0.45 resume instrumentation
2+
3+
Date: 2026-08-14, Asia/Singapore
4+
5+
## Change
6+
7+
Future guarded CosyVoice3 LoRA checkpoints write sidecar-bound
8+
`optimizer-state.pt` and `scheduler-state.pt` files in addition to the combined
9+
`runtime-state.pt` loader source. The five evaluator 0.45 roles now map as
10+
follows:
11+
12+
| Evaluator role | CosyVoice checkpoint member |
13+
| --- | --- |
14+
| `model_state` | `adapter_model.safetensors` |
15+
| `optimizer_state` | `optimizer-state.pt` |
16+
| `scheduler_state` | `scheduler-state.pt` |
17+
| `trainer_state` | `training-state.json` |
18+
| `rng_state` | `runtime-state.pt` |
19+
20+
The runtime file also contains optimizer, scheduler, AMP scaler, and RNG state
21+
because the existing loader consumes that combined structure. The decomposed
22+
optimizer and scheduler copies make the required file roles independently
23+
addressable without breaking the old loader. Older guarded checkpoints that
24+
contain only the combined file remain resumable under their original sidecars.
25+
26+
`evaluator_lora_artifact_paths(...)` requires the new files, rechecks the live
27+
sidecar manifest, and rejects ambiguous adapter files or cross-role hardlinks.
28+
29+
## OOD and compatibility controls
30+
31+
Dependency-free tests cover:
32+
33+
- the five-role mapping for a new checkpoint;
34+
- a legacy combined-only checkpoint that still resumes but cannot claim 0.45
35+
readiness;
36+
- ambiguous adapter model files;
37+
- cross-role optimizer and scheduler hardlinks;
38+
- source-level confirmation that the trainer writes both decomposed files;
39+
- sidecar and continuation-state byte drift;
40+
- interruption during partial publication; and
41+
- unsupported DeepSpeed, multi-rank, and completed-target continuation.
42+
43+
The public contract workflow pins evaluator revision
44+
`29c38cfd86b889abc8b79df063c817dd8f684903` and verifies its schema 1.1 receipt
45+
builder and comparison APIs.
46+
47+
## Evidence boundary
48+
49+
No model training or GPU test was run for this instrumentation change. It
50+
establishes repository behavior and dependency-free contract coverage only.
51+
Historical checkpoints and the selected epoch-12 adapter predate the new files
52+
and schema 1.1 live-conditioning receipts. They are not upgraded.
53+
54+
A stronger comparison must preregister and fingerprint the Base artifact,
55+
dataset-lineage receipt, training controls, and initial state, then build
56+
independent uninterrupted and interrupted-resumed receipts before inspecting
57+
the outcome. Both runs must reach the same target update and the resumed run
58+
must bind an observed interruption before that target.
59+
60+
Even a passing comparison establishes byte equality only for the declared
61+
files. It does not prove trainer semantics, hidden floating-point equivalence,
62+
adaptation benefit, perceptual quality, vLLM equivalence, or distributed resume.

tests/test_resume_contract.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
from __future__ import annotations
22

33
import json
4+
import os
45
import tempfile
56
import unittest
67
from pathlib import Path
78

89
from tools.cosyvoice_resume_contract import (
910
LOCK_NAME,
11+
OPTIMIZER_STATE_NAME,
1012
ResumeContractError,
13+
SCHEDULER_STATE_NAME,
1114
acquire_output_lock,
1215
build_contract,
1316
checkpoint_children,
1417
epoch_checkpoint_name,
18+
evaluator_lora_artifact_paths,
1519
prune_owned_checkpoints,
1620
publish_checkpoint,
1721
require_fresh_output,
@@ -72,6 +76,8 @@ def _adapter_saver(directory: Path) -> None:
7276
@staticmethod
7377
def _runtime_saver(path: Path) -> None:
7478
path.write_bytes(b"trusted-pickle-fixture")
79+
(path.parent / OPTIMIZER_STATE_NAME).write_bytes(b"optimizer-fixture")
80+
(path.parent / SCHEDULER_STATE_NAME).write_bytes(b"scheduler-fixture")
7581

7682
def _checkpoint(
7783
self,
@@ -110,6 +116,97 @@ def test_exact_trusted_checkpoint_validates(self) -> None:
110116
self.assertEqual(state["completed_epoch"], 1)
111117
self.assertEqual(state["monitor_state"]["cv_no_improve_epochs"], 1)
112118

119+
def test_evaluator_lora_artifact_roles_are_independent(self) -> None:
120+
checkpoint = self._checkpoint(1)
121+
artifacts = evaluator_lora_artifact_paths(checkpoint)
122+
self.assertEqual(
123+
{role: path.name for role, path in artifacts.items()},
124+
{
125+
"model_state": "adapter_model.safetensors",
126+
"optimizer_state": OPTIMIZER_STATE_NAME,
127+
"scheduler_state": SCHEDULER_STATE_NAME,
128+
"trainer_state": "training-state.json",
129+
"rng_state": "runtime-state.pt",
130+
},
131+
)
132+
133+
def test_legacy_combined_runtime_state_remains_resumable(self) -> None:
134+
checkpoint = publish_checkpoint(
135+
output_dir=self.output,
136+
completed_epoch=1,
137+
completed_step=10,
138+
contract=self.contract,
139+
adapter_saver=self._adapter_saver,
140+
runtime_state_saver=lambda path: path.write_bytes(b"legacy-combined-state"),
141+
monitor_state={"best_cv_loss": 3.0},
142+
)
143+
selected, _ = validate_checkpoint(
144+
checkpoint,
145+
output_dir=self.output,
146+
expected_contract=self.contract,
147+
trust_resume_state=True,
148+
world_size=1,
149+
train_engine="torch_ddp",
150+
)
151+
self.assertEqual(selected, checkpoint)
152+
with self.assertRaisesRegex(ResumeContractError, "omits decomposed state"):
153+
evaluator_lora_artifact_paths(checkpoint)
154+
155+
def test_evaluator_mapping_rejects_ambiguous_model_and_hardlinks(self) -> None:
156+
def ambiguous_adapter(directory: Path) -> None:
157+
self._adapter_saver(directory)
158+
(directory / "adapter_model.bin").write_bytes(b"second-adapter")
159+
160+
ambiguous = publish_checkpoint(
161+
output_dir=self.output,
162+
completed_epoch=1,
163+
completed_step=10,
164+
contract=self.contract,
165+
adapter_saver=ambiguous_adapter,
166+
runtime_state_saver=self._runtime_saver,
167+
monitor_state={},
168+
)
169+
with self.assertRaisesRegex(ResumeContractError, "exactly one adapter"):
170+
evaluator_lora_artifact_paths(ambiguous)
171+
172+
other_output = self.root / "hardlink-output"
173+
other_output.mkdir()
174+
hardlink_contract = build_contract(
175+
output_dir=other_output,
176+
base_checkpoint=self.base,
177+
config_file=self.config,
178+
qwen_pretrain=self.qwen,
179+
data_files={"train": [self.train_list], "cross_validation": [self.cv_list]},
180+
source_files=[self.source],
181+
training_config={"max_epoch": 5, "train_engine": "torch_ddp"},
182+
runtime={"python": "fixture", "world_size": 1},
183+
)
184+
185+
def hardlinked_runtime(path: Path) -> None:
186+
path.write_bytes(b"runtime")
187+
optimizer = path.parent / OPTIMIZER_STATE_NAME
188+
optimizer.write_bytes(b"shared-state")
189+
os.link(optimizer, path.parent / SCHEDULER_STATE_NAME)
190+
191+
hardlinked = publish_checkpoint(
192+
output_dir=other_output,
193+
completed_epoch=1,
194+
completed_step=10,
195+
contract=hardlink_contract,
196+
adapter_saver=self._adapter_saver,
197+
runtime_state_saver=hardlinked_runtime,
198+
monitor_state={},
199+
)
200+
with self.assertRaisesRegex(ResumeContractError, "must not share hardlinks"):
201+
evaluator_lora_artifact_paths(hardlinked)
202+
203+
def test_trainer_writes_decomposed_state_before_publication(self) -> None:
204+
source = (
205+
Path(__file__).parents[1] / "tools" / "train_cosyvoice3_lora.py"
206+
).read_text(encoding="utf-8")
207+
self.assertIn("path.parent / OPTIMIZER_STATE_NAME", source)
208+
self.assertIn("path.parent / SCHEDULER_STATE_NAME", source)
209+
113210
def test_resume_requires_explicit_trust(self) -> None:
114211
checkpoint = self._checkpoint(1)
115212
with self.assertRaisesRegex(ResumeContractError, "pickle-capable"):

tools/cosyvoice_resume_contract.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
SIDECAR_NAME = "resume-contract.json"
1919
STATE_NAME = "training-state.json"
2020
RUNTIME_STATE_NAME = "runtime-state.pt"
21+
OPTIMIZER_STATE_NAME = "optimizer-state.pt"
22+
SCHEDULER_STATE_NAME = "scheduler-state.pt"
2123
LOCK_NAME = ".instavar-training.lock"
2224
_CHECKPOINT_RE = re.compile(r"^resume_epoch_(\d{6})$")
2325

@@ -376,6 +378,65 @@ def _validate_checkpoint_files(
376378
return sidecar
377379

378380

381+
def evaluator_lora_artifact_paths(checkpoint_dir: str | Path) -> dict[str, Path]:
382+
"""Map a new guarded LoRA checkpoint to evaluator 0.45 state roles."""
383+
raw = Path(checkpoint_dir).expanduser()
384+
if raw.is_symlink():
385+
raise ResumeContractError("Evaluator checkpoint must not be a symlink")
386+
checkpoint = raw.resolve(strict=True)
387+
if not checkpoint.is_dir() or not _CHECKPOINT_RE.fullmatch(checkpoint.name):
388+
raise ResumeContractError("Evaluator checkpoint must be a guarded epoch directory")
389+
390+
sidecar_path = checkpoint / SIDECAR_NAME
391+
if sidecar_path.is_symlink() or not sidecar_path.is_file():
392+
raise ResumeContractError(f"Checkpoint has no safe {SIDECAR_NAME}")
393+
try:
394+
sidecar = json.loads(sidecar_path.read_text(encoding="utf-8"))
395+
except json.JSONDecodeError as error:
396+
raise ResumeContractError("Checkpoint sidecar is invalid JSON") from error
397+
if sidecar.get("schema_version") != SCHEMA_VERSION:
398+
raise ResumeContractError("Unsupported guarded checkpoint schema")
399+
if sidecar.get("checkpoint_name") != checkpoint.name:
400+
raise ResumeContractError("Checkpoint sidecar names a different directory")
401+
manifest = sidecar.get("files")
402+
if not isinstance(manifest, list) or manifest != _required_manifest(checkpoint):
403+
raise ResumeContractError("Guarded checkpoint file identity drift detected")
404+
405+
names = {item["path"] for item in manifest}
406+
required = {
407+
"adapter_model.safetensors",
408+
OPTIMIZER_STATE_NAME,
409+
SCHEDULER_STATE_NAME,
410+
STATE_NAME,
411+
RUNTIME_STATE_NAME,
412+
}
413+
missing = sorted(required - names)
414+
if missing:
415+
raise ResumeContractError(
416+
"Evaluator mapping omits decomposed state: " + ", ".join(missing)
417+
)
418+
model_candidates = sorted(
419+
name
420+
for name in names
421+
if name in {"adapter_model.safetensors", "adapter_model.bin"}
422+
)
423+
if len(model_candidates) != 1:
424+
raise ResumeContractError("Evaluator mapping needs exactly one adapter model state")
425+
426+
relative_by_role = {
427+
"model_state": model_candidates[0],
428+
"optimizer_state": OPTIMIZER_STATE_NAME,
429+
"scheduler_state": SCHEDULER_STATE_NAME,
430+
"trainer_state": STATE_NAME,
431+
"rng_state": RUNTIME_STATE_NAME,
432+
}
433+
resolved = {role: checkpoint / name for role, name in relative_by_role.items()}
434+
identities = [(path.stat().st_dev, path.stat().st_ino) for path in resolved.values()]
435+
if len(identities) != len(set(identities)):
436+
raise ResumeContractError("Evaluator artifact roles must not share hardlinks")
437+
return resolved
438+
439+
379440
def prune_owned_checkpoints(
380441
output_dir: str | Path,
381442
*,

tools/train_cosyvoice3_lora.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323

2424
from distributed_early_stop import synchronize_early_stop
2525
from cosyvoice_resume_contract import (
26+
OPTIMIZER_STATE_NAME,
2627
ResumeContractError,
28+
SCHEDULER_STATE_NAME,
2729
acquire_output_lock,
2830
build_contract,
2931
prune_owned_checkpoints,
@@ -201,9 +203,15 @@ def save_runtime_state(path: Path, optimizer, scheduler, scaler) -> None:
201203
"torch_rng": torch.get_rng_state(),
202204
"cuda_rng": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else [],
203205
}
204-
torch.save(state, path)
205-
with path.open("rb") as handle:
206-
os.fsync(handle.fileno())
206+
outputs = {
207+
path: state,
208+
path.parent / OPTIMIZER_STATE_NAME: state["optimizer"],
209+
path.parent / SCHEDULER_STATE_NAME: state["scheduler"],
210+
}
211+
for output, value in outputs.items():
212+
torch.save(value, output)
213+
with output.open("rb") as handle:
214+
os.fsync(handle.fileno())
207215

208216

209217
def restore_runtime_state(path: Path, optimizer, scheduler, scaler) -> None:

0 commit comments

Comments
 (0)