utterplan 0.6.9__tar.gz → 0.6.12__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 (253) hide show
  1. {utterplan-0.6.9 → utterplan-0.6.12}/.github/workflows/python-publish.yml +1 -1
  2. {utterplan-0.6.9 → utterplan-0.6.12}/.github/workflows/tests.yml +2 -2
  3. {utterplan-0.6.9 → utterplan-0.6.12}/PKG-INFO +4 -2
  4. {utterplan-0.6.9 → utterplan-0.6.12}/README.md +2 -0
  5. {utterplan-0.6.9 → utterplan-0.6.12}/docs/architecture.md +15 -1
  6. {utterplan-0.6.9 → utterplan-0.6.12}/docs/changelog.md +16 -0
  7. {utterplan-0.6.9 → utterplan-0.6.12}/docs/consumer-guide.md +2 -0
  8. {utterplan-0.6.9 → utterplan-0.6.12}/docs/format.md +2 -0
  9. {utterplan-0.6.9 → utterplan-0.6.12}/docs/pykokoro-integration.md +2 -0
  10. {utterplan-0.6.9 → utterplan-0.6.12}/docs/python-api.md +6 -0
  11. {utterplan-0.6.9 → utterplan-0.6.12}/pyproject.toml +1 -1
  12. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_bundle_document_render.py +14 -0
  13. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_cli_bundle_render_book.py +14 -0
  14. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_identity_units.py +61 -1
  15. utterplan-0.6.12/tests/test_prepared_semantics.py +188 -0
  16. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_renderplan_dependency_contract.py +1 -1
  17. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_renderplan_export.py +30 -2
  18. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_renderplan_v7_contract.py +46 -0
  19. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_replacement_provenance.py +23 -0
  20. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_ssmd_substitution_preparation.py +176 -0
  21. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_toml_format.py +39 -0
  22. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/_version.py +3 -3
  23. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/flow_projection.py +87 -11
  24. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/planner.py +19 -1
  25. utterplan-0.6.12/utterplan/prepared_semantics.py +196 -0
  26. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan.egg-info/PKG-INFO +4 -2
  27. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan.egg-info/SOURCES.txt +2 -0
  28. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan.egg-info/requires.txt +1 -1
  29. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan.egg-info/scm_file_list.json +13 -0
  30. utterplan-0.6.12/utterplan.egg-info/scm_version.json +8 -0
  31. utterplan-0.6.9/utterplan.egg-info/scm_version.json +0 -8
  32. {utterplan-0.6.9 → utterplan-0.6.12}/.codecrate.toml +0 -0
  33. {utterplan-0.6.9 → utterplan-0.6.12}/.github/workflows/codecov.yml +0 -0
  34. {utterplan-0.6.9 → utterplan-0.6.12}/.github/workflows/pre-commit.yml +0 -0
  35. {utterplan-0.6.9 → utterplan-0.6.12}/.gitignore +0 -0
  36. {utterplan-0.6.9 → utterplan-0.6.12}/.pre-commit-config.yaml +0 -0
  37. {utterplan-0.6.9 → utterplan-0.6.12}/AGENTS.md +0 -0
  38. {utterplan-0.6.9 → utterplan-0.6.12}/LICENSE +0 -0
  39. {utterplan-0.6.9 → utterplan-0.6.12}/MANIFEST.in +0 -0
  40. {utterplan-0.6.9 → utterplan-0.6.12}/NOTICE +0 -0
  41. {utterplan-0.6.9 → utterplan-0.6.12}/docs/Makefile +0 -0
  42. {utterplan-0.6.9 → utterplan-0.6.12}/docs/bundles.md +0 -0
  43. {utterplan-0.6.9 → utterplan-0.6.12}/docs/cli.md +0 -0
  44. {utterplan-0.6.9 → utterplan-0.6.12}/docs/conf.py +0 -0
  45. {utterplan-0.6.9 → utterplan-0.6.12}/docs/coordinate-spaces.md +0 -0
  46. {utterplan-0.6.9 → utterplan-0.6.12}/docs/debugging.md +0 -0
  47. {utterplan-0.6.9 → utterplan-0.6.12}/docs/getting-started.md +0 -0
  48. {utterplan-0.6.9 → utterplan-0.6.12}/docs/index.md +0 -0
  49. {utterplan-0.6.9 → utterplan-0.6.12}/docs/make.bat +0 -0
  50. {utterplan-0.6.9 → utterplan-0.6.12}/docs/phase-a-completion.md +0 -0
  51. {utterplan-0.6.9 → utterplan-0.6.12}/docs/requirements.txt +0 -0
  52. {utterplan-0.6.9 → utterplan-0.6.12}/examples/basic.py +0 -0
  53. {utterplan-0.6.9 → utterplan-0.6.12}/examples/chapter.ssmd +0 -0
  54. {utterplan-0.6.9 → utterplan-0.6.12}/examples/compile_file.py +0 -0
  55. {utterplan-0.6.9 → utterplan-0.6.12}/examples/inspect_plan.py +0 -0
  56. {utterplan-0.6.9 → utterplan-0.6.12}/examples/multilingual.py +0 -0
  57. {utterplan-0.6.9 → utterplan-0.6.12}/examples/ssmd_example.py +0 -0
  58. {utterplan-0.6.9 → utterplan-0.6.12}/setup.cfg +0 -0
  59. {utterplan-0.6.9 → utterplan-0.6.12}/spec/schemas/v1.schema.json +0 -0
  60. {utterplan-0.6.9 → utterplan-0.6.12}/spec/schemas/v2.schema.json +0 -0
  61. {utterplan-0.6.9 → utterplan-0.6.12}/spec/schemas/v3.schema.json +0 -0
  62. {utterplan-0.6.9 → utterplan-0.6.12}/spec/schemas/v4.schema.json +0 -0
  63. {utterplan-0.6.9 → utterplan-0.6.12}/spec/utterplan.schema.json +0 -0
  64. {utterplan-0.6.9 → utterplan-0.6.12}/tests/__init__.py +0 -0
  65. {utterplan-0.6.9 → utterplan-0.6.12}/tests/compiler_helpers.py +0 -0
  66. {utterplan-0.6.9 → utterplan-0.6.12}/tests/fixtures/canonical_consumer_contract.expected.toml +0 -0
  67. {utterplan-0.6.9 → utterplan-0.6.12}/tests/fixtures/canonical_consumer_contract.ssmd +0 -0
  68. {utterplan-0.6.9 → utterplan-0.6.12}/tests/fixtures/document-bundle-v1/story.ssmd-bundle/document.ssmd.md +0 -0
  69. {utterplan-0.6.9 → utterplan-0.6.12}/tests/fixtures/document-bundle-v1/story.ssmd-bundle/manifest.json +0 -0
  70. {utterplan-0.6.9 → utterplan-0.6.12}/tests/fixtures/ssmd_09_comprehensive.ssmd +0 -0
  71. {utterplan-0.6.9 → utterplan-0.6.12}/tests/golden/basic_en.utterplan.toml +0 -0
  72. {utterplan-0.6.9 → utterplan-0.6.12}/tests/golden/directives.utterplan.toml +0 -0
  73. {utterplan-0.6.9 → utterplan-0.6.12}/tests/golden/markers.utterplan.toml +0 -0
  74. {utterplan-0.6.9 → utterplan-0.6.12}/tests/golden/multilingual.utterplan.toml +0 -0
  75. {utterplan-0.6.9 → utterplan-0.6.12}/tests/golden/parenthetical.utterplan.toml +0 -0
  76. {utterplan-0.6.9 → utterplan-0.6.12}/tests/golden/spokenform_offsets.utterplan.toml +0 -0
  77. {utterplan-0.6.9 → utterplan-0.6.12}/tests/golden/ssmd_09_comprehensive.utterplan.toml +0 -0
  78. {utterplan-0.6.9 → utterplan-0.6.12}/tests/golden/ssmd_breaks.utterplan.toml +0 -0
  79. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/__init__.py +0 -0
  80. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/cases.py +0 -0
  81. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/fixtures/v4/basic_en.utterplan.json +0 -0
  82. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/fixtures/v4/directives.utterplan.json +0 -0
  83. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/fixtures/v4/markers.utterplan.json +0 -0
  84. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/fixtures/v4/multilingual.utterplan.json +0 -0
  85. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/fixtures/v4/parenthetical.utterplan.json +0 -0
  86. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/fixtures/v4/spokenform_offsets.utterplan.json +0 -0
  87. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/fixtures/v4/ssmd_09_comprehensive.utterplan.json +0 -0
  88. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/fixtures/v4/ssmd_breaks.utterplan.json +0 -0
  89. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/goldens/multilingual_preparation.json +0 -0
  90. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/goldens/spokenform_numbers.json +0 -0
  91. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/goldens/ssmd_break.json +0 -0
  92. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/goldens/ssmd_language_detection.json +0 -0
  93. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/goldens/ssmd_marker.json +0 -0
  94. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/goldens/ssmd_pronunciation.json +0 -0
  95. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/test_legacy_json_import.py +0 -0
  96. {utterplan-0.6.9 → utterplan-0.6.12}/tests/migration/test_semantic_migration_regressions.py +0 -0
  97. {utterplan-0.6.9 → utterplan-0.6.12}/tests/schema_history/README.md +0 -0
  98. {utterplan-0.6.9 → utterplan-0.6.12}/tests/schema_history/v1/basic.json +0 -0
  99. {utterplan-0.6.9 → utterplan-0.6.12}/tests/schema_history/v1/directives.json +0 -0
  100. {utterplan-0.6.9 → utterplan-0.6.12}/tests/schema_history/v1/markers.json +0 -0
  101. {utterplan-0.6.9 → utterplan-0.6.12}/tests/schema_history/v1/multilingual.json +0 -0
  102. {utterplan-0.6.9 → utterplan-0.6.12}/tests/schema_history/v1/parenthetical.json +0 -0
  103. {utterplan-0.6.9 → utterplan-0.6.12}/tests/schema_history/v1/spokenform_offsets.json +0 -0
  104. {utterplan-0.6.9 → utterplan-0.6.12}/tests/schema_history/v1/ssmd_breaks.json +0 -0
  105. {utterplan-0.6.9 → utterplan-0.6.12}/tests/schema_history/v2/directives.json +0 -0
  106. {utterplan-0.6.9 → utterplan-0.6.12}/tests/schema_history/v3/multilingual.json +0 -0
  107. {utterplan-0.6.9 → utterplan-0.6.12}/tests/schema_history/v3/ssmd_09_comprehensive.json +0 -0
  108. {utterplan-0.6.9 → utterplan-0.6.12}/tests/schema_history/v5/canonical_consumer_contract.toml +0 -0
  109. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_atomic_io.py +0 -0
  110. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_batch.py +0 -0
  111. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_boundaries.py +0 -0
  112. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_bundle_book_replan.py +0 -0
  113. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_bundle_book_services.py +0 -0
  114. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_bundle_document_compile.py +0 -0
  115. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_bundle_replan.py +0 -0
  116. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_bundle_role_inventory.py +0 -0
  117. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_bundle_services.py +0 -0
  118. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_cli.py +0 -0
  119. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_cli_batch.py +0 -0
  120. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_cli_bundle_compile_book.py +0 -0
  121. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_cli_bundle_document.py +0 -0
  122. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_cli_bundle_render_document.py +0 -0
  123. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_cli_bundle_replan_book.py +0 -0
  124. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_compiler.py +0 -0
  125. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_consumer_contract.py +0 -0
  126. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_consumer_readiness.py +0 -0
  127. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_coordinates.py +0 -0
  128. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_dependency_architecture.py +0 -0
  129. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_directives.py +0 -0
  130. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_explain.py +0 -0
  131. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_fake_renderer.py +0 -0
  132. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_flow_compilation.py +0 -0
  133. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_format.py +0 -0
  134. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_golden.py +0 -0
  135. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_import_boundaries.py +0 -0
  136. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_language.py +0 -0
  137. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_linguistics.py +0 -0
  138. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_migration_framework.py +0 -0
  139. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_migration_v3_to_v4.py +0 -0
  140. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_migration_v4_to_v5.py +0 -0
  141. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_pauses.py +0 -0
  142. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_planner.py +0 -0
  143. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_preparation.py +0 -0
  144. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_preview.py +0 -0
  145. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_progress.py +0 -0
  146. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_public_identity.py +0 -0
  147. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_renderability.py +0 -0
  148. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_renderability_repair.py +0 -0
  149. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_renderplan_batch.py +0 -0
  150. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_renderplan_replan.py +0 -0
  151. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_role_coverage.py +0 -0
  152. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_schema_history.py +0 -0
  153. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_schema_registry.py +0 -0
  154. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_schema_strict.py +0 -0
  155. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_schema_v6_source.py +0 -0
  156. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_schema_v7.py +0 -0
  157. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_segment_topology.py +0 -0
  158. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_segmentation_adapter.py +0 -0
  159. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_segmentation_recovery.py +0 -0
  160. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_semantic_boundaries.py +0 -0
  161. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_sentence_topology.py +0 -0
  162. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_source_input.py +0 -0
  163. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_ssmd_09_parser.py +0 -0
  164. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_ssmd_bundle.py +0 -0
  165. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_ssmd_bundle_book.py +0 -0
  166. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_ssmd_bundle_document.py +0 -0
  167. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_token_topology.py +0 -0
  168. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_ttsready_preparation_adapter.py +0 -0
  169. {utterplan-0.6.9 → utterplan-0.6.12}/tests/test_version.py +0 -0
  170. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/__init__.py +0 -0
  171. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/__main__.py +0 -0
  172. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/_book_replan.py +0 -0
  173. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/_book_service.py +0 -0
  174. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/_document_service.py +0 -0
  175. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/atomic_io.py +0 -0
  176. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/attempt_serialization.py +0 -0
  177. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/attempts.py +0 -0
  178. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/batch.py +0 -0
  179. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/boundaries.py +0 -0
  180. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/bundle.py +0 -0
  181. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/bundle_replan.py +0 -0
  182. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/bundle_role_inventory.py +0 -0
  183. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/cli.py +0 -0
  184. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/codecs/__init__.py +0 -0
  185. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/codecs/v4_toml.py +0 -0
  186. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/codecs/v5_toml.py +0 -0
  187. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/codecs/v6_toml.py +0 -0
  188. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/codecs/v7_toml.py +0 -0
  189. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/compiler.py +0 -0
  190. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/config.py +0 -0
  191. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/diagnostics.py +0 -0
  192. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/directives.py +0 -0
  193. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/exceptions.py +0 -0
  194. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/explain.py +0 -0
  195. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/format.py +0 -0
  196. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/hashing.py +0 -0
  197. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/language.py +0 -0
  198. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/linguistics.py +0 -0
  199. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/migration.py +0 -0
  200. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/migrations/__init__.py +0 -0
  201. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/migrations/registry.py +0 -0
  202. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/migrations/v1_to_v2.py +0 -0
  203. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/migrations/v2_to_v3.py +0 -0
  204. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/migrations/v3_to_v4.py +0 -0
  205. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/migrations/v4_to_v5.py +0 -0
  206. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/migrations/v5_to_v6.py +0 -0
  207. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/migrations/v6_to_v7.py +0 -0
  208. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/model.py +0 -0
  209. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/parsers.py +0 -0
  210. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/pauses.py +0 -0
  211. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/preparation.py +0 -0
  212. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/preview.py +0 -0
  213. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/progress.py +0 -0
  214. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/py.typed +0 -0
  215. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/renderability.py +0 -0
  216. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/renderplan_batch.py +0 -0
  217. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/renderplan_export.py +0 -0
  218. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/renderplan_grouping.py +0 -0
  219. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/renderplan_projection.py +0 -0
  220. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/renderplan_replan.py +0 -0
  221. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/renderplan_workspace.py +0 -0
  222. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/renderprofile_io.py +0 -0
  223. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/role_coverage.py +0 -0
  224. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/schema_registry.py +0 -0
  225. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/schemas/v1.schema.json +0 -0
  226. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/schemas/v2.schema.json +0 -0
  227. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/schemas/v3.schema.json +0 -0
  228. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/schemas/v4.schema.json +0 -0
  229. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/segmentation.py +0 -0
  230. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/serialization.py +0 -0
  231. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/source_input.py +0 -0
  232. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/spacy_models.py +0 -0
  233. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/spans.py +0 -0
  234. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/ssmd_bundle.py +0 -0
  235. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/stages/__init__.py +0 -0
  236. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/stages/document/__init__.py +0 -0
  237. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/stages/document/plain.py +0 -0
  238. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/stages/document/ssmd.py +0 -0
  239. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/stages/preparation/__init__.py +0 -0
  240. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/stages/preparation/identity.py +0 -0
  241. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/stages/preparation/ssmd.py +0 -0
  242. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/stages/preparation/ttsready.py +0 -0
  243. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/stages/protocols.py +0 -0
  244. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/token_topology.py +0 -0
  245. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/toml_codec.py +0 -0
  246. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/trace_codec.py +0 -0
  247. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/units.py +0 -0
  248. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/utterplan.schema.json +0 -0
  249. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/versioning.py +0 -0
  250. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan/workspace_paths.py +0 -0
  251. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan.egg-info/dependency_links.txt +0 -0
  252. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan.egg-info/entry_points.txt +0 -0
  253. {utterplan-0.6.9 → utterplan-0.6.12}/utterplan.egg-info/top_level.txt +0 -0
