utterplan 0.5.0__tar.gz → 0.5.1__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 (199) hide show
  1. {utterplan-0.5.0 → utterplan-0.5.1}/PKG-INFO +1 -1
  2. {utterplan-0.5.0 → utterplan-0.5.1}/docs/architecture.md +6 -0
  3. {utterplan-0.5.0 → utterplan-0.5.1}/docs/changelog.md +12 -0
  4. {utterplan-0.5.0 → utterplan-0.5.1}/docs/coordinate-spaces.md +4 -0
  5. {utterplan-0.5.0 → utterplan-0.5.1}/docs/debugging.md +2 -0
  6. {utterplan-0.5.0 → utterplan-0.5.1}/docs/format.md +6 -0
  7. {utterplan-0.5.0 → utterplan-0.5.1}/docs/python-api.md +6 -0
  8. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/goldens/multilingual_preparation.json +2 -2
  9. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/goldens/ssmd_break.json +1 -1
  10. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_cli.py +6 -4
  11. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_cli_batch.py +8 -4
  12. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_cli_ssmdbook.py +39 -0
  13. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_consumer_contract.py +5 -2
  14. utterplan-0.5.1/tests/test_flow_compilation.py +220 -0
  15. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_linguistics.py +3 -16
  16. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_planner.py +27 -11
  17. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_renderability_repair.py +23 -28
  18. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_segment_topology.py +32 -7
  19. utterplan-0.5.1/tests/test_segmentation_adapter.py +203 -0
  20. utterplan-0.5.1/tests/test_segmentation_recovery.py +114 -0
  21. utterplan-0.5.1/tests/test_sentence_topology.py +361 -0
  22. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_ssmd_09_parser.py +10 -1
  23. utterplan-0.5.1/tests/test_token_topology.py +73 -0
  24. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/_version.py +3 -3
  25. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/flow_projection.py +41 -11
  26. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/planner.py +255 -186
  27. utterplan-0.5.1/utterplan/segmentation.py +847 -0
  28. utterplan-0.5.1/utterplan/token_topology.py +102 -0
  29. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan.egg-info/PKG-INFO +1 -1
  30. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan.egg-info/SOURCES.txt +6 -0
  31. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan.egg-info/scm_file_list.json +17 -0
  32. utterplan-0.5.1/utterplan.egg-info/scm_version.json +8 -0
  33. utterplan-0.5.0/tests/test_flow_compilation.py +0 -97
  34. utterplan-0.5.0/utterplan.egg-info/scm_version.json +0 -8
  35. {utterplan-0.5.0 → utterplan-0.5.1}/.codecrate.toml +0 -0
  36. {utterplan-0.5.0 → utterplan-0.5.1}/.github/workflows/codecov.yml +0 -0
  37. {utterplan-0.5.0 → utterplan-0.5.1}/.github/workflows/pre-commit.yml +0 -0
  38. {utterplan-0.5.0 → utterplan-0.5.1}/.github/workflows/python-publish.yml +0 -0
  39. {utterplan-0.5.0 → utterplan-0.5.1}/.github/workflows/tests.yml +0 -0
  40. {utterplan-0.5.0 → utterplan-0.5.1}/.gitignore +0 -0
  41. {utterplan-0.5.0 → utterplan-0.5.1}/.pre-commit-config.yaml +0 -0
  42. {utterplan-0.5.0 → utterplan-0.5.1}/AGENTS.md +0 -0
  43. {utterplan-0.5.0 → utterplan-0.5.1}/LICENSE +0 -0
  44. {utterplan-0.5.0 → utterplan-0.5.1}/MANIFEST.in +0 -0
  45. {utterplan-0.5.0 → utterplan-0.5.1}/NOTICE +0 -0
  46. {utterplan-0.5.0 → utterplan-0.5.1}/README.md +0 -0
  47. {utterplan-0.5.0 → utterplan-0.5.1}/docs/Makefile +0 -0
  48. {utterplan-0.5.0 → utterplan-0.5.1}/docs/cli.md +0 -0
  49. {utterplan-0.5.0 → utterplan-0.5.1}/docs/conf.py +0 -0
  50. {utterplan-0.5.0 → utterplan-0.5.1}/docs/consumer-guide.md +0 -0
  51. {utterplan-0.5.0 → utterplan-0.5.1}/docs/getting-started.md +0 -0
  52. {utterplan-0.5.0 → utterplan-0.5.1}/docs/index.md +0 -0
  53. {utterplan-0.5.0 → utterplan-0.5.1}/docs/make.bat +0 -0
  54. {utterplan-0.5.0 → utterplan-0.5.1}/docs/phase-a-completion.md +0 -0
  55. {utterplan-0.5.0 → utterplan-0.5.1}/docs/pykokoro-integration.md +0 -0
  56. {utterplan-0.5.0 → utterplan-0.5.1}/docs/requirements.txt +0 -0
  57. {utterplan-0.5.0 → utterplan-0.5.1}/docs/ssmdbook.md +0 -0
  58. {utterplan-0.5.0 → utterplan-0.5.1}/examples/basic.py +0 -0
  59. {utterplan-0.5.0 → utterplan-0.5.1}/examples/chapter.ssmd +0 -0
  60. {utterplan-0.5.0 → utterplan-0.5.1}/examples/compile_file.py +0 -0
  61. {utterplan-0.5.0 → utterplan-0.5.1}/examples/inspect_plan.py +0 -0
  62. {utterplan-0.5.0 → utterplan-0.5.1}/examples/multilingual.py +0 -0
  63. {utterplan-0.5.0 → utterplan-0.5.1}/examples/ssmd_example.py +0 -0
  64. {utterplan-0.5.0 → utterplan-0.5.1}/pyproject.toml +0 -0
  65. {utterplan-0.5.0 → utterplan-0.5.1}/setup.cfg +0 -0
  66. {utterplan-0.5.0 → utterplan-0.5.1}/spec/schemas/v1.schema.json +0 -0
  67. {utterplan-0.5.0 → utterplan-0.5.1}/spec/schemas/v2.schema.json +0 -0
  68. {utterplan-0.5.0 → utterplan-0.5.1}/spec/schemas/v3.schema.json +0 -0
  69. {utterplan-0.5.0 → utterplan-0.5.1}/spec/schemas/v4.schema.json +0 -0
  70. {utterplan-0.5.0 → utterplan-0.5.1}/spec/utterplan.schema.json +0 -0
  71. {utterplan-0.5.0 → utterplan-0.5.1}/tests/__init__.py +0 -0
  72. {utterplan-0.5.0 → utterplan-0.5.1}/tests/compiler_helpers.py +0 -0
  73. {utterplan-0.5.0 → utterplan-0.5.1}/tests/fixtures/canonical_consumer_contract.expected.toml +0 -0
  74. {utterplan-0.5.0 → utterplan-0.5.1}/tests/fixtures/canonical_consumer_contract.ssmd +0 -0
  75. {utterplan-0.5.0 → utterplan-0.5.1}/tests/fixtures/ssmd_09_comprehensive.ssmd +0 -0
  76. {utterplan-0.5.0 → utterplan-0.5.1}/tests/golden/basic_en.utterplan.toml +0 -0
  77. {utterplan-0.5.0 → utterplan-0.5.1}/tests/golden/directives.utterplan.toml +0 -0
  78. {utterplan-0.5.0 → utterplan-0.5.1}/tests/golden/markers.utterplan.toml +0 -0
  79. {utterplan-0.5.0 → utterplan-0.5.1}/tests/golden/multilingual.utterplan.toml +0 -0
  80. {utterplan-0.5.0 → utterplan-0.5.1}/tests/golden/parenthetical.utterplan.toml +0 -0
  81. {utterplan-0.5.0 → utterplan-0.5.1}/tests/golden/spokenform_offsets.utterplan.toml +0 -0
  82. {utterplan-0.5.0 → utterplan-0.5.1}/tests/golden/ssmd_09_comprehensive.utterplan.toml +0 -0
  83. {utterplan-0.5.0 → utterplan-0.5.1}/tests/golden/ssmd_breaks.utterplan.toml +0 -0
  84. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/__init__.py +0 -0
  85. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/cases.py +0 -0
  86. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/fixtures/v4/basic_en.utterplan.json +0 -0
  87. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/fixtures/v4/directives.utterplan.json +0 -0
  88. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/fixtures/v4/markers.utterplan.json +0 -0
  89. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/fixtures/v4/multilingual.utterplan.json +0 -0
  90. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/fixtures/v4/parenthetical.utterplan.json +0 -0
  91. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/fixtures/v4/spokenform_offsets.utterplan.json +0 -0
  92. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/fixtures/v4/ssmd_09_comprehensive.utterplan.json +0 -0
  93. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/fixtures/v4/ssmd_breaks.utterplan.json +0 -0
  94. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/goldens/spokenform_numbers.json +0 -0
  95. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/goldens/ssmd_language_detection.json +0 -0
  96. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/goldens/ssmd_marker.json +0 -0
  97. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/goldens/ssmd_pronunciation.json +0 -0
  98. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/test_legacy_json_import.py +0 -0
  99. {utterplan-0.5.0 → utterplan-0.5.1}/tests/migration/test_semantic_migration_regressions.py +0 -0
  100. {utterplan-0.5.0 → utterplan-0.5.1}/tests/schema_history/README.md +0 -0
  101. {utterplan-0.5.0 → utterplan-0.5.1}/tests/schema_history/v1/basic.json +0 -0
  102. {utterplan-0.5.0 → utterplan-0.5.1}/tests/schema_history/v1/directives.json +0 -0
  103. {utterplan-0.5.0 → utterplan-0.5.1}/tests/schema_history/v1/markers.json +0 -0
  104. {utterplan-0.5.0 → utterplan-0.5.1}/tests/schema_history/v1/multilingual.json +0 -0
  105. {utterplan-0.5.0 → utterplan-0.5.1}/tests/schema_history/v1/parenthetical.json +0 -0
  106. {utterplan-0.5.0 → utterplan-0.5.1}/tests/schema_history/v1/spokenform_offsets.json +0 -0
  107. {utterplan-0.5.0 → utterplan-0.5.1}/tests/schema_history/v1/ssmd_breaks.json +0 -0
  108. {utterplan-0.5.0 → utterplan-0.5.1}/tests/schema_history/v2/directives.json +0 -0
  109. {utterplan-0.5.0 → utterplan-0.5.1}/tests/schema_history/v3/multilingual.json +0 -0
  110. {utterplan-0.5.0 → utterplan-0.5.1}/tests/schema_history/v3/ssmd_09_comprehensive.json +0 -0
  111. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_atomic_io.py +0 -0
  112. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_batch.py +0 -0
  113. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_boundaries.py +0 -0
  114. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_compiler.py +0 -0
  115. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_consumer_readiness.py +0 -0
  116. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_coordinates.py +0 -0
  117. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_directives.py +0 -0
  118. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_explain.py +0 -0
  119. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_fake_renderer.py +0 -0
  120. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_format.py +0 -0
  121. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_golden.py +0 -0
  122. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_identity_units.py +0 -0
  123. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_import_boundaries.py +0 -0
  124. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_language.py +0 -0
  125. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_migration_framework.py +0 -0
  126. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_migration_v3_to_v4.py +0 -0
  127. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_migration_v4_to_v5.py +0 -0
  128. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_pauses.py +0 -0
  129. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_preparation.py +0 -0
  130. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_progress.py +0 -0
  131. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_public_identity.py +0 -0
  132. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_renderability.py +0 -0
  133. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_schema_history.py +0 -0
  134. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_schema_registry.py +0 -0
  135. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_schema_strict.py +0 -0
  136. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_semantic_boundaries.py +0 -0
  137. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_ssmdbook.py +0 -0
  138. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_toml_format.py +0 -0
  139. {utterplan-0.5.0 → utterplan-0.5.1}/tests/test_version.py +0 -0
  140. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/__init__.py +0 -0
  141. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/__main__.py +0 -0
  142. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/atomic_io.py +0 -0
  143. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/attempt_serialization.py +0 -0
  144. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/attempts.py +0 -0
  145. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/batch.py +0 -0
  146. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/cli.py +0 -0
  147. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/codecs/__init__.py +0 -0
  148. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/codecs/v4_toml.py +0 -0
  149. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/codecs/v5_toml.py +0 -0
  150. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/compiler.py +0 -0
  151. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/config.py +0 -0
  152. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/diagnostics.py +0 -0
  153. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/directives.py +0 -0
  154. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/exceptions.py +0 -0
  155. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/explain.py +0 -0
  156. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/format.py +0 -0
  157. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/hashing.py +0 -0
  158. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/language.py +0 -0
  159. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/linguistics.py +0 -0
  160. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/migration.py +0 -0
  161. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/migrations/__init__.py +0 -0
  162. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/migrations/registry.py +0 -0
  163. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/migrations/v1_to_v2.py +0 -0
  164. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/migrations/v2_to_v3.py +0 -0
  165. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/migrations/v3_to_v4.py +0 -0
  166. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/migrations/v4_to_v5.py +0 -0
  167. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/model.py +0 -0
  168. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/parsers.py +0 -0
  169. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/pauses.py +0 -0
  170. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/preparation.py +0 -0
  171. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/progress.py +0 -0
  172. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/py.typed +0 -0
  173. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/renderability.py +0 -0
  174. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/schema_registry.py +0 -0
  175. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/schemas/v1.schema.json +0 -0
  176. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/schemas/v2.schema.json +0 -0
  177. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/schemas/v3.schema.json +0 -0
  178. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/schemas/v4.schema.json +0 -0
  179. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/serialization.py +0 -0
  180. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/spacy_models.py +0 -0
  181. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/spans.py +0 -0
  182. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/ssmdbook.py +0 -0
  183. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/stages/__init__.py +0 -0
  184. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/stages/document/__init__.py +0 -0
  185. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/stages/document/plain.py +0 -0
  186. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/stages/document/ssmd.py +0 -0
  187. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/stages/preparation/__init__.py +0 -0
  188. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/stages/preparation/identity.py +0 -0
  189. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/stages/preparation/spokenform.py +0 -0
  190. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/stages/protocols.py +0 -0
  191. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/toml_codec.py +0 -0
  192. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/trace_codec.py +0 -0
  193. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/units.py +0 -0
  194. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/utterplan.schema.json +0 -0
  195. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan/versioning.py +0 -0
  196. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan.egg-info/dependency_links.txt +0 -0
  197. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan.egg-info/entry_points.txt +0 -0
  198. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan.egg-info/requires.txt +0 -0
  199. {utterplan-0.5.0 → utterplan-0.5.1}/utterplan.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: utterplan
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: Engine-independent text-to-speech planning compiler and interchange format
5
5
  Author-email: Holger Nahrstaedt <nahrstaedt@gmail.com>
