renderplan 0.1.3__tar.gz → 0.1.4__tar.gz

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.
Files changed (98) hide show
  1. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/events/events.jsonl +2 -0
  2. renderplan-0.1.4/.ledger/releaseledger/data/ledgers/main/releases/v0.1.4/entries/entry-0001.md +29 -0
  3. renderplan-0.1.4/.ledger/releaseledger/data/ledgers/main/releases/v0.1.4/release.md +27 -0
  4. {renderplan-0.1.3/renderplan.egg-info → renderplan-0.1.4}/PKG-INFO +21 -1
  5. {renderplan-0.1.3 → renderplan-0.1.4}/README.md +20 -0
  6. {renderplan-0.1.3 → renderplan-0.1.4}/docs/changelog.md +8 -0
  7. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/__init__.py +14 -1
  8. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/_version.py +3 -3
  9. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/contracts.py +17 -1
  10. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/model.py +23 -0
  11. {renderplan-0.1.3 → renderplan-0.1.4/renderplan.egg-info}/PKG-INFO +21 -1
  12. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan.egg-info/SOURCES.txt +2 -0
  13. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan.egg-info/scm_file_list.json +2 -0
  14. renderplan-0.1.4/renderplan.egg-info/scm_version.json +8 -0
  15. {renderplan-0.1.3 → renderplan-0.1.4}/tests/test_contracts.py +82 -0
  16. {renderplan-0.1.3 → renderplan-0.1.4}/tests/test_identity.py +18 -0
  17. renderplan-0.1.3/renderplan.egg-info/scm_version.json +0 -8
  18. {renderplan-0.1.3 → renderplan-0.1.4}/.codecrate.toml +0 -0
  19. {renderplan-0.1.3 → renderplan-0.1.4}/.github/workflows/codecov.yml +0 -0
  20. {renderplan-0.1.3 → renderplan-0.1.4}/.github/workflows/pre-commit.yml +0 -0
  21. {renderplan-0.1.3 → renderplan-0.1.4}/.github/workflows/python-publish.yml +0 -0
  22. {renderplan-0.1.3 → renderplan-0.1.4}/.github/workflows/tests.yml +0 -0
  23. {renderplan-0.1.3 → renderplan-0.1.4}/.gitignore +0 -0
  24. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/ledger.toml +0 -0
  25. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/.ledger-project.toml +0 -0
  26. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/config.toml +0 -0
  27. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/.ledger-project.toml +0 -0
  28. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.0/audit/commit-audit.yaml +0 -0
  29. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.0/entries/entry-0001.md +0 -0
  30. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.0/entries/entry-0002.md +0 -0
  31. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.0/entries/entry-0003.md +0 -0
  32. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.0/entries/entry-0004.md +0 -0
  33. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.0/release.md +0 -0
  34. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.1/audit/commit-audit.yaml +0 -0
  35. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.1/entries/entry-0001.md +0 -0
  36. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.1/entries/entry-0002.md +0 -0
  37. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.1/release.md +0 -0
  38. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.2/audit/commit-audit.yaml +0 -0
  39. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.2/entries/entry-0001.md +0 -0
  40. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.2/release.md +0 -0
  41. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.3/audit/commit-audit.yaml +0 -0
  42. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.3/entries/entry-0001.md +0 -0
  43. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/data/ledgers/main/releases/v0.1.3/release.md +0 -0
  44. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.1/audit-decisions.yaml +0 -0
  45. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.1/audit.yaml +0 -0
  46. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.1/entries.yaml +0 -0
  47. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.1/evidence/0e39bcd.patch +0 -0
  48. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.1/evidence/f27def9.patch +0 -0
  49. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.1/evidence/manifest.json +0 -0
  50. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.1/range.json +0 -0
  51. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.2/audit-decisions.yaml +0 -0
  52. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.2/audit.yaml +0 -0
  53. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.2/entries.yaml +0 -0
  54. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.2/evidence/f88173e.patch +0 -0
  55. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.2/evidence/manifest.json +0 -0
  56. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.2/range.json +0 -0
  57. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.3/audit-decisions.yaml +0 -0
  58. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.3/audit.yaml +0 -0
  59. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.3/entries.yaml +0 -0
  60. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.3/evidence/8c9c937.patch +0 -0
  61. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.3/evidence/manifest.json +0 -0
  62. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/work/v0.1.3/range.json +0 -0
  63. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/releaseledger/write.lock +0 -0
  64. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/taskledger/.ledger-project.toml +0 -0
  65. {renderplan-0.1.3 → renderplan-0.1.4}/.ledger/taskledger/config.toml +0 -0
  66. {renderplan-0.1.3 → renderplan-0.1.4}/.pre-commit-config.yaml +0 -0
  67. {renderplan-0.1.3 → renderplan-0.1.4}/LICENSE +0 -0
  68. {renderplan-0.1.3 → renderplan-0.1.4}/docs/Makefile +0 -0
  69. {renderplan-0.1.3 → renderplan-0.1.4}/docs/conf.py +0 -0
  70. {renderplan-0.1.3 → renderplan-0.1.4}/docs/make.bat +0 -0
  71. {renderplan-0.1.3 → renderplan-0.1.4}/docs/make.py +0 -0
  72. {renderplan-0.1.3 → renderplan-0.1.4}/docs/requirements.txt +0 -0
  73. {renderplan-0.1.3 → renderplan-0.1.4}/examples/af_heart.renderprofile.toml +0 -0
  74. {renderplan-0.1.3 → renderplan-0.1.4}/examples/chapter.preflight.toml +0 -0
  75. {renderplan-0.1.3 → renderplan-0.1.4}/examples/chapter.render-manifest.toml +0 -0
  76. {renderplan-0.1.3 → renderplan-0.1.4}/examples/chapter.renderplan.toml +0 -0
  77. {renderplan-0.1.3 → renderplan-0.1.4}/examples/chapter.replan.preflight.toml +0 -0
  78. {renderplan-0.1.3 → renderplan-0.1.4}/pyproject.toml +0 -0
  79. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/__main__.py +0 -0
  80. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/_toml.py +0 -0
  81. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/cli.py +0 -0
  82. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/codec.py +0 -0
  83. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/collection.py +0 -0
  84. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/errors.py +0 -0
  85. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/identity.py +0 -0
  86. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/paths.py +0 -0
  87. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/py.typed +0 -0
  88. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/set.py +0 -0
  89. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/validation.py +0 -0
  90. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan/version.py +0 -0
  91. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan.egg-info/dependency_links.txt +0 -0
  92. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan.egg-info/entry_points.txt +0 -0
  93. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan.egg-info/requires.txt +0 -0
  94. {renderplan-0.1.3 → renderplan-0.1.4}/renderplan.egg-info/top_level.txt +0 -0
  95. {renderplan-0.1.3 → renderplan-0.1.4}/setup.cfg +0 -0
  96. {renderplan-0.1.3 → renderplan-0.1.4}/tests/test_collection.py +0 -0
  97. {renderplan-0.1.3 → renderplan-0.1.4}/tests/test_profile_reidentification.py +0 -0
  98. {renderplan-0.1.3 → renderplan-0.1.4}/tests/test_renderplan_set.py +0 -0