@@ -82,7 +82,7 @@ jobs:
82
82
  assert has_range(requirements, "phrasplit", "0.3.9", "0.4")
83
83
  assert has_range(requirements, "ssmd", "0.9.3", "0.10")
84
84
  assert has_range(requirements, "ttsready", "0.2.3", "0.3")
85
- assert has_range(requirements, "renderplan", "0.1.1", "0.2")
85
+ assert has_range(requirements, "renderplan", "0.2.0", "0.3")
86
86
  names = set(archive.namelist())
87
87
  expected = {
88
88
  "utterplan/py.typed",
@@ -51,7 +51,7 @@ jobs:
51
51
  "ssmd==0.9.3" \
52
52
  "ttsready==0.2.3" \
53
53
  "spokenform==0.4.5" \
54
- "renderplan==0.1.1" \
54
+ "renderplan==0.2.0" \
55
55
  "typing_extensions==4.0.0" \
56
56
  "tomlkit==0.13.0"
57
57
  python -m pip install --no-deps -e .
@@ -120,7 +120,7 @@ jobs:
120
120
  "phrasplit": ("0.3.9", "0.4"),
121
121
  "ssmd": ("0.9.3", "0.10"),
122
122
  "ttsready": ("0.2.3", "0.3"),
123
- "renderplan": ("0.1.1", "0.2"),
123
+ "renderplan": ("0.2.0", "0.3"),
124
124
  }
