feat: publish downloadable wakeword trainer source

This commit is contained in:
2026-08-14 05:03:00 -05:00
parent 000671eb93
commit aab9ce0fe7
30 changed files with 6757 additions and 2 deletions
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
# Build the isolated training image from the reviewed, complete hash lock. This
# is an operator preparation command, never a CI step or training invocation.
# It intentionally does not download corpora or rewrite the reviewed lock.
readonly build_dir="deploy/wakeword-training"
readonly lock_file="${build_dir}/requirements.wheelhouse.lock"
readonly base_image="docker.io/pytorch/pytorch@sha256:639b8229ccfd8a3aa803cf49c33d6d6fe406750d79aaf723fe8c0eb1060d8cff"
if [[ $# -ne 1 ]]; then
echo "usage: $0 <output-image-tag>" >&2
exit 64
fi
if [[ ! -s "$lock_file" ]]; then
echo "required reviewed dependency lock is missing: $lock_file" >&2
exit 66
fi
if ! grep -F "livekit-wakeword" "$lock_file" >/dev/null \
|| ! grep -F "0.2.1" "$lock_file" >/dev/null; then
echo "compiled lock did not retain livekit-wakeword 0.2.1" >&2
exit 65
fi
if ! grep -F "cf2d9cf4867812c06788f64c15e49abd909d9d6291f0a13f1c3f9cb649fa6127" "$lock_file" >/dev/null; then
echo "compiled lock did not retain the attested LiveKit source hash" >&2
exit 65
fi
if ! grep -F "torch==2.7.1+cu126" "$lock_file" >/dev/null \
|| ! grep -F "torchaudio==2.7.1+cu126" "$lock_file" >/dev/null; then
echo "compiled lock must match the base image's PyTorch/TorchAudio 2.7.1 CUDA 12.6 pair" >&2
exit 65
fi
docker build --pull=false --build-arg "BASE_IMAGE=$base_image" -f "${build_dir}/Dockerfile" -t "$1" .
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python3
"""Executable entrance for the downloadable JRich Wakeword Trainer."""
from wakeword_trainer import main
if __name__ == "__main__":
raise SystemExit(main())
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Start one sealed offline Wakeword Training attempt.
The command is deliberately operator-only. It has no gateway/API-key path and
refuses to start unless both ``--enable`` and an active maintenance window are
explicitly supplied. See docs/wakeword-training-operator-runner.md.
"""
from __future__ import annotations
import argparse
import asyncio
import json
from pathlib import Path
from wakeword_training import (
DockerTrainingExecutor,
FakeTrainingExecutor,
NvidiaSmiGpuEvidenceSampler,
NvidiaSmiProcessInspector,
RunnerSettings,
StaticGpuProcessInspector,
StaticMaintenanceWindow,
WakewordTrainingRequest,
WakewordTrainingRunner,
terminal_exit_code,
)
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--request", required=True, type=Path, help="immutable request JSON")
parser.add_argument("--work-root", required=True, type=Path, help="operator run root")
parser.add_argument("--enable", action="store_true", help="explicitly permit this invocation")
parser.add_argument(
"--maintenance-window-active",
action="store_true",
help="attest that the configured GPU maintenance window is active",
)
parser.add_argument("--retry-of", help="terminal matching run ID for a new immutable attempt")
parser.add_argument("--timeout-s", type=float, default=6 * 60 * 60)
parser.add_argument(
"--fake", action="store_true", help="CI/local quarantined contract executor"
)
parser.add_argument(
"--image-ref", help="digest-pinned training image, e.g. registry/image@sha256:…"
)
parser.add_argument("--gpu-uuid", help="stable configured RTX UUID, never a mutable ordinal")
parser.add_argument(
"--approved-inputs-dir",
type=Path,
help="hash-locked data mount; mounted read-only at /inputs",
)
parser.add_argument(
"--allow-active-gpu-processes",
action="store_true",
help="explicit shared-GPU override; never terminates another process",
)
parser.add_argument(
"--stream-output", action="store_true", help="mirror container progress to stderr"
)
return parser
async def _main(args: argparse.Namespace) -> int:
request = WakewordTrainingRequest.from_file(args.request)
if args.fake:
executor = FakeTrainingExecutor()
else:
if not (args.image_ref and args.gpu_uuid and args.approved_inputs_dir):
raise SystemExit(
"a real run requires --image-ref, --gpu-uuid, and --approved-inputs-dir; "
"use --fake only for a quarantined contract smoke"
)
executor = DockerTrainingExecutor(
image_ref=args.image_ref,
gpu_uuid=args.gpu_uuid,
approved_inputs_dir=args.approved_inputs_dir,
stream_output=args.stream_output,
)
runner = WakewordTrainingRunner(
settings=RunnerSettings(
work_root=args.work_root,
enabled=args.enable,
maintenance_window=StaticMaintenanceWindow(args.maintenance_window_active),
process_inspector=(
StaticGpuProcessInspector(active_unapproved_processes=False)
if args.fake or args.allow_active_gpu_processes
else NvidiaSmiProcessInspector(args.gpu_uuid)
),
gpu_uuid=None if args.fake else args.gpu_uuid,
gpu_sampler=None if args.fake else NvidiaSmiGpuEvidenceSampler(args.gpu_uuid),
timeout_s=args.timeout_s,
),
executor=executor,
)
result = await runner.run(request, retry_of=args.retry_of)
print(
json.dumps(
{
"run_dir": str(result.run_dir),
"run_id": result.state.run_id,
"status": result.state.status,
},
sort_keys=True,
)
)
return terminal_exit_code(result.state.status)
if __name__ == "__main__":
raise SystemExit(asyncio.run(_main(_parser().parse_args())))
+603
View File
@@ -0,0 +1,603 @@
"""Standard-library host launcher for the downloadable JRich Wakeword Trainer."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
RUNNER_VERSION = "0.2.1-jrich-2"
DEFAULT_IMAGE = (
"git.jimandkrista.com/jr-public/jrich-wakeword-training"
"@sha256:cef5b1e769a6160bbde69f67df76f0b865f02c27794a3da096398c18cc9a3c2f"
)
DEFAULT_CACHE = Path.home() / ".cache" / "jrich-wakeword" / "livekit-0.2.1"
DEFAULT_WORK_ROOT = Path.cwd() / "jrich-wakeword-runs"
MIN_FREE_BYTES = 40 * 1024**3
PRESET_PATH = (
Path(__file__).resolve().parents[1]
/ "deploy"
/ "wakeword-training"
/ "presets"
/ "manual-poc-v1.json"
)
_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
_MODEL_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
def _canonical(value: object) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _sha256(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
result = dict(base)
for key, value in override.items():
if key not in base:
raise ValueError(f"unknown pipeline override: {key}")
if isinstance(base[key], dict) and isinstance(value, dict):
result[key] = _deep_merge(base[key], value)
else:
result[key] = value
return result
def default_pipeline_config(
*,
model_name: str,
family_slug: str,
version: int,
phrases: tuple[str, ...],
near_misses: tuple[str, ...],
) -> dict[str, Any]:
"""Return the measured, versioned manual-POC profile before user overrides."""
preset = json.loads(PRESET_PATH.read_text(encoding="utf-8"))
if not isinstance(preset, dict) or preset.get("preset_version") != "manual-poc-v1":
raise ValueError("the bundled manual POC preset is malformed")
config = preset.get("pipeline_config")
if not isinstance(config, dict):
raise ValueError("the bundled manual POC preset has no pipeline_config")
return {
"model_name": model_name,
"family_slug": family_slug,
"version": version,
"target_phrases": list(phrases),
"custom_negative_phrases": list(near_misses),
**config,
}
def build_setup_command(
*, image_ref: str, gpu_uuid: str, cache_dir: Path, config_path: Path
) -> tuple[str, ...]:
"""Build the explicit networked cache-population command; training stays offline."""
cache_dir.mkdir(parents=True, exist_ok=True)
return (
"docker",
"run",
"--rm",
"--init",
"--gpus",
f"device={gpu_uuid}",
"--read-only",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--mount",
f"type=bind,src={cache_dir.resolve()},dst=/data",
"--mount",
f"type=bind,src={config_path.resolve()},dst=/config.json,readonly",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=4g",
image_ref,
"livekit-wakeword",
"setup",
"--config",
"/config.json",
)
def _run(
command: tuple[str, ...] | list[str], *, capture: bool = False
) -> subprocess.CompletedProcess:
return subprocess.run(command, check=False, capture_output=capture, text=True)
def _inspect_image(image_ref: str) -> dict[str, Any] | None:
completed = _run(
["docker", "image", "inspect", "--format", "{{json .}}", image_ref],
capture=True,
)
if completed.returncode != 0:
return None
try:
value = json.loads(completed.stdout)
except json.JSONDecodeError as exc:
raise ValueError("Docker returned malformed image metadata") from exc
if not isinstance(value, dict):
raise ValueError("Docker returned malformed image metadata")
return value
def _image_identity(image_ref: str) -> str:
if image_ref.startswith("sha256:") and len(image_ref) == 71:
return image_ref.lower()
inspected = _inspect_image(image_ref)
if inspected is None:
print(f"Downloading pinned training runtime: {image_ref}", file=sys.stderr)
pulled = _run(["docker", "pull", image_ref])
if pulled.returncode != 0:
raise ValueError(f"training image download failed: {image_ref}")
inspected = _inspect_image(image_ref)
if inspected is None:
raise ValueError(
f"training image is unavailable after download: {image_ref}"
)
if "@sha256:" in image_ref:
expected = image_ref.lower()
repo_digests = inspected.get("RepoDigests", [])
if not isinstance(repo_digests, list) or expected not in {
str(value).lower() for value in repo_digests
}:
raise ValueError("Docker did not retain the requested registry manifest digest")
digest = str(inspected.get("Id", "")).lower()
if not digest.startswith("sha256:") or len(digest) != 71:
raise ValueError("Docker returned an invalid immutable image ID")
return digest
def _default_gpu_uuid() -> str:
completed = _run(
["nvidia-smi", "--query-gpu=uuid", "--format=csv,noheader"], capture=True
)
values = [line.strip() for line in completed.stdout.splitlines() if line.strip()]
if completed.returncode != 0 or not values:
raise ValueError("no NVIDIA GPU UUID was found; verify the driver and nvidia-smi")
if len(values) > 1:
raise ValueError("multiple NVIDIA GPUs found; choose one with --gpu-uuid")
return values[0]
def _prompt(label: str, default: str | None = None) -> str:
suffix = f" [{default}]" if default else ""
value = input(f"{label}{suffix}: ").strip()
return value or (default or "")
def _slug(value: str) -> str:
candidate = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
if not _SLUG.fullmatch(candidate):
raise ValueError("family slug must use lowercase letters, numbers, and single hyphens")
return candidate
def _interactive_values(args: argparse.Namespace) -> None:
if args.phrase:
return
print("JRich Wakeword Trainer — the defaults below match the measured RTX 3090 POC.")
primary = _prompt("Primary spoken phrase", "Hey Jay Rich")
variants = _prompt("Additional spoken phrases, comma-separated", "Yo Jay Rich")
near_misses = _prompt("Near-miss phrases that must not activate, comma-separated", "Jay Rich")
label = _prompt("Model name", "jrich")
args.phrase = [primary, *[item.strip() for item in variants.split(",") if item.strip()]]
args.near_miss = [item.strip() for item in near_misses.split(",") if item.strip()]
args.model_name = args.model_name or label
args.family_slug = args.family_slug or _slug(label)
def _load_override(path: Path | None) -> dict[str, Any]:
if path is None:
return {}
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError("pipeline override must be a JSON object")
return value
def _file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
for block in iter(lambda: file.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def write_local_source_lock(work_root: Path, cache_dir: Path) -> tuple[Path, str]:
"""Write a path-independent, content-addressed inventory of the local cache."""
request_root = work_root / "requests"
request_root.mkdir(parents=True, exist_ok=True)
cache_dir.mkdir(parents=True, exist_ok=True)
files: list[dict[str, object]] = []
for path in sorted(cache_dir.rglob("*")):
if path.is_symlink():
raise ValueError(f"training cache must not contain symlinks: {path}")
if not path.is_file():
continue
files.append(
{
"relative_path": path.relative_to(cache_dir).as_posix(),
"size_bytes": path.stat().st_size,
"sha256": _file_sha256(path),
}
)
value = {
"schema_version": 1,
"run_kind": "local_experiment",
"sources": [
{
"source_id": "livekit-setup-cache-v0.2.1",
"kind": "user-local-cache",
"publication_approved": False,
"files": files,
}
],
}
encoded = (_canonical(value) + "\n").encode("utf-8")
digest = _sha256(encoded)
path = request_root / f"sources-{digest}.lock.json"
if not path.exists():
path.write_bytes(encoded)
path.chmod(0o444)
return path, digest
def resolve_plan(args: argparse.Namespace) -> dict[str, Any]:
phrases = tuple(" ".join(value.split()) for value in (args.phrase or ()) if value.strip())
if not phrases:
raise ValueError("at least one --phrase is required in non-interactive mode")
near_misses = tuple(
" ".join(value.split()) for value in (args.near_miss or ()) if value.strip()
)
model_name = (args.model_name or _slug(phrases[0]).replace("-", "_")).strip()
if not _MODEL_NAME.fullmatch(model_name):
raise ValueError(
"model name must start with a letter or number and contain only letters, "
"numbers, underscores, or hyphens"
)
if args.version < 1:
raise ValueError("version must be a positive integer")
family_slug = _slug(args.family_slug or model_name)
image_digest = _image_identity(args.image_ref)
gpu_uuid = args.gpu_uuid or _default_gpu_uuid()
cache_dir = args.cache_dir.resolve()
work_root = args.work_root.resolve()
pipeline = default_pipeline_config(
model_name=model_name,
family_slug=family_slug,
version=args.version,
phrases=phrases,
near_misses=near_misses,
)
pipeline = _deep_merge(pipeline, _load_override(args.pipeline_config))
identity = {
"model_name": model_name,
"family_slug": family_slug,
"version": args.version,
"target_phrases": list(phrases),
"custom_negative_phrases": list(near_misses),
}
divergent = [name for name, value in identity.items() if pipeline.get(name) != value]
if divergent:
raise ValueError(
"pipeline override cannot change request identity fields: " + ", ".join(divergent)
)
source_lock, source_digest = write_local_source_lock(work_root, cache_dir)
request = {
"schema_version": 1,
"primary_phrase": phrases[0],
"variants": list(phrases[1:]),
"near_misses": list(near_misses),
"evaluation_profile": "local-balanced-v1",
"source_lock_path": str(source_lock),
"source_lock_sha256": source_digest,
"intended_release_id": f"{family_slug}-v{args.version}",
"runner_image_digest": image_digest,
"pipeline_config": pipeline,
"run_kind": "local_experiment",
"derived_from_run_id": args.reuse_from,
}
# Keep the local wizard and future API worker on the exact same fail-closed
# validation boundary. Import shape differs only between the extracted
# archive (sibling module) and repository tests (scripts package).
try:
from wakeword_training import RunKind, WakewordTrainingRequest
except ModuleNotFoundError:
from scripts.wakeword_training import RunKind, WakewordTrainingRequest
WakewordTrainingRequest(
primary_phrase=request["primary_phrase"],
variants=tuple(request["variants"]),
near_misses=tuple(request["near_misses"]),
evaluation_profile=request["evaluation_profile"],
source_lock_path=Path(request["source_lock_path"]),
source_lock_sha256=request["source_lock_sha256"],
intended_release_id=request["intended_release_id"],
runner_image_digest=request["runner_image_digest"],
pipeline_config=request["pipeline_config"],
run_kind=RunKind.LOCAL_EXPERIMENT,
derived_from_run_id=request["derived_from_run_id"],
).canonical_payload()
return {
"schema_version": 1,
"runner_version": RUNNER_VERSION,
"request": request,
"runtime": {
"image_ref": image_digest,
"image_source": args.image_ref,
"gpu_uuid": gpu_uuid,
"cache_dir": str(cache_dir),
"work_root": str(work_root),
"hardware_profile": "shared-conservative"
if args.allow_active_gpu_processes
else "exclusive",
},
}
def _preflight(plan: dict[str, Any]) -> None:
runtime = plan["runtime"]
for executable in ("docker", "nvidia-smi"):
if shutil.which(executable) is None:
raise ValueError(f"required host command is missing: {executable}")
docker = _run(["docker", "info"], capture=True)
if docker.returncode != 0:
raise ValueError("Docker daemon is unavailable to this user")
gpu = _run(
[
"nvidia-smi",
f"--id={runtime['gpu_uuid']}",
"--query-gpu=uuid,name,memory.total,memory.free,driver_version",
"--format=csv,noheader,nounits",
],
capture=True,
)
if gpu.returncode != 0:
raise ValueError("the selected GPU UUID is unavailable")
container_gpu = _run(
[
"docker",
"run",
"--rm",
"--gpus",
f"device={runtime['gpu_uuid']}",
"--network",
"none",
"--read-only",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
runtime["image_ref"],
"python",
"-c",
"import torch; assert torch.cuda.is_available(); print(torch.cuda.get_device_name())",
],
capture=True,
)
if container_gpu.returncode != 0:
raise ValueError("NVIDIA Container Toolkit did not expose the selected GPU to the image")
for key in ("cache_dir", "work_root"):
path = Path(runtime[key])
path.mkdir(parents=True, exist_ok=True)
free = shutil.disk_usage(path).free
if free < MIN_FREE_BYTES:
raise ValueError(f"{path} has less than the recommended 40 GiB free")
print(f"GPU: {gpu.stdout.strip()}", file=sys.stderr)
print(f"Container CUDA: {container_gpu.stdout.strip()}", file=sys.stderr)
print(f"Host CPUs: {os.cpu_count() or 'unknown'}", file=sys.stderr)
print("Preflight passed; training will run with network disabled.", file=sys.stderr)
def _write_request(plan: dict[str, Any]) -> Path:
work_root = Path(plan["runtime"]["work_root"])
encoded = (_canonical(plan["request"]) + "\n").encode("utf-8")
path = work_root / "requests" / f"request-{_sha256(encoded)}.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(encoded)
return path
def _setup(plan: dict[str, Any]) -> None:
runtime = plan["runtime"]
config = dict(plan["request"]["pipeline_config"])
config["data_dir"] = "/data"
config["output_dir"] = "/tmp/setup-output"
setup_path = Path(runtime["work_root"]) / "requests" / "setup-config.json"
setup_path.write_text(_canonical(config) + "\n", encoding="utf-8")
command = build_setup_command(
image_ref=runtime["image_ref"],
gpu_uuid=runtime["gpu_uuid"],
cache_dir=Path(runtime["cache_dir"]),
config_path=setup_path,
)
completed = _run(command)
if completed.returncode != 0:
raise ValueError("LiveKit asset setup failed")
def _train(plan: dict[str, Any], request_path: Path, args: argparse.Namespace) -> int:
runtime = plan["runtime"]
command = [
sys.executable,
str(Path(__file__).with_name("run-wakeword-training")),
"--request",
str(request_path),
"--work-root",
runtime["work_root"],
"--enable",
"--maintenance-window-active",
"--image-ref",
runtime["image_ref"],
"--gpu-uuid",
runtime["gpu_uuid"],
"--approved-inputs-dir",
runtime["cache_dir"],
"--timeout-s",
str(args.timeout_s),
"--stream-output",
]
if args.allow_active_gpu_processes:
command.append("--allow-active-gpu-processes")
if args.retry_of:
command.extend(("--retry-of", args.retry_of))
return _run(command).returncode
def _find_run(work_root: Path, run_id: str) -> tuple[Path, dict[str, Any]]:
if len(run_id) != 32 or any(character not in "0123456789abcdef" for character in run_id):
raise ValueError("run ID must be 32 lowercase hexadecimal characters")
matches: list[tuple[Path, dict[str, Any]]] = []
for run_dir in (work_root / "runs").glob(f"*-{run_id}"):
state_path = run_dir / "state.json"
if not state_path.is_file():
continue
state = json.loads(state_path.read_text(encoding="utf-8"))
if isinstance(state, dict) and state.get("run_id") == run_id:
matches.append((run_dir, state))
if len(matches) != 1:
raise ValueError("run ID was not found under the selected work root")
return matches[0]
def run_local_operation(args: argparse.Namespace) -> int | None:
operation = next(
(
(name, run_id)
for name, run_id in (
("status", args.status),
("logs", args.logs),
("cancel", args.cancel),
)
if run_id is not None
),
None,
)
if operation is None:
return None
name, run_id = operation
run_dir, state = _find_run(args.work_root.resolve(), run_id)
if name == "status":
result_path = run_dir / "training-result.json"
result = (
json.loads(result_path.read_text(encoding="utf-8"))
if result_path.is_file()
else None
)
print(json.dumps({"run_dir": str(run_dir), "state": state, "result": result}, indent=2))
return 0
if name == "logs":
for label in ("stdout.log", "stderr.log"):
print(f"== {label} ==")
path = run_dir / label
if path.is_file():
print(path.read_text(encoding="utf-8", errors="replace"), end="")
return 0
if state.get("status") in {
"completed",
"pending_publication",
"failed",
"canceled",
"timed_out",
"abandoned",
}:
raise ValueError("terminal run cannot be canceled")
(run_dir / "cancel-requested").write_text("requested\n", encoding="utf-8")
print(f"Cancellation requested for {run_id}; the runner will stop its own container.")
return 0
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Train a LiveKit Wakeword model in pinned Docker")
parser.add_argument("--non-interactive", action="store_true")
parser.add_argument("--plan", action="store_true", help="print resolved JSON without running")
operation = parser.add_mutually_exclusive_group()
operation.add_argument("--status", metavar="RUN_ID", help="show one local attempt")
operation.add_argument("--logs", metavar="RUN_ID", help="show one local attempt's logs")
operation.add_argument("--cancel", metavar="RUN_ID", help="request safe cancellation")
parser.add_argument("--phrase", action="append", help="spoken phrase; repeat for alternatives")
parser.add_argument("--near-miss", action="append", help="phrase that must not activate")
parser.add_argument("--model-name")
parser.add_argument("--family-slug")
parser.add_argument("--version", type=int, default=1)
parser.add_argument("--pipeline-config", type=Path, help="JSON deep overrides")
parser.add_argument("--image-ref", default=os.getenv("JR_WAKEWORD_IMAGE", DEFAULT_IMAGE))
parser.add_argument("--gpu-uuid")
parser.add_argument("--cache-dir", type=Path, default=DEFAULT_CACHE)
parser.add_argument("--work-root", type=Path, default=DEFAULT_WORK_ROOT)
parser.add_argument("--skip-setup", action="store_true")
parser.add_argument(
"--setup-only",
action="store_true",
help="populate the pinned input cache, print its source-lock digest, and exit",
)
parser.add_argument(
"--reuse-from",
help="terminal local run ID whose sealed generated corpus seeds a new attempt",
)
parser.add_argument(
"--retry-of", help="matching terminal run ID to retry as a new immutable attempt"
)
parser.add_argument("--allow-active-gpu-processes", action="store_true")
parser.add_argument("--timeout-s", type=float, default=6 * 60 * 60)
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
try:
if getattr(args, "setup_only", False) and args.skip_setup:
raise ValueError("--setup-only and --skip-setup cannot be combined")
operation_result = run_local_operation(args)
if operation_result is not None:
return operation_result
if not args.non_interactive:
_interactive_values(args)
plan = resolve_plan(args)
if args.plan:
print(json.dumps(plan, indent=2, sort_keys=True))
return 0
print(json.dumps(plan, indent=2, sort_keys=True))
if not args.non_interactive and _prompt("Continue with this configuration", "yes") != "yes":
return 0
_preflight(plan)
if not args.skip_setup:
_setup(plan)
refreshed = resolve_plan(args)
if refreshed["request"]["source_lock_sha256"] != plan["request"][
"source_lock_sha256"
]:
plan = refreshed
print("Final content-locked request after setup:", file=sys.stderr)
print(json.dumps(plan, indent=2, sort_keys=True))
if getattr(args, "setup_only", False):
print(
"WAKEWORD_TRAINING_SOURCE_LOCK_SHA256="
+ plan["request"]["source_lock_sha256"]
)
return 0
request_path = _write_request(plan)
return _train(plan, request_path, args)
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+395
View File
@@ -0,0 +1,395 @@
"""Pinned-image input and provenance contract for Wakeword Training (#163).
This module deliberately does not implement wakeword generation, augmentation,
feature extraction, training, export, or evaluation. It validates the
commercial source lock, materializes only its declared inputs, and leaves the
actual ML stages to the released LiveKit CLI inside the isolated image.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from typing import Any
LIVEKIT_WAKEWORD_VERSION = "0.2.1"
LIVEKIT_WAKEWORD_REVISION = "1ec7f680df30ff4ca0ebae6b5983441e94b10980"
LIVEKIT_WAKEWORD_SDIST_SHA256 = "cf2d9cf4867812c06788f64c15e49abd909d9d6291f0a13f1c3f9cb649fa6127"
_COMMERCIAL_PURPOSES = frozenset({"wakeword_training", "classifier_distribution"})
_BLOCKED_LICENSE_MARKERS = ("unknown", "nc", "non-commercial", "research-only")
_BLOCKED_SOURCE_MARKERS = ("acav", "unknown_rir_mirror")
_SYNTHETIC_SOURCE_KINDS = frozenset({"piper_output", "voxcpm_output"})
_SAFE_TOKEN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,127}$")
_ALLOWED_PURPOSES = frozenset(
{
"positive",
"adversarial_negative",
"near_miss",
"general_negative_speech",
"background",
"rir",
}
)
_ALLOWED_PARTITIONS = frozenset({"train", "validation", "calibration", "release_test"})
_REQUIRED_PURPOSE_PARTITIONS = frozenset(
{
("positive", "train"),
("positive", "release_test"),
("general_negative_speech", "train"),
("general_negative_speech", "validation"),
("background", "train"),
("rir", "train"),
}
)
class SourceLockError(ValueError):
"""A source lock cannot safely enter the requested training lane."""
class RunClassification(StrEnum):
COMMERCIAL_REVIEW_REQUIRED = "commercial_review_required"
LOCAL_ONLY = "local_only"
QUARANTINED = "quarantined"
@property
def commercial_promotion_eligible(self) -> bool:
return self is RunClassification.COMMERCIAL_REVIEW_REQUIRED
def classify_run(run_kind: str) -> RunClassification:
if run_kind == "commercial":
return RunClassification.COMMERCIAL_REVIEW_REQUIRED
if run_kind == "local_experiment":
return RunClassification.LOCAL_ONLY
if run_kind == "quarantined_smoke":
return RunClassification.QUARANTINED
raise SourceLockError("run_kind must be commercial, local_experiment, or quarantined_smoke")
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
for block in iter(lambda: file.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _require_mapping(value: object, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise SourceLockError(f"{label} must be an object")
return value
def _require_string(value: object, label: str) -> str:
if not isinstance(value, str) or not value.strip():
raise SourceLockError(f"{label} must be a non-empty string")
return value.strip()
def _require_sha256(value: object, label: str) -> str:
digest = _require_string(value, label).removeprefix("sha256:").lower()
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
raise SourceLockError(f"{label} must be a SHA-256 digest")
return digest
def _safe_input_path(root: Path, declared_path: str) -> Path:
candidate = (root / declared_path).resolve(strict=True)
if not candidate.is_file() or os.path.commonpath((root, candidate)) != str(root):
raise SourceLockError("source lock file path must identify a regular file below input_root")
return candidate
@dataclass(frozen=True)
class MaterializedInputs:
provenance_manifest_path: Path
recorded_manifest_path: Path
class InputMaterializer:
"""Verify and copy a source lock's declared inputs into one sealed run.
The input root is read-only when this code runs in the training image. The
run directory is the sole persistence target, so no upstream ``setup``
download can become an undeclared training input.
"""
def __init__(self, lock_path: Path, input_root: Path, run_dir: Path, *, run_kind: str) -> None:
self.lock_path = lock_path.resolve(strict=True)
self.input_root = input_root.resolve(strict=True)
self.run_dir = run_dir.resolve()
self.run_kind = run_kind
def materialize(self) -> MaterializedInputs:
classification = classify_run(self.run_kind)
lock = self._read_lock()
if lock.get("run_kind") != self.run_kind:
raise SourceLockError("source lock run_kind does not match the requested run")
sources = lock.get("sources")
if not isinstance(sources, list) or not sources:
raise SourceLockError("source lock must contain at least one source")
materialized_root = self.run_dir / "materialized"
materialized_root.mkdir(parents=True, exist_ok=False)
source_manifest: list[dict[str, object]] = []
supplied_purposes: set[tuple[str, str]] = set()
seen_source_ids: set[str] = set()
materialized_destinations: set[Path] = set()
for index, raw_source in enumerate(sources):
source = _require_mapping(raw_source, f"sources[{index}]")
source_id, purpose, partition, file_records = self._validate_source(
source, index, commercial=classification.commercial_promotion_eligible
)
if source_id in seen_source_ids:
raise SourceLockError(f"duplicate source_id: {source_id}")
seen_source_ids.add(source_id)
supplied_purposes.add((purpose, partition))
target_root = materialized_root / partition / purpose / source_id
target_root.mkdir(parents=True)
materialized_files: list[dict[str, str]] = []
for record in file_records:
source_path = _safe_input_path(self.input_root, record["path"])
if _sha256_file(source_path) != record["sha256"]:
raise SourceLockError(f"source hash mismatch: {record['path']}")
target = target_root / source_path.name
if target in materialized_destinations:
raise SourceLockError(f"duplicate materialized destination: {target.name}")
materialized_destinations.add(target)
shutil.copyfile(source_path, target)
target.chmod(0o444)
materialized_files.append(
{
"source_path": record["path"],
"materialized_path": target.relative_to(self.run_dir).as_posix(),
"sha256": record["sha256"],
}
)
source_manifest.append(
{
"source_id": source_id,
"purpose": purpose,
"partition": partition,
"files": materialized_files,
}
)
if classification.commercial_promotion_eligible:
missing = _REQUIRED_PURPOSE_PARTITIONS - supplied_purposes
if missing:
formatted = ", ".join(
f"{purpose}:{partition}" for purpose, partition in sorted(missing)
)
raise SourceLockError(
f"commercial source lock is missing required inputs: {formatted}"
)
provenance_path = self.run_dir / "sources.lock.json"
if self.lock_path != provenance_path:
shutil.copyfile(self.lock_path, provenance_path)
provenance_path.chmod(0o444)
attribution_dir = self.run_dir / "attribution"
attribution_dir.mkdir(exist_ok=True)
self._materialize_attribution(lock["sources"], attribution_dir)
recorded_manifest_path = self.run_dir / "recorded-inputs.json"
recorded_manifest_path.write_text(
json.dumps(
{
"schema_version": 1,
"classification": classification.value,
"sources": source_manifest,
},
sort_keys=True,
separators=(",", ":"),
)
+ "\n",
encoding="utf-8",
)
recorded_manifest_path.chmod(0o444)
return MaterializedInputs(
provenance_manifest_path=provenance_path,
recorded_manifest_path=recorded_manifest_path,
)
def _read_lock(self) -> dict[str, Any]:
try:
lock = json.loads(self.lock_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise SourceLockError("source lock must be readable JSON") from exc
lock = _require_mapping(lock, "source lock")
if lock.get("schema_version") != 1:
raise SourceLockError("source lock schema_version must be 1")
return lock
def _validate_source(
self, source: dict[str, Any], index: int, *, commercial: bool
) -> tuple[str, str, str, list[dict[str, str]]]:
source_id = _require_string(source.get("source_id"), f"sources[{index}].source_id")
purpose = _require_string(source.get("purpose"), f"sources[{index}].purpose")
partition = _require_string(source.get("partition"), f"sources[{index}].partition")
source_kind = _require_string(source.get("source_kind"), f"sources[{index}].source_kind")
if not _SAFE_TOKEN.fullmatch(source_id):
raise SourceLockError(f"sources[{index}].source_id must be a safe token")
if purpose not in _ALLOWED_PURPOSES:
raise SourceLockError(f"sources[{index}].purpose is not an allowed purpose")
if partition not in _ALLOWED_PARTITIONS:
raise SourceLockError(f"sources[{index}].partition is not an allowed partition")
publisher = _require_mapping(
source.get("original_publisher"), f"sources[{index}].original_publisher"
)
_require_string(publisher.get("name"), f"sources[{index}].original_publisher.name")
_require_string(publisher.get("url"), f"sources[{index}].original_publisher.url")
_require_string(source.get("version"), f"sources[{index}].version")
_require_string(source.get("revision"), f"sources[{index}].revision")
_require_string(source.get("attribution"), f"sources[{index}].attribution")
license_data = _require_mapping(source.get("license"), f"sources[{index}].license")
license_id = _require_string(
license_data.get("spdx_id"), f"sources[{index}].license.spdx_id"
)
_require_string(license_data.get("terms_url"), f"sources[{index}].license.terms_url")
captured_text = _require_string(
license_data.get("captured_text_path"),
f"sources[{index}].license.captured_text_path",
)
captured_hash = _require_sha256(
license_data.get("captured_text_sha256"),
f"sources[{index}].license.captured_text_sha256",
)
license_path = _safe_input_path(self.input_root, captured_text)
if _sha256_file(license_path) != captured_hash:
raise SourceLockError(f"captured license hash mismatch: {captured_text}")
files = source.get("files")
if not isinstance(files, list) or not files:
raise SourceLockError(f"sources[{index}].files must be a non-empty list")
records: list[dict[str, str]] = []
for file_index, raw_file in enumerate(files):
file_data = _require_mapping(raw_file, f"sources[{index}].files[{file_index}]")
path = _require_string(
file_data.get("path"), f"sources[{index}].files[{file_index}].path"
)
digest = _require_sha256(
file_data.get("sha256"), f"sources[{index}].files[{file_index}].sha256"
)
size = file_data.get("size_bytes")
if not isinstance(size, int) or size < 1:
raise SourceLockError(
f"sources[{index}].files[{file_index}].size_bytes must be positive"
)
if _safe_input_path(self.input_root, path).stat().st_size != size:
raise SourceLockError(f"source size mismatch: {path}")
records.append({"path": path, "sha256": digest})
if commercial:
self._validate_commercial_source(source_id, source_kind, license_id, source, index)
return source_id, purpose, partition, records
@staticmethod
def _validate_commercial_source(
source_id: str, source_kind: str, license_id: str, source: dict[str, Any], index: int
) -> None:
normalized_id = source_id.lower()
normalized_license = license_id.lower()
if any(marker in normalized_id for marker in _BLOCKED_SOURCE_MARKERS) or any(
marker in normalized_license for marker in _BLOCKED_LICENSE_MARKERS
):
raise SourceLockError(f"blocked source for commercial mode: {source_id}")
commercial_data = _require_mapping(source.get("commercial"), f"sources[{index}].commercial")
if commercial_data.get("disposition") != "approved":
raise SourceLockError(f"commercial source is not approved: {source_id}")
allowed_purposes = commercial_data.get("allowed_purposes")
if not isinstance(allowed_purposes, list) or not _COMMERCIAL_PURPOSES.issubset(
set(allowed_purposes)
):
raise SourceLockError(f"commercial source has incomplete allowed_purposes: {source_id}")
_require_string(commercial_data.get("approver"), f"sources[{index}].commercial.approver")
_require_string(
commercial_data.get("approved_at"), f"sources[{index}].commercial.approved_at"
)
if (
source_kind in _SYNTHETIC_SOURCE_KINDS
and commercial_data.get("tts_output_approved") is not True
):
raise SourceLockError(f"unapproved synthetic source for commercial mode: {source_id}")
def _materialize_attribution(self, sources: object, attribution_dir: Path) -> None:
assert isinstance(sources, list)
for raw_source in sources:
source = _require_mapping(raw_source, "source")
source_id = _require_string(source.get("source_id"), "source.source_id")
license_data = _require_mapping(source.get("license"), "source.license")
license_path = _safe_input_path(
self.input_root,
_require_string(
license_data.get("captured_text_path"), "source.license.captured_text_path"
),
)
target = attribution_dir / f"{source_id}-license.txt"
shutil.copyfile(license_path, target)
target.chmod(0o444)
def build_stage_command(run_kind: str) -> tuple[str, ...]:
"""Return the only command an isolated training container may execute."""
classification = classify_run(run_kind)
if classification is RunClassification.COMMERCIAL_REVIEW_REQUIRED:
return (
"python",
"/opt/jr-wakeword/run-commercial-pipeline.py",
"/run/resolved-config.yaml",
)
if classification is RunClassification.LOCAL_ONLY:
return (
"python",
"/opt/jr-wakeword/run-local-experiment.py",
"/run/resolved-config.yaml",
)
return (
"python",
"/opt/jr-wakeword/run-quarantined-smoke.py",
"/run/resolved-config.yaml",
)
def collect_image_provenance(
*,
dependency_lock_path: Path,
frontend_asset_paths: list[Path],
installed_distributions: dict[str, str],
image_digest: str,
python_version: str,
cuda_version: str,
os_packages: list[str],
) -> dict[str, object]:
"""Return manifest-safe image evidence; callers persist it in the run bundle."""
if not image_digest.startswith("sha256:"):
raise ValueError("image_digest must be sha256-pinned")
lock_path = dependency_lock_path.resolve(strict=True)
assets = []
for path in frontend_asset_paths:
resolved = path.resolve(strict=True)
assets.append({"path": resolved.name, "sha256": _sha256_file(resolved)})
return {
"schema_version": 1,
"image_digest": image_digest,
"python_version": python_version,
"cuda_version": cuda_version,
"os_packages": sorted(os_packages),
"livekit_wakeword": {
"distribution": "livekit-wakeword",
"version": LIVEKIT_WAKEWORD_VERSION,
"source_revision": LIVEKIT_WAKEWORD_REVISION,
"source_distribution_sha256": LIVEKIT_WAKEWORD_SDIST_SHA256,
"installed_version": installed_distributions.get("livekit-wakeword"),
},
"installed_distributions": dict(sorted(installed_distributions.items())),
"dependency_lock": {"path": lock_path.name, "sha256": _sha256_file(lock_path)},
"frontend_assets": assets,
}