@@ -22,3 +22,5 @@
22
22
  {"data":{"updated_rows":1},"event":"audit.updated","event_id":"event-0022","record_revisions":{"commit_audit_sheet":2},"release_version":"v0.1.3","schema_version":2}
23
23
  {"data":{"entry_ids":["entry-0001"],"synced_audit_rows":1},"event":"entry.batch_added","event_id":"event-0023","record_revisions":{"commit_audit_sheet":3,"entry:v0.1.3/entry-0001":1,"release:v0.1.3":3},"release_version":"v0.1.3","schema_version":2}
24
24
  {"data":{"released_at":"2026-10-25"},"event":"release.finalized","event_id":"event-0024","record_revisions":{"release:v0.1.3":4},"release_version":"v0.1.3","schema_version":2}
25
+ {"data":{"status":"planned"},"event":"release.created","event_id":"event-0025","record_revisions":{"release:v0.1.4":1},"release_version":"v0.1.4","schema_version":2}
26
+ {"data":{"kind":"added","status":"accepted"},"entry_id":"entry-0001","event":"entry.added","event_id":"event-0026","record_revisions":{"entry:v0.1.4/entry-0001":1,"release:v0.1.4":2},"release_version":"v0.1.4","schema_version":2}
@@ -0,0 +1,29 @@
1
+ ---
2
+ schema_version: 2
3
+ object_type: release_entry
4
+ versioning:
5
+ schema_version: 1
6
+ revision: 1
7
+ entry_id: entry-0001
8
+ release_version: v0.1.4
9
+ kind: added
10
+ summary: Added portable character interpretation semantics to speech controls
11
+ status: accepted
12
+ audience: null
13
+ scopes: []
14
+ source_refs:
15
+ - tl:task-0004
16
+ paths:
17
+ - renderplan/model.py
18
+ - renderplan/contracts.py
19
+ - README.md
20
+ issues: []
21
+ prs: []
22
+ sources: []
23
+ contributors: []
24
+ breaking: false
25
+ internal: false
26
+ order: 1
27
+ ---
28
+
29
+ RenderPlan formalizes say_as parameter validation while preserving exact request text and leaving engine-specific realization to renderer preflight.
@@ -0,0 +1,27 @@
1
+ ---
2
+ schema_version: 2
3
+ object_type: release
4
+ versioning:
5
+ schema_version: 1
6
+ revision: 2
7
+ version: v0.1.4
8
+ status: planned
9
+ history_state: curated
10
+ title: null
11
+ released_at: null
12
+ previous_version: v0.1.3
13
+ cancel_reason: null
14
+ superseded_by: null
15
+ changelog_file: docs/changelog.md
16
+ boundary_ref: null
17
+ source_refs: []
18
+ source_count: null
19
+ entry_count: 1
20
+ artifact_count: 0
21
+ git_base_ref: null
22
+ git_base_sha: null
23
+ git_head_ref: null
24
+ git_head_sha: null
25
+ git_range: null
26
+ git_commit_count: null
27
+ ---
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: renderplan
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Engine-neutral render profile, plan, preflight, manifest, and collection contracts for speech pipelines.
5
5
  Classifier: Development Status :: 3 - Alpha