6
6
  License: Apache License
@@ -28,6 +28,12 @@ The dependency direction is from SSMD, spokenform, and phrasplit into UtterPlan,
28
28
 
29
29
  `FlowPlan` is the current v5 executable contract: ordered `FlowUnit` objects each own ordered `FlowSegment` records. Segments include prepared text, effective language, local tokens, semantic pause intents, directives, markers, and optional heading metadata. `DocumentInfo` retains compact portable document information; `LinguisticProvenance` identifies providers without retaining their documents. UtterPlan ends before G2P. Engine/model selection, synthesis, and audio composition happen downstream.
30
30
 
31
+ ## Compiler-owned segmentation topology
32
+
33
+ Segmentation has four distinct layers: parser-owned paragraph events define paragraph regions; PhraseSplit supplies advisory sentence-boundary proposals; UtterPlan validates those proposals and owns canonical paragraph, sentence, and private sentence-part spans in prepared spoken-text coordinates; renderer-required language, directive, pause, marker, and media cuts subdivide a part into final segments. Positive-width atomic media spans protect their interiors from automatic sentence/part boundaries; zero-width media events remain renderer operations, not parts. The public v5 `FlowPlan` contains only the resulting compact ordered flow—there is no serialized sentence-parts table.
34
+
35
+ PhraseSplit failure or an invalid proposal batch falls back to deterministic, conservative terminal scanning. Unsafe automatic cuts inside provider-aware lexical content are merged or dropped before projection; the final flow-projection token-edge check remains defense in depth. Exact surface and non-whitespace coverage checks run before and after applicable repairs. This normalization is independent of `renderability_mode`: strict and repair compilation use the same topology, while renderability mode governs only the existing punctuation-repair policy. See the [coordinate-space contract](coordinate-spaces) for offset ownership.
36
+
31
37
  ## SSMD and preparation