125
125
 
126
126
  def check_metadata(contents):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: utterplan
3
- Version: 0.6.9
3
+ Version: 0.6.12
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
@@ -227,7 +227,7 @@ Requires-Dist: ssmd<0.10,>=0.9.3
227
227
  Requires-Dist: ttsready<0.3,>=0.2.3
228
228
  Requires-Dist: typing_extensions>=4.0
229
229
  Requires-Dist: tomlkit<1,>=0.13
230
- Requires-Dist: renderplan<0.2,>=0.1.1
230
+ Requires-Dist: renderplan<0.3,>=0.2.0
231
231
  Provides-Extra: dev
232
232
  Requires-Dist: build; extra == "dev"
233
233
  Requires-Dist: jsonschema; extra == "dev"
@@ -408,6 +408,8 @@ engine-backed profiles, then preflights and renders the resulting RenderPlans.
408
408
  UtterPlan's single-file `render-plan` command remains the low-level projection and
409
409
  deterministic replan primitive.
410
410
 
411
+ Character-spelling segments keep exact identity in their portable token lemmas: a single alphanumeric token under `say_as = {interpret_as = "characters"}` has the exact token surface as its lemma, even when a linguistic provider reports a context-sensitive lowercase lemma. Ordinary lexical lemmas retain compact casefold-default encoding. Newly generated RenderPlans use schema v2 and store each used role's concrete configuration once in a top-level `[bindings.<role>]` table. The RenderPlan codec restores the complete `SpeechItem.binding` mapping on load; this changes disk layout, not the consumer API.
412
+
411
413
  ### Troubleshooting RenderProfile identity mismatch