6
6
  Classifier: Programming Language :: Python :: 3
@@ -90,6 +90,26 @@ The destination start offset of source span `N` inside the request is the sum of
90
90
 
91
91
  `SpeechControl` is request-local and renderer-neutral: it carries `start`, `end`, `name`, and TOML-safe `params`. Initial semantic names include `pronunciation`, `prosody`, `emphasis`, `say_as`, and `extension:<name>`. Controls participate in `segment_id`; renderers decide support, and unsupported controls must fail preflight rather than be ignored. Control ranges may overlap only when the source semantics permit it. Engine-specific voice selection is resolved into the opaque logical role/binding; audio directives become `AudioItem`s.
92
92
 
93
+ ### Standard `say_as` semantics
94
+
95
+ `SpeechControl(name="say_as")` requires a non-empty `interpret_as` string and accepts optional non-empty `format` and `detail` strings. The standard value `interpret_as = "characters"` means: speak the covered text as a sequence of separately pronounced characters, preserving the surrounding request as one continuous speech request when the engine can do so. It is portable intent; RenderPlan does not dictate an engine's realization or restrict future `interpret_as` values.
96
+
97
+ For example, the following control covers exactly `A R T`:
98
+
99
+ ```toml
100
+ [[flow.control]]
101
+ start = 0
102
+ end = 5
103
+ name = "say_as"
104
+
105
+ [flow.control.params]
106
+ interpret_as = "characters"
107
+ ```
108
+
109
+ The exact-range invariant is `speech.text[0:5] == "A R T"`. The control does not rewrite the canonical request text, prescribe spaces, hyphens, commas, SSML, phonemes, or pauses, and does not split a sentence or add a `PauseItem`. A renderer that does not support the requested semantics must reject the request during preflight rather than silently ignore the control.
110
+
111
+ Do not store `A- R- T` in RenderPlan merely because one Kokoro frontend prefers that spelling; an engine may choose such a frontend-specific realization internally.
112
+
93
113
  Bindings copied into plans must be self-contained enough to execute without reopening the RenderProfile. This contract does not define renderer-specific path keys or local-asset resolution.
94
114
  This schema has no generic asset-reference type yet. A VoiceRender profile producer must refuse bindings that depend on profile-local or otherwise uncopied asset state; do not introduce renderer-specific asset-path keys into this shared contract.
95
115
 
@@ -67,6 +67,26 @@ The destination start offset of source span `N` inside the request is the sum of
67
67
 
