Answer the controller's data request, and audit what we send

The UmbraTrans controller asked (commit 70c23fd) for machine-readable metadata
about the audio system, in a specified layout, with a validator that ships with
the request so the sender can check its own work. That request is answered here.

scripts/build_controller_bundle.py emits that bundle: one real book (Deal
Hunter), 8,884 records, no media, no credentials, no hostnames. It passes the
controller's own validator, exit 0.

Three things in this system will break a mapping that does not know them, and
the bundle's real purpose is to say so precisely rather than to look tidy:

* A chapter has THREE numbers. The database row id is what filenames use,
  chapter_index is reading order, and the distribution track number is a third
  thing again. Only the row id survives an edit. Front and back matter are
  numbered with roman numerals in the MP3 filenames.
* The narrator is written three ways, and the most common way is nothing at all.
  1,803 utterances have a NULL speaker_name, meaning narrator; 614 say
  "narrator" and 15 say "Narrator". A join that skips nulls loses most of the
  narration silently. Utterances now carry speaker_name_raw, the resolved name,
  and speaker_resolution, and the two spellings are not merged - that is a
  decision for whoever owns speaker identity, not something an export should do
  quietly.
* A .wav file is not always a WAV. 30 of the 91 voice reference files are MPEG.
  Every render reports the container measured from the bytes, with
  extension_matches_container, so a reader cannot be misled by the name.

A validator proves the bundle is internally consistent; it cannot prove the
bundle is true. scripts/audit_controller_bundle.py re-measures every hash, byte
size, duration and count against the project on disk - 66,011 claims, all
holding. It caught two real defects in the first draft of this bundle, including
one of my own: produced_at was stamped with the build time, which is a claim
about the audio that nothing supported.

The validator was also run against seven deliberately broken bundles to confirm
it can fail. A validator nobody has seen fail is not evidence of anything; those
cases are now tests.

scripts/audio_controller_connect.py runs the T01-T10 suite: 4 pass, 1 fails,
5 are blocked. T04 fails honestly - there is no ingest endpoint, and nothing was
pushed. T05-T08 are blocked because the controller's own plan sequences ingest
after the metadata arrives; a proposed contract is included rather than an
invented endpoint. T02 is blocked: no node key or service token is configured
here, and an operator has to supply one.

Two corrections to the node profile, which the controller reads: it claimed the
web UI has no authentication (a session gate was added 2026-10-09) and that the
controller host is offline (it is reachable and serving umbratrans-web 0.1.0).

Full suite 751 passed, 8 failed - the same pre-existing pronunciation and
accent failures, unchanged. ruff and mypy clean on everything added.
This commit is contained in:
B.D. Murphy
2026-10-14 10:56:32 -07:00
parent b63bc5bd0a
commit 3d69187449
6 changed files with 2923 additions and 11 deletions

View File

@@ -36,17 +36,17 @@
"stack": "FastAPI + HTMX",
"public_name": "umbraaudio.bdmurphy.me",
"exposure": "Caddy reverse proxy on ANOTHER LAN machine -> this port",
"auth": "NONE (unsigned active_project cookie). Do not expose beyond the LAN.",
"autostart": "launchd LaunchAgent com.umbraaudio.web (login-scanned, KeepAlive)"
"auth": "HTTP Basic session auth (umbraaudio/auth.py, PBKDF2-HMAC-SHA256) enforced by middleware in umbraaudio/web/__init__.py, fail-closed. added 2026-10-09.",
"autostart": "launchd LaunchAgent com.umbraaudio.web at /Library/LaunchAgents (system-wide, KeepAlive)"
},
"tts_local": {
"url": "http://localhost:8881",
"engine": "Qwen3-TTS (transformers, MPS)",
"autostart": "NONE - manual nohup .venv/bin/python scripts/qwen_tts_server.py"
"autostart": "launchd LaunchAgent com.umbraaudio.qwentts (KeepAlive). LM Studio is MANUAL by design: an LLM model and the TTS model cannot be co-resident in 16 GB."
},
"tts_relay_listener": {
"url": "http://127.0.0.1:18881",
"note": "root LaunchDaemon on this host forwards to a LAN GPU box; has no upstream fallback"
"status": "RETIRED 2026-10-08. The GPU box 192.168.123.14 was taken offline and the root relay removed; port 18881 is closed. Local :8881 is the only TTS backend.",
"note": "Do not re-add qwen_tts_helper_url or :18881 speculatively; re-add a TTS backend only when the controller exposes one."
},
"llm_local": {
"url": "http://localhost:1234/v1",
@@ -160,9 +160,9 @@
"has_shared_postgres_client": false,
"has_heartbeat_or_lease": false,
"controller_stage_reached": "0 verified 2026-10-03 (health 200, heartbeat 401, no-Host 400 - all as documented)",
"test_suite": "pytest, 484 passing at 2026-10-03 (8 pre-existing/parallel failures)",
"test_suite": "pytest, 751 passing at 2026-10-14 (8 pre-existing failures: 2 test_pronunciation helper_url mock, 6 test_voice_profile_manager accent)",
"open_defects_blocking_exposure": [
"no authentication on any endpoint (spec 20 J.6)",
"no authentication on any endpoint (spec 20 J.6) -- RESOLVED 2026-10-09, auth gate added in umbraaudio/web/__init__.py",
"download path traversal via str.startswith (routes.py ~5049)",
"upload filename not sanitised with Path(...).name",
"no CSRF, no security headers, no TrustedHost"

View File

@@ -0,0 +1,564 @@
#!/usr/bin/env python3
"""Test this audio system's readiness to talk to the UmbraTrans controller.
Writes two artefacts and prints a summary:
* ``audio_controller_test_report.json`` -- the machine-readable result
* ``audio_controller_sync.log`` -- one JSON object per line, per step
Every request is logged with its endpoint, status and duration, and **no secret
is ever logged** -- not in the report, not in the log, not in an error message.
Header values are reduced to a presence flag before they reach a log line.
What this deliberately does not do: invent an endpoint. The brief anticipated
``/api/audio/ingest`` and friends; if none exists, that is recorded as
``MISSING_INGEST_ENDPOINT`` with a proposed contract, and the tests that depend
on it report BLOCKED rather than PASS or FAIL. A test that cannot run has not
passed.
python3 scripts/audio_controller_connect.py --report-dir /tmp/connect
Exit 0 when no test failed. BLOCKED does not fail the run; it is a finding.
"""
from __future__ import annotations
import argparse
import json
import os
import ssl
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
#: The controller as found by discovery. Plain HTTP: the port answers uvicorn
#: with no TLS, so the ``https://`` in the original brief cannot connect and
#: ``UMBRA_AUDIO_CONTROLLER_VERIFY`` has nothing to verify. Recorded, not worked
#: around -- see ``transport`` in the report.
DEFAULT_BASE_URL = os.environ.get("UMBRA_AUDIO_CONTROLLER_URL", "http://192.168.123.14:8442")
INGEST_CANDIDATES = (
"/api/audio/ingest",
"/api/projects/import/audio",
"/api/import/audio",
"/api/audio/dataset",
"/api/projects/audio-dataset",
"/api/manifest/ingest",
)
PROBE_PATHS = (
"/api/health", "/healthz", "/health", "/openapi.json", "/api/openapi.json",
"/docs", "/api/setup/status", "/api/projects", "/api/artifacts", "/api/workspace",
"/api/nodes", "/api/jobs", "/api/audit", "/api/me", "/api/auth/me",
"/api/definitions", "/api/ingest", "/api/datasets",
)
def now_iso() -> str:
stamp = datetime.now(UTC).replace(microsecond=0)
return stamp.isoformat().replace("+00:00", "Z")
class SyncLog:
"""One JSON object per line. A log you cannot parse is not a log."""
def __init__(self, path: Path) -> None:
self.path = path
self.path.parent.mkdir(parents=True, exist_ok=True)
self.handle = path.open("w", encoding="utf-8")
self.entries = 0
def write(self, phase: str, action: str, **fields: Any) -> None:
entry = {
"timestamp": now_iso(),
"phase": phase,
"action": action,
**{k: v for k, v in fields.items()},
}
self.handle.write(json.dumps(entry, sort_keys=True, ensure_ascii=False) + "\n")
self.handle.flush()
self.entries += 1
def close(self) -> None:
self.handle.close()
@dataclass
class Result:
test_id: str
name: str
status: str # PASS | FAIL | BLOCKED | INFO
detail: str
evidence: dict[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return {"id": self.test_id, "name": self.name, "status": self.status,
"detail": self.detail, "evidence": self.evidence}
class Client:
def __init__(self, base_url: str, log: SyncLog) -> None:
self.base_url = base_url.rstrip("/")
self.log = log
# A self-signed certificate would need an override; recorded if set, and
# never silently applied. Plain HTTP needs no context at all.
self.verify = os.environ.get("UMBRA_AUDIO_CONTROLLER_VERIFY", "true").lower() != "false"
self.context: ssl.SSLContext | None = None
if self.base_url.startswith("https") and not self.verify:
self.context = ssl.create_default_context()
self.context.check_hostname = False
self.context.verify_mode = ssl.CERT_NONE
def request(self, method: str, path: str, *, body: Any = None,
headers: dict[str, str] | None = None,
phase: str = "probe") -> tuple[int, Any, int]:
"""Returns (status, parsed_body_or_text, duration_ms). Never raises."""
url = self.base_url + path
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(url, data=data, method=method)
for key, value in (headers or {}).items():
request.add_header(key, value)
if data is not None:
request.add_header("Content-Type", "application/json")
started = time.monotonic()
try:
with urllib.request.urlopen(request, timeout=15,
context=self.context) as response:
status, raw = response.status, response.read()
except urllib.error.HTTPError as error:
status, raw = error.code, error.read()
except Exception as error: # noqa: BLE001 - connectivity is a result
duration = int((time.monotonic() - started) * 1000)
self.log.write(phase, method, endpoint=path,
status=None, duration_ms=duration,
result_hash=None,
error={"code": type(error).__name__,
"message": str(error)[:200],
"retryable": True,
"suggested_fix": "check the controller is running and reachable"})
failure = {"error": {"code": type(error).__name__,
"message": str(error)[:200]}}
return 0, failure, duration
duration = int((time.monotonic() - started) * 1000)
try:
parsed: Any = json.loads(raw)
except ValueError:
parsed = raw[:400].decode("utf-8", "replace")
# Header values are never logged; only whether one was present.
self.log.write(phase, method, endpoint=path, status=status,
duration_ms=duration,
credentials_present=bool(headers),
request_summary=_summarise(body),
result_hash=_hash(parsed))
return status, parsed, duration
def authenticate(self, log: SyncLog) -> tuple[dict[str, str], str]:
"""Node key, then service token. Returns (headers, how)."""
node_key = os.environ.get("UMBRA_AUDIO_NODE_KEY")
token = os.environ.get("UMBRA_AUDIO_API_TOKEN")
if node_key:
log.write("auth", "try_node_key", endpoint="/api/auth/me",
note="X-Node-Key header")
return {"X-Node-Key": node_key}, "node_key_header"
if token:
log.write("auth", "try_service_token", endpoint="/api/auth/me",
note="Authorization: Bearer")
return {"Authorization": f"Bearer {token}"}, "service_token_bearer"
log.write("auth", "no_credentials",
note="no node key or service token configured on this machine")
return {}, "none"
def _summarise(body: Any) -> Any:
"""A description of a request, small enough to log and free of secrets."""
if body is None:
return None
if isinstance(body, dict):
return {k: (f"<{len(v)} items>" if isinstance(v, (list, dict)) else v)
for k, v in body.items()
if k not in {"token", "api_key", "password", "secret"}}
return f"<{type(body).__name__}>"
def _hash(value: Any) -> str | None:
import hashlib
try:
return hashlib.sha256(json.dumps(value, sort_keys=True,
default=str).encode()).hexdigest()[:16]
except (TypeError, ValueError):
return None
# --------------------------------------------------------------------------
# tests
# --------------------------------------------------------------------------
def t01_health(client: Client) -> Result:
status, body, _ = client.request("GET", "/api/health")
ok = status == 200 and isinstance(body, dict) and body.get("status") == "ok"
return Result("T01", "Health", "PASS" if ok else "FAIL",
"controller reports status=ok" if ok else f"health returned {status}",
{"status_code": status, "body": body if ok else str(body)[:200]})
def t02_auth(client: Client, headers: dict[str, str], how: str) -> Result:
if not headers:
return Result("T02", "Auth", "BLOCKED",
"no credential is configured on this machine; "
"an operator must supply a node key or a scoped service token",
{"headers_tried": [], "how": how})
status, body, _ = client.request("GET", "/api/auth/me", headers=headers)
ok = status == 200
return Result("T02", "Auth", "PASS" if ok else "FAIL",
f"authenticated with {how}" if ok else f"credential rejected ({status})",
{"status_code": status, "mechanism": how,
"secret_logged": False})
def t03_version(client: Client) -> Result:
status, body, _ = client.request("GET", "/api/health")
controller_version = (body or {}).get("version") if isinstance(body, dict) else None
here = "1.0.0 (bundle format)"
compatible = bool(controller_version)
return Result("T03", "Version compatibility", "PASS" if compatible else "FAIL",
f"controller {controller_version}; this side speaks "
f"bundle format {here}. No versioned API exists to negotiate "
f"against, so there is nothing to be incompatible with yet.",
{"controller_version": controller_version,
"controller_service": ((body or {}).get("service")
if isinstance(body, dict) else None),
"client_bundle_format": 1,
"hard_incompatibility": False})
def t04_ingest_exists(client: Client) -> Result:
probed: dict[str, int] = {}
for path in INGEST_CANDIDATES:
status, _, _ = client.request("GET", path)
probed[path] = status
if status in (200, 405):
return Result("T04", "Ingest endpoint exists", "PASS",
f"{path} exists ({status})", {"probed": probed})
return Result("T04", "Ingest endpoint exists", "FAIL",
"MISSING_INGEST_ENDPOINT: no bulk audio-dataset ingest path "
"exists on the controller. This blocks a push, not the "
"metadata handover, which is file-based by design.",
{"probed": probed, "finding": "MISSING_INGEST_ENDPOINT"})
def t05_dry_run(client: Client) -> Result:
return Result("T05", "Dry-run validation", "BLOCKED",
"depends on an ingest endpoint; see T04. The controller's own "
"plan sequences ingest after the metadata bundle, so this is "
"the designed order rather than a defect.",
{"depends_on": "T04"})
def t06_idempotency(client: Client) -> Result:
return Result("T06", "Idempotency check", "BLOCKED",
"depends on an ingest endpoint; see T04",
{"depends_on": "T04",
"client_side_equivalent": "the bundle build is deterministic: "
"the same project yields the same manifest.jsonl bytes and the "
"same content_hash, so re-shipping is detectable without a server"})
def t07_schema_contract(client: Client) -> Result:
return Result("T07", "Schema contract", "BLOCKED",
"the controller has not yet issued a manifest format to accept; "
"its request says the mapping and the accepted format come after "
"the bundle. A contract is proposed in the report, not assumed.",
{"depends_on": "T04",
"bundle_validates_locally": True})
def t08_audit_round_trip(client: Client) -> Result:
return Result("T08", "Bidirectional audit", "BLOCKED",
"no ingest event can be created without an ingest endpoint; "
"the controller does expose an audit table but only to "
"authenticated principals",
{"depends_on": "T04"})
def t09_error_handling(client: Client) -> Result:
"""The brief asked for a structured error with an actionable code.
The interesting part is that the controller does have its own envelope:
``{"error": {"code", "message"}}``. Probing with an unsupported method is
enough to see it, and it is worth seeing -- a service-to-service caller
needs to key off ``error.code`` rather than parsing prose. The probe also
revealed that **CSRF is enforced**, which is a real constraint on any future
browser-authenticated path and is recorded rather than left as a surprise.
"""
status, body, _ = client.request("DELETE", "/api/health")
envelope = body.get("error") if isinstance(body, dict) else None
has_code = bool(isinstance(envelope, dict) and envelope.get("code"))
has_message = bool(isinstance(envelope, dict) and envelope.get("message"))
structured = has_code and has_message
if structured:
detail = (f"error envelope carries code={envelope['code']!r} and an "
f"actionable message; probe returned {status}")
else:
detail = f"no structured error envelope; probe returned {status}: {str(body)[:160]}"
return Result("T09", "Error handling", "PASS" if structured else "FAIL",
detail,
{"status_code": status, "body": body,
"error_code": ((envelope or {}).get("code")
if isinstance(envelope, dict) else None),
"csrf_required": status == 403
and isinstance(envelope, dict)
and envelope.get("code") == "csrf_token_missing",
"note": ("CSRF is enforced on this origin. A machine-to-machine "
"client should use a bearer/node-key path rather than "
"cookie auth, or the CSRF requirement becomes a "
"per-request concern.")})
def t10_payload_readiness(bundle_records: int | None, bundle_bytes: int | None) -> Result:
if bundle_records is None:
return Result("T10", "Large payload readiness", "BLOCKED",
"no bundle was built in this run", {})
return Result("T10", "Large payload readiness", "PASS",
f"the dataset is {bundle_records} records / "
f"{bundle_bytes} bytes, which is small enough for a single "
f"request; no chunking or streaming strategy is needed yet",
{"records": bundle_records, "bytes": bundle_bytes,
"max_records_per_request": bundle_records,
"chunking_required": False,
"strategy": "single request, with content_hash for idempotency"})
# --------------------------------------------------------------------------
# proposed contract
# --------------------------------------------------------------------------
def proposed_contract() -> dict[str, Any]:
return {
"note": ("A proposal only. No endpoint was invented, probed into "
"existence, or assumed to exist; this is what the audio side "
"would send if the controller chooses to add it."),
"openapi_paths": {
"/api/audio/ingest": {
"post": {
"summary": "Accept an audio-system metadata dataset",
"requestBody": {
"required": True,
"content": {"application/json": {
"schema": {"$ref": "#/components/schemas/AudioDatasetEnvelope"},
}},
},
"responses": {
"200": {"description": "Dry run; validation report, nothing written"},
"201": {"description": "Accepted; import_id and audit_id returned"},
"400": {"description": "Structured validation error", "content":
{"application/json": {"schema": {
"$ref": "#/components/schemas/Error"}}}},
"409": {"description": ("Idempotency key already seen "
"with a different hash")},
},
}
}
},
"components": {
"schemas": {
"AudioDatasetEnvelope": {
"type": "object",
"required": ["bundle_format_version", "source", "content_hash", "dry_run"],
"properties": {
"bundle_format_version": {"type": "integer", "const": 1},
"source": {
"type": "object",
"required": ["system", "node_id"],
"properties": {
"system": {"type": "string"},
"node_id": {"type": "string"},
"product_version": {"type": "string"},
"database_schema_fingerprint": {"type": "string"},
},
},
"content_hash": {
"type": "string",
"description": ("sha256 of the manifest bytes. The server "
"uses it as the idempotency key so a "
"re-send of unchanged data is a no-op."),
},
"dry_run": {"type": "boolean", "default": True},
"records": {"type": "array", "items": {"type": "object"}},
},
},
"ValidationReport": {
"type": "object",
"required": ["valid", "counts", "errors", "warnings"],
"properties": {
"valid": {"type": "boolean"},
"counts": {"type": "object"},
"errors": {"type": "array", "items": {"type": "object"}},
"warnings": {"type": "array", "items": {"type": "object"}},
"proposed_changes": {"type": "array", "items": {"type": "object"}},
},
},
"Error": {
"type": "object",
"required": ["code", "message"],
"properties": {"code": {"type": "string"},
"message": {"type": "string"},
"retryable": {"type": "boolean"}},
},
}
},
"invariants_the_controller_should_enforce": [
"content_hash is the idempotency key; a repeat with the same hash is a no-op.",
"A dry run must not write. It returns counts and errors, and nothing else.",
"Records are keyed by (source.system, source.node_id, id) so a re-import "
"updates rather than duplicates.",
"Nothing is deleted on re-import; a record absent from a later dataset is "
"marked superseded, not removed.",
"Chapter identity uses the database row id, never chapter_index or a "
"filename-derived number.",
"Speaker identity is not merged by the server. Case-variant and alias names "
"are surfaced as warnings for a human to decide.",
],
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--report-dir", default=".", help="where to write the two artefacts")
parser.add_argument("--bundle", default=None,
help="a built bundle directory, to report its size for T10")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
args = parser.parse_args()
out = Path(args.report_dir)
out.mkdir(parents=True, exist_ok=True)
log = SyncLog(out / "audio_controller_sync.log")
log.write("start", "run", base_url=args.base_url, note="no secret is logged")
client = Client(args.base_url, log)
# --- discovery ---------------------------------------------------
discovery: dict[str, Any] = {"base_url": args.base_url, "probes": {}}
for path in PROBE_PATHS:
status, body, _ = client.request("GET", path, phase="discovery")
discovery["probes"][path] = {
"status": status,
"body": body if status == 200 else str(body)[:160],
}
log.write("discovery", "complete", endpoints_found=sum(
1 for p in discovery["probes"].values() if p["status"] in (200, 401, 403)))
headers, how = client.authenticate(log)
bundle_records = bundle_bytes = None
if args.bundle:
manifest = Path(args.bundle) / "manifest.jsonl"
if manifest.exists():
bundle_records = sum(1 for line in manifest.read_text().splitlines() if line.strip())
bundle_bytes = manifest.stat().st_size
results = [
t01_health(client),
t02_auth(client, headers, how),
t03_version(client),
t04_ingest_exists(client),
t05_dry_run(client),
t06_idempotency(client),
t07_schema_contract(client),
t08_audit_round_trip(client),
t09_error_handling(client),
t10_payload_readiness(bundle_records, bundle_bytes),
]
for result in results:
log.write("test", result.test_id, name=result.name, status=result.status)
passing = [r for r in results if r.status == "PASS"]
failed = [r for r in results if r.status == "FAIL"]
blocked = [r for r in results if r.status == "BLOCKED"]
missing_ingest = any(r.evidence.get("finding") == "MISSING_INGEST_ENDPOINT" for r in results)
transport = "plain HTTP (uvicorn); no TLS on this port" \
if client.base_url.startswith("http") else "HTTPS"
report = {
"report_version": 1,
"generated_at": now_iso(),
"source_system": {"system": "umbraaudio", "node_id": "umbraaudio-m4"},
"transport": {
"base_url": args.base_url,
"tls": transport,
"tls_verify_override": os.environ.get("UMBRA_AUDIO_CONTROLLER_VERIFY"),
"note": ("The brief specified https:// on this port. The port answers "
"plain HTTP, so the TLS handshake fails with a protocol-version "
"alert and there is nothing for a verify override to do. "
"Reported rather than worked around."),
},
"authentication": {
"mechanism_available": how,
"node_key_configured": bool(os.environ.get("UMBRA_AUDIO_NODE_KEY")),
"service_token_configured": bool(os.environ.get("UMBRA_AUDIO_API_TOKEN")),
"secrets_recorded_anywhere": False,
},
"discovery": discovery,
"tests": [r.as_dict() for r in results],
"summary": {
"counts": {"pass": len(passing), "fail": len(failed),
"blocked": len(blocked)},
"passing": [r.test_id for r in passing],
"failing": [r.test_id for r in failed],
"blocked_ids": [r.test_id for r in blocked],
"blocking_findings": ["MISSING_INGEST_ENDPOINT"] if missing_ingest else [],
},
"readiness": {
"verdict": "READY_FOR_METADATA_HANDOVER_NOT_READY_FOR_PUSH",
"rationale": [
"The controller has no bulk ingest endpoint, so a dataset cannot "
"be pushed to it; nothing was pushed and nothing should be.",
"The controller's own request is for a metadata bundle, validated "
"by its own validator, handed over as a file. That is done.",
"Its request also says the accepted manifest format comes after "
"it receives the bundle, so T05-T08 are sequenced, not broken.",
],
},
"proposed_ingest_contract": proposed_contract() if missing_ingest else None,
"confirmation_gate": {
"required_before_any_push": True,
"current_state": "NOT CONFIRMED - nothing has been pushed",
"instruction": ("A non-dry-run import requires explicit operator "
"confirmation. This run made no write of any kind."),
},
}
(out / "audio_controller_test_report.json").write_text(
json.dumps(report, indent=2, sort_keys=True) + "\n")
log.write("end", "run", passed=len(passing), failed=len(failed), blocked=len(blocked))
log.close()
print(f"{'ID':4} {'STATUS':8} {'NAME'}")
print("-" * 52)
for result in results:
print(f"{result.test_id:4} {result.status:8} {result.name}")
print("-" * 52)
print(f"pass {len(passing)} fail {len(failed)} blocked {len(blocked)}")
print(f"report: {out / 'audio_controller_test_report.json'}")
print(f"log: {out / 'audio_controller_sync.log'} ({log.entries} entries)")
print(f"verdict: {report['readiness']['verdict']}")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""Audit a built bundle against the project it claims to describe.
The controller's validator checks that the bundle is internally consistent: a
required field is present, an id resolves, no credential is present. It cannot
check that the bundle is *true*. Every hash, byte size and duration in here is
something the audio system asserted about itself, and this is the file that goes
and checks those assertions against the filesystem.
That distinction is not pedantic. The failure this project has actually hit is a
record that says one thing and means another -- an approved chapter whose
published MP3 is four seconds longer than the approved WAV because padding was
added afterwards. A validator passes that bundle happily. Only re-measuring
finds it.
python3 scripts/audit_controller_bundle.py --bundle /tmp/dh-bundle
Exits 0 when every checked claim holds, 1 when any does not. Prints one line per
problem and a summary of how many claims were checked.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sqlite3
import wave
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
def load(path: Path) -> Any:
return json.loads(path.read_text())
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--bundle", required=True, help="bundle directory")
parser.add_argument("--project", required=True, help="project directory name")
parser.add_argument("--hash-renders", action="store_true", default=True,
help="re-hash every render file (slower, thorough)")
parser.add_argument("--no-hash-renders", dest="hash_renders", action="store_false")
args = parser.parse_args()
bundle = Path(args.bundle)
repo = Path(__file__).resolve().parent.parent
book_dir = repo / args.project
umbra = book_dir / ".umbra"
index = load(bundle / "bundle.json")
raw = (bundle / "manifest.jsonl").read_text().splitlines()
records = [json.loads(line) for line in raw if line.strip()]
by_id = {r["id"]: r for r in records}
kinds = Counter(r["kind"] for r in records)
problems: list[str] = []
checked = 0
def check(condition: bool, message: str) -> None:
nonlocal checked
checked += 1
if not condition:
problems.append(message)
# 1. The index's own content hash must describe the file it ships with.
manifest_bytes = (bundle / "manifest.jsonl").read_bytes()
actual = hashlib.sha256(manifest_bytes).hexdigest()
check(actual == index["content_hash"]["value"],
f"bundle.json content_hash {index['content_hash']['value'][:16]}... "
f"does not match manifest.jsonl ({actual[:16]}...)")
check(index["counts"]["records_total"] == len(records),
f"bundle.json claims {index['counts']['records_total']} records, file has {len(records)}")
# 2. Every utterance's text hash must match the text it ships with.
for record in records:
if record["kind"] == "utterance":
digest = hashlib.sha256(record["text"].encode("utf-8")).hexdigest()
check(digest == record["text_sha256"],
f"utterance {record['id']}: text_sha256 does not match its own text")
check(len(record["text"]) == record["text_char_count"],
f"utterance {record['id']}: text_char_count disagrees with text length")
# 3. Every referenced id must resolve, including the ones the controller's
# validator does not check (speaker_id, render_id).
for record in records:
for field in ("book_id", "chapter_id", "speaker_id", "render_id", "voice_id"):
value = record.get(field)
if isinstance(value, str):
check(value in by_id,
f"{record['kind']} {record['id']}: {field} -> {value!r} is not in the bundle")
for field in ("chapters", "renders", "inputs", "published_artifact_ids"):
for value in record.get(field, []) or []:
check(value in by_id,
f"{record['kind']} {record['id']}: {field} contains "
f"{value!r}, not in the bundle")
# 4. Render paths must exist, be the right size, and (optionally) hash right.
renders = [r for r in records if r["kind"] == "render"]
missing = unreadable = 0
for record in renders:
path = book_dir / ".umbra" / record["path"]
if not path.exists():
if record.get("file_present") is False:
continue
missing += 1
problems.append(f"render {record['id']}: path {record['path']} does not exist")
continue
checked += 1
size = path.stat().st_size
check(size == record["byte_size"],
f"render {record['id']}: byte_size {record['byte_size']} != actual {size}")
if record.get("content_sha256") and args.hash_renders:
check(sha256_file(path) == record["content_sha256"],
f"render {record['id']}: content_sha256 does not match the file on disk")
if record["audio_format"] and record["audio_format"].get("codec") == "unreadable":
unreadable += 1
# 5. Durations must be re-derivable for WAV files, which is the one format
# the standard library can measure without trusting the recorder.
sampled = 0
for record in renders:
if record["path"].endswith(".wav") and record.get("duration_ms"):
path = book_dir / ".umbra" / record["path"]
if not path.exists():
continue
try:
with wave.open(str(path)) as handle:
rate = handle.getframerate()
if not rate:
continue
measured = round(handle.getnframes() / rate * 1000)
except (OSError, wave.Error, EOFError):
continue
checked += 1
sampled += 1
check(measured == record["duration_ms"],
f"render {record['id']}: duration_ms "
f"{record['duration_ms']} != measured {measured}")
# 6. The bundle's own counts must match the database it was built from.
conn = sqlite3.connect(f"file:{umbra / 'project.db'}?mode=ro", uri=True)
db_chapters = conn.execute("SELECT count(*) FROM chapters WHERE project_id=1").fetchone()[0]
db_utterances = conn.execute(
"SELECT count(*) FROM utterances u JOIN chapters c ON c.id=u.chapter_id "
"WHERE c.project_id=1").fetchone()[0]
db_names = conn.execute(
"SELECT count(DISTINCT trim(speaker_name)) FROM utterances u JOIN chapters c "
"ON c.id=u.chapter_id WHERE c.project_id=1 AND speaker_name IS NOT NULL").fetchone()[0]
db_null_speakers = conn.execute(
"SELECT count(*) FROM utterances u JOIN chapters c ON c.id=u.chapter_id "
"WHERE c.project_id=1 AND (u.speaker_name IS NULL OR trim(u.speaker_name)='')"
).fetchone()[0]
db_speaker_counts = dict(conn.execute(
"SELECT trim(u.speaker_name), count(*) FROM utterances u JOIN chapters c "
"ON c.id=u.chapter_id WHERE c.project_id=1 AND u.speaker_name IS NOT NULL "
"GROUP BY trim(u.speaker_name)").fetchall())
check(len([r for r in records if r["kind"] == "chapter"]) == db_chapters,
f"chapter records != database chapters ({db_chapters})")
check(len([r for r in records if r["kind"] == "utterance"]) == db_utterances,
f"utterance records != database utterances ({db_utterances})")
check(len([r for r in records if r["kind"] == "speaker"]) == db_names,
f"speaker records != distinct speaker names in the database ({db_names})")
conn.close()
# 7. Speaker utterance counts must be true, because they are the number a
# mapping is most likely to trust. `utterance_count` is the exact count
# for that exact spelling; the implicit-narrator figure is checked
# separately because it is a deliberate inference, not a stored value.
for record in records:
if record["kind"] != "speaker":
continue
actual_count = db_speaker_counts.get(record["primary_name"])
check(actual_count == record["utterance_count"],
f"speaker {record['id']}: utterance_count {record['utterance_count']} "
f"!= database {actual_count}")
expected_implicit = record["utterance_count"] + (
db_null_speakers if record["primary_name"].casefold() == "narrator" else 0
)
check(expected_implicit == record["utterance_count_including_implicit_narrator"],
f"speaker {record['id']}: implicit-narrator count "
f"{record['utterance_count_including_implicit_narrator']} "
f"!= expected {expected_implicit}")
# 7b. Every utterance's speaker must resolve, and every implicit-narrator
# row must actually have had a null speaker name. This is the assertion
# that would catch the bundle quietly inventing a narrator.
null_rows = 0
for record in records:
if record["kind"] != "utterance":
continue
implicit = record["speaker_resolution"] == "implicit_narrator"
if implicit:
null_rows += 1
check(record["speaker_name_raw"] in (None, ""),
f"utterance {record['id']}: marked implicit_narrator but "
f"speaker_name_raw is {record['speaker_name_raw']!r}")
else:
check(record["speaker_name_raw"] not in (None, ""),
f"utterance {record['id']}: marked explicit but speaker_name_raw is null")
check(record["speaker_id"] is not None,
f"utterance {record['id']}: speaker did not resolve")
check(null_rows == db_null_speakers,
f"{null_rows} utterances marked implicit_narrator != {db_null_speakers} "
"rows with an empty speaker_name")
# 8. Every chapter's utterance_count must be true.
db_per_chapter = {}
conn = sqlite3.connect(f"file:{umbra / 'project.db'}?mode=ro", uri=True)
for row_id, count in conn.execute(
"SELECT u.chapter_id, count(*) FROM utterances u JOIN chapters c "
"ON c.id=u.chapter_id WHERE c.project_id=1 GROUP BY u.chapter_id").fetchall():
db_per_chapter[f"ch-{row_id}"] = count
conn.close()
for record in records:
if record["kind"] != "chapter":
continue
check(db_per_chapter.get(record["id"], 0) == record["utterance_count"],
f"chapter {record['id']}: utterance_count {record['utterance_count']} "
f"!= database {db_per_chapter.get(record['id'], 0)}")
# 9. Casefold collisions must be recorded, not silently merged.
collisions: dict[str, list[str]] = defaultdict(list)
for record in records:
if record["kind"] == "speaker":
collisions[record["primary_name"].casefold()].append(record["primary_name"])
real_collisions = {k: v for k, v in collisions.items() if len(v) > 1}
for key, names in real_collisions.items():
declared = [r for r in records if r["kind"] == "speaker"
and r.get("casefold_collision_group") == key]
check(len(declared) == len(names),
f"speaker name collision {key!r} ({names}) is not declared in the bundle")
print(f"records by kind: {dict(kinds)}")
print(f"claims checked: {checked}")
print(f"renders: {len(renders)} "
f"({missing} missing, {unreadable} unreadable, {sampled} durations re-measured)")
print(f"speaker name collisions declared: "
f"{ {k: sorted(v) for k, v in real_collisions.items()} }")
print(f"implicit-narrator utterances: {null_rows} (database agrees: "
f"{null_rows == db_null_speakers})")
if problems:
print(f"\n{len(problems)} problem(s):\n")
for problem in problems[:60]:
print(f" - {problem}")
if len(problems) > 60:
print(f" ... and {len(problems) - 60} more")
return 1
print("\nEvery checked claim holds: hashes, byte sizes, durations, counts and "
"references agree with the project on disk.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load Diff

View File

@@ -320,11 +320,18 @@ def build_report(project_dir: str | None = None) -> dict[str, Any]:
],
"speaker_identity_lives_in": "speakers.json + voice_profiles.json + ref WAVs",
"audio_record_lives_in": "filesystem + *.wav.txt markers",
"authentication": "none; active_project is an unsigned browser cookie",
"authentication": ("session auth via umbraaudio/auth.py, enforced by "
"fail-closed middleware in umbraaudio/web/__init__.py "
"(added 2026-10-09)"),
"shared_database_present_here": False,
"controller_endpoint": None,
"controller_status": "unavailable: host 192.168.123.14 retired/offline (2026-10-08)",
"controller_stage_reached": "0 (was reachable 2026-10-03, before retirement)",
"controller_endpoint": "http://192.168.123.14:8442",
"controller_transport": "plain HTTP (uvicorn). No TLS on this port.",
"controller_status": "reachable: /api/health 200, service umbratrans-web 0.1.0",
"controller_stage_reached": ("1 - health confirmed and an audio metadata "
"bundle delivered to oldbox:audio-read/bundles "
"(2026-10-14). No ingest endpoint exists yet."),
"controller_ingest_endpoint": None,
"controller_blocking_findings": ["MISSING_INGEST_ENDPOINT"],
"secret_values_reported": False,
}
return report

View File

@@ -0,0 +1,477 @@
"""Tests for the controller bundle builder.
The builder's job is to emit something the *controller's* validator accepts, so
most of these tests build a small synthetic project and run the real validator
against the result. Running the actual validator -- copied into the bundle by the
builder -- is the point: a test that re-implements the checks would only prove the
test agrees with itself.
The synthetic project is built rather than the real one, so the suite does not
depend on 4,000 utterances and 4,500 audio files being present and current. The
real project is exercised by ``scripts/audit_controller_bundle.py``.
"""
from __future__ import annotations
import importlib.util
import json
import sqlite3
import subprocess
import sys
from pathlib import Path
from typing import Any
import pytest
REPO = Path(__file__).resolve().parent.parent
BUILDER_PATH = REPO / "scripts" / "build_controller_bundle.py"
VALIDATOR_SOURCE = Path("/tmp/controller-validator.py")
def _load_builder() -> Any:
spec = importlib.util.spec_from_file_location("build_controller_bundle", BUILDER_PATH)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
builder = _load_builder()
SCHEMA = """
CREATE TABLE project (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, source_path TEXT NOT NULL,
source_format TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
status TEXT NOT NULL DEFAULT 'initialized'
);
CREATE TABLE chapters (
id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES project(id),
chapter_index INTEGER NOT NULL, title TEXT, source_text TEXT NOT NULL,
word_count INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'parsed',
audio_path TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE utterances (
id INTEGER PRIMARY KEY, chapter_id INTEGER NOT NULL REFERENCES chapters(id),
character_id INTEGER REFERENCES characters(id),
utterance_index INTEGER NOT NULL, text TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'narration', context_before TEXT, context_after TEXT,
emotion TEXT, pace TEXT, emphasis_words TEXT, pause_before_ms INTEGER,
pause_after_ms INTEGER, instruct TEXT, speaker_name TEXT,
status TEXT NOT NULL DEFAULT 'pending'
);
CREATE TABLE characters (id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL,
name TEXT NOT NULL, aliases TEXT NOT NULL DEFAULT '[]', gender TEXT,
is_narrator INTEGER NOT NULL DEFAULT 0);
"""
def make_project(root: Path, *, narrator_spellings: tuple[str, ...] = ("narrator",),
null_narrator_rows: int = 2, chapters: int = 3) -> Path:
"""A project shaped like the real one, including its awkward edges."""
umbra = root / ".umbra"
(umbra / "chapter_audio").mkdir(parents=True)
(umbra / "voices").mkdir(parents=True)
db = umbra / "project.db"
conn = sqlite3.connect(db)
conn.executescript(SCHEMA)
conn.execute(
"INSERT INTO project (id, name, source_path, source_format, status) "
"VALUES (1, ?, ?, 'docx', 'analyzed')", (root.name, str(root)))
for index in range(chapters):
conn.execute(
"INSERT INTO chapters (id, project_id, chapter_index, title, source_text, word_count) "
"VALUES (?, 1, ?, ?, 'body text', 10)",
(index + 1, index - 1, f"Chapter {index}"))
utterance_id = 0
for chapter in range(1, chapters + 1):
for position in range(2):
utterance_id += 1
is_narrator_row = position == 0
speaker = narrator_spellings[chapter % len(narrator_spellings)] \
if is_narrator_row else "Kainda"
conn.execute(
"INSERT INTO utterances (id, chapter_id, utterance_index, text, "
"type, speaker_name) VALUES (?, ?, ?, ?, ?, ?)",
(utterance_id, chapter, position,
f"line {utterance_id}", "narration" if is_narrator_row else "dialogue",
speaker))
# A row whose speaker is expressed by being empty, as the real data does.
for _ in range(null_narrator_rows if chapter == 1 else 0):
utterance_id += 1
conn.execute(
"INSERT INTO utterances (id, chapter_id, utterance_index, text, "
"type, speaker_name) VALUES (?, ?, ?, ?, 'narration', NULL)",
(utterance_id, chapter, 99, "implicit narration"))
conn.commit()
conn.close()
(umbra / "speakers.json").write_text(json.dumps({
"speakers": [
{"primary_name": "narrator", "is_narrator": True, "aliases": [],
"chapters_appeared": [1, 2, 3], "has_voice_ref": True},
{"primary_name": "Kainda", "aliases": ["Princess Kainda"],
"chapters_appeared": [1], "has_voice_ref": True},
],
"book_title": "Test Book", "total_chapters": chapters, "total_words": 30,
}))
(umbra / "voice_status.json").write_text(json.dumps({"narrator": "approved"}))
(umbra / "voice_engines.json").write_text(json.dumps(
{"Kainda": {"engine": "qwen", "reason": "ok", "at": "2026-10-10T00:00:00+00:00"}}))
(umbra / "voice_assignments.json").write_text(json.dumps({"narrator": "en-US-GuyNeural"}))
return root
@pytest.fixture()
def built(tmp_path: Path) -> Path:
make_project(tmp_path / "TestProject")
out = tmp_path / "bundle"
builder.build("TestProject", out, "2026-10-14T00:00:00Z",
VALIDATOR_SOURCE if VALIDATOR_SOURCE.exists() else None,
source_root=tmp_path)
return out
def read_manifest(bundle: Path) -> list[dict[str, Any]]:
return [json.loads(line) for line in
(bundle / "manifest.jsonl").read_text().splitlines() if line.strip()]
# --------------------------------------------------------------------------
# the controller's own validator
# --------------------------------------------------------------------------
@pytest.mark.skipif(not VALIDATOR_SOURCE.exists(),
reason="controller validator not fetched to /tmp")
def test_controller_validator_accepts_the_bundle(built: Path) -> None:
validator = built / "validate.py"
if not validator.exists():
pytest.skip("validator was not copied into the bundle")
result = subprocess.run([sys.executable, str(validator), str(built)],
capture_output=True, text=True)
assert result.returncode == 0, result.stdout + result.stderr
@pytest.mark.skipif(not VALIDATOR_SOURCE.exists(),
reason="controller validator not fetched to /tmp")
@pytest.mark.parametrize("mutation,expect", [
("dangling_id", "not in the manifest"),
("required_file_removed", "missing required file"),
("unitless_duration", "no unit"),
("credential", "credential"),
# The index declares relative_to required, so that check fires before the
# convention check does; either message would be correct.
("path_without_base", "relative_to"),
])
def test_controller_validator_rejects_a_broken_bundle(built: Path, mutation: str,
expect: str) -> None:
"""A validator nobody has seen fail is not evidence of anything.
Each case breaks one thing the request asks for and asserts the validator
notices, so a passing bundle means something.
"""
validator = built / "validate.py"
if not validator.exists():
pytest.skip("validator was not copied into the bundle")
lines = (built / "manifest.jsonl").read_text().splitlines()
if mutation == "dangling_id":
record = json.loads(lines[0])
record["chapter_id"] = "ch-does-not-exist"
lines[0] = json.dumps(record, sort_keys=True, separators=(",", ":"))
elif mutation == "unitless_duration":
record = json.loads(lines[0])
record["duration"] = 1.5
lines[0] = json.dumps(record, sort_keys=True, separators=(",", ":"))
elif mutation == "credential":
record = json.loads(lines[0])
record["token"] = "sk-" + "A" * 24
lines[0] = json.dumps(record, sort_keys=True, separators=(",", ":"))
elif mutation == "path_without_base":
for index, line in enumerate(lines):
record = json.loads(line)
if record.get("kind") == "render":
record.pop("relative_to", None)
record["file"] = "x.wav"
lines[index] = json.dumps(record, sort_keys=True, separators=(",", ":"))
break
else:
pytest.skip("no render record to break")
if mutation == "required_file_removed":
(built / "VOCABULARY.md").unlink()
else:
(built / "manifest.jsonl").write_text("\n".join(lines) + "\n")
result = subprocess.run([sys.executable, str(validator), str(built)],
capture_output=True, text=True)
assert result.returncode == 1
assert expect in result.stdout
# --------------------------------------------------------------------------
# structure and self-consistency
# --------------------------------------------------------------------------
def test_bundle_has_every_required_piece(built: Path) -> None:
for name in ("README.md", "bundle.json", "VOCABULARY.md", "manifest.jsonl"):
assert (built / name).exists(), name
assert (built / "schemas").is_dir()
assert (built / "examples").is_dir()
examples = [p for p in (built / "examples").iterdir() if p.is_dir()]
assert examples
for book in examples:
assert (book / "manifest.json").exists()
assert (book / "lineage.json").exists()
def test_content_hash_describes_the_file_it_ships_with(built: Path) -> None:
import hashlib
index = json.loads((built / "bundle.json").read_text())
actual = hashlib.sha256((built / "manifest.jsonl").read_bytes()).hexdigest()
assert index["content_hash"]["value"] == actual
assert index["counts"]["records_total"] == len(read_manifest(built))
def test_build_is_deterministic(tmp_path: Path) -> None:
"""Regenerating an unchanged project must produce the same bytes.
Without this, the content hash is decoration: it changes on every run for
reasons unrelated to the data, and comparing two builds tells you nothing.
"""
make_project(tmp_path / "TestProject")
first = tmp_path / "one"
second = tmp_path / "two"
builder.build("TestProject", first, "2026-10-14T00:00:00Z", None, source_root=tmp_path)
builder.build("TestProject", second, "2026-10-14T00:00:00Z", None, source_root=tmp_path)
assert (first / "manifest.jsonl").read_bytes() == (second / "manifest.jsonl").read_bytes()
def test_every_reference_resolves_including_unvalidated_fields(built: Path) -> None:
"""The controller checks some reference fields; these are the rest.
`speaker_id` and `render_id` are not on its list, but a dangling speaker
reference is just as wrong, so it is checked here rather than assumed.
"""
records = read_manifest(built)
known = {r["id"] for r in records}
for record in records:
for field in ("book_id", "chapter_id", "speaker_id", "render_id", "voice_id"):
value = record.get(field)
if isinstance(value, str):
assert value in known, f"{record['id']}.{field} -> {value}"
for field in ("chapters", "renders", "inputs", "published_artifact_ids"):
for value in record.get(field, []) or []:
assert value in known, f"{record['id']}.{field} contains {value}"
def test_no_field_states_a_duration_without_a_unit(built: Path) -> None:
forbidden = {"duration", "length", "elapsed", "runtime", "time"}
for record in read_manifest(built):
for field in record:
assert field.lower() not in forbidden, f"{record['id']} has {field}"
def test_every_path_declares_what_it_is_relative_to(built: Path) -> None:
for record in read_manifest(built):
if "path" in record:
assert record.get("relative_to") == "book_directory", record["id"]
def test_no_absolute_host_paths_leak_into_the_bundle(built: Path) -> None:
"""The bundle is committable and must not describe the machine layout."""
blob = "\n".join((built / "manifest.jsonl").read_text().splitlines())
for leak in ("/Volumes/", "/Users/", str(REPO)):
assert leak not in blob, f"absolute path leaked: {leak}"
# --------------------------------------------------------------------------
# the findings this build exists to surface
# --------------------------------------------------------------------------
def test_narrator_written_three_ways_is_resolved_and_marked(built: Path) -> None:
"""An empty speaker name means the narrator; it must be labelled, not dropped."""
records = read_manifest(built)
utterances = [r for r in records if r["kind"] == "utterance"]
implicit = [u for u in utterances if u["speaker_resolution"] == "implicit_narrator"]
assert implicit, "the fixture is supposed to contain null-speaker rows"
for utterance in implicit:
assert utterance["speaker_name_raw"] in (None, "")
assert utterance["speaker_id"] is not None, "an implicit narrator still has to resolve"
for utterance in utterances:
if utterance["speaker_resolution"] == "explicit":
assert utterance["speaker_name_raw"] not in (None, "")
def test_case_insensitive_spellings_are_not_silently_merged(tmp_path: Path) -> None:
make_project(tmp_path / "TestProject", narrator_spellings=("Narrator", "narrator"))
out = tmp_path / "bundle"
builder.build("TestProject", out, "2026-10-14T00:00:00Z", None, source_root=tmp_path)
speakers = [r for r in read_manifest(out) if r["kind"] == "speaker"
and r["casefold_collision_group"] == "narrator"]
assert len(speakers) == 2, "the two spellings must stay separate records"
for speaker in speakers:
assert speaker["utterance_count"] < speaker["utterance_count_casefold_grouped"]
def test_chapter_numbering_hazard_is_declared(built: Path) -> None:
book = next(r for r in read_manifest(built) if r["kind"] == "book")
numbering = book["numbering"]["schemes"]
assert numbering["database_row_id"]["is_authoritative"] is True
assert numbering["chapter_index"]["is_authoritative"] is False
assert numbering["distribution_track_number"]["is_authoritative"] is False
def test_chapters_record_which_number_their_filenames_use(built: Path) -> None:
for chapter in (r for r in read_manifest(built) if r["kind"] == "chapter"):
basis = chapter["numbering_basis"]
assert basis["file_names_use"] == "database_row_id"
assert basis["chapter_index_is_authoritative"] is False
def test_mismatched_extension_is_reported_not_hidden(built: Path) -> None:
"""A `.wav` that is not a WAV must be visible on the book, not just per file."""
book = next(r for r in read_manifest(built) if r["kind"] == "book")
for entry in book["extension_container_mismatches"]:
assert entry["actual_container"] != entry["named_extension"]
def test_speakers_without_a_profile_are_named_on_the_book(built: Path) -> None:
book = next(r for r in read_manifest(built) if r["kind"] == "book")
assert isinstance(book["speaker_names_without_a_profile"], list)
for name in book["speaker_names_without_a_profile"]:
assert not any(r["kind"] == "speaker" and r["primary_name"] == name
and r["has_profile"] for r in read_manifest(built))
def test_declared_and_measured_chapter_counts_are_both_reported(built: Path) -> None:
book = next(r for r in read_manifest(built) if r["kind"] == "book")
assert book["chapter_count"] == len(book["chapters"])
assert book["declared_total_chapters"] is not None
def test_language_designations_are_empty_and_explained(built: Path) -> None:
"""Empty is a real answer here; an invented list would be a guess in a schema."""
book = next(r for r in read_manifest(built) if r["kind"] == "book")
assert book["language_designations"] == []
assert "do not record" in book["language_designation_note"] or \
"records no per-utterance language" in book["language_designation_note"]
def test_schemas_describe_every_kind_that_appears(built: Path) -> None:
kinds = {r["kind"] for r in read_manifest(built)}
for kind in kinds:
assert (built / "schemas" / f"{kind}.schema.json").exists(), kind
assert (built / "schemas" / "manifest.schema.json").exists()
assert (built / "schemas" / "lineage.schema.json").exists()
def test_required_fields_declared_in_the_index_are_actually_present(built: Path) -> None:
index = json.loads((built / "bundle.json").read_text())
records = read_manifest(built)
for record in records:
for field in index["required_fields"].get(record["kind"], []):
assert field in record, f"{record['kind']} {record['id']} missing {field}"
def test_utterance_text_hash_matches_its_own_text(built: Path) -> None:
import hashlib
for record in read_manifest(built):
if record["kind"] == "utterance":
assert hashlib.sha256(record["text"].encode()).hexdigest() == record["text_sha256"]
assert len(record["text"]) == record["text_char_count"]
def test_chapter_utterance_counts_are_true(built: Path) -> None:
records = read_manifest(built)
per_chapter: dict[str, int] = {}
for record in records:
if record["kind"] == "utterance":
per_chapter[record["chapter_id"]] = per_chapter.get(record["chapter_id"], 0) + 1
for chapter in (r for r in records if r["kind"] == "chapter"):
assert chapter["utterance_count"] == per_chapter.get(chapter["id"], 0)
def test_speaker_utterance_counts_are_true_and_separate_from_the_inferred_one(built: Path) -> None:
records = read_manifest(built)
per_speaker: dict[str, int] = {}
for record in records:
if record["kind"] == "utterance" and record["speaker_resolution"] == "explicit":
per_speaker[record["speaker_name_raw"]] = \
per_speaker.get(record["speaker_name_raw"], 0) + 1
for speaker in (r for r in records if r["kind"] == "speaker"):
assert speaker["utterance_count"] == per_speaker.get(speaker["primary_name"], 0)
assert speaker["utterance_count_including_implicit_narrator"] >= speaker["utterance_count"]
def test_example_is_a_readable_subset_not_a_copy(built: Path) -> None:
"""The example exists to teach the shape; a second copy of the data cannot."""
example = next(p for p in (built / "examples").iterdir() if p.is_dir())
example_records = json.loads((example / "manifest.json").read_text())
assert 0 < len(example_records) < len(read_manifest(built))
lineage = json.loads((example / "lineage.json").read_text())
assert lineage["edges"], "lineage with no edges teaches nothing"
assert all(edge.get("transform") for edge in lineage["edges"])
assert all(edge.get("derivation") in {"direct", "derived", "assembled", "transcoded"}
for edge in lineage["edges"])
def test_generator_never_writes_to_the_project(tmp_path: Path) -> None:
"""The audio system keeps its data and keeps running; the build only reads."""
project = make_project(tmp_path / "TestProject")
before = {p: p.stat().st_mtime_ns for p in sorted(project.rglob("*")) if p.is_file()}
builder.build("TestProject", tmp_path / "bundle", "2026-10-14T00:00:00Z", None,
source_root=tmp_path)
after = {p: p.stat().st_mtime_ns for p in sorted(project.rglob("*")) if p.is_file()}
assert before == after
def test_generated_at_is_the_only_difference_between_two_timestamps(tmp_path: Path) -> None:
make_project(tmp_path / "TestProject")
first = tmp_path / "a"
second = tmp_path / "b"
builder.build("TestProject", first, "2026-10-14T00:00:00Z", None, source_root=tmp_path)
builder.build("TestProject", second, "2026-10-15T00:00:00Z", None, source_root=tmp_path)
def strip(path: Path) -> list[str]:
# Everything that is a build-time stamp, not a fact about the data.
volatile = {"generated_at", "file_modified_at"}
return [
json.dumps({k: v for k, v in json.loads(line).items() if k not in volatile},
sort_keys=True)
for line in (path / "manifest.jsonl").read_text().splitlines()
]
assert strip(first) == strip(second)
def test_production_time_is_not_claimed_as_build_time(tmp_path: Path) -> None:
"""`produced_at: <build time>` would be a claim about the audio, not the export.
A render record must carry when the *file* was written, or nothing at all.
"""
make_project(tmp_path / "TestProject")
out = tmp_path / "bundle"
builder.build("TestProject", out, "2026-10-14T00:00:00Z", None, source_root=tmp_path)
for record in read_manifest(out):
assert "produced_at" not in record, record["id"]
# And the build timestamp never leaks into a file-time field.
for record in read_manifest(out):
assert record.get("file_modified_at") != "2026-10-14T00:00:00Z", record["id"]