412
414
 
413
415
  A RenderProfile `profile_id` is a semantic content identity validated by RenderPlan; it is not a hash of the TOML file bytes. If the profile's identity-bearing semantics change without refreshing its ID, UtterPlan rejects it before creating RenderPlan artifacts. Regenerate or re-save the complete profile with VoiceRender, then validate it before rendering:
@@ -164,6 +164,8 @@ engine-backed profiles, then preflights and renders the resulting RenderPlans.
164
164
  UtterPlan's single-file `render-plan` command remains the low-level projection and
165
165
  deterministic replan primitive.
166
166
 
167
+ Character-spelling segments keep exact identity in their portable token lemmas: a single alphanumeric token under `say_as = {interpret_as = "characters"}` has the exact token surface as its lemma, even when a linguistic provider reports a context-sensitive lowercase lemma. Ordinary lexical lemmas retain compact casefold-default encoding. Newly generated RenderPlans use schema v2 and store each used role's concrete configuration once in a top-level `[bindings.<role>]` table. The RenderPlan codec restores the complete `SpeechItem.binding` mapping on load; this changes disk layout, not the consumer API.
168
+
167
169
  ### Troubleshooting RenderProfile identity mismatch
168
170
 
169
171
  A RenderProfile `profile_id` is a semantic content identity validated by RenderPlan; it is not a hash of the TOML file bytes. If the profile's identity-bearing semantics change without refreshing its ID, UtterPlan rejects it before creating RenderPlan artifacts. Regenerate or re-save the complete profile with VoiceRender, then validate it before rendering:
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Architecture Documentation"
3
- version: 4
3
+ version: 6
4
4
  generator: "archledger 0.4.0"
5
5
  arc42_template_version: "9.0-EN"
6
6
  ---
@@ -237,6 +237,14 @@ Supporting tactics:
237
237
  evidence live in an optional versioned trace TOML that never affects plan
238
238
  identity or executable content.
239
239
 
240
+ ## Prepared character-spelling semantics
241
+
242
+ After written-to-spoken preparation and before segmentation, UtterPlan inspects verified replacement records for the exact character-spelling transformation: an ASCII uppercase-letter/digit core of at least two characters becomes the same characters joined by single spaces, optionally retaining an identical `'s` or `’s` suffix. This derives a compiler-owned `say_as=characters` annotation over only the prepared character core. For example, `[ART]{sub=\"A R T\"}` keeps prepared text `A R T`; `[ART's]{sub=\"A R T's\"}` keeps `A R T's` and marks only `A R T`.
243
+
244
+ The derivation is portable executable intent, not a renderer-specific spelling rewrite. Prepared text never becomes `A- R- T`; a renderer adapter may choose that frontend form. Existing semantic-edge segmentation produces the exact controlled span, while RenderPlan sentence grouping retains the full sentence as one normal speech request with a request-local control. This feature adds neither a sentence split nor an automatic pause.
245
+
246
+ Authored `say-as` semantics take precedence, and the consumed SSMD `sub` annotation remains consumed. The derived directive uses existing schema-v7 `SayAsDirective` serialization and identity hashing; replacement records remain diagnostic provenance and are not synthesis input. No schema version or migration changes are required.
247
+
240
248
  ## Strategy Items
241
249
 
242
250
  <!-- archledger: no accepted records for this section yet -->
@@ -281,6 +289,8 @@ from `manifest.json`, normalizes item selection, and enforces path containment.
281
289
  services; private book and document helpers retain kind-specific persistence and
282
290
  report behavior. The generic source loader remains limited to standalone files.
283
291
 
292
+ Preparation semantics include the compiler-only `prepared_semantics.py` pass. It consumes verified preparation replacement records after text preparation and before language-run/segment construction, appending deterministic `say-as=characters` annotations without changing prepared text, replacement provenance, boundaries, source maps, or consumed substitutions. It reuses existing directive resolution, semantic-edge segmentation, FlowPlan v7 serialization/identity, and RenderPlan request-local control projection.
293
+
284
294
  <!-- archledger: no accepted records for this section yet -->
285
295
 
286
296
  # Runtime View
@@ -479,6 +489,10 @@ semantic blockers. Blocked planning outcomes may be reviewed through the separat
479
489
  `utterplan.planning-attempt.v1` inspect-only artifact; such candidates remain
480
490
  drafts and never become canonical plans.
481
491
 
492
+ ### Character spelling: text, intent, and realization
493
+
494
+ Character spelling keeps four concerns separate: source spelling (for example, `ART`), prepared spoken text (`A R T`), portable executable intent (`say_as=characters` over the exact prepared core), and downstream renderer realization. The matcher uses only verified replacement metadata and does not guess from arbitrary uppercase words or normalization changes. Possessive suffixes remain outside the semantic range. Replacement provenance is excluded from executable identity; the derived directive affects FlowUnit and FlowPlan identity. UtterPlan never persists a frontend-specific hyphen form or adds a pause for this feature.
495
+
482
496
  <!-- archledger: no accepted records for this section yet -->
483
497
 
484
498
  # Architecture Decisions
@@ -4,6 +4,22 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [v0.6.12] - Unreleased
8
+
9
+ ### Changed
10
+
11
+ - Preserved exact character spelling and adopted RenderPlan v2 binding catalogs
12
+
13
+ <!-- generated by releaseledger -->
14
+
15
+ ## [v0.6.10] - 2026-10-28
16
+
17
+ ### Added
18
+
19
+ - Added portable character-spelling semantics for verified substitutions
20
+
21
+ <!-- generated by releaseledger -->
22
+
7
23
  ## [v0.6.9] - 2026-10-27
8
24
 
9
25
  ### Added
@@ -42,6 +42,8 @@ In Python, `PlannerConfig.document_format` defaults to `"plain"`; select `"ssmd"
42
42
  Token offsets are half-open Python-character ranges into the owning `segment.text`. Do not use them to slice document text or add offsets from another segment. Consumers preserve stored segment order but decide downstream chunk boundaries; a segment is not necessarily one TTS call.
43
43
  Token `pos`, `tag`, `lemma`, and `morph` describe that exact prepared text and are the authoritative linguistic context, not model-token IDs or counts. Consumers may pass them through without retokenizing or rerunning spaCy. Optional replacements are provenance only: their output spans are segment-local, source values are original spellings, and they do not alter hashes independently of the source digest and are not synthesis inputs.
44
44
 
45
+ For `say_as.interpret_as = "characters"`, a single alphanumeric spelling token keeps its exact surface as the portable lemma (`T`, not a provider's contextual `t`); POS, tag, and morphology remain provider facts. The ordinary casefold-default encoding is unchanged. If the plan is projected and saved as RenderPlan v2, role bindings are interned in top-level `[bindings.<role>]` tables; `renderplan.codec.load_plan()` restores each complete `SpeechItem.binding` mapping for consumers.
46
+
45
47
  ```python
46
48
  from utterplan import FlowPlan
47
49
 
@@ -55,6 +55,8 @@ Pause intents represent authored `none`, strength values (`x-weak`, `weak`, `med
55
55
 
56
56
  `plan_id` identifies canonical executable semantics and, in schema v7, the SHA-256 digest of exact raw source when available; it does not hash TOML bytes. The plan-level `hash_schema` is `utterplan-flow-v3`; canonical boundary opportunities and authored pause intents participate in executable identity and flow-unit hashes. Compact replacement provenance is excluded. Producer identity and formatting do not alter executable identity. Plans contain no phonemes, model token IDs, models, sessions, renderer configuration, or audio.
57
57
 
58
+ For a segment with `say_as = {interpret_as = "characters"}`, each single alphanumeric token's portable lemma is its exact surface (for example, `T` remains `T` even if a provider reports `t`). This semantic normalization occurs before v7 sparse lemma encoding; ordinary lexical lemmas still default to the case-folded surface, and provider POS, tag, and morphology are retained. When projected separately, RenderPlan v2 stores concrete role bindings in one top-level `[bindings.<role>]` table per used role rather than repeating them in speech entries. Loading with the RenderPlan codec still exposes the complete mapping as `SpeechItem.binding`.
59
+
58
60
  ## TOML API and optional compiler trace
59
61
 
60
62
  `FlowPlan.to_toml()`, `FlowPlan.from_toml()`, `FlowPlan.save()`, and `FlowPlan.load()` are the persistence APIs. `save()` writes atomically. Normal plan loading and the `validate`, `inspect`, and `explain` commands accept TOML; JSON is never auto-detected as a current plan format. Import a historical plan explicitly with `utterplan migrate`; schema-v6 data cannot be promoted to v7 without recompiling the original source, so migration may report `migration.recompile_required` instead of writing a file.
@@ -38,6 +38,8 @@ for unit in plan.flow:
38
38
 
39
39
  Use the v7 linguistic token fields (not model-token IDs or counts) as authoritative context: pass available `pos`, `tag`, `lemma`, and `morph` through unchanged rather than rerunning spaCy or inferring tags. Default `auto` uses installed compatible local models, preferring `sm`, `md`, `lg`, `trf`, and safely falls back if unavailable; model packages are never downloaded. When fields are unavailable, follow the adapter's documented non-contextual fallback.
40
40
 
41
+ Character spelling is an explicit semantic exception to contextual lemmatization: within `say_as.interpret_as = "characters"`, each single alphanumeric token's portable lemma is its exact surface (so `T` remains uppercase even if the provider reports `t`). POS, tag, and morphology are not rewritten, and ordinary lexical lemmas keep the compact casefold default. On the separate RenderPlan path, schema-v2 files store role bindings once in a plan-level catalog; loading them still supplies the full `SpeechItem.binding` to the consumer.
42
+
41
43
  ## Pause behavior
42
44
 
43
45
  Schema v7 carries safe boundary opportunities separately from authored pause intents and exact timed breaks; neither selects a renderer activation policy or fixes a chunk. PyKokoro may choose its own chunking and native, automatic, or manual pause realization. A `FlowSegment` is not necessarily one TTS call, and renderer/acoustic variance remains outside UtterPlan and plan identity.
@@ -329,6 +329,12 @@ print(segment.directives.emphasis)
329
329
 
330
330
  The `document` record retains compact portable document information. Effective directives and pause intents live on their owning flow segments. Compiler-only source spans and preparation coordinate maps remain in the optional trace. See the [coordinate-space contract](coordinate-spaces) and [consumer guide](consumer-guide) for renderer responsibilities.
331
331
 
332
+ ### Character-spelling substitutions
333
+
334
+ When a verified preparation replacement is exactly an ASCII uppercase-letter/digit sequence expanded into its individual characters separated by single spaces, UtterPlan derives `say_as=characters` for that prepared core. For example, `[ART]{sub="A R T"}` keeps prepared text `A R T`; `[ART's]{sub="A R T's"}` keeps `A R T's` and covers only `A R T`. This applies to exact SSMD substitutions and other preparation backends' verified replacement records, including TTSReady. It does not guess from arbitrary acronyms, case changes, normalization, punctuation, or altered spacing. An authored `say-as` takes precedence.
335
+
336
+ The derived directive is executable semantic intent and participates in FlowPlan identity. Replacement provenance remains diagnostic only. With sentence-level RenderPlan grouping, the full sentence stays one speech request and the request-local `say_as` control covers only the spelled core. UtterPlan never rewrites prepared text to frontend-specific forms such as `A- R- T` and does not add an automatic pause for this feature; an engine adapter owns any renderer-specific realization.
337
+
332
338
  ## Flow segments and local tokens
333
339
 
334
340
  A `FlowPlan` stores ordered `FlowUnit` objects. Each unit owns its ordered segments, so consumers do not join plan-global IDs. Token offsets use Python-character coordinates local to `segment.text`:
@@ -17,7 +17,7 @@ dependencies = [
17
17
  "ttsready>=0.2.3,<0.3",
18
18
  "typing_extensions>=4.0",
19
19
  "tomlkit>=0.13,<1",
20
- "renderplan>=0.1.1,<0.2",
20
+ "renderplan>=0.2.0,<0.3",
21
21
  ]
22
22
  classifiers = [
23
23
  "Development Status :: 3 - Alpha",
@@ -47,6 +47,12 @@ def test_render_document_bundle_projects_canonical_flowplan_without_book_index(
47
47
  assert result.renderplan_path.name == "document.renderplan.toml"
48
48
  plan = renderplan_codec.load_plan(result.renderplan_path)
49
49
  plan.validate_against_profile(profile)
50
+ serialized = result.renderplan_path.read_text(encoding="utf-8")
51
+ assert plan.schema_version == 2
52
+ assert serialized.count("[bindings.narrator]") == 1
53
+ assert "[flow.binding]" not in serialized
54
+ assert plan.plan_id == plan.computed_id()
55
+ assert all(speech.binding == dict(profile.roles[speech.role].binding) for speech in plan.speech)
50
56
  flow = FlowPlan.load(result.source_plan_path)
51
57
  assert result.variant.startswith("narration--")
52
58
  assert result.snapshot_path.is_file()
@@ -119,3 +125,11 @@ def test_render_document_bundle_returns_multi_role_coverage_and_bindings(tmp_pat
119
125
  plan = renderplan_codec.load_plan(result.renderplan_path)
120
126
  assert {speech.role for speech in plan.speech} == {"host", "guest", "narrator"}
121
127
  assert {speech.binding["engine"] for speech in plan.speech} == {"kokoro", "kitten", "piper"}
128
+ serialized = result.renderplan_path.read_text(encoding="utf-8")
129
+ for role in ("narrator", "host", "guest"):
130
+ assert serialized.count(f"[bindings.{role}]") == 1
131
+ assert "[flow.binding]" not in serialized
132
+ assert plan.schema_version == 2
133
+ assert plan.plan_id == plan.computed_id()
134
+ assert all(speech.binding == dict(profile.roles[speech.role].binding) for speech in plan.speech)
135
+ plan.validate_against_profile(profile)
@@ -319,6 +319,20 @@ def test_render_book_reports_multi_role_coverage(tmp_path: Path, capsys) -> None
319
319
  "narrator",
320
320
  }
321
321
 
322
+ for chapter, plan in zip(collection.chapters, plans, strict=True):
323
+ serialized = (variant / chapter.renderplan).read_text(encoding="utf-8")
324
+ roles = {speech.role for speech in plan.speech}
325
+ assert len(roles) == 1
326
+ for role in roles:
327
+ assert serialized.count(f"[bindings.{role}]") == 1
328
+ assert "[flow.binding]" not in serialized
329
+ assert plan.schema_version == 2
330
+ assert plan.plan_id == plan.computed_id()
331
+ assert all(
332
+ speech.binding == dict(profile.roles[speech.role].binding) for speech in plan.speech
333
+ )
334
+ plan.validate_against_profile(profile)
335
+
322
336
 
323
337
  def test_render_book_accumulates_successful_chapters_and_replaces_latest_report(
324
338
  tmp_path: Path, capsys
@@ -2,7 +2,7 @@ from dataclasses import replace
2
2
  from types import SimpleNamespace
3
3
 
4
4
  from tests.compiler_helpers import CompilerTestPlanner as UtterancePlanner
5
- from utterplan import PlannerConfig, SemanticBoundary
5
+ from utterplan import FlowUnit, LinguisticsConfig, PlannerConfig, SemanticBoundary, compile_document
6
6
  from utterplan.hashing import (
7
7
  LEGACY_UNIT_HASH_SCHEMA,
8
8
  UNIT_HASH_SCHEMA,
@@ -182,3 +182,63 @@ def test_serialized_and_runtime_unit_hash_payloads_match() -> None:
182
182
  migrated_payload = unit_hash_payload_from_serialized(serialized_unit, serialized_plan)
183
183
 
184
184
  assert migrated_payload == runtime_payload
185
+
186
+
187
+ def test_derived_say_as_changes_identity_and_v7_roundtrips_without_hashing_provenance() -> None:
188
+ source = 'Because someone called [ART]{sub="A R T"} finally.'
189
+ plan = compile_document(
190
+ source,
191
+ input_format="ssmd",
192
+ config=PlannerConfig(
193
+ language="en-us",
194
+ document_format="ssmd",
195
+ linguistics=LinguisticsConfig(use_spacy=False),
196
+ ),
197
+ ).plan
198
+ assert "".join(segment.text for unit in plan.flow for segment in unit.segments) == (
199
+ "Because someone called A R T finally."
200
+ )
201
+ spelled_ref = next(
202
+ (unit_index, segment_index, segment)
203
+ for unit_index, unit in enumerate(plan.flow)
204
+ for segment_index, segment in enumerate(unit.segments)
205
+ if segment.text == "A R T"
206
+ )
207
+ unit_index, segment_index, spelled = spelled_ref
208
+ assert spelled.directives.say_as is not None
209
+ assert spelled.directives.say_as.interpret_as == "characters"
210
+
211
+ without_units = []
212
+ for unit in plan.flow:
213
+ without_units.append(
214
+ FlowUnit(
215
+ tuple(
216
+ replace(
217
+ segment,
218
+ directives=replace(segment.directives, say_as=None),
219
+ )
220
+ for segment in unit.segments
221
+ )
222
+ )
223
+ )
224
+ without_say_as = replace(plan, flow=tuple(without_units), plan_id="")
225
+ assert without_say_as.flow[unit_index].segments[segment_index].text == spelled.text
226
+ assert without_say_as.flow[unit_index].content_hash != plan.flow[unit_index].content_hash
227
+ assert without_say_as.plan_id != plan.plan_id
228
+
229
+ replacement = spelled.replacements[0]
230
+ provenance_only = replace(replacement, source="different diagnostic spelling")
231
+ changed_segments = list(plan.flow[unit_index].segments)
232
+ changed_segments[segment_index] = replace(spelled, replacements=(provenance_only,))
233
+ changed_units = list(plan.flow)
234
+ changed_units[unit_index] = FlowUnit(tuple(changed_segments))
235
+ provenance_changed = replace(plan, flow=tuple(changed_units), plan_id="")
236
+ assert provenance_changed.flow[unit_index].content_hash == plan.flow[unit_index].content_hash
237
+ assert provenance_changed.plan_id == plan.plan_id
238
+
239
+ restored = type(plan).from_toml(plan.to_toml())
240
+ assert restored == plan
241
+ assert restored.schema_version == 7
242
+ assert restored.flow[unit_index].segments[segment_index].directives.say_as == (
243
+ spelled.directives.say_as
244
+ )
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import pytest
7
+
8
+ from utterplan.model import AnnotationSpan, BoundaryEvent, TextPreparationInfo
9
+ from utterplan.preparation import PreparedText
10
+ from utterplan.prepared_semantics import (
11
+ CharacterSpellingMatch,
12
+ detect_character_spelling,
13
+ infer_prepared_semantics,
14
+ )
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class _MappedSource:
19
+ source_length: int
20
+ output_length: int
21
+ source_start: int
22
+ source_end: int
23
+ output_start: int
24
+ output_end: int
25
+
26
+ def map_source_span(self, start: int, end: int) -> tuple[int, int]:
27
+ if (start, end) == (self.source_start, self.source_end):
28
+ return self.output_start, self.output_end
29
+ return start, end
30
+
31
+ def map_output_span(self, start: int, end: int) -> tuple[int, int]:
32
+ if (start, end) == (self.output_start, self.output_end):
33
+ return self.source_start, self.source_end
34
+ return start, end
35
+
36
+
37
+ def _replacement(source: str, replacement: str, *, start: int = 0) -> PreparedText:
38
+ output_start = start + 10
39
+ source_map = _MappedSource(
40
+ source_length=start + len(source) + 5,
41
+ output_length=output_start + len(replacement) + 5,
42
+ source_start=start,
43
+ source_end=start + len(source),
44
+ output_start=output_start,
45
+ output_end=output_start + len(replacement),
46
+ )
47
+ spoken = " " * output_start + replacement + " " * 5
48
+ record: dict[str, Any] = {
49
+ "source_start": start,
50
+ "source_end": start + len(source),
51
+ "output_start": output_start,
52
+ "output_end": output_start + len(replacement),
53
+ "source": source,
54
+ "replacement": replacement,
55
+ "kind": "ssmd-sub",
56
+ "rule": "ssmd.sub",
57
+ "annotation_id": "sub-1",
58
+ }
59
+ source_annotation = AnnotationSpan(
60
+ id="sub-1",
61
+ kind="directive",
62
+ attrs={"sub": replacement},
63
+ structural_start=start,
64
+ structural_end=start + len(source),
65
+ spoken_start=output_start,
66
+ spoken_end=output_start + len(replacement),
67
+ source_start=100 + start,
68
+ source_end=100 + start + len(source),
69
+ source_node_id="source-node-1",
70
+ )
71
+ return PreparedText(
72
+ spoken_text=spoken,
73
+ info=TextPreparationInfo("ssmd", None, replacements=(record,)),
74
+ annotations=(source_annotation,),
75
+ boundaries=(BoundaryEvent("b-1", 0, "sentence"),),
76
+ source_map=source_map,
77
+ consumed_substitutions=("sub-1",),
78
+ )
79
+
80
+
81
+ @pytest.mark.parametrize(
82
+ ("source", "replacement", "expected"),
83
+ [
84
+ ("ART", "A R T", CharacterSpellingMatch(0, 3, 0, 5)),
85
+ ("AI", "A I", CharacterSpellingMatch(0, 2, 0, 3)),
86
+ ("USA", "U S A", CharacterSpellingMatch(0, 3, 0, 5)),
87
+ ("R2D2", "R 2 D 2", CharacterSpellingMatch(0, 4, 0, 7)),
88
+ ("ART's", "A R T's", CharacterSpellingMatch(0, 3, 0, 5)),
89
+ ("ART’s", "A R T’s", CharacterSpellingMatch(0, 3, 0, 5)),
90
+ ],
91
+ )
92
+ def test_detect_exact_character_spelling(
93
+ source: str, replacement: str, expected: CharacterSpellingMatch
94
+ ) -> None:
95
+ assert detect_character_spelling(source, replacement) == expected
96
+
97
+
98
+ @pytest.mark.parametrize(
99
+ ("source", "replacement"),
100
+ [
101
+ ("A", "A"),
102
+ ("NASA", "nasa"),
103
+ ("NASA", "N A S A agency"),
104
+ ("Dr.", "Doctor"),
105
+ ("kg", "kilograms"),
106
+ ("ART", "A-R-T"),
107
+ ("ABC", "A B C D"),
108
+ ("AB", "A B"),
109
+ ("A&B", "A and B"),
110
+ ("ARTs", "A R T s"),
111
+ ("ART's", "A R T’s"),
112
+ ],
113
+ )
114
+ def test_rejects_non_exact_character_spelling(source: str, replacement: str) -> None:
115
+ assert detect_character_spelling(source, replacement) is None
116
+
117
+
118
+ def test_inference_adds_immutable_core_only_deterministic_annotation() -> None:
119
+ prepared = _replacement("ART's", "A R T's", start=4)
120
+ result = infer_prepared_semantics(prepared)
121
+ derived = result.annotations[-1]
122
+
123
+ assert result.spoken_text == prepared.spoken_text
124
+ assert result.info is prepared.info
125
+ assert result.info.replacements == prepared.info.replacements
126
+ assert result.boundaries is prepared.boundaries
127
+ assert result.source_map is prepared.source_map
128
+ assert result.consumed_substitutions is prepared.consumed_substitutions
129
+ assert derived.id == "derived.say-as.characters.4.7.14.19"
130
+ assert derived.kind == "directive"
131
+ assert derived.attrs == {
132
+ "tag": "say-as",
133
+ "as": "characters",
134
+ "origin": "derived-character-spelling",
135
+ }
136
+ assert (derived.structural_start, derived.structural_end) == (4, 7)
137
+ assert (derived.spoken_start, derived.spoken_end) == (14, 19)
138
+ assert prepared.spoken_text[derived.spoken_start : derived.spoken_end] == "A R T"
139
+ assert (derived.source_start, derived.source_end) == (104, 109)
140
+ assert derived.source_node_id == "source-node-1"
141
+ assert infer_prepared_semantics(result).annotations == result.annotations
142
+
143
+
144
+ def test_authored_say_as_prevents_derived_overlap_and_wins() -> None:
145
+ prepared = _replacement("ART", "A R T")
146
+ authored = AnnotationSpan(
147
+ id="authored-say-as",
148
+ kind="directive",
149
+ attrs={"tag": "say-as", "as": "initialism"},
150
+ structural_start=0,
151
+ structural_end=3,
152
+ spoken_start=10,
153
+ spoken_end=15,
154
+ )
155
+ prepared = PreparedText(
156
+ prepared.spoken_text,
157
+ prepared.info,
158
+ (*prepared.annotations, authored),
159
+ prepared.boundaries,
160
+ prepared.source_map,
161
+ prepared.consumed_substitutions,
162
+ )
163
+
164
+ result = infer_prepared_semantics(prepared)
165
+
166
+ assert result is prepared
167
+ assert [item.id for item in result.annotations].count("authored-say-as") == 1
168
+ assert all(
169
+ item.attrs.get("origin") != "derived-character-spelling" for item in result.annotations
170
+ )
171
+
172
+
173
+ def test_unverified_replacement_is_ignored() -> None:
174
+ prepared = _replacement("ART", "A R T")
175
+ bad = dict(
176
+ prepared.info.replacements[0], output_end=prepared.info.replacements[0]["output_end"] - 1
177
+ )
178
+ info = TextPreparationInfo("ssmd", None, replacements=(bad,))
179
+ unverified = PreparedText(
180
+ prepared.spoken_text,
181
+ info,
182
+ prepared.annotations,
183
+ prepared.boundaries,
184
+ prepared.source_map,
185
+ prepared.consumed_substitutions,
186
+ )
187
+
188
+ assert infer_prepared_semantics(unverified) is unverified
@@ -29,4 +29,4 @@ def test_renderplan_collection_api_minimum_is_declared_as_runtime_dependency() -
29
29
  project = tomlkit.parse(
30
30
  (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text(encoding="utf-8")
31
31
  ).unwrap()
32
- assert "renderplan>=0.1.1,<0.2" in project["project"]["dependencies"]
32
+ assert "renderplan>=0.2.0,<0.3" in project["project"]["dependencies"]
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ from dataclasses import replace
4
5
  from pathlib import Path
5
6
 
6
7
  import pytest
@@ -91,7 +92,7 @@ def _preflight(plan, profile) -> PreflightReport:
91
92
  ).with_identity()
92
93
 
93
94
 
94
- def test_public_build_is_deterministic_and_rejects_incomplete_replan_inputs() -> None:
95
+ def test_public_build_is_deterministic_and_rejects_incomplete_replan_inputs(tmp_path: Path) -> None:
95
96
  source = _source()
96
97
  profile = _profile()
97
98
 
@@ -103,6 +104,23 @@ def test_public_build_is_deterministic_and_rejects_incomplete_replan_inputs() ->
103
104
  assert first.speech[0].text == "one two three."
104
105
  assert first.speech[0].source[0].locator == "f000000.s000"
105
106
  assert first.speech[0].wav == "000001.wav"
107
+ renderplan_path = tmp_path / "single-role.renderplan.toml"
108
+ renderplan_codec.save_plan(first, renderplan_path)
109
+ serialized = renderplan_path.read_text(encoding="utf-8")
110
+ loaded = renderplan_codec.load_plan(renderplan_path)
111
+ assert first.schema_version == 2
112
+ assert serialized.count("[bindings.narrator]") == 1
113
+ assert "[flow.binding]" not in serialized
114
+ assert loaded.plan_id == first.plan_id == first.computed_id()
115
+ assert all(
116
+ speech.binding == dict(profile.roles[speech.role].binding) for speech in loaded.speech
117
+ )
118
+ loaded.validate_against_profile(profile)
119
+
120
+ changed_role = replace(profile.roles["narrator"], binding={"engine": "changed"})
121
+ changed_profile = replace(profile, roles={"narrator": changed_role}, profile_id=None)
122
+ changed_plan = build_render_plan(source, changed_profile)
123
+ assert changed_plan.speech[0].segment_id != first.speech[0].segment_id
106
124
  try:
107
125
  build_render_plan(source, profile, preflight=_preflight(first, profile))
108
126
  except RenderPlanProjectionError as error:
@@ -314,7 +332,8 @@ def test_render_plan_json_reports_role_coverage_metadata(tmp_path: Path, capsys)
314
332
  profile_path = tmp_path / "cast.renderprofile.toml"
315
333
  output_path = tmp_path / "roles.renderplan.toml"
316
334
  _multi_role_source().save(source_path)
317
- renderplan_codec.save_profile(_multi_role_profile(), profile_path)
335
+ profile = _multi_role_profile()
336
+ renderplan_codec.save_profile(profile, profile_path)
318
337
 
319
338
  assert (
320
339
  main(
@@ -341,6 +360,15 @@ def test_render_plan_json_reports_role_coverage_metadata(tmp_path: Path, capsys)
341
360
  }
342
361
  assert output_path.is_file()
343
362
 
363
+ plan = renderplan_codec.load_plan(output_path)
364
+ serialized = output_path.read_text(encoding="utf-8")
365
+ for role in ("narrator", "host", "guest"):
366
+ assert serialized.count(f"[bindings.{role}]") == 1
367
+ assert "[flow.binding]" not in serialized
368
+ assert plan.schema_version == 2
369
+ assert plan.plan_id == plan.computed_id()
370
+ assert all(speech.binding == dict(profile.roles[speech.role].binding) for speech in plan.speech)
371
+ plan.validate_against_profile(profile)
344
372
  human_output_path = tmp_path / "roles-human.renderplan.toml"
345
373
  assert (
346
374
  main(
@@ -22,10 +22,13 @@ from utterplan import (
22
22
  FlowUnit,
23
23
  LegacyFlowPlan,
24
24
  PauseIntent,
25
+ PlannerConfig,
25
26
  ProsodyDirective,
26
27
  SegmentDirectives,
27
28
  TokenView,
28
29
  VoiceDirective,
30
+ build_render_plan,
31
+ compile_document,
29
32
  )
30
33
  from utterplan.hashing import V5_FLOW_HASH_SCHEMA, V6_FLOW_HASH_SCHEMA
31
34
  from utterplan.renderplan_grouping import group_initial_plan
@@ -554,3 +557,46 @@ def test_model_token_capacity_is_not_interpreted_by_utterplan() -> None:
554
557
  with pytest.raises(RenderPlanProjectionError) as error:
555
558
  _group(plan, profile)
556
559
  assert error.value.code == "renderplan.capacity.metric"
560
+
561
+
562
+ @pytest.mark.parametrize(
563
+ ("source", "expected"),
564
+ [
565
+ (
566
+ "Because someone called [ART's]{sub=\"A R T's\"} finally.",
567
+ "Because someone called A R T's finally.",
568
+ ),
569
+ (
570
+ 'Because someone called [ART’s]{sub="A R T’s"} finally.',
571
+ "Because someone called A R T’s finally.",
572
+ ),
573
+ ],
574
+ )
575
+ def test_character_spelling_projects_one_sentence_with_exact_request_control(
576
+ source: str, expected: str
577
+ ) -> None:
578
+ compiled = compile_document(
579
+ source,
580
+ input_format="ssmd",
581
+ config=PlannerConfig(language="en-us", document_format="ssmd"),
582
+ )
583
+ rendered = build_render_plan(compiled.plan, _profile(pauses={"sentence": 120}))
584
+ speech = [item for item in rendered.flow if isinstance(item, SpeechItem)]
585
+
586
+ assert len(speech) == 1
587
+ assert speech[0].planning_level == "sentence"
588
+ assert speech[0].text == expected
589
+ tokens = speech[0].tokens
590
+ assert tokens is not None
591
+ t_index = next(
592
+ index for index, (start, end) in enumerate(tokens.span) if speech[0].text[start:end] == "T"
593
+ )
594
+ assert dict(tokens.lemma)[t_index] == "T"
595
+ assert "A- R- T" not in speech[0].text
596
+ assert not any(isinstance(item, PauseItem) for item in rendered.flow)
597
+ say_as = [control for control in speech[0].controls if control.name == "say_as"]
598
+ assert len(say_as) == 1
599
+ control = say_as[0]
600
+ start = expected.index("A R T")
601
+ assert (control.start, control.end) == (start, start + len("A R T"))
602
+ assert dict(control.params) == {"interpret_as": "characters"}