68
68
  `SpeechControl` is request-local and renderer-neutral: it carries `start`, `end`, `name`, and TOML-safe `params`. Initial semantic names include `pronunciation`, `prosody`, `emphasis`, `say_as`, and `extension:<name>`. Controls participate in `segment_id`; renderers decide support, and unsupported controls must fail preflight rather than be ignored. Control ranges may overlap only when the source semantics permit it. Engine-specific voice selection is resolved into the opaque logical role/binding; audio directives become `AudioItem`s.
69
69
 
70
+ ### Standard `say_as` semantics
71
+
72
+ `SpeechControl(name="say_as")` requires a non-empty `interpret_as` string and accepts optional non-empty `format` and `detail` strings. The standard value `interpret_as = "characters"` means: speak the covered text as a sequence of separately pronounced characters, preserving the surrounding request as one continuous speech request when the engine can do so. It is portable intent; RenderPlan does not dictate an engine's realization or restrict future `interpret_as` values.
73
+
74
+ For example, the following control covers exactly `A R T`:
75
+
76
+ ```toml
77
+ [[flow.control]]
78
+ start = 0
79
+ end = 5
80
+ name = "say_as"
81
+
82
+ [flow.control.params]
83
+ interpret_as = "characters"
84
+ ```
85
+
86
+ The exact-range invariant is `speech.text[0:5] == "A R T"`. The control does not rewrite the canonical request text, prescribe spaces, hyphens, commas, SSML, phonemes, or pauses, and does not split a sentence or add a `PauseItem`. A renderer that does not support the requested semantics must reject the request during preflight rather than silently ignore the control.
87
+
88
+ Do not store `A- R- T` in RenderPlan merely because one Kokoro frontend prefers that spelling; an engine may choose such a frontend-specific realization internally.
89
+
70
90
  Bindings copied into plans must be self-contained enough to execute without reopening the RenderProfile. This contract does not define renderer-specific path keys or local-asset resolution.
71
91
  This schema has no generic asset-reference type yet. A VoiceRender profile producer must refuse bindings that depend on profile-local or otherwise uncopied asset state; do not introduce renderer-specific asset-path keys into this shared contract.
72
92
 
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.1.4] - Unreleased
4
+
5
+ ### Added
6
+
7
+ - Added portable character interpretation semantics to speech controls
8
+
9
+ <!-- generated by releaseledger -->
10
+
3
11
  ## [v0.1.3] - 2026-10-25
4
12
 
5
13
  ### Added
@@ -24,7 +24,15 @@ from .collection import (
24
24
  validate_renderplan_set,
25
25
  validate_renderplan_set_profile,
26
26
  )
27
- from .contracts import CONTRACTS, supported_contracts
27
+ from .contracts import (
28
+ CONTRACTS,
29
+ CONTROL_EMPHASIS,
30
+ CONTROL_PRONUNCIATION,
31
+ CONTROL_PROSODY,
32
+ CONTROL_SAY_AS,
33
+ SAY_AS_CHARACTERS,
34
+ supported_contracts,
35
+ )
28
36
  from .errors import CodecError, RenderPlanError, ValidationError