32
38
 
33
39
  The document parser calls SSMD with `dialect="0.9"` for every SSMD source path, including unversioned fragments selected with `document_format="ssmd"`. UtterPlan does not parse SSMD 0.8 or migrate source. Older documents must first be converted with `ssmd migrate FILE --to 0.9`. The `utterplan migrate` command imports serialized legacy plans and applies schema migrations only.
@@ -4,6 +4,18 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.1] - 2026-10-10
8
+
9
+ ### Changed
10
+
11
+ - Improved sentence topology with validated fallback recovery and token-edge safeguards
12
+
13
+ ### Fixed
14
+
15
+ - Fixed fallback token projection and sentence-boundary repair for punctuation-separated text
16
+
17
+ <!-- generated by releaseledger -->
18
+
7
19
  ## [0.5.0] - 2026-10-09
8
20
 
9
21
  ### Added
@@ -19,4 +19,8 @@ Trace coordinates use Python string character boundaries (Unicode code points),
19
19
  - `structural_to_spoken` maps structural character boundaries to spoken boundaries; `spoken_to_structural` maps spoken boundaries back to structural boundaries.
20
20
  - `PreparationTraceUnit.source_start` / `source_end` address structural text. `PreparationChange.source_start` / `source_end` use structural coordinates; `output_start` / `output_end` use prepared spoken coordinates.
21
21
 
22
+ ## Compiler-only segmentation coordinates
23
+
24
+ Paragraph, sentence, and sentence-part spans use half-open Python-character offsets into the prepared global `spoken_text`. PhraseSplit offsets are run-local and are translated to global spoken coordinates exactly once before validation. Renderer segments retain the exact `spoken_text[start:end]` slice; language-run edges and other required renderer cuts do not change their owning paragraph, sentence, or part index. These spans are compiler topology, not fields in the v5 flow. Segmentation diagnostics map their spoken ranges back to exact source coordinates through the compiler's source map.
25
+
22
26
  Diagnostic source offsets use the exact original-source coordinate space. Diagnostic `line` and `column` values are 1-based; columns count Python characters from the start of a line. The trace retains all these maps and source evidence. The executable FlowPlan intentionally does not.
@@ -17,6 +17,8 @@ utterplan inspect-trace chapter.trace.toml --preparation --boundaries
17
17
 
18
18
  Safe punctuation-only repair is the default during compilation. If a semantic blocker prevents repair, the error includes source location, prepared fragment, spoken context, blocked repair reason, and a concrete next action; UtterPlan never guesses symbol pronunciation. Use `--renderability strict` to see safe repair opportunities without applying them. Batch compilation reports each repair and continues with later sources.
19
19
 
20
+ Sentence segmentation is a compiler-integrity step, not a renderability repair. PhraseSplit is advisory: dependency failure or a malformed/partial proposal batch produces a `segmentation.phrasplit_*` diagnostic and triggers conservative fallback. Unsafe automatic token-edge candidates are dropped or merged before projection; exact text and non-whitespace coverage are checked in both strict and repair modes. Inspect compiler diagnostics with `compile --trace FILE` and `inspect-trace FILE --boundaries`; sentence-part topology itself is intentionally not persisted in v5.
21
+
20
22
  Use `inspect --tokens` to inspect local `TokenView` values. Fallback analysis may leave POS, tag, and morphology unavailable. `explain --details` shows token spans relative to the owning segment and its flow hash. Token spans always slice `segment.text`; they are not offsets into the original source or another segment.
21
23
 
22
24
  If spoken wording is wrong, inspect `FlowSegment.text`. If effective language is wrong, inspect the segment and `FlowPlan.linguistics`. If a pause seems missing, distinguish the semantic `PauseIntent` on the segment edge from the consumer's engine-specific timing policy. Exact authored timed breaks remain explicit; semantic strengths do not prescribe a duration.
@@ -24,6 +24,12 @@ text = "Hello world."
24
24
  token.span = [[0, 5], [6, 12]]
25
25
  ```
26
26
 
27
+ ## Compiler-only segmentation topology
28
+
29
+ The compiler validates PhraseSplit proposals and owns paragraph, sentence, and sentence-part spans in spoken-text coordinates. Semicolon/context-safe colon boundaries and optional clause/parenthetical proposals may create renderer cuts; language, directive, pause, marker, and media subdivisions retain the owning sentence-part identity internally. If proposals are malformed or incomplete, deterministic fallback is used and non-whitespace text remains covered exactly.
30
+
31
+ Sentence parts and their indices are deliberately absent from this v5 format. The `flow` contains only executable renderer segments and remains the compact public contract; no schema field, FlowPlan/FlowUnit/FlowSegment shape, or migration rule records private topology. Schema v5 is unchanged.
32
+
27
33
  ## Executable flow
28
34
 
29
35
  Each ordered `flow` entry is a `FlowUnit` with a stable local `hash` and one or more `segment` entries. A segment contains already-prepared speech `text`, an optional language override, local token columns, optional semantic `pause_before` / `pause_after` intents, typed renderer-neutral directives, markers, and an optional heading level. Language defaults to the root `language`; `unit` identifies paragraph or sentence grouping. Consumers render segments in the stored order and do not need compiler lookup tables or segment-ID joins.
@@ -12,6 +12,12 @@ The Python defaults are deliberately spaCy-free and repair-first: `PlannerConfig
12
12
  For an explicit contextual-G2P configuration, use `LinguisticsConfig(use_spacy=True, spacy_model="en_core_web_sm", require_spacy=True)`. The resulting plan records final pass-B token provenance in `linguistic_runs`; no provider document is retained.
13
13
  spaCy enrichment is opt-in through the CLI's `--spacy auto` policy or an explicit `LinguisticsConfig` with a compatible local model. It may provide richer tokenization, POS tags, lemmas, and tags, but UtterPlan never downloads a model implicitly.
14
14
 
15
+ ## Sentence segmentation and private topology
16
+
17
+ PhraseSplit is an advisory sentence-boundary provider, not an owner of compiler topology. UtterPlan validates each whole proposal batch, maps accepted run-local offsets once into prepared spoken-text coordinates, then recomputes paragraph and sentence indices from parser paragraph events and accepted boundaries. If PhraseSplit fails or returns malformed/incomplete coverage, deterministic conservative fallback preserves the text and emits diagnostics. Automatic cuts that divide provider-aware lexical content are discarded or merged; the final flow-projection token-edge check remains active.
18
+
19
+ Semicolon and context-safe colon separators, plus optional trusted clause/parenthetical candidates, form private sentence parts. Renderer-required language, directive, pause, marker, and media cuts subdivide those parts without changing their indices. Exact surface and non-whitespace coverage validation runs in strict and repair renderability modes alike. Parts are not a public field: only the compact ordered v5 `flow` is serialized, and `unit="sentence"` groups by the recomputed sentence topology.
20
+
15
21
  ## Canonical single-document compiler
16
22
 
17
23
  `compile_document` is the shared semantic interpretation boundary for independent
@@ -63,8 +63,8 @@
63
63
  "annotation_ids": []
64
64
  },
65
65
  {
66
- "text": "Bonjour.",
67
- "spoken_start": 6,
66
+ "text": " Bonjour.",
67
+ "spoken_start": 5,
68
68
  "spoken_end": 14,
69
69
  "language": "fr",
70
70
  "paragraph": 0,
@@ -60,7 +60,7 @@
60
60
  "language": "en-us",
61
61
  "paragraph": 0,
62
62
  "sentence": 0,
63
- "clause": 1,
63
+ "clause": 0,
64
64
  "pause_before": {
65
65
  "seconds": 0.0,
66
66
  "events": []
@@ -220,7 +220,7 @@ def test_compile_strict_renderability_reports_source_and_writes_no_plan(
220
220
  assert "Automatic repair: safe;" in captured.err
221
221
 
222
222
 
223
- def test_compile_repair_cli_reports_guaranteed_renderability(
223
+ def test_compile_repair_cli_blocks_unattachable_punctuation_without_loss(
224
224
  tmp_path: Path, capsys: pytest.CaptureFixture[str]
225
225
  ) -> None:
226
226
  source = tmp_path / "repair.txt"
@@ -244,9 +244,11 @@ def test_compile_repair_cli_reports_guaranteed_renderability(
244
244
  )
245
245
 
246
246
  captured = capsys.readouterr()
247
- assert result == 0
248
- assert FlowPlan.load(output).flow
249
- assert "renderability: guaranteed; 1 punctuation segment repaired" in captured.err
247
+ assert result == 1
248
+ assert not output.exists()
249
+ assert "isolated punctuation became its own speech segment" in captured.err
250
+ assert "Prepared fragment: '.'" in captured.err
251
+ assert "Automatic repair: safe; remove the isolated punctuation-only segment." in captured.err
250
252
 
251
253
 
252
254
  def test_top_level_version(capsys: pytest.CaptureFixture[str]) -> None:
@@ -237,7 +237,7 @@ def test_compile_many_protects_existing_output_and_force_replaces_it(
237
237
  )
238
238
 
239
239
 
240
- def test_compile_many_default_repair_and_strict_mode_are_both_observable(
240
+ def test_compile_many_repair_and_strict_modes_preserve_unattachable_punctuation(
241
241
  tmp_path: Path, capsys: pytest.CaptureFixture[str]
242
242
  ) -> None:
243
243
  source = _write(tmp_path / "chapter.txt", "Hello.\n\n.\n\nWorld.")
@@ -257,9 +257,13 @@ def test_compile_many_default_repair_and_strict_mode_are_both_observable(
257
257
  ]
258
258
  )
259
259
  repair_output = capsys.readouterr()
260
- assert repair_result == 0
261
- assert "repaired at 3:1" in repair_output.err
262
- assert "removing the punctuation-only segment" in repair_output.err
260
+ assert repair_result == 1
261
+ assert "isolated punctuation became its own speech segment" in repair_output.err
262
+ assert "Prepared fragment: '.'" in repair_output.err
263
+ assert (
264
+ "Automatic repair: safe; remove the isolated punctuation-only segment." in repair_output.err
265
+ )
266
+ assert not (repair_dir / "chapter.utterplan.toml").exists()
263
267
 
264
268
  strict_dir = tmp_path / "strict"
265
269
  strict_result = main(
@@ -429,3 +429,42 @@ def test_report_path_collision_with_plan_output_is_preflighted(
429
429
  assert result == 2
430
430
  assert "report path collides with plan output" in captured.err
431
431
  assert not output_dir.exists()
432
+
433
+
434
+ def test_compile_book_localizes_fallback_boundaries_with_default_settings(
435
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
436
+ ) -> None:
437
+ root = tmp_path / "fallback-boundaries.ssmdbook"
438
+ _write_book(
439
+ root,
440
+ (5, 6),
441
+ contents={
442
+ 5: _ssmd('[alpha]{emphasis="strong"}—beta'),
443
+ 6: _ssmd('alpha—[beta]{emphasis="strong"}'),
444
+ },
445
+ )
446
+
447
+ result = main(["compile-book", str(root), "--chapters", "5-6"])
448
+ capsys.readouterr()
449
+
450
+ assert result == 0
451
+ output_dir = root / "utterplan"
452
+ report = _report(output_dir / "compile-report.toml")
453
+ assert report["requested"] == 2
454
+ assert report["written"] == 2
455
+ assert report["failed"] == 0
456
+ assert report["skipped"] == 0
457
+ assert report["complete"] is True
458
+
459
+ for number, expected_surface in ((5, "alpha"), (6, "beta")):
460
+ plan_path = output_dir / f"chapter-{number:04d}.utterplan.toml"
461
+ assert plan_path.exists()
462
+ plan = FlowPlan.load(plan_path)
463
+ assert plan.linguistics[0].provider == "fallback"
464
+ surfaces = {
465
+ segment.text[token.start : token.end]
466
+ for unit in plan.flow
467
+ for segment in unit.segments
468
+ for token in segment.tokens
469
+ }
470
+ assert expected_surface in surfaces
@@ -128,8 +128,11 @@ def test_multilingual_and_sentence_units_remain_in_render_order() -> None:
128
128
 
129
129
  rendered = _fake_renderer(plan)
130
130
  assert [item[1] for item in rendered] == ["en-us", "fr"]
131
- assert len(plan.flow) == 1
132
- assert len(plan.flow[0].segments) == 2
131
+ assert len(plan.flow) == 2
132
+ assert [[segment.text for segment in unit.segments] for unit in plan.flow] == [
133
+ ["One."],
134
+ ["Bonjour."],
135
+ ]
133
136
  assert_public_consumer_contract(plan)
134
137
 
135
138
 
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+
9
+ from utterplan import (
10
+ FlowPlan,
11
+ LinguisticsConfig,
12
+ PauseConfig,
13
+ PlannerConfig,
14
+ UtterancePlanner,
15
+ compile_document,
16
+ )
17
+ from utterplan.exceptions import PlanValidationError
18
+ from utterplan.flow_projection import _token_providers, _tokens_for_segment
19
+ from utterplan.migrations.v4_to_v5 import migrate_v4_to_v5
20
+ from utterplan.model import LinguisticRun, TokenAnnotation
21
+
22
+
23
+ def _segments(plan: FlowPlan):
24
+ return tuple(segment for unit in plan.flow for segment in unit.segments)
25
+
26
+
27
+ def test_fresh_compile_returns_current_v5_local_flow() -> None:
28
+ text = 'Hello [world]{emphasis="strong"}.'
29
+ config = PlannerConfig(language="en-us", document_format="ssmd", text_preparation="identity")
30
+ result = compile_document(text, input_format="ssmd", config=config)
31
+ planner = UtterancePlanner(config)
32
+ try:
33
+ planned = planner.plan(text)
34
+ finally:
35
+ planner.close()
36
+
37
+ assert isinstance(result.plan, FlowPlan)
38
+ assert result.plan.schema_version == 5
39
+ assert planned == result.plan
40
+ payload = result.plan.to_toml()
41
+ assert payload == result.plan.to_toml()
42
+ assert FlowPlan.from_toml(payload) == result.plan
43
+ assert not hasattr(result.plan, "tokens")
44
+ assert not hasattr(result.plan, "segments")
45
+ assert any(segment.directives.emphasis is not None for segment in _segments(result.plan))
46
+ for segment in _segments(result.plan):
47
+ assert all(segment.text[token.start : token.end] for token in segment.tokens)
48
+
49
+
50
+ @pytest.mark.parametrize(
51
+ "fixture_name",
52
+ ("basic_en", "ssmd_breaks", "markers", "multilingual", "parenthetical", "spokenform_offsets"),
53
+ )
54
+ def test_fresh_compilation_converges_with_v4_migration_on_unambiguous_input(
55
+ fixture_name: str,
56
+ ) -> None:
57
+ fixture = Path(__file__).parent / f"migration/fixtures/v4/{fixture_name}.utterplan.json"
58
+ v4_plan = json.loads(fixture.read_text(encoding="utf-8"))
59
+ old_config = v4_plan["config"]
60
+ old_pauses = old_config.get("pauses", {})
61
+ config = PlannerConfig(
62
+ language=old_config["language"],
63
+ document_format=old_config["document_format"],
64
+ unit=old_config.get("unit", "paragraph"),
65
+ text_preparation=old_config.get("text_preparation", "spokenform"),
66
+ pauses=PauseConfig(
67
+ mode=old_pauses.get("mode", "tts"),
68
+ enabled=old_pauses.get("enabled", True),
69
+ ),
70
+ )
71
+ fresh = compile_document(
72
+ v4_plan["source"]["text"],
73
+ input_format=old_config["document_format"],
74
+ config=config,
75
+ ).plan
76
+ migrated = FlowPlan.from_dict(migrate_v4_to_v5(v4_plan))
77
+
78
+ if fixture_name == "multilingual":
79
+ # The compiler now preserves the inter-language whitespace on the right-hand
80
+ # renderer segment instead of dropping it at the run boundary.
81
+ assert fresh.flow != migrated.flow
82
+ assert [[segment.text for segment in unit.segments] for unit in fresh.flow] == [
83
+ ["Hello", " Bonjour."]
84
+ ]
85
+ assert fresh.document == migrated.document
86
+ assert fresh.linguistics == migrated.linguistics
87
+ assert fresh.language == migrated.language
88
+ assert fresh.unit == migrated.unit
89
+ else:
90
+ assert fresh.plan_id == migrated.plan_id
91
+ assert fresh.flow == migrated.flow
92
+ assert fresh.document == migrated.document
93
+ assert fresh.linguistics == migrated.linguistics
94
+ assert fresh.language == migrated.language
95
+ assert fresh.unit == migrated.unit
96
+
97
+
98
+ def test_fresh_compile_localizes_a_token_cut_by_ssmd_semantics() -> None:
99
+ fixture = Path(__file__).parent / "migration/fixtures/v4/ssmd_09_comprehensive.utterplan.json"
100
+ source = json.loads(fixture.read_text(encoding="utf-8"))["source"]["text"]
101
+ result = compile_document(
102
+ source,
103
+ input_format="ssmd",
104
+ config=PlannerConfig(
105
+ language="sr-Latn",
106
+ document_format="ssmd",
107
+ text_preparation="identity",
108
+ ),
109
+ )
110
+
111
+ segments = _segments(result.plan)
112
+ water_segment = next(segment for segment in segments if segment.text == "H2O")
113
+ assert any(segment.text == ", and" for segment in segments)
114
+ assert any(
115
+ token.start == 0 and token.end == 3 and water_segment.text[token.start : token.end] == "H2O"
116
+ for token in water_segment.tokens
117
+ )
118
+ for segment in segments:
119
+ for token in segment.tokens:
120
+ assert segment.text[token.start : token.end]
121
+
122
+
123
+ def _project_partial_token(
124
+ text: str,
125
+ start: int,
126
+ end: int,
127
+ *,
128
+ provider: str = "fallback",
129
+ lemma: str | None = None,
130
+ ):
131
+ token = TokenAnnotation(0, len(text), text, lemma=lemma)
132
+ plan = SimpleNamespace(
133
+ texts=SimpleNamespace(spoken=text),
134
+ tokens=(token,),
135
+ linguistic_runs=(LinguisticRun("language-0001", provider, 0, 1),),
136
+ )
137
+ segment = SimpleNamespace(
138
+ id="seg-000123",
139
+ language="en-US",
140
+ spoken_start=start,
141
+ spoken_end=end,
142
+ token_indices=(0,),
143
+ )
144
+ return _tokens_for_segment(
145
+ plan,
146
+ segment,
147
+ _token_providers(plan),
148
+ "$.flow[0].segments[0].tokens",
149
+ )
150
+
151
+
152
+ @pytest.mark.parametrize(
153
+ ("body", "expected_surface"),
154
+ (
155
+ ('[alpha]{emphasis="strong"}—beta', "alpha"),
156
+ ('alpha—[beta]{emphasis="strong"}', "beta"),
157
+ ),
158
+ )
159
+ def test_fresh_compile_localizes_fallback_tokens_at_internal_punctuation(
160
+ body: str, expected_surface: str
161
+ ) -> None:
162
+ source = f'---\nssmd_version: "0.9"\nlanguage: en-US\n---\n{body}\n'
163
+ result = compile_document(
164
+ source,
165
+ input_format="ssmd",
166
+ config=PlannerConfig(
167
+ language="en-US",
168
+ document_format="ssmd",
169
+ text_preparation="identity",
170
+ linguistics=LinguisticsConfig(use_spacy=False),
171
+ ),
172
+ )
173
+
174
+ assert result.plan.linguistics[0].provider == "fallback"
175
+ segments = _segments(result.plan)
176
+ assert any(
177
+ segment.text[token.start : token.end] == expected_surface
178
+ for segment in segments
179
+ for token in segment.tokens
180
+ )
181
+ for segment in segments:
182
+ assert all(segment.text[token.start : token.end] for token in segment.tokens)
183
+
184
+
185
+ @pytest.mark.parametrize(
186
+ ("start", "end", "split_side"),
187
+ ((0, 3, "end"), (2, 5, "start")),
188
+ )
189
+ def test_fallback_provider_rejects_true_alphanumeric_splits_with_coordinates(
190
+ start: int, end: int, split_side: str
191
+ ) -> None:
192
+ with pytest.raises(PlanValidationError) as exc_info:
193
+ _project_partial_token("hello", start, end, lemma="hello")
194
+
195
+ error = exc_info.value
196
+ assert error.code == "token.segment_split"
197
+ assert error.path == "$.flow[0].segments[0].tokens"
198
+ assert f"segment seg-000123 [{start}:{end}]" in str(error)
199
+ assert f"token 0 [0:5] at its {split_side}" in str(error)
200
+
201
+
202
+ @pytest.mark.parametrize("provider", ("spacy", "unknown"))
203
+ def test_nonfallback_provider_keeps_punctuation_connected_partial_tokens_strict(
204
+ provider: str,
205
+ ) -> None:
206
+ with pytest.raises(PlanValidationError) as exc_info:
207
+ _project_partial_token("alpha—beta", 0, 5, provider=provider, lemma="alpha—beta")
208
+
209
+ assert exc_info.value.code == "token.segment_split"
210
+
211
+
212
+ def test_fallback_partial_default_lemmas_are_casefolded_per_fragment() -> None:
213
+ text = "Straße—Bahn"
214
+ default_lemma = text.lower()
215
+
216
+ left = _project_partial_token(text, 0, 6, lemma=default_lemma)
217
+ right = _project_partial_token(text, 7, 11, lemma=default_lemma)
218
+
219
+ assert [token.lemma for token in left] == ["strasse"]
220
+ assert [token.lemma for token in right] == ["bahn"]
@@ -13,11 +13,9 @@ from utterplan import (
13
13
  PlannerConfig,
14
14
  UtterancePlan,
15
15
  )
16
- from utterplan.exceptions import PlanningError
17
16
  from utterplan.explain import format_explanation
18
17
  from utterplan.language import LanguageRun
19
18
  from utterplan.linguistics import LinguisticResourcePool, analyze_run_analyses
20
- from utterplan.planner import _split_run
21
19
 
22
20
 
23
21
  class FakeProviderDoc(list):
@@ -209,7 +207,7 @@ def test_clausal_boundaries_reuse_provider_document(monkeypatch):
209
207
  assert len(docs) == 1
210
208
  assert detected == [(source, docs[-1])]
211
209
  boundary = next(item for item in plan.boundaries if item.kind == "clausal_comma")
212
- assert boundary.position == source.index(",")
210
+ assert boundary.position == source.index(",") + 2
213
211
  semantic = next(item for item in plan.semantic_boundaries if item.kind == "clause")
214
212
  assert semantic.position == source.index(",") + 2
215
213
  assert semantic.attrs["detector_start"] == source.index(",")
@@ -274,8 +272,8 @@ def test_semantic_clause_is_independent_of_pause_activation(monkeypatch):
274
272
  assert [(item.position, item.kind, item.id) for item in clauses] == [
275
273
  (source.index(",") + 2, "clause", "semantic-boundary-000000")
276
274
  ] * 3
277
- assert len(plans[0].segments) == 1
278
- assert plans[0].segments[0].spoken_start < clauses[0].position < plans[0].segments[0].spoken_end
275
+ assert len(plans[0].segments) == 2
276
+ assert plans[0].segments[0].spoken_end == clauses[0].position
279
277
  assert not any(
280
278
  segment.pause_before is not None or segment.pause_after is not None
281
279
  for segment in plans[0].segments
@@ -331,17 +329,6 @@ def test_fallback_does_not_attempt_clausal_boundary_detection(monkeypatch):
331
329
  assert not any(item.kind == "clause" for item in plan.semantic_boundaries)
332
330
 
333
331
 
334
- def test_segmentation_type_error_is_not_a_whole_document_fallback(monkeypatch):
335
- import phrasplit
336
-
337
- def broken_split(*args, **kwargs):
338
- raise TypeError("invalid nlp integration")
339
-
340
- monkeypatch.setattr(phrasplit, "split_with_offsets", broken_split)
341
- with pytest.raises(PlanningError, match="sentence segmentation integration failed"):
342
- _split_run("One sentence. Two sentences.", "en-us")
343
-
344
-
345
332
  def _fake_spacy_plan(
346
333
  monkeypatch, *, tag: str = "VBP", morph: str = "Tense=Pres|VerbForm=Fin"
347
334
  ) -> UtterancePlan:
@@ -2,6 +2,7 @@ import pytest
2
2
 
3
3
  from tests.compiler_helpers import CompilerTestPlanner as UtterancePlanner
4
4
  from utterplan import PauseConfig, PauseIntent, PlannerConfig, PlanningError
5
+ from utterplan.exceptions import PlanValidationError
5
6
 
6
7
 
7
8
  def test_plain_sentences_paragraphs_and_pauses():
@@ -16,6 +17,28 @@ def test_plain_sentences_paragraphs_and_pauses():
16
17
  assert plan.segments[1].pause_after == PauseIntent("paragraph")
17
18
 
18
19
 
20
+ def test_sentence_part_edges_split_renderer_segments_and_auto_pause_at_part_boundary():
21
+ source = "Before; after."
22
+ plans = [
23
+ UtterancePlanner(
24
+ PlannerConfig(
25
+ language="en-us",
26
+ text_preparation="identity",
27
+ pauses=PauseConfig(mode=mode, enabled=enabled),
28
+ )
29
+ ).plan(source)
30
+ for mode, enabled in (("tts", True), ("auto", True), ("auto", False))
31
+ ]
32
+ expected_texts = ["Before; ", "after."]
33
+ for plan in plans:
34
+ assert [segment.text for segment in plan.segments] == expected_texts
35
+ assert [segment.clause for segment in plan.segments] == [0, 1]
36
+ assert plan.semantic_boundaries[0].position == len("Before; ")
37
+ assert plans[0].segments[0].pause_after is None
38
+ assert plans[1].segments[0].pause_after == PauseIntent("clause")
39
+ assert plans[2].segments[0].pause_after is None
40
+
41
+
19
42
  def test_ssmd_preparation_and_explicit_directives():
20
43
  plan = UtterancePlanner(PlannerConfig(language="en-us", document_format="ssmd")).plan(
21
44
  '[tomato]{ph="təˈmeɪtoʊ"}'
@@ -97,7 +120,7 @@ def test_unrepresentable_audio_topology_is_rejected(source: str, message: str) -
97
120
  planner.plan(source)
98
121
 
99
122
 
100
- def test_adjacent_audio_annotations_are_distinct_and_supported() -> None:
123
+ def test_adjacent_audio_annotations_report_required_token_conflict() -> None:
101
124
  planner = UtterancePlanner(
102
125
  PlannerConfig(
103
126
  language="en-us",
@@ -105,18 +128,11 @@ def test_adjacent_audio_annotations_are_distinct_and_supported() -> None:
105
128
  text_preparation="identity",
106
129
  )
107
130
  )
108
- plan = planner.plan('[first]{src="same.wav"}[second]{src="same.wav"}')
109
- audio_segments = [segment for segment in plan.segments if segment.directives.audio is not None]
110
131
 
111
- assert len(audio_segments) == 2
112
- first_audio = audio_segments[0].directives.audio
113
- second_audio = audio_segments[1].directives.audio
132
+ with pytest.raises(PlanValidationError) as exc_info:
133
+ planner.plan('[first]{src="same.wav"}[second]{src="same.wav"}')
114
134
 
115
- assert first_audio is not None
116
- assert second_audio is not None
117
- assert first_audio.src == second_audio.src
118
- assert audio_segments[0].id != audio_segments[1].id
119
- assert audio_segments[0].spoken_start < audio_segments[1].spoken_start
135
+ assert exc_info.value.code == "segmentation.required_cut_conflict"
120
136
 
121
137
 
122
138
  def test_audio_fallback_is_atomic_across_sentence_segmentation() -> None:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import replace
4
+ from typing import Literal
4
5
 
5
6
  import pytest
6
7
 
@@ -72,26 +73,20 @@ def _assess(
72
73
  return replace(issue, repair_assessment=assessment)
73
74
 
74
75
 
75
- def test_repair_is_the_default_and_records_a_source_located_guarantee() -> None:
76
+ def test_repair_mode_preserves_isolated_punctuation_instead_of_dropping_content() -> None:
76
77
  config = PlannerConfig(language="en-US", text_preparation="identity")
77
- plan = UtterancePlanner(config).plan("Hello.\n\n.\n\nWorld.")
78
- repeated = UtterancePlanner(config).plan("Hello.\n\n.\n\nWorld.")
78
+ planner = UtterancePlanner(config)
79
+ attempt = planner.compile_attempt("Hello.\n\n.\n\nWorld.")
80
+ repeated = UtterancePlanner(config).compile_attempt("Hello.\n\n.\n\nWorld.")
79
81
 
80
82
  assert config.renderability_mode == "repair"
81
- assert repeated.plan_id == plan.plan_id
82
- assert repeated.to_dict() == plan.to_dict()
83
- assert [segment.text for segment in plan.segments] == ["Hello.", "World."]
84
- assert plan.document_metadata["planning"]["renderability"] == {
85
- "mode": "repair",
86
- "checked_segments": 3,
87
- "repair_count": 1,
88
- "guaranteed": True,
89
- }
90
- repairs = [item for item in plan.diagnostics if item.code == "planning.renderability.repaired"]
91
- assert len(repairs) == 1
92
- assert "by removing the punctuation-only segment" in repairs[0].message
93
- assert repairs[0].line == 3
94
- assert plan.texts.spoken == "Hello.\n\n.\n\nWorld."
83
+ assert attempt.status == "blocked"
84
+ assert repeated.attempt_id == attempt.attempt_id
85
+ assert [segment.text for segment in attempt.candidate.segments] == ["Hello.", ".", "World."]
86
+ assert attempt.candidate.document_metadata["planning"]["renderability"]["guaranteed"] is False
87
+ assert not any(item.code == "planning.renderability.repaired" for item in attempt.diagnostics)
88
+ assert attempt.renderability.issues[0].line == 3
89
+ assert attempt.candidate.texts.spoken == "Hello.\n\n.\n\nWorld."
95
90
 
96
91
 
97
92
  def test_default_repairs_exact_comma_space_with_isolated_planner_topology(
@@ -362,18 +357,18 @@ def test_strict_attempt_retains_blocked_candidate_and_compile_still_raises() ->
362
357
  assert error.value.issues == attempt.renderability.issues
363
358
 
364
359
 
365
- def test_safe_repair_attempt_returns_a_canonical_plan() -> None:
360
+ def test_unattachable_punctuation_remains_in_a_blocked_repair_attempt() -> None:
366
361
  config = PlannerConfig(language="en-US", text_preparation="identity")
367
362
  attempt = UtterancePlanner(config).compile_attempt("Hello.\n\n.\n\nWorld.")
368
363
 
369
- assert attempt.status == "repaired"
370
- assert attempt.ok
371
- assert len(attempt.repairs) == 1
372
- assert attempt.repairs[0].repair_assessment is not None
373
- assert attempt.repairs[0].repair_assessment.safe
374
- plan = attempt.candidate.to_plan()
375
- plan.validate()
376
- assert [segment.text for segment in plan.segments] == ["Hello.", "World."]
364
+ assert attempt.status == "blocked"
365
+ assert not attempt.ok
366
+ assert attempt.repairs == ()
367
+ assert attempt.renderability.issues[0].repair_assessment is not None
368
+ assert attempt.renderability.issues[0].repair_assessment.safe
369
+ assert [segment.text for segment in attempt.candidate.segments] == ["Hello.", ".", "World."]
370
+ with pytest.raises(ValueError, match="blocked planning-attempt draft"):
371
+ attempt.candidate.to_plan()
377
372
 
378
373
 
379
374
  def test_unsafe_repair_attempt_is_blocked_and_draft_structure_is_checked() -> None:
@@ -405,12 +400,12 @@ def test_unsafe_repair_attempt_is_blocked_and_draft_structure_is_checked() -> No
405
400
  ("source", "mode", "expected_status"),
406
401
  [
407
402
  ("Ready.", "strict", "renderable"),
408
- ("Hello.\n\n.\n\nWorld.", "repair", "repaired"),
403
+ ("Hello.\n\n.\n\nWorld.", "repair", "blocked"),
409
404
  ("€", "strict", "blocked"),
410
405
  ],
411
406
  )
412
407
  def test_planning_attempt_toml_roundtrips_all_outcomes_and_stays_noncanonical(
413
- source: str, mode: str, expected_status: str
408
+ source: str, mode: Literal["strict", "repair"], expected_status: str
414
409
  ) -> None:
415
410
  config = PlannerConfig(
416
411
  language="en-US",