renderplan 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
renderplan/model.py ADDED
@@ -0,0 +1,937 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field, replace
5
+ from typing import Any, Literal
6
+
7
+ from .errors import ValidationError
8
+ from .identity import (
9
+ canonical_json_bytes,
10
+ require_nonempty,
11
+ require_sha256_id,
12
+ require_toml_value,
13
+ sha256_id,
14
+ )
15
+
16
+ PROFILE_FORMAT = "renderprofile"
17
+ PLAN_FORMAT = "renderplan"
18
+ PREFLIGHT_FORMAT = "renderplan-preflight"
19
+ MANIFEST_FORMAT = "render-manifest"
20
+ SCHEMA_VERSION = 1
21
+
22
+ SplitLevel = Literal["paragraph", "sentence", "clause"]
23
+ FallbackLevel = Literal["clause", "token"]
24
+ Guarantee = Literal["hard", "advisory"]
25
+ PreflightStatus = Literal["ok", "replan", "reject"]
26
+ CheckStatus = Literal["ok", "too_long", "error"]
27
+
28
+ _ALLOWED_TOKEN_FIELDS = frozenset({"span", "pos", "tag", "lemma", "morph"})
29
+ _ALLOWED_SPLITS = frozenset({"paragraph", "sentence", "clause"})
30
+ _ALLOWED_FALLBACKS = frozenset({"clause", "token"})
31
+ _ALLOWED_SEMANTIC_ENDS = frozenset({"none", "clause", "sentence", "paragraph", "voice_change"})
32
+ _ALLOWED_SPLIT_REASONS = frozenset(
33
+ {"policy", "capacity", "voice_change", "directive_change", "media"}
34
+ )
35
+ _ALLOWED_PLANNING_LEVELS = frozenset({"paragraph", "sentence", "clause", "token", "hard"})
36
+
37
+
38
+ def _tuple_strings(values: tuple[str, ...], field_name: str) -> tuple[str, ...]:
39
+ seen: set[str] = set()
40
+ for item in values:
41
+ require_nonempty(item, field_name)
42
+ if item in seen:
43
+ raise ValidationError(f"{field_name} contains duplicate {item!r}")
44
+ seen.add(item)
45
+ return values
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class Producer:
50
+ name: str
51
+ version: str
52
+
53
+ def __post_init__(self) -> None:
54
+ require_nonempty(self.name, "producer.name")
55
+ require_nonempty(self.version, "producer.version")
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ return {"name": self.name, "version": self.version}
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class CapacityConstraint:
63
+ metric: str
64
+ maximum: int
65
+ guarantee: Guarantee = "advisory"
66
+
67
+ def __post_init__(self) -> None:
68
+ require_nonempty(self.metric, "capacity.metric")
69
+ if (
70
+ isinstance(self.maximum, bool)
71
+ or not isinstance(self.maximum, int)
72
+ or self.maximum <= 0
73
+ ):
74
+ raise ValidationError("capacity.maximum must be a positive integer")
75
+ if self.guarantee not in {"hard", "advisory"}:
76
+ raise ValidationError("capacity.guarantee must be 'hard' or 'advisory'")
77
+
78
+ def to_dict(self) -> dict[str, Any]:
79
+ return {"metric": self.metric, "maximum": self.maximum, "guarantee": self.guarantee}
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class RoleRequirements:
84
+ token_fields: tuple[str, ...] = ()
85
+ frontend: str = "renderer"
86
+ pronunciation: str = "renderer"
87
+
88
+ def __post_init__(self) -> None:
89
+ _tuple_strings(self.token_fields, "requirements.token_fields")
90
+ unknown = set(self.token_fields).difference(_ALLOWED_TOKEN_FIELDS)
91
+ if unknown:
92
+ raise ValidationError(f"unknown token fields: {', '.join(sorted(unknown))}")
93
+ require_nonempty(self.frontend, "requirements.frontend")
94
+ require_nonempty(self.pronunciation, "requirements.pronunciation")
95
+
96
+ def to_dict(self) -> dict[str, Any]:
97
+ return {
98
+ "token_fields": list(self.token_fields),
99
+ "frontend": self.frontend,
100
+ "pronunciation": self.pronunciation,
101
+ }
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class RoleProfile:
106
+ language: str
107
+ binding: Mapping[str, Any]
108
+ requirements: RoleRequirements = field(default_factory=RoleRequirements)
109
+ planner_capacity: CapacityConstraint | None = None
110
+ renderer_capacity: CapacityConstraint | None = None
111
+
112
+ def __post_init__(self) -> None:
113
+ require_nonempty(self.language, "role.language")
114
+ if not self.binding:
115
+ raise ValidationError("role.binding must not be empty")
116
+ require_toml_value(self.binding, "role.binding")
117
+
118
+ def to_dict(self) -> dict[str, Any]:
119
+ data: dict[str, Any] = {
120
+ "language": self.language,
121
+ "binding": dict(self.binding),
122
+ "requirements": self.requirements.to_dict(),
123
+ }
124
+ capacity: dict[str, Any] = {}
125
+ if self.planner_capacity is not None:
126
+ capacity["planner"] = self.planner_capacity.to_dict()
127
+ if self.renderer_capacity is not None:
128
+ capacity["renderer"] = self.renderer_capacity.to_dict()
129
+ if capacity:
130
+ data["capacity"] = capacity
131
+ return data
132
+
133
+
134
+ @dataclass(frozen=True, slots=True)
135
+ class PlanningPolicy:
136
+ split: SplitLevel = "sentence"
137
+ capacity_fallback: tuple[FallbackLevel, ...] = ("clause", "token")
138
+
139
+ def __post_init__(self) -> None:
140
+ if self.split not in _ALLOWED_SPLITS:
141
+ raise ValidationError(f"planning.split must be one of {sorted(_ALLOWED_SPLITS)}")
142
+ _tuple_strings(self.capacity_fallback, "planning.capacity_fallback")
143
+ unknown = set(self.capacity_fallback).difference(_ALLOWED_FALLBACKS)
144
+ if unknown:
145
+ raise ValidationError(f"unknown capacity fallback: {', '.join(sorted(unknown))}")
146
+
147
+ def to_dict(self) -> dict[str, Any]:
148
+ return {"split": self.split, "capacity_fallback": list(self.capacity_fallback)}
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class PausePolicy:
153
+ mode: Literal["manual", "none"] = "manual"
154
+ durations_ms: Mapping[str, int] = field(default_factory=dict)
155
+
156
+ def __post_init__(self) -> None:
157
+ if self.mode not in {"manual", "none"}:
158
+ raise ValidationError("pauses.mode must be manual or none")
159
+ for name, duration in self.durations_ms.items():
160
+ require_nonempty(name, "pause name")
161
+ if isinstance(duration, bool) or not isinstance(duration, int) or duration < 0:
162
+ raise ValidationError(
163
+ f"pause {name!r} duration must be a non-negative integer ms"
164
+ )
165
+
166
+ def to_dict(self) -> dict[str, Any]:
167
+ data: dict[str, Any] = {"mode": self.mode}
168
+ for key, value in sorted(self.durations_ms.items()):
169
+ data[key] = f"{value}ms"
170
+ return data
171
+
172
+
173
+ @dataclass(frozen=True, slots=True)
174
+ class RenderProfile:
175
+ roles: Mapping[str, RoleProfile]
176
+ default_role: str
177
+ planning: PlanningPolicy = field(default_factory=PlanningPolicy)
178
+ pauses: PausePolicy = field(default_factory=PausePolicy)
179
+ profile_id: str | None = None
180
+ producer: Producer | None = None
181
+ format: str = PROFILE_FORMAT
182
+ schema_version: int = SCHEMA_VERSION
183
+
184
+ def __post_init__(self) -> None:
185
+ if self.format != PROFILE_FORMAT or self.schema_version != SCHEMA_VERSION:
186
+ raise ValidationError("unsupported render profile format/schema")
187
+ if not self.roles:
188
+ raise ValidationError("render profile requires at least one role")
189
+ for role in self.roles:
190
+ require_nonempty(role, "role name")
191
+ require_nonempty(self.default_role, "default_role")
192
+ if self.default_role not in self.roles:
193
+ raise ValidationError("default_role must name one of roles")
194
+ if self.profile_id is not None:
195
+ require_sha256_id(self.profile_id, "profile_id")
196
+ if self.profile_id != self.computed_id():
197
+ raise ValidationError("profile_id does not match profile content")
198
+
199
+ def identity_payload(self) -> dict[str, Any]:
200
+ return {
201
+ "format": self.format,
202
+ "schema_version": self.schema_version,
203
+ "roles": {name: role.to_dict() for name, role in sorted(self.roles.items())},
204
+ "default_role": self.default_role,
205
+ "planning": self.planning.to_dict(),
206
+ "pauses": self.pauses.to_dict(),
207
+ }
208
+
209
+ def computed_id(self) -> str:
210
+ return sha256_id(self.identity_payload())
211
+
212
+ def with_identity(self) -> RenderProfile:
213
+ return replace(self, profile_id=self.computed_id())
214
+
215
+
216
+ @dataclass(frozen=True, slots=True)
217
+ class SourceSpan:
218
+ locator: str
219
+ start: int
220
+ end: int
221
+
222
+ def __post_init__(self) -> None:
223
+ require_nonempty(self.locator, "source.locator")
224
+ if any(isinstance(v, bool) or not isinstance(v, int) for v in (self.start, self.end)):
225
+ raise ValidationError("source start/end must be integers")
226
+ if self.start < 0 or self.end <= self.start:
227
+ raise ValidationError("source span must satisfy 0 <= start < end")
228
+
229
+ def to_dict(self) -> dict[str, Any]:
230
+ return {"locator": self.locator, "start": self.start, "end": self.end}
231
+
232
+
233
+ @dataclass(frozen=True, slots=True)
234
+ class SpeechControl:
235
+ start: int
236
+ end: int
237
+ name: str
238
+ params: Mapping[str, Any] = field(default_factory=dict)
239
+
240
+ def __post_init__(self) -> None:
241
+ if any(
242
+ isinstance(value, bool) or not isinstance(value, int)
243
+ for value in (self.start, self.end)
244
+ ):
245
+ raise ValidationError("control start/end must be integers")
246
+ if self.start < 0 or self.end <= self.start:
247
+ raise ValidationError("control must satisfy 0 <= start < end")
248
+ require_nonempty(self.name, "control.name")
249
+ require_toml_value(self.params, "control.params")
250
+
251
+ def ordering_key(self) -> tuple[int, int, str, bytes]:
252
+ return (self.start, self.end, self.name, canonical_json_bytes(self.params))
253
+
254
+ def to_dict(self) -> dict[str, Any]:
255
+ return {
256
+ "start": self.start,
257
+ "end": self.end,
258
+ "name": self.name,
259
+ "params": dict(self.params),
260
+ }
261
+
262
+
263
+ @dataclass(frozen=True, slots=True)
264
+ class TokenBundle:
265
+ """Dense token spans/pos/tag and sparse, token-indexed lemma/morph values."""
266
+
267
+ span: tuple[tuple[int, int], ...]
268
+ pos: tuple[str, ...] = ()
269
+ tag: tuple[str, ...] = ()
270
+ lemma: tuple[tuple[int, str], ...] = ()
271
+ morph: tuple[tuple[int, str], ...] = ()
272
+
273
+ def __post_init__(self) -> None:
274
+ previous_end = -1
275
+ for index, pair in enumerate(self.span):
276
+ if len(pair) != 2:
277
+ raise ValidationError(f"token.span[{index}] must contain start and end offsets")
278
+ start, end = pair
279
+ if any(isinstance(value, bool) or not isinstance(value, int) for value in pair):
280
+ raise ValidationError(f"token.span[{index}] must contain integer offsets")
281
+ if start < 0 or end <= start or start < previous_end:
282
+ raise ValidationError(f"token.span[{index}] is invalid or out of order")
283
+ previous_end = end
284
+ for field_name, dense_values in (("pos", self.pos), ("tag", self.tag)):
285
+ if dense_values and len(dense_values) != len(self.span):
286
+ raise ValidationError(f"token.{field_name} length must match token.span")
287
+ for field_name, sparse_values in (("lemma", self.lemma), ("morph", self.morph)):
288
+ previous_index = -1
289
+ for token_index, value in sparse_values:
290
+ if (
291
+ isinstance(token_index, bool)
292
+ or not isinstance(token_index, int)
293
+ or token_index < 0
294
+ or token_index >= len(self.span)
295
+ or token_index <= previous_index
296
+ ):
297
+ raise ValidationError(
298
+ f"token.{field_name} has invalid or unordered token index {token_index}"
299
+ )
300
+ require_nonempty(value, f"token.{field_name} value")
301
+ previous_index = token_index
302
+
303
+ def validate_text(self, text: str) -> None:
304
+ for index, (_, end) in enumerate(self.span):
305
+ if end > len(text):
306
+ raise ValidationError(f"token.span[{index}] exceeds speech text length")
307
+
308
+ def field_is_complete(self, field_name: str) -> bool:
309
+ if not self.span:
310
+ return False
311
+ if field_name == "span":
312
+ return True
313
+ if field_name in {"pos", "tag"}:
314
+ dense_values = self.pos if field_name == "pos" else self.tag
315
+ return len(dense_values) == len(self.span) and all(
316
+ bool(value.strip()) for value in dense_values
317
+ )
318
+ if field_name in {"lemma", "morph"}:
319
+ sparse_values = self.lemma if field_name == "lemma" else self.morph
320
+ return tuple(index for index, _ in sparse_values) == tuple(range(len(self.span)))
321
+ return False
322
+
323
+ def fields_present(self) -> frozenset[str]:
324
+ present = {"span"}
325
+ if self.pos:
326
+ present.add("pos")
327
+ if self.tag:
328
+ present.add("tag")
329
+ if self.lemma:
330
+ present.add("lemma")
331
+ if self.morph:
332
+ present.add("morph")
333
+ return frozenset(present)
334
+
335
+ def to_dict(self) -> dict[str, Any]:
336
+ data: dict[str, Any] = {"span": [list(pair) for pair in self.span]}
337
+ if self.pos:
338
+ data["pos"] = list(self.pos)
339
+ if self.tag:
340
+ data["tag"] = list(self.tag)
341
+ if self.lemma:
342
+ data["lemma"] = [[index, value] for index, value in self.lemma]
343
+ if self.morph:
344
+ data["morph"] = [[index, value] for index, value in self.morph]
345
+ return data
346
+
347
+
348
+ @dataclass(frozen=True, slots=True)
349
+ class SpeechItem:
350
+ number: int
351
+ text: str
352
+ language: str
353
+ role: str
354
+ binding: Mapping[str, Any]
355
+ source: tuple[SourceSpan, ...]
356
+ split_reason: str = "policy"
357
+ planning_level: str = "sentence"
358
+ semantic_end: str = "none"
359
+ tokens: TokenBundle | None = None
360
+ controls: tuple[SpeechControl, ...] = ()
361
+ preflight_required: bool = True
362
+ segment_id: str | None = None
363
+ kind: str = "speech"
364
+
365
+ def __post_init__(self) -> None:
366
+ if (
367
+ isinstance(self.number, bool)
368
+ or not isinstance(self.number, int)
369
+ or self.number <= 0
370
+ ):
371
+ raise ValidationError("speech.number must be a positive integer")
372
+ require_nonempty(self.text, "speech.text")
373
+ require_nonempty(self.language, "speech.language")
374
+ require_nonempty(self.role, "speech.role")
375
+ if not self.binding:
376
+ raise ValidationError("speech.binding must not be empty")
377
+ require_toml_value(self.binding, "speech.binding")
378
+ if not self.source:
379
+ raise ValidationError("speech.source must not be empty")
380
+ if self.split_reason not in _ALLOWED_SPLIT_REASONS:
381
+ raise ValidationError(f"unsupported speech.split_reason {self.split_reason!r}")
382
+ if self.planning_level not in _ALLOWED_PLANNING_LEVELS:
383
+ raise ValidationError(f"unsupported speech.planning_level {self.planning_level!r}")
384
+ if self.semantic_end not in _ALLOWED_SEMANTIC_ENDS:
385
+ raise ValidationError(f"unsupported speech.semantic_end {self.semantic_end!r}")
386
+ if self.tokens is not None:
387
+ self.tokens.validate_text(self.text)
388
+ for index, control in enumerate(self.controls):
389
+ if not isinstance(control, SpeechControl):
390
+ raise ValidationError(f"speech.controls[{index}] must be a SpeechControl")
391
+ if control.end > len(self.text):
392
+ raise ValidationError(f"speech.controls[{index}] exceeds speech text length")
393
+ if (
394
+ tuple(sorted(self.controls, key=lambda control: control.ordering_key()))
395
+ != self.controls
396
+ ):
397
+ raise ValidationError(
398
+ "speech.controls must be ordered by start, end, name, and params"
399
+ )
400
+ if self.segment_id is not None:
401
+ require_sha256_id(self.segment_id, "speech.segment_id")
402
+ if self.segment_id != self.computed_id():
403
+ raise ValidationError(f"speech {self.number} segment_id does not match content")
404
+
405
+ @property
406
+ def wav(self) -> str:
407
+ return f"{self.number:06d}.wav"
408
+
409
+ def identity_payload(self) -> dict[str, Any]:
410
+ """Identity of the exact renderer request, excluding plan provenance and numbering."""
411
+ data: dict[str, Any] = {
412
+ "text": self.text,
413
+ "language": self.language,
414
+ "role": self.role,
415
+ "binding": dict(self.binding),
416
+ "controls": [control.to_dict() for control in self.controls],
417
+ }
418
+ if self.tokens is not None:
419
+ data["tokens"] = self.tokens.to_dict()
420
+ return data
421
+
422
+ def plan_payload(self) -> dict[str, Any]:
423
+ return {
424
+ "kind": self.kind,
425
+ "number": self.number,
426
+ "segment_id": self.segment_id or self.computed_id(),
427
+ "wav": self.wav,
428
+ "source": [item.to_dict() for item in self.source],
429
+ "split_reason": self.split_reason,
430
+ "planning_level": self.planning_level,
431
+ "semantic_end": self.semantic_end,
432
+ "preflight_required": self.preflight_required,
433
+ }
434
+
435
+ def computed_id(self) -> str:
436
+ return sha256_id(self.identity_payload())
437
+
438
+ def with_identity(self) -> SpeechItem:
439
+ return replace(self, segment_id=self.computed_id())
440
+
441
+
442
+ @dataclass(frozen=True, slots=True)
443
+ class PauseItem:
444
+ duration_ms: int
445
+ reason: str
446
+ source: tuple[SourceSpan, ...] = ()
447
+ pause_id: str | None = None
448
+ kind: str = "pause"
449
+
450
+ def __post_init__(self) -> None:
451
+ if (
452
+ isinstance(self.duration_ms, bool)
453
+ or not isinstance(self.duration_ms, int)
454
+ or self.duration_ms < 0
455
+ ):
456
+ raise ValidationError("pause.duration_ms must be a non-negative integer")
457
+ require_nonempty(self.reason, "pause.reason")
458
+ if self.pause_id is not None:
459
+ require_sha256_id(self.pause_id, "pause.pause_id")
460
+ if self.pause_id != self.computed_id():
461
+ raise ValidationError("pause_id does not match pause content")
462
+
463
+ def identity_payload(self) -> dict[str, Any]:
464
+ return {
465
+ "kind": self.kind,
466
+ "duration_ms": self.duration_ms,
467
+ "reason": self.reason,
468
+ "source": [item.to_dict() for item in self.source],
469
+ }
470
+
471
+ def computed_id(self) -> str:
472
+ return sha256_id(self.identity_payload())
473
+
474
+ def with_identity(self) -> PauseItem:
475
+ return replace(self, pause_id=self.computed_id())
476
+
477
+
478
+ @dataclass(frozen=True, slots=True)
479
+ class AudioItem:
480
+ src: str
481
+ source: tuple[SourceSpan, ...]
482
+ options: Mapping[str, Any] = field(default_factory=dict)
483
+ audio_id: str | None = None
484
+ kind: str = "audio"
485
+
486
+ def __post_init__(self) -> None:
487
+ require_nonempty(self.src, "audio.src")
488
+ if not self.source:
489
+ raise ValidationError("audio.source must not be empty")
490
+ require_toml_value(self.options, "audio.options")
491
+ if self.audio_id is not None:
492
+ require_sha256_id(self.audio_id, "audio.audio_id")
493
+ if self.audio_id != self.computed_id():
494
+ raise ValidationError("audio_id does not match audio content")
495
+
496
+ def identity_payload(self) -> dict[str, Any]:
497
+ return {
498
+ "kind": self.kind,
499
+ "src": self.src,
500
+ "source": [item.to_dict() for item in self.source],
501
+ "options": dict(self.options),
502
+ }
503
+
504
+ def computed_id(self) -> str:
505
+ return sha256_id(self.identity_payload())
506
+
507
+ def with_identity(self) -> AudioItem:
508
+ return replace(self, audio_id=self.computed_id())
509
+
510
+
511
+ FlowItem = SpeechItem | PauseItem | AudioItem
512
+
513
+
514
+ @dataclass(frozen=True, slots=True)
515
+ class RenderPlan:
516
+ source_plan_id: str
517
+ render_profile_id: str
518
+ flow: tuple[FlowItem, ...]
519
+ plan_id: str | None = None
520
+ producer: Producer | None = None
521
+ format: str = PLAN_FORMAT
522
+ schema_version: int = SCHEMA_VERSION
523
+
524
+ def __post_init__(self) -> None:
525
+ if self.format != PLAN_FORMAT or self.schema_version != SCHEMA_VERSION:
526
+ raise ValidationError("unsupported render plan format/schema")
527
+ require_sha256_id(self.source_plan_id, "source_plan_id")
528
+ require_sha256_id(self.render_profile_id, "render_profile_id")
529
+ if not self.flow:
530
+ raise ValidationError("render plan flow must not be empty")
531
+ expected_number = 1
532
+ for index, item in enumerate(self.flow):
533
+ if isinstance(item, SpeechItem):
534
+ if item.number != expected_number:
535
+ raise ValidationError(
536
+ f"speech numbers must be contiguous from 1; flow[{index}] is {item.number}, expected {expected_number}"
537
+ )
538
+ if item.segment_id is None and self.plan_id is not None:
539
+ raise ValidationError(f"speech {item.number} must have segment_id")
540
+ expected_number += 1
541
+ elif isinstance(item, PauseItem):
542
+ if item.pause_id is None and self.plan_id is not None:
543
+ raise ValidationError(f"flow[{index}] pause must have pause_id")
544
+ elif isinstance(item, AudioItem):
545
+ if item.audio_id is None and self.plan_id is not None:
546
+ raise ValidationError(f"flow[{index}] audio must have audio_id")
547
+ else:
548
+ raise ValidationError(f"flow[{index}] has an unsupported item type")
549
+ if self.plan_id is not None:
550
+ require_sha256_id(self.plan_id, "plan_id")
551
+ if self.plan_id != self.computed_id():
552
+ raise ValidationError("plan_id does not match render plan content")
553
+
554
+ def identity_payload(self) -> dict[str, Any]:
555
+ flow: list[dict[str, Any]] = []
556
+ for item in self.flow:
557
+ if isinstance(item, SpeechItem):
558
+ payload = item.plan_payload()
559
+ elif isinstance(item, PauseItem):
560
+ payload = {
561
+ **item.identity_payload(),
562
+ "pause_id": item.pause_id or item.computed_id(),
563
+ }
564
+ else:
565
+ payload = {
566
+ **item.identity_payload(),
567
+ "audio_id": item.audio_id or item.computed_id(),
568
+ }
569
+ flow.append(payload)
570
+ return {
571
+ "format": self.format,
572
+ "schema_version": self.schema_version,
573
+ "source_plan_id": self.source_plan_id,
574
+ "render_profile_id": self.render_profile_id,
575
+ "flow": flow,
576
+ }
577
+
578
+ def validate_against_profile(self, profile: RenderProfile) -> None:
579
+ expected_profile_id = profile.profile_id or profile.computed_id()
580
+ if self.render_profile_id != expected_profile_id:
581
+ raise ValidationError("render plan render_profile_id does not match profile")
582
+ for speech in self.speech:
583
+ role = profile.roles.get(speech.role)
584
+ if role is None:
585
+ raise ValidationError(
586
+ f"speech {speech.number} names unknown profile role {speech.role!r}"
587
+ )
588
+ if dict(speech.binding) != dict(role.binding):
589
+ raise ValidationError(
590
+ f"speech {speech.number} binding does not match role {speech.role!r}"
591
+ )
592
+ speech_language = speech.language.lower().replace("_", "-")
593
+ role_language = role.language.lower().replace("_", "-")
594
+ if not (
595
+ speech_language == role_language
596
+ or speech_language.startswith(role_language + "-")
597
+ or role_language.startswith(speech_language + "-")
598
+ ):
599
+ raise ValidationError(
600
+ f"speech {speech.number} language {speech.language!r} is incompatible with role language {role.language!r}"
601
+ )
602
+ required_fields = role.requirements.token_fields
603
+ if required_fields and speech.tokens is None:
604
+ raise ValidationError(
605
+ f"speech {speech.number} is missing required token fields"
606
+ )
607
+ if speech.tokens is not None:
608
+ missing = [
609
+ field_name
610
+ for field_name in required_fields
611
+ if not speech.tokens.field_is_complete(field_name)
612
+ ]
613
+ if missing:
614
+ raise ValidationError(
615
+ f"speech {speech.number} has incomplete required token fields: {', '.join(missing)}"
616
+ )
617
+
618
+ def computed_id(self) -> str:
619
+ return sha256_id(self.identity_payload())
620
+
621
+ def with_identity(self) -> RenderPlan:
622
+ identified: list[FlowItem] = []
623
+ for item in self.flow:
624
+ identified.append(item.with_identity())
625
+ provisional = replace(self, flow=tuple(identified), plan_id=None)
626
+ return replace(provisional, plan_id=provisional.computed_id())
627
+
628
+ @property
629
+ def speech(self) -> tuple[SpeechItem, ...]:
630
+ return tuple(item for item in self.flow if isinstance(item, SpeechItem))
631
+
632
+
633
+ @dataclass(frozen=True, slots=True)
634
+ class PreflightCheck:
635
+ segment_number: int
636
+ segment_id: str
637
+ status: CheckStatus
638
+ metric: str
639
+ measured: int | None
640
+ maximum: int | None
641
+ source: str
642
+ detail: str | None = None
643
+
644
+ def __post_init__(self) -> None:
645
+ if self.segment_number <= 0:
646
+ raise ValidationError("preflight check segment_number must be positive")
647
+ require_sha256_id(self.segment_id, "preflight.segment_id")
648
+ if self.status not in {"ok", "too_long", "error"}:
649
+ raise ValidationError("invalid preflight check status")
650
+ require_nonempty(self.metric, "preflight.metric")
651
+ require_nonempty(self.source, "preflight.source")
652
+ for field_name, value in (("measured", self.measured), ("maximum", self.maximum)):
653
+ if value is not None and (
654
+ isinstance(value, bool) or not isinstance(value, int) or value < 0
655
+ ):
656
+ raise ValidationError(
657
+ f"preflight.{field_name} must be a non-negative integer or null"
658
+ )
659
+
660
+ def to_dict(self) -> dict[str, Any]:
661
+ data: dict[str, Any] = {
662
+ "segment_number": self.segment_number,
663
+ "segment_id": self.segment_id,
664
+ "status": self.status,
665
+ "metric": self.metric,
666
+ "source": self.source,
667
+ }
668
+ if self.measured is not None:
669
+ data["measured"] = self.measured
670
+ if self.maximum is not None:
671
+ data["maximum"] = self.maximum
672
+ if self.detail is not None:
673
+ data["detail"] = self.detail
674
+ return data
675
+
676
+
677
+ @dataclass(frozen=True, slots=True)
678
+ class ReplanRequest:
679
+ segment_number: int
680
+ segment_id: str
681
+ source: tuple[SourceSpan, ...]
682
+ current_level: str
683
+ action: str = "split"
684
+ reason: str = "capacity"
685
+ metric: str | None = None
686
+ measured: int | None = None
687
+ maximum: int | None = None
688
+
689
+ def __post_init__(self) -> None:
690
+ if self.segment_number <= 0:
691
+ raise ValidationError("replan segment_number must be positive")
692
+ require_sha256_id(self.segment_id, "replan.segment_id")
693
+ if not self.source:
694
+ raise ValidationError("replan.source must not be empty")
695
+ if self.current_level not in _ALLOWED_PLANNING_LEVELS:
696
+ raise ValidationError("replan.current_level is invalid")
697
+ if self.action != "split":
698
+ raise ValidationError("MVP replan.action must be 'split'")
699
+ require_nonempty(self.reason, "replan.reason")
700
+ if self.reason != "capacity":
701
+ raise ValidationError("replan.reason must be 'capacity'")
702
+ if self.metric is not None:
703
+ require_nonempty(self.metric, "replan.metric")
704
+ for field_name, value in (("measured", self.measured), ("maximum", self.maximum)):
705
+ if value is not None and (
706
+ isinstance(value, bool) or not isinstance(value, int) or value < 0
707
+ ):
708
+ raise ValidationError(
709
+ f"replan.{field_name} must be a non-negative integer or null"
710
+ )
711
+
712
+ def to_dict(self) -> dict[str, Any]:
713
+ data: dict[str, Any] = {
714
+ "segment_number": self.segment_number,
715
+ "segment_id": self.segment_id,
716
+ "source": [item.to_dict() for item in self.source],
717
+ "current_level": self.current_level,
718
+ "action": self.action,
719
+ "reason": self.reason,
720
+ }
721
+ if self.metric is not None:
722
+ data["metric"] = self.metric
723
+ if self.measured is not None:
724
+ data["measured"] = self.measured
725
+ if self.maximum is not None:
726
+ data["maximum"] = self.maximum
727
+ return data
728
+
729
+
730
+ @dataclass(frozen=True, slots=True)
731
+ class PreflightReport:
732
+ render_plan_id: str
733
+ source_plan_id: str
734
+ render_profile_id: str
735
+ status: PreflightStatus
736
+ checks: tuple[PreflightCheck, ...]
737
+ replan: tuple[ReplanRequest, ...] = ()
738
+ producer: Producer | None = None
739
+ report_id: str | None = None
740
+ format: str = PREFLIGHT_FORMAT
741
+ schema_version: int = SCHEMA_VERSION
742
+
743
+ def __post_init__(self) -> None:
744
+ if self.format != PREFLIGHT_FORMAT or self.schema_version != SCHEMA_VERSION:
745
+ raise ValidationError("unsupported preflight format/schema")
746
+ require_sha256_id(self.render_plan_id, "render_plan_id")
747
+ require_sha256_id(self.source_plan_id, "source_plan_id")
748
+ require_sha256_id(self.render_profile_id, "render_profile_id")
749
+ if self.status not in {"ok", "replan", "reject"}:
750
+ raise ValidationError("preflight.status must be ok, replan, or reject")
751
+ if self.status == "reject" and self.replan:
752
+ raise ValidationError("reject preflight cannot contain replan requests")
753
+ check_keys = {(item.segment_number, item.segment_id) for item in self.checks}
754
+ if len(check_keys) != len(self.checks):
755
+ raise ValidationError("preflight checks contain duplicate segments")
756
+ for request in self.replan:
757
+ if (request.segment_number, request.segment_id) not in check_keys:
758
+ raise ValidationError("replan request must refer to a checked segment")
759
+ if self.status == "ok" and (
760
+ self.replan or any(item.status != "ok" for item in self.checks)
761
+ ):
762
+ raise ValidationError(
763
+ "ok preflight cannot contain failed checks or replan requests"
764
+ )
765
+ if self.status == "replan" and not self.replan:
766
+ raise ValidationError("replan preflight requires at least one replan request")
767
+ if self.report_id is not None:
768
+ require_sha256_id(self.report_id, "report_id")
769
+ if self.report_id != self.computed_id():
770
+ raise ValidationError("report_id does not match preflight content")
771
+
772
+ def identity_payload(self) -> dict[str, Any]:
773
+ return {
774
+ "format": self.format,
775
+ "schema_version": self.schema_version,
776
+ "render_plan_id": self.render_plan_id,
777
+ "source_plan_id": self.source_plan_id,
778
+ "render_profile_id": self.render_profile_id,
779
+ "status": self.status,
780
+ "checks": [item.to_dict() for item in self.checks],
781
+ "replan": [item.to_dict() for item in self.replan],
782
+ }
783
+
784
+ def computed_id(self) -> str:
785
+ return sha256_id(self.identity_payload())
786
+
787
+ def with_identity(self) -> PreflightReport:
788
+ return replace(self, report_id=self.computed_id())
789
+
790
+ def validate_against(self, plan: RenderPlan) -> None:
791
+ expected_plan_id = plan.plan_id or plan.computed_id()
792
+ if self.render_plan_id != expected_plan_id:
793
+ raise ValidationError("preflight render_plan_id does not match plan")
794
+ if self.source_plan_id != plan.source_plan_id:
795
+ raise ValidationError("preflight source_plan_id does not match plan")
796
+ if self.render_profile_id != plan.render_profile_id:
797
+ raise ValidationError("preflight render_profile_id does not match plan")
798
+
799
+ occurrences = {
800
+ (speech.number, speech.segment_id or speech.computed_id()): speech
801
+ for speech in plan.speech
802
+ }
803
+ checks = {(check.segment_number, check.segment_id): check for check in self.checks}
804
+ for key in checks:
805
+ if key not in occurrences:
806
+ raise ValidationError(
807
+ f"preflight check refers to unknown speech occurrence {key}"
808
+ )
809
+ if not occurrences[key].preflight_required:
810
+ raise ValidationError(
811
+ f"preflight check refers to speech occurrence not requiring preflight {key}"
812
+ )
813
+ if self.status == "ok":
814
+ missing = [
815
+ key
816
+ for key, speech in occurrences.items()
817
+ if speech.preflight_required and key not in checks
818
+ ]
819
+ if missing:
820
+ raise ValidationError(
821
+ f"ok preflight is missing required speech checks: {missing}"
822
+ )
823
+ for request in self.replan:
824
+ key = (request.segment_number, request.segment_id)
825
+ speech = occurrences.get(key)
826
+ matched_check = checks.get(key)
827
+ if matched_check is None or matched_check.status != "too_long":
828
+ raise ValidationError(
829
+ f"replan request {key} must refer to a failed too_long check"
830
+ )
831
+ if speech is None:
832
+ raise ValidationError(
833
+ f"replan request refers to unknown speech occurrence {key}"
834
+ )
835
+ if request.source != speech.source:
836
+ raise ValidationError(
837
+ f"replan request {key} source does not match speech source coverage"
838
+ )
839
+ if request.current_level != speech.planning_level:
840
+ raise ValidationError(
841
+ f"replan request {key} current_level does not match speech planning_level"
842
+ )
843
+
844
+
845
+ @dataclass(frozen=True, slots=True)
846
+ class SpeechArtifact:
847
+ segment_number: int
848
+ segment_id: str
849
+ wav: str
850
+ sha256: str
851
+ frames: int
852
+ sample_rate: int
853
+ channels: int
854
+
855
+ def __post_init__(self) -> None:
856
+ if self.segment_number <= 0:
857
+ raise ValidationError("artifact.segment_number must be positive")
858
+ require_sha256_id(self.segment_id, "artifact.segment_id")
859
+ expected = f"{self.segment_number:06d}.wav"
860
+ if self.wav != expected:
861
+ raise ValidationError(f"artifact.wav must be {expected!r}")
862
+ require_sha256_id(self.sha256, "artifact.sha256")
863
+ for field_name, value in (
864
+ ("frames", self.frames),
865
+ ("sample_rate", self.sample_rate),
866
+ ("channels", self.channels),
867
+ ):
868
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
869
+ raise ValidationError(f"artifact.{field_name} must be a positive integer")
870
+
871
+ def to_dict(self) -> dict[str, Any]:
872
+ return {
873
+ "segment_number": self.segment_number,
874
+ "segment_id": self.segment_id,
875
+ "wav": self.wav,
876
+ "sha256": self.sha256,
877
+ "frames": self.frames,
878
+ "sample_rate": self.sample_rate,
879
+ "channels": self.channels,
880
+ }
881
+
882
+
883
+ @dataclass(frozen=True, slots=True)
884
+ class RenderManifest:
885
+ render_plan_id: str
886
+ speech: tuple[SpeechArtifact, ...]
887
+ complete: bool
888
+ producer: Producer | None = None
889
+ manifest_id: str | None = None
890
+ format: str = MANIFEST_FORMAT
891
+ schema_version: int = SCHEMA_VERSION
892
+
893
+ def __post_init__(self) -> None:
894
+ if self.format != MANIFEST_FORMAT or self.schema_version != SCHEMA_VERSION:
895
+ raise ValidationError("unsupported render manifest format/schema")
896
+ require_sha256_id(self.render_plan_id, "render_plan_id")
897
+ occurrences = [
898
+ (artifact.segment_number, artifact.segment_id) for artifact in self.speech
899
+ ]
900
+ if len(set(occurrences)) != len(occurrences):
901
+ raise ValidationError("manifest speech contains duplicate occurrences")
902
+ if self.manifest_id is not None:
903
+ require_sha256_id(self.manifest_id, "manifest_id")
904
+ if self.manifest_id != self.computed_id():
905
+ raise ValidationError("manifest_id does not match manifest content")
906
+
907
+ def identity_payload(self) -> dict[str, Any]:
908
+ return {
909
+ "format": self.format,
910
+ "schema_version": self.schema_version,
911
+ "render_plan_id": self.render_plan_id,
912
+ "complete": self.complete,
913
+ "speech": [item.to_dict() for item in self.speech],
914
+ }
915
+
916
+ def computed_id(self) -> str:
917
+ return sha256_id(self.identity_payload())
918
+
919
+ def with_identity(self) -> RenderManifest:
920
+ return replace(self, manifest_id=self.computed_id())
921
+
922
+ def validate_against(self, plan: RenderPlan) -> None:
923
+ expected_plan_id = plan.plan_id or plan.computed_id()
924
+ if self.render_plan_id != expected_plan_id:
925
+ raise ValidationError("manifest does not belong to this render plan")
926
+ planned = {(item.number, item.segment_id or item.computed_id()) for item in plan.speech}
927
+ included = {(item.segment_number, item.segment_id) for item in self.speech}
928
+ extra = included - planned
929
+ if extra:
930
+ raise ValidationError(
931
+ f"manifest contains speech occurrences not in plan: {sorted(extra)}"
932
+ )
933
+ missing = planned - included
934
+ if self.complete and missing:
935
+ raise ValidationError(
936
+ f"complete manifest is missing speech occurrences: {sorted(missing)}"
937
+ )