29
37
  from .model import (
30
38
  MANIFEST_FORMAT,
@@ -66,12 +74,17 @@ from .version import __version__
66
74
 
67
75
  __all__ = [
68
76
  "CONTRACTS",
77
+ "CONTROL_EMPHASIS",
78
+ "CONTROL_PRONUNCIATION",
79
+ "CONTROL_PROSODY",
80
+ "CONTROL_SAY_AS",
69
81
  "MANIFEST_FORMAT",
70
82
  "PLAN_FORMAT",
71
83
  "PREFLIGHT_FORMAT",
72
84
  "PROFILE_FORMAT",
73
85
  "RENDERPLAN_SET_FORMAT",
74
86
  "RENDERPLAN_SET_VERSION",
87
+ "SAY_AS_CHARACTERS",
75
88
  "AudioItem",
76
89
  "CapacityConstraint",
77
90
  "CodecError",
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.3'
22
- __version_tuple__ = version_tuple = (0, 1, 3)
21
+ __version__ = version = '0.1.4'
22
+ __version_tuple__ = version_tuple = (0, 1, 4)
23
23
 
24
- __commit_id__ = commit_id = 'g3d1e5588a'
24
+ __commit_id__ = commit_id = 'ga434ece68'
@@ -25,9 +25,25 @@ CONTRACTS: Mapping[str, int] = MappingProxyType(
25
25
  )
26
26
 
27
27
 
28
+ CONTROL_PRONUNCIATION = "pronunciation"
29
+ CONTROL_PROSODY = "prosody"
30
+ CONTROL_EMPHASIS = "emphasis"
31
+ CONTROL_SAY_AS = "say_as"
32
+
33
+ SAY_AS_CHARACTERS = "characters"
34
+
35
+
28
36
  def supported_contracts() -> Mapping[str, int]:
29
37
  """Return the immutable mapping of supported document formats to versions."""
30
38
  return CONTRACTS
31
39
 
32
40
 
33
- __all__ = ["CONTRACTS", "supported_contracts"]
41
+ __all__ = [
42
+ "CONTRACTS",
43
+ "CONTROL_EMPHASIS",
44
+ "CONTROL_PRONUNCIATION",
45
+ "CONTROL_PROSODY",
46
+ "CONTROL_SAY_AS",
47
+ "SAY_AS_CHARACTERS",
48
+ "supported_contracts",
49
+ ]
@@ -247,6 +247,7 @@ class SpeechControl:
247
247
  raise ValidationError("control must satisfy 0 <= start < end")
248
248
  require_nonempty(self.name, "control.name")
249
249
  require_toml_value(self.params, "control.params")
250
+ validate_standard_control(self)
250
251
 
251
252
  def ordering_key(self) -> tuple[int, int, str, bytes]:
252
253
  return (self.start, self.end, self.name, canonical_json_bytes(self.params))
@@ -260,6 +261,28 @@ class SpeechControl:
260
261
  }
261
262
 
262
263
 
264
+ def validate_standard_control(control: SpeechControl) -> None:
265
+ """Validate parameters for known standard speech controls."""
266
+ if control.name != "say_as":
267
+ return
268
+
269
+ allowed = {"interpret_as", "format", "detail"}
270
+ unknown = set(control.params).difference(allowed)
271
+ if unknown:
272
+ raise ValidationError(
273
+ "say_as control has unsupported parameter(s): " + ", ".join(sorted(unknown))
274
+ )
275
+
276
+ interpret_as = control.params.get("interpret_as")
277
+ if not isinstance(interpret_as, str) or not interpret_as.strip():
278
+ raise ValidationError("say_as.interpret_as must be a non-empty string")
279
+
280
+ for name in ("format", "detail"):
281
+ value = control.params.get(name)
282
+ if value is not None and (not isinstance(value, str) or not value.strip()):
283
+ raise ValidationError(f"say_as.{name} must be a non-empty string when present")
284
+
285
+
263
286
  @dataclass(frozen=True, slots=True)
264
287
  class TokenBundle:
265
288
  """Dense token spans/pos/tag and sparse, token-indexed lemma/morph values."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: renderplan
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Engine-neutral render profile, plan, preflight, manifest, and collection contracts for speech pipelines.
5
5
  Classifier: Development Status :: 3 - Alpha
6
6
  Classifier: Programming Language :: Python :: 3
@@ -90,6 +90,26 @@ The destination start offset of source span `N` inside the request is the sum of
90
90
 
91
91
  `SpeechControl` is request-local and renderer-neutral: it carries `start`, `end`, `name`, and TOML-safe `params`. Initial semantic names include `pronunciation`, `prosody`, `emphasis`, `say_as`, and `extension:<name>`. Controls participate in `segment_id`; renderers decide support, and unsupported controls must fail preflight rather than be ignored. Control ranges may overlap only when the source semantics permit it. Engine-specific voice selection is resolved into the opaque logical role/binding; audio directives become `AudioItem`s.
92
92
 
93
+ ### Standard `say_as` semantics
94
+
95
+ `SpeechControl(name="say_as")` requires a non-empty `interpret_as` string and accepts optional non-empty `format` and `detail` strings. The standard value `interpret_as = "characters"` means: speak the covered text as a sequence of separately pronounced characters, preserving the surrounding request as one continuous speech request when the engine can do so. It is portable intent; RenderPlan does not dictate an engine's realization or restrict future `interpret_as` values.
96
+
97
+ For example, the following control covers exactly `A R T`:
98
+
99
+ ```toml
100
+ [[flow.control]]
101
+ start = 0
102
+ end = 5
103
+ name = "say_as"
104
+
105
+ [flow.control.params]
106
+ interpret_as = "characters"
107
+ ```
108
+
109
+ The exact-range invariant is `speech.text[0:5] == "A R T"`. The control does not rewrite the canonical request text, prescribe spaces, hyphens, commas, SSML, phonemes, or pauses, and does not split a sentence or add a `PauseItem`. A renderer that does not support the requested semantics must reject the request during preflight rather than silently ignore the control.
110
+
111
+ Do not store `A- R- T` in RenderPlan merely because one Kokoro frontend prefers that spelling; an engine may choose such a frontend-specific realization internally.
112
+
93
113
  Bindings copied into plans must be self-contained enough to execute without reopening the RenderProfile. This contract does not define renderer-specific path keys or local-asset resolution.
94
114
  This schema has no generic asset-reference type yet. A VoiceRender profile producer must refuse bindings that depend on profile-local or otherwise uncopied asset state; do not introduce renderer-specific asset-path keys into this shared contract.
95
115
 
@@ -30,6 +30,8 @@ pyproject.toml
30
30
  .ledger/releaseledger/data/ledgers/main/releases/v0.1.3/release.md
31
31
  .ledger/releaseledger/data/ledgers/main/releases/v0.1.3/audit/commit-audit.yaml
32
32
  .ledger/releaseledger/data/ledgers/main/releases/v0.1.3/entries/entry-0001.md
33
+ .ledger/releaseledger/data/ledgers/main/releases/v0.1.4/release.md
34
+ .ledger/releaseledger/data/ledgers/main/releases/v0.1.4/entries/entry-0001.md
33
35
  .ledger/releaseledger/work/v0.1.1/audit-decisions.yaml
34
36
  .ledger/releaseledger/work/v0.1.1/audit.yaml
35
37
  .ledger/releaseledger/work/v0.1.1/entries.yaml
@@ -27,6 +27,8 @@
27
27
  ".ledger/releaseledger/data/ledgers/main/releases/v0.1.3/audit/commit-audit.yaml",
28
28
  ".ledger/releaseledger/data/ledgers/main/releases/v0.1.3/entries/entry-0001.md",
29
29
  ".ledger/releaseledger/data/ledgers/main/releases/v0.1.3/release.md",
30
+ ".ledger/releaseledger/data/ledgers/main/releases/v0.1.4/entries/entry-0001.md",
31
+ ".ledger/releaseledger/data/ledgers/main/releases/v0.1.4/release.md",
30
32
  ".ledger/releaseledger/work/v0.1.1/audit-decisions.yaml",
31
33
  ".ledger/releaseledger/work/v0.1.1/audit.yaml",
32
34
  ".ledger/releaseledger/work/v0.1.1/entries.yaml",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.1.4",
3
+ "distance": 0,
4
+ "node": "ga434ece686229c4accf193c4fdc8dd4357039586",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-28"
8
+ }
@@ -9,6 +9,11 @@ import pytest
9
9
 
10
10
  from renderplan import (
11
11
  CONTRACTS,
12
+ CONTROL_EMPHASIS,
13
+ CONTROL_PRONUNCIATION,
14
+ CONTROL_PROSODY,
15
+ CONTROL_SAY_AS,
16
+ SAY_AS_CHARACTERS,
12
17
  AudioItem,
13
18
  CapacityConstraint,
14
19
  CodecError,
@@ -218,6 +223,83 @@ def test_renderplan_roundtrip_with_controls_and_generic_binding(tmp_path: Path)
218
223
  assert loaded.speech[0].controls[0].params == {"level": "moderate"}
219
224
 
220
225
 
226
+ @pytest.mark.parametrize(
227
+ "params",
228
+ [
229
+ {},
230
+ {"format": "letters"},
231
+ {"interpret_as": ""},
232
+ {"interpret_as": 1},
233
+ {"interpret_as": "characters", "format": ""},
234
+ {"interpret_as": "characters", "detail": False},
235
+ {"interpret_as": "characters", "detail": ""},
236
+ {"interpret_as": "characters", "unexpected": "value"},
237
+ ],
238
+ )
239
+ def test_say_as_control_rejects_invalid_standard_parameters(params: dict[str, Any]) -> None:
240
+ with pytest.raises(ValidationError, match="say_as"):
241
+ SpeechControl(0, 5, "say_as", params)
242
+
243
+
244
+ def test_say_as_standard_constants_and_future_interpretations() -> None:
245
+ assert CONTROL_SAY_AS == "say_as"
246
+ assert CONTROL_EMPHASIS == "emphasis"
247
+ assert CONTROL_PRONUNCIATION == "pronunciation"
248
+ assert CONTROL_PROSODY == "prosody"
249
+ assert SAY_AS_CHARACTERS == "characters"
250
+ control = SpeechControl(
251
+ 0,
252
+ 5,
253
+ CONTROL_SAY_AS,
254
+ {"interpret_as": "date", "format": "dd.mm.yyyy", "detail": "1"},
255
+ )
256
+ assert control.params["interpret_as"] == "date"
257
+
258
+
259
+ def test_say_as_characters_roundtrip_preserves_sentence_text_and_flow(tmp_path: Path) -> None:
260
+ text = "Because someone called A R T finally."
261
+ start = text.index("A R T")
262
+ control = SpeechControl(
263
+ start,
264
+ start + len("A R T"),
265
+ CONTROL_SAY_AS,
266
+ {"interpret_as": SAY_AS_CHARACTERS},
267
+ )
268
+ value = plan(speech(text=text, controls=(control,)))
269
+ path = tmp_path / "say-as.renderplan.toml"
270
+ save_plan(value, path)
271
+ loaded = load_plan(path)
272
+
273
+ assert len(loaded.flow) == 1
274
+ assert len(loaded.speech) == 1
275
+ restored = loaded.speech[0]
276
+ restored_control = restored.controls[0]
277
+ assert restored.text == text
278
+ assert restored.text[restored_control.start : restored_control.end] == "A R T"
279
+ assert restored_control.name == CONTROL_SAY_AS
280
+ assert restored_control.params["interpret_as"] == SAY_AS_CHARACTERS
281
+ assert restored.planning_level == "sentence"
282
+ assert not any(isinstance(item, PauseItem) for item in loaded.flow)
283
+ assert "A- R- T" not in path.read_text(encoding="utf-8")
284
+
285
+
286
+ @pytest.mark.parametrize(
287
+ ("text", "possessive"),
288
+ [
289
+ ("A R T's house is nice.", "'s"),
290
+ ("A R T’s house is nice.", "’s"),
291
+ ],
292
+ )
293
+ def test_say_as_control_excludes_possessive_suffix(text: str, possessive: str) -> None:
294
+ start = text.index("A R T")
295
+ end = start + len("A R T")
296
+ control = SpeechControl(start, end, CONTROL_SAY_AS, {"interpret_as": SAY_AS_CHARACTERS})
297
+ item = speech(text=text, controls=(control,))
298
+
299
+ assert item.text[control.start : control.end] == "A R T"
300
+ assert item.text[control.end :].startswith(possessive)
301
+
302
+
221
303
  def test_leading_pause_is_legal() -> None:
222
304
  item = speech()
223
305
  value = plan(flow=(PauseItem(120, "authored"), item))
@@ -7,6 +7,7 @@ from renderplan import (
7
7
  RenderProfile,
8
8
  RoleProfile,
9
9
  SourceSpan,
10
+ SpeechControl,
10
11
  SpeechItem,
11
12
  )
12
13
 
@@ -43,6 +44,23 @@ def test_speech_request_identity_excludes_number_and_provenance() -> None:
43
44
  assert first.segment_id == renumbered.segment_id == different_source.segment_id
44
45
 
45
46
 
47
+ def test_say_as_control_changes_speech_request_identity() -> None:
48
+ plain = replace(
49
+ speech(),
50
+ text="A R T",
51
+ source=(SourceSpan("f000000.s000", 0, 5),),
52
+ segment_id=None,
53
+ ).with_identity()
54
+ controlled = replace(
55
+ plain,
56
+ controls=(SpeechControl(0, 5, "say_as", {"interpret_as": "characters"}),),
57
+ segment_id=None,
58
+ ).with_identity()
59
+
60
+ assert plain.text == controlled.text == "A R T"
61
+ assert plain.segment_id != controlled.segment_id
62
+
63
+
46
64
  def test_pause_is_part_of_plan_identity() -> None:
47
65
  item = speech()
48
66
  p1 = RenderPlan(SOURCE, PROFILE_ID, (item, PauseItem(100, "sentence"))).with_identity()
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.1.3",
3
- "distance": 0,
4
- "node": "g3d1e5588ab31de2474b94f84e17e9a44721ae3d2",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-10-25"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes